Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 53 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
CodeTruss CLI follows semantic versioning. Release artifacts and their SHA-256
checksums are published at <https://codetruss.com/downloads/codetruss-cli-latest.json>.

The current public release is [v0.2.41 on GitHub](https://github.com/CodeTruss/codetruss-cli/releases/tag/v0.2.41),
The current public release is [v0.2.42 on GitHub](https://github.com/CodeTruss/codetruss-cli/releases/tag/v0.2.42),
distributed from <https://codetruss.com/downloads/codetruss-cli-latest.json>.
The npm `latest` tag is still
[`@codetruss/cli@0.2.24`](https://www.npmjs.com/package/@codetruss/cli/v/0.2.24):
Expand All @@ -16,6 +16,58 @@ were superseded before distribution.

No unreleased changes.

## 0.2.42 — 2026-08-07

- **A security scan that could have run for hours now finishes in seconds.**
Naming the callee of a chained call went the long way round, through a helper
that eagerly re-derived that same name twice more — so a left-deep method chain
cost 3^links to analyze. Eighteen chained `.replace()` calls in a single 19 KB
file extrapolated to roughly 2.1 hours, and a thirteen-link chain took
30,839 ms; that chain now takes 2 ms. Only the number of times the name is
computed changed, never the answer. Two wall-clock ceilings back that up —
five seconds for one file, five minutes for a whole pass — so no future shape
can hang a scan instead of finishing it. A ceiling that fires is disclosed
rather than absorbed: the receipt names the files it cut and says plainly that
the rules which had not run there reported nothing, which is not the same as
finding nothing.
- **A finding in a file your change never touched is no longer reported as one
your change introduced.** Analyzers cap how many findings they report. Resolve
two and two cap slots free up, so findings that had merely been hidden in
untouched files entered the reported list for the first time — where the
baseline comparison called them introduced, and a signed receipt then asserted
that a change broke code its author never opened. The mirror image was just as
wrong: a finding pushed below the cap read as resolved when nothing had fixed
it. The comparison now runs over everything each pass found rather than only
what it reported, while the cap still decides what a receipt shows. The new
time ceilings above can hide a finding the same way, and that door is shut
too — but by the opposite means, because a capped finding was found and then
dropped whereas a file the clock cut was never analyzed at all. There is
nothing to recover in that case, so a file either the baseline or the final
could not finish is dropped from both sides, and the comparison makes no claim
about it in either direction.
- **A verification that passed is no longer reported as timed out because
something it started outlived it.** On Windows a descendant that escapes the
process tree keeps the inherited output pipes open, and CodeTruss waited on
those pipes — so a suite that passed in ten seconds and left a watcher behind
burned its entire deadline and produced exit code 124 on a signed receipt. A
local review provider lost finished reviews the same way. Capture now settles
two seconds after the command's own process exits, on the status the command
actually produced, and the escape is named in the output instead of being
absorbed silently. That grace is only ever paid when something really did
escape. What Windows still does not allow CodeTruss to reap, and what closing
it would cost, is stated in the code at the point the choice is made.
- **CodeTruss no longer reads its own receipts back in as your source code.** A
receipt `.patch` is the captured session diff — the full text of every changed
line — and it classified as source, so the tool analyzed its own audit trail.
Against this repository's real 156-receipt store that produced 52 spurious
findings, including duplication findings over receipts that repeat each other
by construction; those consumed the per-analyzer finding budgets and crowded
genuine findings out of the report entirely. It also ran the other way: an
identifier appearing anywhere in a receipt looked referenced, so "exported with
no consumer" findings silently vanished and returned depending on what the last
session happened to touch. A repository holding receipts now yields the same
findings as the same tree with no receipts in it at all.

## 0.2.41 — 2026-08-07

- **You can dismiss a finding you have judged wrong, in the place the judgement
Expand Down
34 changes: 23 additions & 11 deletions packages/analyzer-engine/src/comment-slop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -337,7 +337,6 @@ export const commentSlopAnalyzer: Analyzer = {
const isDensityOutlier = (item: FileMeasurement) =>
item.ratio >= densityThreshold && item.commentLines >= DENSITY_MIN_COMMENTS

const findings: AnalyzerFinding[] = []
const restating = measurements
.filter((item) => item.redundant.length >= REDUNDANT_FILE_THRESHOLD)
.sort((left, right) => right.redundant.length - left.redundant.length)
Expand All @@ -346,11 +345,15 @@ export const commentSlopAnalyzer: Analyzer = {
.sort((left, right) => right.narration.length - left.narration.length)

const findingLimit = 10
for (const item of restating.slice(0, findingLimit)) {
// Built for every matching file, then split by the cap into reported and
// withheld. A finding that is never constructed cannot be compared against
// another run, and the delta then reads it as introduced the first time a
// cap slot frees up — see `analyzerWithheld` in types.ts.
const restatingFindings: AnalyzerFinding[] = restating.map((item) => {
const outlier = isDensityOutlier(item)
const low = item.redundant.length >= REDUNDANT_LOW_THRESHOLD && outlier
const sample = item.redundant[0]
findings.push({
return {
category: 'DOCUMENTATION',
severity: low ? 'LOW' : 'INFO',
title: `${item.redundant.length} comments restate the code in ${item.path}`,
Expand All @@ -368,15 +371,15 @@ export const commentSlopAnalyzer: Analyzer = {
impactScore: low ? 25 : 15,
effort: 'low',
metadata: { count: item.redundant.length, sample: item.redundant.slice(0, 5) },
})
}
}
})

for (const item of narrating.slice(0, findingLimit)) {
const narratingFindings: AnalyzerFinding[] = narrating.map((item) => {
const low = item.narration.length >= NARRATION_LOW_THRESHOLD
const sample = item.narration[0]
const others = item.narration.length - 1
const allPlaceholder = item.narration.every((hit) => hit.tag === 'placeholder-deferral')
findings.push({
return {
category: 'DOCUMENTATION',
severity: low ? 'LOW' : 'INFO',
title: allPlaceholder
Expand All @@ -399,8 +402,17 @@ export const commentSlopAnalyzer: Analyzer = {
impactScore: low ? 25 : 15,
effort: 'low',
metadata: { count: item.narration.length, sample: item.narration.slice(0, 5) },
})
}
}
})

const findings = [
...restatingFindings.slice(0, findingLimit),
...narratingFindings.slice(0, findingLimit),
]
const withheld = [
...restatingFindings.slice(findingLimit),
...narratingFindings.slice(findingLimit),
]

const metrics = {
eligibleFiles: measurements.length,
Expand All @@ -420,13 +432,13 @@ export const commentSlopAnalyzer: Analyzer = {
truncated: true,
detail: `Comment analysis hit a candidate bound (${candidates.length} candidate files).`,
metrics: { ...metrics, candidates: candidates.length, candidateLimit },
})
}, withheld)
}
if (restating.length > findingLimit || narrating.length > findingLimit) {
return annotatedAnalyzerOutput(findings, {
detail: `Comment output capped at ${findingLimit} files per rule (${restating.length} restating, ${narrating.length} narrating).`,
metrics: { ...metrics, candidates: candidates.length, candidateLimit },
})
}, withheld)
}
return annotatedAnalyzerOutput(findings, {
metrics: { ...metrics, candidates: candidates.length, candidateLimit },
Expand Down
8 changes: 6 additions & 2 deletions packages/analyzer-engine/src/complexity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,10 @@ export const complexityAnalyzer: Analyzer = {

const findingLimit = 20
const output = findings.slice(0, findingLimit)
// Handed to the runner, never reported: proof that this tree already
// contained them, so a comparison cannot read one entering a freed cap slot
// as a finding the change introduced.
const withheld = findings.slice(findingLimit)
// Only the candidate-file cap loses coverage. The finding cap bounds the
// persisted/displayed output after every candidate was analyzed, so it must
// not make otherwise authoritative scores disappear.
Expand All @@ -162,13 +166,13 @@ export const complexityAnalyzer: Analyzer = {
truncated: true,
detail: `Complexity analysis hit a candidate bound (${candidates.length} candidate files, ${findings.length} matches).`,
metrics: { candidates: candidates.length, candidateLimit, matches: findings.length, findingLimit },
})
}, withheld)
}
if (findings.length > findingLimit) {
return annotatedAnalyzerOutput(output, {
detail: `Complexity output capped at ${findingLimit} of ${findings.length} matches.`,
metrics: { candidates: candidates.length, candidateLimit, matches: findings.length, findingLimit },
})
}, withheld)
}
return output
},
Expand Down
8 changes: 6 additions & 2 deletions packages/analyzer-engine/src/dead-code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,18 +102,22 @@ export const deadCodeAnalyzer: Analyzer = {
// bounds OUTPUT over an analysis that covered every candidate file.
const truncated = jsFiles.length > candidateLimit
const output = findings.slice(0, findingLimit)
// Unreported, but retained as evidence that this tree already contained
// them — a baseline/final comparison must not read a finding surfacing into
// a freed cap slot as one the change introduced.
const withheld = findings.slice(findingLimit)
if (truncated) {
return incompleteAnalyzerOutput(output, {
truncated: true,
detail: `Dead-code analysis hit a bound (${jsFiles.length} candidate files, ${findings.length} matches).`,
metrics: { candidates: jsFiles.length, candidateLimit, matches: findings.length, findingLimit },
})
}, withheld)
}
if (findings.length > findingLimit) {
return annotatedAnalyzerOutput(output, {
detail: `Dead-code output capped at ${findingLimit} of ${findings.length} matches.`,
metrics: { candidates: jsFiles.length, candidateLimit, matches: findings.length, findingLimit },
})
}, withheld)
}
return output
},
Expand Down
7 changes: 5 additions & 2 deletions packages/analyzer-engine/src/duplication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,9 @@ export const duplicationAnalyzer: Analyzer = {

const findingLimit = 25
const output = findings.slice(0, findingLimit)
// Retained unreported so a comparison against another run can tell a pair
// that was merely over the cap here from one that did not exist here.
const withheld = findings.slice(findingLimit)
// Scanning fewer candidates is real coverage loss. Capping the number of
// persisted duplicate pairs after every candidate was compared is only an
// output bound and keeps the pass authoritative.
Expand All @@ -77,13 +80,13 @@ export const duplicationAnalyzer: Analyzer = {
truncated: true,
detail: `Duplication analysis hit a candidate bound (${candidates.length} candidate files, ${findings.length} matches).`,
metrics: { candidates: candidates.length, candidateLimit, matches: findings.length, findingLimit },
})
}, withheld)
}
if (findings.length > findingLimit) {
return annotatedAnalyzerOutput(output, {
detail: `Duplication output capped at ${findingLimit} of ${findings.length} matches.`,
metrics: { candidates: candidates.length, candidateLimit, matches: findings.length, findingLimit },
})
}, withheld)
}
return output
},
Expand Down
12 changes: 12 additions & 0 deletions packages/analyzer-engine/src/indexer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,18 @@ const IGNORED_DIRS = new Set([
'.git', 'node_modules', '.next', 'dist', 'build', 'out', 'coverage',
'.venv', 'venv', '__pycache__', '.pytest_cache', 'vendor', 'target',
'.turbo', '.cache', '.idea', '.vscode',
// CodeTruss's own receipt/audit store. A receipt `.patch` is the captured
// session diff — the full text of every changed line — and classifies as
// `source`, so its identifiers counted as repo-wide usage and silently
// suppressed genuine "exported with no consumer" findings, flickering on and
// off with whatever the last session happened to touch. Ignored like `.git`
// rather than disclosed as an exclusion: the store is CodeTruss's own
// metadata, gitignored by design, not committed product code. The CLI pins it
// under `.codetruss/` (config `receiptDir` rejects any relocation outside it),
// so the directory name is the whole surface to exclude. This is an analysis
// exclusion only — scope classification reads Git, not this walk, and already
// filters `.codetruss/` in git.ts.
'.codetruss',
])

