Skip to content
Merged
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
71 changes: 69 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@
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.50 on GitHub](https://github.com/CodeTruss/codetruss-cli/releases/tag/v0.2.50),
The current public release is [v0.2.51 on GitHub](https://github.com/CodeTruss/codetruss-cli/releases/tag/v0.2.51),
distributed from <https://codetruss.com/downloads/codetruss-cli-latest.json>.
The npm `latest` tag is still
[`@codetruss/cli@0.2.41`](https://www.npmjs.com/package/@codetruss/cli/v/0.2.41):
[`@codetruss/cli@0.2.50`](https://www.npmjs.com/package/@codetruss/cli/v/0.2.50):
npm publication is a separate, manually dispatched step, so npm can trail the
website and the GitHub release.
Entries explicitly marked `(unpublished)` are retained release candidates that
Expand All @@ -16,6 +16,73 @@ were superseded before distribution.

No unreleased changes.

## 0.2.51 — 2026-08-08

- **A file CodeTruss could not parse reported the user's change as FAILED.** On
`sindresorhus/ky`, a one-line comment change returned `FAILED`, exit 2, for a
reason that named no file: `1 file(s) could not be parsed locally`. The
trigger was a `unique symbol` declaration — standard TypeScript since 2018 —
in `source/utils/merge.ts`, which the bundled zero-dependency grammar cannot
read. Reproduced identically on `honojs/hono` and `colinhacks/zod`. Because
`codetruss setup` installs a pre-commit hook, FAILED also blocked the next
`git commit`, with uninstalling as the only escape.

Our inability to read a file is our limitation, not a defect in the change.
Every entry that reaches the verdict as an evidence issue is now classified at
the point where its cause is still known, rather than by matching on message
text at the verdict:

- **`missing` — no evidence at all — still FAILS.** No required analyzer pass
ran; the index did not report coverage. Nothing can be concluded from a run
like that in either direction, so the receipt refuses rather than reporting
a verdict it has no basis for.
- **`partial` — a hole in evidence that otherwise exists — is now
`REVIEW_REQUIRED`.** A file the parser could not read, a file too large to
load, an unreadable file, the file-walk bound, a wall-clock ceiling, a
truncated diff capture. None of these is a statement about the change; each
is a limit of this tool. They withhold PASS, are named on the receipt, and
exit 1 — which the pre-commit hook allows.

- **Coverage gaps now name their files.** The engine recorded that *n* files
could not be parsed and dropped which ones before the receipt was signed. A
count with no path is unactionable — a reader is told something in their
repository is unreadable and given no way to find it. Parse failures and
in-file scan errors are now carried as bounded path lists through the scan
diagnostics, disclosed in the pass detail (so they reach the terminal), and
recorded in the signed pass metrics alongside `degradedLanguages` (so a later
reader recovers them without re-parsing an English sentence).

- **New `exclude` key in `.codetruss.yml`.** Globs listed there keep their files
out of the analysis index entirely, so a file this tool cannot read need not
sit on every receipt forever. It is an analysis exclusion only: an excluded
path is still inventoried as a changed file, still classified against scope,
and is named — with its glob and its matched paths — in the receipt's coverage
notes. It also enters the policy fingerprint, because what a repository chose
not to have analyzed is part of its policy. An exclusion that hid itself would
be a worse bug than the coverage gap it works around.

- **One design asset no longer forces REVIEW_REQUIRED forever.** A `logo.ai`
committed with a text-ish extension made every change to that repository
REVIEW_REQUIRED, permanently, via `apparent text file(s) contained binary
data`. That contradicted the same file's own arithmetic: binary-in-text files
are already subtracted from the coverage denominator as unanalyzable, so the
ratio said nothing was lost while the verdict said coverage was partial. It is
now disclosed as a classification note on the receipt and does not gate the
verdict.

- **A commented-out regex ran the analyzer phase past seven minutes at 100%
CPU.** `colinhacks/zod` never finished a review. The cause was not the parser:
the literal-stripping expression shared by the `complexity` and `comment-slop`
analyzers spelled its escape handling as `(?:\\.|(?!\1).)*`, which lets a
backslash be consumed by either branch. On an unterminated literal the engine
then tries every partition of the backslashes in it. `packages/zod/src/v3/
types.ts:607` is a commented-out email regex with 133 backslashes and no
closing quote: 2^133 on one 928-character line. It outlived both advertised
wall-clock ceilings because those bound the SAST pass and this runs in the
registry analyzers. Excluding the backslash from the second branch makes the
alternatives disjoint; the same line now completes in under a millisecond with
byte-identical output, and the fixture is pinned in the test suite.

## 0.2.50 — 2026-08-08

- **`dead-code` spent 26 of this analysis's 27 seconds and bought nothing with
Expand Down
4 changes: 2 additions & 2 deletions packages/analyzer-engine/src/comment-slop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
type AnalyzerFinding,
} from './types'
import { classifyLines, commentSyntaxFor, contentWords, dataBlockIds, type ClassifiedLine } from './comments'
import { stripStringLiterals } from './support'

/**
* `looksGenerated` reads five lines, and codegen that leads with an
Expand Down Expand Up @@ -90,11 +91,10 @@ const EXPLANATORY =
* shares are the strings being matched. Every measured true positive shares at
* least one word with an identifier, so requiring that costs no recall.
*/
const STRING_LITERAL = /(["'`])(?:\\.|(?!\1).)*\1/g
const REGEX_LITERAL = /\/(?:\\.|\[[^\]]*\]|[^/\n\\])+\/[gimsuy]*/g

function withoutLiterals(code: string): string {
return code.replace(STRING_LITERAL, ' ').replace(REGEX_LITERAL, ' ')
return stripStringLiterals(code, ' ').replace(REGEX_LITERAL, ' ')
}

/**
Expand Down
4 changes: 2 additions & 2 deletions packages/analyzer-engine/src/complexity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
type Analyzer,
type AnalyzerFinding,
} from './types'
import { looksGenerated } from './support'
import { looksGenerated, stripStringLiterals } from './support'

const MAX_NESTING = 5
const LONG_FUNCTION_LINES = 120
Expand Down Expand Up @@ -64,7 +64,7 @@ export const complexityAnalyzer: Analyzer = {
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
// strip strings & comments crudely to avoid counting braces in them
const code = line.replace(/(["'`])(?:\\.|(?!\1).)*\1/g, '""').replace(/\/\/.*$/, '')
const code = stripStringLiterals(line, '""').replace(/\/\/.*$/, '')

const isFuncDecl = /\b(function\b|=>\s*{|def |func |fn )/.test(code)
if (isFuncDecl && funcStart === -1) {
Expand Down
26 changes: 26 additions & 0 deletions packages/analyzer-engine/src/indexer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ const IGNORED_DIRS = new Set([

const DEFAULT_MAX_FILES = 20_000
const DEFAULT_MAX_FILE_BYTES = 1_000_000 // skip reading content over 1MB
/** How many excluded paths the coverage record names. The count stays
* authoritative for the total; this bounds only what gets listed. */
const MAX_DISCLOSED_EXCLUDED_PATHS = 50
const TEXT_KINDS = new Set(['source', 'component', 'route', 'test', 'config', 'doc', 'migration'])
const EXTENDED_BINARY_ASSET_RE = /\.(?:webp|avif|woff2?|ttf|otf|eot|pdf|zip|tar|tgz|gz|bz2|xz|zst|br|lz4|7z|rar|jar|war|ear|apk|deb|rpm|dmg|iso|cab|wasm|bin|exe|dll|so|dylib)$/i
/** Markup/data languages that must never appear in code LOC stats. */
Expand All @@ -47,6 +50,17 @@ export interface IndexWorkingTreeOptions {
* assets, preventing local release files from becoming incomplete text.
*/
assetMode?: 'historical' | 'binary-aware'
/**
* Repository-relative POSIX paths the caller has been told to leave alone.
*
* A predicate rather than a glob list on purpose: the syntax of an exclusion
* is the caller's contract with its user (the CLI answers for `.codetruss.yml`
* globs), and the indexer's job is only to honor the answer. An excluded path
* never becomes an IndexedFile, so no analyzer can see it; it is counted in
* {@link IndexCoverage.excludedFiles} and named in `excludedPaths` so the
* exclusion is disclosed rather than silent.
*/
exclude?: (path: string) => boolean
}

/**
Expand Down Expand Up @@ -174,8 +188,19 @@ export async function indexWorkingTree(
let oversizedTextFiles = 0
let unreadableTextFiles = 0
let binaryTextFiles = 0
let excludedFiles = 0
const excludedPaths: string[] = []

for (const path of paths) {
// Before stat, before classification: an excluded path is not evidence of
// anything, so it must not reach an analyzer OR move a coverage counter.
// It is named below instead, which is the whole difference between an
// exclusion and a blind spot.
if (options.exclude?.(path)) {
excludedFiles++
if (excludedPaths.length < MAX_DISCLOSED_EXCLUDED_PATHS) excludedPaths.push(path)
continue
}
let size = 0
try {
size = (await stat(join(root, path))).size
Expand Down Expand Up @@ -287,6 +312,7 @@ export async function indexWorkingTree(
oversizedTextFiles,
unreadableTextFiles,
binaryTextFiles,
...(excludedFiles ? { excludedFiles, excludedPaths } : {}),
},
}
}
61 changes: 61 additions & 0 deletions packages/analyzer-engine/src/security/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,8 +129,12 @@ export async function scanFiles(
const degraded = new Set<SastLanguage>()
let timeCappedFiles = 0
let timeSkippedFiles = 0
let unparsedFiles = 0
let erroredFiles = 0
const timeCappedPaths: string[] = []
const timeSkippedPaths: string[] = []
const unparsedPaths: string[] = []
const erroredPaths: string[] = []

const fileBudgetMs = options.fileTimeBudgetMs ?? FILE_TIME_BUDGET_MS
const passDeadline = Date.now() + (options.passTimeBudgetMs ?? PASS_TIME_BUDGET_MS)
Expand Down Expand Up @@ -183,6 +187,8 @@ export async function scanFiles(
)
if (fileFindings === null) {
filesSkipped++
unparsedFiles++
if (unparsedPaths.length < MAX_DISCLOSED_PATHS) unparsedPaths.push(file.filePath)
continue
}
filesScanned++
Expand All @@ -202,6 +208,8 @@ export async function scanFiles(
} catch {
// never let one file crash the scan
filesSkipped++
erroredFiles++
if (erroredPaths.length < MAX_DISCLOSED_PATHS) erroredPaths.push(file.filePath)
}
}

Expand All @@ -220,6 +228,8 @@ export async function scanFiles(
resourceLimitReached: memoryLimitReached,
...(timeCappedFiles ? { timeCappedFiles, timeCappedPaths } : {}),
...(timeSkippedFiles ? { timeSkippedFiles, timeSkippedPaths } : {}),
...(unparsedFiles ? { unparsedFiles, unparsedPaths } : {}),
...(erroredFiles ? { erroredFiles, erroredPaths } : {}),
...(passBudgetExceeded ? { budgetExceeded: true } : {}),
},
}
Expand All @@ -244,24 +254,38 @@ export function mergeSastResults(results: SastResult[], inputFiles: number): Sas
let truncatedFiles = 0
let timeCappedFiles = 0
let timeSkippedFiles = 0
let unparsedFiles = 0
let erroredFiles = 0
const timeCappedPaths: string[] = []
const timeSkippedPaths: string[] = []
const unparsedPaths: string[] = []
const erroredPaths: string[] = []
for (const result of results) {
filesScanned += result.diagnostics.filesScanned
filesSkipped += result.diagnostics.filesSkipped
truncatedFiles += result.diagnostics.truncatedFiles
timeCappedFiles += result.diagnostics.timeCappedFiles ?? 0
timeSkippedFiles += result.diagnostics.timeSkippedFiles ?? 0
unparsedFiles += result.diagnostics.unparsedFiles ?? 0
erroredFiles += result.diagnostics.erroredFiles ?? 0
for (const language of result.diagnostics.degradedLanguages) degradedLanguages.add(language)
for (const path of result.diagnostics.timeCappedPaths ?? []) {
if (timeCappedPaths.length < MAX_DISCLOSED_PATHS) timeCappedPaths.push(path)
}
for (const path of result.diagnostics.timeSkippedPaths ?? []) {
if (timeSkippedPaths.length < MAX_DISCLOSED_PATHS) timeSkippedPaths.push(path)
}
for (const path of result.diagnostics.unparsedPaths ?? []) {
if (unparsedPaths.length < MAX_DISCLOSED_PATHS) unparsedPaths.push(path)
}
for (const path of result.diagnostics.erroredPaths ?? []) {
if (erroredPaths.length < MAX_DISCLOSED_PATHS) erroredPaths.push(path)
}
}
timeCappedPaths.sort()
timeSkippedPaths.sort()
unparsedPaths.sort()
erroredPaths.sort()

return {
findings,
Expand All @@ -276,6 +300,8 @@ export function mergeSastResults(results: SastResult[], inputFiles: number): Sas
budgetExceeded: results.some((result) => result.diagnostics.budgetExceeded),
...(timeCappedFiles ? { timeCappedFiles, timeCappedPaths } : {}),
...(timeSkippedFiles ? { timeSkippedFiles, timeSkippedPaths } : {}),
...(unparsedFiles ? { unparsedFiles, unparsedPaths } : {}),
...(erroredFiles ? { erroredFiles, erroredPaths } : {}),
failureReason: results.find((result) => result.diagnostics.failureReason)?.diagnostics.failureReason,
},
}
Expand Down Expand Up @@ -321,6 +347,41 @@ export function timeCeilingDisclosure(diagnostics: SastDiagnostics): string | un
return parts.length > 0 ? parts.join('; ') : undefined
}

/**
* The sentence a receipt prints when the parser could not read a file — naming
* it, and naming the limitation as OURS.
*
* A file we cannot parse is a gap in our grammar, not a defect in the reader's
* code, and the wording has to survive being read by someone whose perfectly
* valid source we just declined to analyze. Counting without naming is the
* failure mode this replaces: "1 file(s) could not be parsed" tells a reader
* that something is wrong and gives them no way to find it, act on it, or
* disagree with it.
*
* Returns undefined when every file parsed, so callers can spread it.
*/
export function parseFailureDisclosure(diagnostics: SastDiagnostics): string | undefined {
const parts: string[] = []
const unparsed = diagnostics.unparsedFiles ?? 0
const errored = diagnostics.erroredFiles ?? 0
if (unparsed > 0) {
const languages = diagnostics.degradedLanguages
parts.push(
`the local parser could not read ${unparsed} file(s), so no security rule ran over them — ` +
`${namePaths(diagnostics.unparsedPaths ?? [], unparsed)}` +
`${languages.length ? ` (${languages.join(', ')})` : ''}; ` +
'this is a limit of the bundled grammar, not a defect in those files',
)
}
if (errored > 0) {
parts.push(
`security analysis threw partway through ${errored} file(s) and reported nothing for them — ` +
`${namePaths(diagnostics.erroredPaths ?? [], errored)}`,
)
}
return parts.length > 0 ? parts.join('; ') : undefined
}

/** Name the paths we kept, and say plainly how many we did not keep. */
function namePaths(paths: string[], total: number): string {
if (paths.length === 0) return 'their paths were not retained'
Expand Down
19 changes: 19 additions & 0 deletions packages/analyzer-engine/src/security/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,25 @@ export interface SastDiagnostics {
filesSkipped: number
/** Languages that could not be parsed (grammar unavailable) — SAST degraded. */
degradedLanguages: SastLanguage[]
/**
* Files the parser could not turn into a tree, so no rule ran over them.
* Also counted in {@link filesSkipped}.
*
* A count alone is not actionable — the reader cannot exclude, fix, or even
* look at a file we decline to name — so the paths travel with it.
*/
unparsedFiles?: number
/** Paths of those files. Bounded; {@link unparsedFiles} stays authoritative. */
unparsedPaths?: string[]
/**
* Files whose scan threw partway through. Also counted in
* {@link filesSkipped}. Kept apart from {@link unparsedFiles} because the two
* ask different things of the reader: an unparsed file is a grammar gap they
* can route around, a thrown one is a defect worth reporting.
*/
erroredFiles?: number
/** Paths of those files, bounded the same way. */
erroredPaths?: string[]
/** Files whose scan hit the per-file budget and returned partial results. */
truncatedFiles: number
/**
Expand Down
24 changes: 24 additions & 0 deletions packages/analyzer-engine/src/support.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,27 @@ export function looksGenerated(content: string): boolean {
const head = content.split('\n', 5).join('\n')
return /auto-?generated|@generated|generated by|do not edit/i.test(head)
}

/**
* Blank out quoted string literals so a brace, slash or keyword inside one is
* not read as code. Shared by every parser-free analyzer that needs it.
*
* The `[^\\]` in the second branch is load-bearing and is why this lives in one
* place. The obvious spelling — `(?:\\.|(?!\1).)*` — lets a backslash be
* consumed by EITHER branch, so an unterminated literal makes the engine try
* every partition of the backslashes in it: 2^n. A commented-out email regex in
* zod (`packages/zod/src/v3/types.ts:607`, 133 backslashes, no closing quote)
* turned one 928-character line into a scan that ran past seven minutes at 100%
* CPU and never finished — outliving both the 5s-per-file and 5min-per-pass
* ceilings, because those bound the SAST pass and this runs in the registry
* analyzers. Excluding the backslash from the second branch makes the two
* disjoint, so each character has exactly one way to match and the same line
* completes in under a millisecond with identical output.
*
* A fresh literal per call rather than a shared `/g` constant: a global regex
* carries `lastIndex`, and one shared between call sites is a stateful bug
* waiting for the first caller that uses it with anything but `replace`.
*/
export function stripStringLiterals(code: string, replacement: string): string {
return code.replace(/(["'`])(?:\\.|(?!\1)[^\\])*\1/g, replacement)
}
4 changes: 4 additions & 0 deletions packages/analyzer-engine/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ export interface IndexCoverage {
oversizedTextFiles: number
unreadableTextFiles: number
binaryTextFiles: number
/** Files a caller-supplied exclusion kept out of the index entirely. */
excludedFiles?: number
/** Paths of those files. Bounded; {@link excludedFiles} stays authoritative. */
excludedPaths?: string[]
}

export interface RepoIndex {
Expand Down
Loading