const DEFAULT_MAX_FILES = 20_000
Expand Down
59 changes: 35 additions & 24 deletions packages/analyzer-engine/src/overengineering.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,12 +249,16 @@ export const overengineeringAnalyzer: Analyzer = {
}
}

const findings: AnalyzerFinding[] = []
const findingLimit = 10
for (const file of speculative.slice(0, findingLimit)) {
// Every match is turned into a finding BEFORE the per-rule cap is applied.
// The cap then splits them into what this pass reports and what it withholds
// — a finding that only exists on one side of that split cannot be compared
// against another run, and an uncomparable finding is one a delta calls
// "introduced" the moment a cap slot frees up somewhere unrelated.
const speculativeFindings: AnalyzerFinding[] = speculative.map((file) => {
const listed = file.names.slice(0, 5).map((name) => `\`${name}\``).join(', ')
const rest = file.names.length - Math.min(file.names.length, 5)
findings.push({
return {
category: 'DEAD_CODE',
severity: 'INFO',
title: `${file.names.length} export(s) with no consumer in ${file.path}`,
Expand All @@ -268,26 +272,33 @@ export const overengineeringAnalyzer: Analyzer = {
impactScore: 15,
effort: 'low',
metadata: { exports: file.names.slice(0, 10) },
})
}
}
})

for (const file of rethrows.slice(0, findingLimit)) {
findings.push({
category: 'TECH_DEBT',
severity: 'INFO',
title: `${file.lines.length} catch block(s) log and rethrow in ${file.path}`,
description:
`${file.path} catches an error at line ${file.lines[0]}${file.lines.length > 1 ? ` and ${file.lines.length - 1} other place(s)` : ''}, `
+ 'logs it, and rethrows it unchanged. The handler adds a duplicate log line and no behaviour: the caller '
+ 'still receives the original error, and the stack is now reported twice.',
filePath: file.path,
line: file.lines[0],
suggestion: 'Remove the catch and let the error propagate, or handle it where the extra context exists.',
impactScore: 15,
effort: 'low',
metadata: { lines: file.lines.slice(0, 10) },
})
}
const rethrowFindings: AnalyzerFinding[] = rethrows.map((file) => ({
category: 'TECH_DEBT',
severity: 'INFO',
title: `${file.lines.length} catch block(s) log and rethrow in ${file.path}`,
description:
`${file.path} catches an error at line ${file.lines[0]}${file.lines.length > 1 ? ` and ${file.lines.length - 1} other place(s)` : ''}, `
+ 'logs it, and rethrows it unchanged. The handler adds a duplicate log line and no behaviour: the caller '
+ 'still receives the original error, and the stack is now reported twice.',
filePath: file.path,
line: file.lines[0],
suggestion: 'Remove the catch and let the error propagate, or handle it where the extra context exists.',
impactScore: 15,
effort: 'low',
metadata: { lines: file.lines.slice(0, 10) },
}))

const findings = [
...speculativeFindings.slice(0, findingLimit),
...rethrowFindings.slice(0, findingLimit),
]
const withheld = [
...speculativeFindings.slice(findingLimit),
...rethrowFindings.slice(findingLimit),
]

// Only the candidate-file cap loses coverage; the finding cap bounds output
// over an analysis that still examined every candidate.
Expand All @@ -304,13 +315,13 @@ export const overengineeringAnalyzer: Analyzer = {
truncated: true,
detail: `Speculative-structure analysis hit a candidate bound (${production.length} candidate files).`,
metrics,
})
}, withheld)
}
if (speculative.length > findingLimit || rethrows.length > findingLimit) {
return annotatedAnalyzerOutput(findings, {
detail: `Speculative-structure output capped at ${findingLimit} files per rule (${speculative.length} export, ${rethrows.length} rethrow).`,
metrics,
})
}, withheld)
}
return annotatedAnalyzerOutput(findings, { metrics })
},
Expand Down
4 changes: 3 additions & 1 deletion packages/analyzer-engine/src/removed-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,12 @@ export const removedRoutesAnalyzer: Analyzer = {
}

if (findings.length > FINDING_LIMIT) {
// The over-cap prefixes ride along unreported so a comparison against
// another run can tell "was already here, just not shown" from "is new".
return annotatedAnalyzerOutput(findings.slice(0, FINDING_LIMIT), {
detail: `Removed-route output capped at ${FINDING_LIMIT} of ${findings.length} prefixes.`,
metrics: { prefixes: findings.length, findingLimit: FINDING_LIMIT },
})
}, findings.slice(FINDING_LIMIT))
}
return findings
},
Expand Down
Loading
Loading