diff --git a/CHANGELOG.md b/CHANGELOG.md index 3dfb867..4aebd7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,10 +3,10 @@ CodeTruss CLI follows semantic versioning. Release artifacts and their SHA-256 checksums are published at . -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 . 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 @@ -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 diff --git a/packages/analyzer-engine/src/comment-slop.ts b/packages/analyzer-engine/src/comment-slop.ts index 2089f2b..fbdd48b 100644 --- a/packages/analyzer-engine/src/comment-slop.ts +++ b/packages/analyzer-engine/src/comment-slop.ts @@ -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 @@ -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, ' ') } /** diff --git a/packages/analyzer-engine/src/complexity.ts b/packages/analyzer-engine/src/complexity.ts index 9a33f5a..6c7dfb1 100644 --- a/packages/analyzer-engine/src/complexity.ts +++ b/packages/analyzer-engine/src/complexity.ts @@ -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 @@ -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) { diff --git a/packages/analyzer-engine/src/indexer.ts b/packages/analyzer-engine/src/indexer.ts index 236523f..44ebf84 100644 --- a/packages/analyzer-engine/src/indexer.ts +++ b/packages/analyzer-engine/src/indexer.ts @@ -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. */ @@ -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 } /** @@ -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 @@ -287,6 +312,7 @@ export async function indexWorkingTree( oversizedTextFiles, unreadableTextFiles, binaryTextFiles, + ...(excludedFiles ? { excludedFiles, excludedPaths } : {}), }, } } diff --git a/packages/analyzer-engine/src/security/engine.ts b/packages/analyzer-engine/src/security/engine.ts index 4d3f453..f616de9 100644 --- a/packages/analyzer-engine/src/security/engine.ts +++ b/packages/analyzer-engine/src/security/engine.ts @@ -129,8 +129,12 @@ export async function scanFiles( const degraded = new Set() 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) @@ -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++ @@ -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) } } @@ -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 } : {}), }, } @@ -244,14 +254,20 @@ 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) @@ -259,9 +275,17 @@ export function mergeSastResults(results: SastResult[], inputFiles: number): Sas 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, @@ -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, }, } @@ -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' diff --git a/packages/analyzer-engine/src/security/types.ts b/packages/analyzer-engine/src/security/types.ts index d57ef20..848f5d2 100644 --- a/packages/analyzer-engine/src/security/types.ts +++ b/packages/analyzer-engine/src/security/types.ts @@ -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 /** diff --git a/packages/analyzer-engine/src/support.ts b/packages/analyzer-engine/src/support.ts index 699fe12..503fa31 100644 --- a/packages/analyzer-engine/src/support.ts +++ b/packages/analyzer-engine/src/support.ts @@ -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) +} diff --git a/packages/analyzer-engine/src/types.ts b/packages/analyzer-engine/src/types.ts index b57f450..121e0ef 100644 --- a/packages/analyzer-engine/src/types.ts +++ b/packages/analyzer-engine/src/types.ts @@ -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 { diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index ef9834b..6553763 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -5,6 +5,73 @@ checksums are published at = AUTHORITATIVE_COVERAGE_RATIO } -/** Content-loading limitations, worded so the reader learns the fix, not just the symptom. */ +/** + * Content-loading limitations that COST coverage, worded so the reader learns + * the fix, not just the symptom. + * + * Binary data found inside an apparent text file is deliberately NOT here. + * indexCoverageRatio() already subtracts those files from the denominator on the + * grounds that they are not analyzable, so counting them as a limitation + * contradicted the same file's own arithmetic: the ratio said nothing was lost + * while the verdict said coverage was partial. In practice one design asset with + * a text-ish extension — a `logo.ai` — made every change to that repository + * REVIEW_REQUIRED forever, for a file no analyzer was ever going to read. + * It is disclosed by analysisClassificationNotes() instead. + */ function coverageLimitations(coverage: IndexCoverage): string[] { const limitations: string[] = [] if (coverage.oversizedTextFiles > 0) { @@ -214,10 +227,83 @@ function coverageLimitations(coverage: IndexCoverage): string[] { ) } if (coverage.unreadableTextFiles > 0) limitations.push(`${coverage.unreadableTextFiles} analyzable file(s) could not be read`) - if (coverage.binaryTextFiles > 0) limitations.push(`${coverage.binaryTextFiles} apparent text file(s) contained binary data`) return limitations } +/** + * What the index RECLASSIFIED rather than failed to read. + * + * Still disclosed — the reader learns exactly what was treated as an asset — + * but on the receipt's coverage notes rather than as a verdict reason, because + * no analyzable input was lost. + */ +export function analysisClassificationNotes(coverage: IndexCoverage | undefined): string[] { + if (!coverage || coverage.binaryTextFiles <= 0) return [] + return [ + `${coverage.binaryTextFiles} apparent text file(s) contained binary data and were indexed as assets rather than analyzed; no analyzable input was lost.`, + ] +} + +/** + * What the repository's own `exclude` globs kept out of analysis, named. + * + * An exclusion that hid itself would be strictly worse than the coverage gap it + * works around: a receipt would read as full coverage over a tree nobody looked + * at. So the globs AND the paths they matched are stated on every receipt that + * used them, whether or not anything else about the run is noteworthy. + */ +export function analysisExclusionNotes( + exclude: string[], + coverage: IndexCoverage | undefined, +): string[] { + if (!exclude.length) return [] + const matched = coverage?.excludedFiles ?? 0 + const named = coverage?.excludedPaths ?? [] + const rest = matched - named.length + const paths = named.length + ? `: ${named.join(', ')}${rest > 0 ? `, and ${rest} more` : ''}` + : '' + return [ + `${CONFIG_FILE} exclude (${exclude.join(', ')}) kept ${matched} file(s) out of every analyzer pass${paths}. ` + + 'Excluded files are still inventoried as changed files and still classified against scope; they are not analyzed, and nothing on this receipt speaks to their contents.', + ] +} + +/** + * Why a receipt's evidence is not whole — and, the part that decides a verdict, + * whether that means the CHANGE is unsafe or only that WE could not look. + * + * `missing` is the absence of evidence. No analyzer pass ran at all, or the + * index cannot state what it covered. A run like that supports no conclusion in + * either direction, so the receipt refuses instead of reporting a verdict it has + * no basis for. That is a real failure and still exits non-zero. + * + * `partial` is a HOLE in evidence that otherwise exists: a file our parser could + * not read, a file too large to load, a clock that fired mid-pass. Every one of + * those is a limit of THIS TOOL, and none of them is a statement about the + * change under review. Reporting FAILED for them inverts what the receipt is + * for — it accuses the author of a defect that is ours — and it is unactionable + * besides, because no edit to their change teaches our grammar to read the file. + * These withhold PASS and are named, with their paths, on the receipt. + * + * The distinction is deliberately made HERE, where the cause is still known, + * rather than by matching on the message text at the verdict. + */ +export type EvidenceIssueKind = 'missing' | 'partial' + +export interface EvidenceIssue { + kind: EvidenceIssueKind + message: string +} + +const missing = (message: string): EvidenceIssue => ({ kind: 'missing', message }) +const partial = (message: string): EvidenceIssue => ({ kind: 'partial', message }) + +/** Re-word an issue while preserving the classification that decides the verdict. */ +export function prefixEvidenceIssue(issue: EvidenceIssue, prefix: string): EvidenceIssue { + return { ...issue, message: `${prefix}${issue.message}` } +} + /** * Offline vulnerability lookup is advisory; every deterministic pass is * required. Index limitations are required too, EXCEPT when the index still @@ -228,19 +314,22 @@ function coverageLimitations(coverage: IndexCoverage): string[] { export function analysisEvidenceIssues( passes: AnalyzerPass[], coverage: IndexCoverage | undefined, -): string[] { - const issues: string[] = [] +): EvidenceIssue[] { + const issues: EvidenceIssue[] = [] const requiredPasses = passes.filter((pass) => pass.id !== 'vulnerabilities') - if (requiredPasses.length === 0) issues.push('no required deterministic analyzer passes ran') + // No pass ran: there is nothing to be partial ABOUT. + if (requiredPasses.length === 0) issues.push(missing('no required deterministic analyzer passes ran')) for (const pass of requiredPasses) { if (pass.error || !pass.result.complete || pass.result.truncated) { - issues.push(`required analyzer ${pass.id} did not complete${pass.result.detail ? `: ${pass.result.detail}` : ''}`) + issues.push(partial(`required analyzer ${pass.id} did not complete${pass.result.detail ? `: ${pass.result.detail}` : ''}`)) } } - if (!coverage) issues.push('repository index did not report coverage') + // The index refusing to describe its own coverage is not a small hole; it is + // the one fact every other coverage claim on the receipt is measured against. + if (!coverage) issues.push(missing('repository index did not report coverage')) else { - if (coverage.truncated) issues.push(`repository index reached its ${coverage.maxFiles}-file bound`) - if (!coverageIsAuthoritative(coverage)) issues.push(...coverageLimitations(coverage)) + if (coverage.truncated) issues.push(partial(`repository index reached its ${coverage.maxFiles}-file bound`)) + if (!coverageIsAuthoritative(coverage)) issues.push(...coverageLimitations(coverage).map(partial)) } return issues } @@ -295,16 +384,21 @@ export function computeVerdict(input: { startDirty: boolean findings: AnalyzerFinding[] llm?: LlmReview - evidenceIssues?: string[] - baselineEvidenceIssues?: string[] + evidenceIssues?: EvidenceIssue[] + baselineEvidenceIssues?: EvidenceIssue[] advisoryEvidenceIssues?: string[] }): { verdict: Verdict; reasons: string[] } { const failed: string[] = [] const review: string[] = [] const notes: string[] = [] if (input.agentExitCode !== undefined && input.agentExitCode !== 0) failed.push(`agent command exited with code ${input.agentExitCode}`) - for (const issue of input.evidenceIssues ?? []) failed.push(`evidence incomplete: ${issue}`) - for (const issue of input.baselineEvidenceIssues ?? []) review.push(`baseline evidence limitation resolved in the final tree: ${issue}`) + // A hole in coverage withholds PASS; it does not fail the change. See + // EvidenceIssueKind for why the two are not the same claim. + for (const issue of input.evidenceIssues ?? []) { + if (issue.kind === 'missing') failed.push(`evidence missing: ${issue.message}`) + else review.push(`not fully inspected: ${issue.message}`) + } + for (const issue of input.baselineEvidenceIssues ?? []) review.push(`baseline evidence limitation resolved in the final tree: ${issue.message}`) for (const issue of input.advisoryEvidenceIssues ?? []) review.push(`index coverage was partial but authoritative: ${issue}`) for (const verification of input.verifications.filter((item) => item.exitCode !== 0)) failed.push(`verification command failed: ${verification.command}`) /** diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 57930e1..be2a5dd 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -2,7 +2,7 @@ import { createHash, randomUUID } from 'node:crypto' import { realpath, rmdir } from 'node:fs/promises' import { dirname, isAbsolute, join, resolve } from 'node:path' -import { analyzeRepository, analysisCoverageNotes, analysisEvidenceIssues, analyzerReceipt, computeVerdict, diffFindings } from './analysis.js' +import { analyzeRepository, analysisClassificationNotes, analysisCoverageNotes, analysisEvidenceIssues, analysisExclusionNotes, analyzerReceipt, computeVerdict, diffFindings, prefixEvidenceIssue, type EvidenceIssue } from './analysis.js' import { loadSyncAuthentication } from './auth-storage.js' import { CONFIG_FILE, initialize, loadConfig, receiptDir, trustLocalSigningKey } from './config.js' import { @@ -374,6 +374,9 @@ async function executeReview(parsed: Parsed, root: string, liveConfig: CliConfig mode, task, allow: many(parsed, 'allow', config.allow), deny: many(parsed, 'deny', config.deny), + // Repository-owned only. A command-line flag that could silence analysis of + // any path would turn the escape hatch into a way to launder a receipt. + exclude: config.exclude, verify: parsed.booleans.has('no-verify') ? [] : many(parsed, 'verify', config.verify), llm: hookEvidence ? false : parsed.booleans.has('llm'), provider: hookEvidence ? config.llm.provider : requestedProvider, @@ -470,8 +473,8 @@ async function executeReview(parsed: Parsed, root: string, liveConfig: CliConfig throw new Error('CodeTruss immutable evidence snapshots did not resolve to Git trees') } - const baselineAnalysis = await analyzeRepository(baselineSnapshot.root) - const analysis = await analyzeRepository(finalSnapshot.root) + const baselineAnalysis = await analyzeRepository(baselineSnapshot.root, options.exclude) + const analysis = await analyzeRepository(finalSnapshot.root, options.exclude) const findingDelta = diffFindings( baselineAnalysis.findings, analysis.findings, @@ -484,9 +487,13 @@ async function executeReview(parsed: Parsed, root: string, liveConfig: CliConfig const relevantFindings = [...findingDelta.introduced, ...findingDelta.worsened] const baselineEvidenceIssues = analysisEvidenceIssues(baselineAnalysis.passes, baselineAnalysis.index.coverage) const finalEvidenceIssues = analysisEvidenceIssues(analysis.passes, analysis.index.coverage) - const evidenceIssues = [ - ...finalEvidenceIssues.map((issue) => `final ${issue}`), - ...(diff.truncated ? [`diff capture retained ${diff.capturedBytes} of ${diff.totalBytes} bytes`] : []), + const evidenceIssues: EvidenceIssue[] = [ + ...finalEvidenceIssues.map((issue) => prefixEvidenceIssue(issue, 'final ')), + // A truncated diff is coverage we did not read, not a defect in the + // change: the bytes we did read are still exactly what the author wrote. + ...(diff.truncated + ? [{ kind: 'partial' as const, message: `diff capture retained ${diff.capturedBytes} of ${diff.totalBytes} bytes` }] + : []), ] // Coverage gaps small enough to leave the index authoritative: reported, not fatal. const advisoryEvidenceIssues = analysisCoverageNotes(analysis.index.coverage).map((issue) => `final ${issue}`) @@ -593,6 +600,7 @@ async function executeReview(parsed: Parsed, root: string, liveConfig: CliConfig scope: { allow: options.allow, deny: options.deny, + ...(options.exclude.length ? { exclude: options.exclude } : {}), ...(inferredScope.length ? { inferred: inferredScope } : {}), }, files, @@ -624,6 +632,11 @@ async function executeReview(parsed: Parsed, root: string, liveConfig: CliConfig ? 'The requested optional LLM review did not produce accepted evidence; the failure is recorded in the verdict reasons.' : 'No source code or diff left the machine.', 'Every verification command ran outside the live repository on a fresh materialization of the same immutable final Git tree, so source-tree mutations could not affect later checks. Trusted commands reused the repository\'s ignored installed Node dependencies when present.', + // What we chose not to read, and what we reclassified rather than read. + // Neither changes the verdict; both change what this receipt may claim, + // so both are stated here rather than left to be inferred from silence. + ...analysisExclusionNotes(options.exclude, analysis.index.coverage), + ...analysisClassificationNotes(analysis.index.coverage), ], verdict: outcome.verdict, reasons: outcome.reasons, evidence: {}, } diff --git a/packages/cli/src/config.ts b/packages/cli/src/config.ts index 95ba0ab..7a82261 100644 --- a/packages/cli/src/config.ts +++ b/packages/cli/src/config.ts @@ -33,6 +33,7 @@ export const DEFAULT_CONFIG: CliConfig = { version: 1, allow: [], deny: [], + exclude: [], verify: [], receipts: { dir: APPROVED_RECEIPT_DIR }, llm: { maxDiffBytes: 200_000 }, @@ -103,6 +104,7 @@ export async function loadConfig(root: string): Promise { version: 1, allow: list('allow'), deny: list('deny'), + exclude: list('exclude'), verify: list('verify'), receipts: { dir: typeof receipts.dir === 'string' ? receipts.dir : DEFAULT_CONFIG.receipts.dir }, llm: { @@ -189,13 +191,22 @@ export async function initialize(root: string, force = false, options: Initializ version: DEFAULT_CONFIG.version, allow, deny, + // Written empty so the key is discoverable in the file a user already has, + // rather than only in documentation they have not read yet. + exclude: [], verify: detected, receipts: DEFAULT_CONFIG.receipts, llm: DEFAULT_CONFIG.llm, signing: { publicKey: key.publicKey }, } await mkdir(dirname(path), { recursive: true }) - await writeFile(path, `# CodeTruss local agent guardrails. Deny wins; unmatched paths are unexpected.\n${stringify(value)}`, 'utf8') + await writeFile( + path, + '# CodeTruss local agent guardrails. Deny wins; unmatched paths are unexpected.\n' + + '# exclude: globs to keep OUT of analysis (still inventoried, still disclosed on the receipt).\n' + + stringify(value), + 'utf8', + ) await mkdir(join(root, DEFAULT_CONFIG.receipts.dir), { recursive: true }) return path } diff --git a/packages/cli/src/hook-runtime.ts b/packages/cli/src/hook-runtime.ts index ac5b1df..4c7e285 100644 --- a/packages/cli/src/hook-runtime.ts +++ b/packages/cli/src/hook-runtime.ts @@ -373,6 +373,7 @@ function frozenConfig(config: CliConfig): CliConfig { version: 1, allow: [...config.allow], deny: [...config.deny], + exclude: [...config.exclude], verify: [...config.verify], receipts: { dir: config.receipts.dir }, llm: { @@ -402,6 +403,10 @@ function validateHookTurnContext(value: unknown): HookTurnContext { || (context.surface !== undefined && context.surface !== 'claude' && context.surface !== 'codex') || !config || config.version !== 1 || !isStringArray(config.allow) || !isStringArray(config.deny) || !isStringArray(config.verify) + // Optional: a turn captured by a CLI released before `exclude` existed is + // still authentic evidence, and rejecting it would strand pending turns + // across an upgrade. Absent means excluded nothing, normalized below. + || (config.exclude !== undefined && !isStringArray(config.exclude)) || !config.receipts || typeof config.receipts.dir !== 'string' || !config.llm || !Number.isFinite(config.llm.maxDiffBytes) || config.llm.maxDiffBytes <= 0 // `codex` is accepted only while authenticating pending legacy hook @@ -414,6 +419,7 @@ function validateHookTurnContext(value: unknown): HookTurnContext { || !isStringArray(context.baselineDirtyFiles)) { throw new Error('hook turn context is invalid') } + config.exclude ??= [] return context as HookTurnContext } diff --git a/packages/cli/src/indexer.ts b/packages/cli/src/indexer.ts index 8459b2a..dbe67f6 100644 --- a/packages/cli/src/indexer.ts +++ b/packages/cli/src/indexer.ts @@ -1,6 +1,7 @@ import { indexWorkingTree } from '@codetruss/analyzer-engine/indexer' +import { excludeMatcher } from './policy.js' /** Keep hosted historical classification stable while making local evidence binary-aware. */ -export function indexRepository(root: string) { - return indexWorkingTree(root, { assetMode: 'binary-aware' }) +export function indexRepository(root: string, exclude: string[] = []) { + return indexWorkingTree(root, { assetMode: 'binary-aware', exclude: excludeMatcher(exclude) }) } diff --git a/packages/cli/src/local-sast.ts b/packages/cli/src/local-sast.ts index 9e39828..af12f88 100644 --- a/packages/cli/src/local-sast.ts +++ b/packages/cli/src/local-sast.ts @@ -1,6 +1,7 @@ import type { AnalyzerFinding, AnalyzerPass, RepoIndex } from '@codetruss/analyzer-engine' import { mergeSastResults, + parseFailureDisclosure, scanFiles, timeCeilingDisclosure, type ScanInput, @@ -163,6 +164,53 @@ const EMPTY_SCAN: SastResult = { }, } +/** + * Skipped files no other disclosure accounts for — a memory ceiling, or an + * input with no language. + * + * Subtraction, not `filesSkipped` outright. The old sentence quoted the whole + * count under the words "could not be parsed", which was wrong twice: it swept + * in files the clock skipped, which are disclosed separately and were therefore + * reported to the reader twice, and it named no path at all. + */ +function unattributedSkipDisclosure(diagnostics: SastDiagnostics): string | undefined { + const unattributed = diagnostics.filesSkipped + - (diagnostics.unparsedFiles ?? 0) + - (diagnostics.erroredFiles ?? 0) + - (diagnostics.timeSkippedFiles ?? 0) + return unattributed > 0 + ? `${unattributed} file(s) were skipped before analysis and were not analyzed` + : undefined +} + +/** + * Which files this pass never saw, as signed receipt data rather than prose. + * + * The detail string carries the same facts in English; these carry them where + * `verify-receipt` and any later reader can recover them without parsing a + * sentence. Each group is omitted when empty, so a repository that parsed + * cleanly signs exactly the bytes it did before. + */ +function unseenFileMetrics(diagnostics: SastDiagnostics): Record { + return { + ...(diagnostics.unparsedFiles + ? { + unparsedFiles: diagnostics.unparsedFiles, + unparsedPaths: (diagnostics.unparsedPaths ?? []).join(', '), + } + : {}), + ...(diagnostics.erroredFiles + ? { + erroredFiles: diagnostics.erroredFiles, + erroredPaths: (diagnostics.erroredPaths ?? []).join(', '), + } + : {}), + ...(diagnostics.degradedLanguages.length + ? { degradedLanguages: diagnostics.degradedLanguages.join(', ') } + : {}), + } +} + export async function runLocalSast(index: RepoIndex, env: NodeJS.ProcessEnv = process.env): Promise { const jsInputs = localSastInputs(index) const pythonInputs = localPythonInputs(index) @@ -228,16 +276,12 @@ export async function runLocalSast(index: RepoIndex, env: NodeJS.ProcessEnv = pr const details = [ jsError ? `the JavaScript pass failed: ${jsError}` : undefined, pythonError, - // Gated on the count it quotes: truncation now has more than one cause, and - // "0 file(s) could not be parsed" on a run where the clock was the limit - // would be a false sentence on a signed document. - diagnostics.filesSkipped > 0 - ? `${diagnostics.filesSkipped} file(s) could not be parsed locally and were not analyzed` - : undefined, - // Named, never merely counted: a file the clock cut short is missing + // Named, never merely counted: a file we could not parse is missing // evidence, and the receipt has to say which file rather than let a shorter // finding list read as a cleaner repository. + parseFailureDisclosure(diagnostics), timeCeilingDisclosure(diagnostics), + unattributedSkipDisclosure(diagnostics), ].filter((entry): entry is string => Boolean(entry)) const error = jsError ?? pythonError @@ -258,6 +302,7 @@ export async function runLocalSast(index: RepoIndex, env: NodeJS.ProcessEnv = pr inputFiles: diagnostics.inputFiles, filesScanned: diagnostics.filesScanned, filesSkipped: diagnostics.filesSkipped, + ...unseenFileMetrics(diagnostics), rules: CLI_SAST_RULE_IDS.size, // The receipt renders its Python disclosure from these, so what a // reader is told about coverage is derived from what actually ran on diff --git a/packages/cli/src/policy-fingerprint.ts b/packages/cli/src/policy-fingerprint.ts index b0a7864..a08845c 100644 --- a/packages/cli/src/policy-fingerprint.ts +++ b/packages/cli/src/policy-fingerprint.ts @@ -19,6 +19,11 @@ export function policyFingerprint(options: ReviewOptions, config: CliConfig): st scope: { allow: canonicalSet(options.allow), deny: canonicalSet(options.deny), + // What a repository chose not to have analyzed is part of its policy — + // changing it changes what a receipt can claim, so it has to move the + // digest. Omitted when empty so a repository that excludes nothing keeps + // the fingerprint it had before this key existed. + ...(options.exclude.length ? { exclude: canonicalSet(options.exclude) } : {}), }, verification: { commandDigests: canonicalSet(options.verify.map(sha256)), diff --git a/packages/cli/src/policy.ts b/packages/cli/src/policy.ts index 5d8893b..025b849 100644 --- a/packages/cli/src/policy.ts +++ b/packages/cli/src/policy.ts @@ -18,6 +18,22 @@ export function classifyPath(path: string, oldPath: string | undefined, allow: s return rank[previous] > rank[current] ? previous : current } +/** + * A predicate for `.codetruss.yml` `exclude` globs, normalizing paths exactly + * the way classifyPath does so a glob that means one thing for scope cannot + * quietly mean another for analysis. + * + * Returns a predicate that is always false for an empty list, so the indexer + * takes no per-file cost in the overwhelmingly common case. + */ +export function excludeMatcher(exclude: string[]): ((path: string) => boolean) | undefined { + if (!exclude.length) return undefined + return (path: string) => { + const normalized = path.replaceAll('\\', '/').replace(/^\.\//, '') + return exclude.some((pattern) => minimatch(normalized, pattern, { dot: true })) + } +} + const SENSITIVE: Array<[string, string]> = [ ['.codetruss.yml', 'policy'], ['**/.gitignore', 'vcs'], ['**/.gitattributes', 'vcs'], ['.github/workflows/**', 'ci'], ['.gitlab-ci.yml', 'ci'], ['.circleci/**', 'ci'], ['.buildkite/**', 'ci'], ['Jenkinsfile', 'ci'], ['azure-pipelines.yml', 'ci'], diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts index df1f3d6..1cb4ae0 100644 --- a/packages/cli/src/types.ts +++ b/packages/cli/src/types.ts @@ -88,6 +88,18 @@ export interface CliConfig { version: 1 allow: string[] deny: string[] + /** + * Globs whose files are kept out of the analysis index entirely. + * + * The escape hatch for a file this tool cannot read — a grammar we do not + * have, a generated blob, a vendored payload our heuristics miss — so a + * coverage gap does not have to sit on every receipt forever. It is an + * ANALYSIS exclusion only: an excluded path is still inventoried as a changed + * file, still classified against allow/deny, still counted as a sensitive + * surface, and is named on the receipt. Hiding a change would be a worse bug + * than the coverage gap it works around. + */ + exclude: string[] verify: string[] receipts: { dir: string } llm: { @@ -212,7 +224,7 @@ export interface Receipt { * only when this turn actually used one — which is what keeps receipts signed * before inference existed rendering, and verifying, byte for byte. */ - scope: { allow: string[]; deny: string[]; inferred?: InferredScopeRoot[] } + scope: { allow: string[]; deny: string[]; exclude?: string[]; inferred?: InferredScopeRoot[] } files: ChangedFile[] diff: { sha256: string; bytes: number; totalBytes?: number; truncated: boolean } analyzers: AnalyzerReceipt @@ -229,6 +241,7 @@ export interface ReviewOptions { task: string allow: string[] deny: string[] + exclude: string[] verify: string[] llm: boolean provider?: string diff --git a/packages/cli/test/command-e2e.test.ts b/packages/cli/test/command-e2e.test.ts index c1d36cd..c02a477 100644 --- a/packages/cli/test/command-e2e.test.ts +++ b/packages/cli/test/command-e2e.test.ts @@ -869,7 +869,7 @@ describe('CLI snapshot and delta enforcement', () => { expect(receipt.analyzers.delta!.recurring).toBeGreaterThanOrEqual(1) }, 20_000) - it('fails closed and records byte counts when diff evidence is truncated', async () => { + it('withholds PASS and records byte counts when diff evidence is truncated', async () => { const root = await repository() await writeFile(join(root, 'large.txt'), '') git(root, 'add', '.') @@ -877,9 +877,13 @@ describe('CLI snapshot and delta enforcement', () => { await writeFile(join(root, 'large.txt'), Buffer.alloc(21 * 1024 * 1024, 65)) const result = runCli(root, ['review', '--task', 'Update the large fixture', '--allow', 'large.txt', '--no-verify']) - expect(result.status, result.stderr).toBe(2) + // A capture bound is a limit of ours, not a defect in the change: the + // analyzers still read the whole final tree, and the bytes we did capture + // are exactly what the author wrote. It withholds PASS and says why; it no + // longer blocks the commit for a 21 MB file the author legitimately added. + expect(result.status, result.stderr).toBe(1) const receipt = await latestReceipt(root) - expect(receipt.verdict).toBe('FAILED') + expect(receipt.verdict).toBe('REVIEW_REQUIRED') expect(receipt.diff.truncated).toBe(true) expect(receipt.diff.totalBytes).toBeGreaterThan(receipt.diff.bytes) expect(receipt.reasons.some((reason) => reason.includes('diff capture retained'))).toBe(true) diff --git a/packages/cli/test/local-sast.test.ts b/packages/cli/test/local-sast.test.ts index 9d7885f..3023436 100644 --- a/packages/cli/test/local-sast.test.ts +++ b/packages/cli/test/local-sast.test.ts @@ -148,6 +148,9 @@ describe('the local pass is a pass, not a registry analyzer', () => { const pass = analysis.passes.find((entry) => entry.id === LOCAL_SAST_PASS_ID) expect(pass?.result.complete).toBe(false) expect(pass?.result.truncated).toBe(true) - expect(pass?.result.detail).toMatch(/could not be parsed locally/) + // Names the file. A count on its own told the reader something in their + // repository was unreadable and gave them no way to find out what. + expect(pass?.result.detail).toMatch(/could not read 1 file\(s\)/) + expect(pass?.result.detail).toContain('src/broken.ts') }) }) diff --git a/packages/cli/test/policy-fingerprint.test.ts b/packages/cli/test/policy-fingerprint.test.ts index eeb1353..d1fdee9 100644 --- a/packages/cli/test/policy-fingerprint.test.ts +++ b/packages/cli/test/policy-fingerprint.test.ts @@ -6,6 +6,7 @@ const config: CliConfig = { version: 1, allow: [], deny: [], + exclude: [], verify: [], receipts: { dir: '.codetruss/receipts' }, llm: { provider: 'claude', model: 'default', maxDiffBytes: 100_000 }, @@ -18,6 +19,7 @@ const options: ReviewOptions = { task: 'Fix auth', allow: ['src/**', 'tests/**'], deny: ['infra/**'], + exclude: [], verify: ['pnpm test', 'pnpm lint'], llm: true, staged: false, diff --git a/packages/cli/test/policy-verdict.test.ts b/packages/cli/test/policy-verdict.test.ts index 85970e7..250db37 100644 --- a/packages/cli/test/policy-verdict.test.ts +++ b/packages/cli/test/policy-verdict.test.ts @@ -30,16 +30,38 @@ describe('verdict rules', () => { expect(computeVerdict({ agentExitCode: 0, verifications: [{ command: 'test', exitCode: 1, durationMs: 1, output: '', truncated: false }], files: [allowed], startDirty: false, findings: [] }).verdict).toBe('FAILED') }) - it('fails closed when required analysis evidence is incomplete', () => { - expect(computeVerdict({ agentExitCode: 0, verifications: [], files: [allowed], startDirty: false, findings: [], evidenceIssues: ['required analyzer secrets did not complete'] }).verdict).toBe('FAILED') + it('withholds PASS, without failing, when a required pass could not finish', () => { + const outcome = computeVerdict({ + agentExitCode: 0, + verifications: [], + files: [allowed], + startDirty: false, + findings: [], + evidenceIssues: [{ kind: 'partial', message: 'required analyzer secrets did not complete' }], + }) + expect(outcome.verdict).toBe('REVIEW_REQUIRED') const passes = [ { id: 'secrets', result: { findings: [], complete: false, detail: 'fixture failure' } }, { id: 'vulnerabilities', result: { findings: [], complete: false, detail: 'offline policy' } }, ] satisfies AnalyzerPass[] expect(analysisEvidenceIssues(passes, { discoveredFiles: 1, maxFiles: 10, truncated: false, textCandidates: 1, contentLoaded: 1, oversizedTextFiles: 0, unreadableTextFiles: 0, binaryTextFiles: 0 })) - .toEqual(['required analyzer secrets did not complete: fixture failure']) + .toEqual([{ kind: 'partial', message: 'required analyzer secrets did not complete: fixture failure' }]) + }) + + it('still fails closed when there is no evidence at all', () => { expect(analysisEvidenceIssues([], { discoveredFiles: 0, maxFiles: 10, truncated: false, textCandidates: 0, contentLoaded: 0, oversizedTextFiles: 0, unreadableTextFiles: 0, binaryTextFiles: 0 })) - .toEqual(['no required deterministic analyzer passes ran']) + .toEqual([{ kind: 'missing', message: 'no required deterministic analyzer passes ran' }]) + expect(analysisEvidenceIssues([{ id: 'secrets', result: { findings: [], complete: true } }], undefined)) + .toEqual([{ kind: 'missing', message: 'repository index did not report coverage' }]) + const outcome = computeVerdict({ + agentExitCode: 0, + verifications: [], + files: [allowed], + startDirty: false, + findings: [], + evidenceIssues: [{ kind: 'missing', message: 'no required deterministic analyzer passes ran' }], + }) + expect(outcome.verdict).toBe('FAILED') }) it('requires review when an incomplete baseline is fully repaired in the final tree', () => { @@ -49,11 +71,11 @@ describe('verdict rules', () => { files: [allowed], startDirty: false, findings: [], - baselineEvidenceIssues: ['1 apparent text file contained binary data'], + baselineEvidenceIssues: [{ kind: 'partial', message: '1 analyzable file could not be read' }], }) expect(result).toEqual({ verdict: 'REVIEW_REQUIRED', - reasons: ['baseline evidence limitation resolved in the final tree: 1 apparent text file contained binary data'], + reasons: ['baseline evidence limitation resolved in the final tree: 1 analyzable file could not be read'], }) }) diff --git a/packages/cli/test/unparsed-file-coverage.test.ts b/packages/cli/test/unparsed-file-coverage.test.ts new file mode 100644 index 0000000..b258209 Binary files /dev/null and b/packages/cli/test/unparsed-file-coverage.test.ts differ diff --git a/public/downloads/codetruss-cli-0.2.51.sbom.cdx.json b/public/downloads/codetruss-cli-0.2.51.sbom.cdx.json new file mode 100644 index 0000000..1abef22 --- /dev/null +++ b/public/downloads/codetruss-cli-0.2.51.sbom.cdx.json @@ -0,0 +1,170 @@ +{ + "$schema": "https://cyclonedx.org/schema/bom-1.6.schema.json", + "bomFormat": "CycloneDX", + "serialNumber": "urn:uuid:adb8b3be-45ad-577d-bf89-efd2410b1e21", + "specVersion": "1.6", + "version": 1, + "metadata": { + "component": { + "type": "application", + "bom-ref": "pkg:npm/%40codetruss/cli@0.2.51", + "name": "@codetruss/cli", + "version": "0.2.51", + "description": "Local-first scope, quality, and verification receipts for coding agents", + "licenses": [ + { + "license": { + "name": "CodeTruss CLI Proprietary License" + } + } + ], + "purl": "pkg:npm/%40codetruss/cli@0.2.51" + }, + "properties": [ + { + "name": "codetruss:distribution", + "value": "single-file JavaScript bundle" + }, + { + "name": "codetruss:runtimeDependencies", + "value": "0" + } + ] + }, + "components": [ + { + "type": "library", + "bom-ref": "pkg:npm/%40codetruss/analyzer-engine@0.1.0", + "name": "@codetruss/analyzer-engine", + "version": "0.1.0", + "licenses": [ + { + "license": { + "name": "CodeTruss CLI Proprietary License" + } + } + ], + "purl": "pkg:npm/%40codetruss/analyzer-engine@0.1.0", + "properties": [ + { + "name": "codetruss:bundled", + "value": "true" + } + ] + }, + { + "type": "library", + "bom-ref": "pkg:npm/balanced-match@4.0.4", + "name": "balanced-match", + "version": "4.0.4", + "licenses": [ + { + "license": { + "id": "MIT" + } + } + ], + "purl": "pkg:npm/balanced-match@4.0.4", + "properties": [ + { + "name": "codetruss:bundled", + "value": "true" + } + ] + }, + { + "type": "library", + "bom-ref": "pkg:npm/brace-expansion@5.0.9", + "name": "brace-expansion", + "version": "5.0.9", + "licenses": [ + { + "license": { + "id": "MIT" + } + } + ], + "purl": "pkg:npm/brace-expansion@5.0.9", + "properties": [ + { + "name": "codetruss:bundled", + "value": "true" + } + ] + }, + { + "type": "library", + "bom-ref": "pkg:npm/minimatch@10.2.6", + "name": "minimatch", + "version": "10.2.6", + "licenses": [ + { + "license": { + "id": "BlueOak-1.0.0" + } + } + ], + "purl": "pkg:npm/minimatch@10.2.6", + "properties": [ + { + "name": "codetruss:bundled", + "value": "true" + } + ] + }, + { + "type": "library", + "bom-ref": "pkg:npm/yaml@2.9.0", + "name": "yaml", + "version": "2.9.0", + "licenses": [ + { + "license": { + "id": "ISC" + } + } + ], + "purl": "pkg:npm/yaml@2.9.0", + "properties": [ + { + "name": "codetruss:bundled", + "value": "true" + } + ] + } + ], + "dependencies": [ + { + "ref": "pkg:npm/%40codetruss/analyzer-engine@0.1.0", + "dependsOn": [] + }, + { + "ref": "pkg:npm/%40codetruss/cli@0.2.51", + "dependsOn": [ + "pkg:npm/%40codetruss/analyzer-engine@0.1.0", + "pkg:npm/minimatch@10.2.6", + "pkg:npm/yaml@2.9.0" + ] + }, + { + "ref": "pkg:npm/balanced-match@4.0.4", + "dependsOn": [] + }, + { + "ref": "pkg:npm/brace-expansion@5.0.9", + "dependsOn": [ + "pkg:npm/balanced-match@4.0.4" + ] + }, + { + "ref": "pkg:npm/minimatch@10.2.6", + "dependsOn": [ + "pkg:npm/brace-expansion@5.0.9" + ] + }, + { + "ref": "pkg:npm/yaml@2.9.0", + "dependsOn": [] + } + ] +} diff --git a/public/downloads/codetruss-cli-0.2.51.tgz b/public/downloads/codetruss-cli-0.2.51.tgz new file mode 100644 index 0000000..0f69844 Binary files /dev/null and b/public/downloads/codetruss-cli-0.2.51.tgz differ diff --git a/public/downloads/codetruss-cli-0.2.51.tgz.sha256 b/public/downloads/codetruss-cli-0.2.51.tgz.sha256 new file mode 100644 index 0000000..96eefbf --- /dev/null +++ b/public/downloads/codetruss-cli-0.2.51.tgz.sha256 @@ -0,0 +1 @@ +0dbd333a638376aa68e4a2f330c6d59cd0e852700104a7dd5444232a2278a862 codetruss-cli-0.2.51.tgz diff --git a/public/downloads/codetruss-cli-latest.json b/public/downloads/codetruss-cli-latest.json index d7968c7..c55b013 100644 --- a/public/downloads/codetruss-cli-latest.json +++ b/public/downloads/codetruss-cli-latest.json @@ -1,13 +1,13 @@ { "name": "@codetruss/cli", - "version": "0.2.50", - "url": "/downloads/codetruss-cli-0.2.50.tgz", + "version": "0.2.51", + "url": "/downloads/codetruss-cli-0.2.51.tgz", "latestUrl": "/downloads/codetruss-cli-latest.tgz", - "sha256": "1f5fc0d4633cda7d82f2c07e315496a9608bc15f01aa14cfc10aebbd88c977f5", - "sbomUrl": "/downloads/codetruss-cli-0.2.50.sbom.cdx.json", - "sbomSha256": "4c17029e967a23a77958147c43dcf21ea0d919c17c12cab70ebbf2334c16d632", + "sha256": "0dbd333a638376aa68e4a2f330c6d59cd0e852700104a7dd5444232a2278a862", + "sbomUrl": "/downloads/codetruss-cli-0.2.51.sbom.cdx.json", + "sbomSha256": "4f01e0111543c4d624e365d1b889377bb2a560048549eac78bb52d0eaedc9ed7", "node": ">=20.9.0", "repository": "https://github.com/CodeTruss/codetruss-cli", - "releaseUrl": "https://github.com/CodeTruss/codetruss-cli/releases/tag/v0.2.50", - "attestationCommand": "gh attestation verify codetruss-cli-0.2.50.tgz --repo CodeTruss/codetruss-cli" + "releaseUrl": "https://github.com/CodeTruss/codetruss-cli/releases/tag/v0.2.51", + "attestationCommand": "gh attestation verify codetruss-cli-0.2.51.tgz --repo CodeTruss/codetruss-cli" } diff --git a/public/downloads/codetruss-cli-latest.sbom.cdx.json b/public/downloads/codetruss-cli-latest.sbom.cdx.json index e360c3d..1abef22 100644 --- a/public/downloads/codetruss-cli-latest.sbom.cdx.json +++ b/public/downloads/codetruss-cli-latest.sbom.cdx.json @@ -1,15 +1,15 @@ { "$schema": "https://cyclonedx.org/schema/bom-1.6.schema.json", "bomFormat": "CycloneDX", - "serialNumber": "urn:uuid:0169b7be-3063-50b2-8e0c-ffed84cfaac3", + "serialNumber": "urn:uuid:adb8b3be-45ad-577d-bf89-efd2410b1e21", "specVersion": "1.6", "version": 1, "metadata": { "component": { "type": "application", - "bom-ref": "pkg:npm/%40codetruss/cli@0.2.50", + "bom-ref": "pkg:npm/%40codetruss/cli@0.2.51", "name": "@codetruss/cli", - "version": "0.2.50", + "version": "0.2.51", "description": "Local-first scope, quality, and verification receipts for coding agents", "licenses": [ { @@ -18,7 +18,7 @@ } } ], - "purl": "pkg:npm/%40codetruss/cli@0.2.50" + "purl": "pkg:npm/%40codetruss/cli@0.2.51" }, "properties": [ { @@ -139,7 +139,7 @@ "dependsOn": [] }, { - "ref": "pkg:npm/%40codetruss/cli@0.2.50", + "ref": "pkg:npm/%40codetruss/cli@0.2.51", "dependsOn": [ "pkg:npm/%40codetruss/analyzer-engine@0.1.0", "pkg:npm/minimatch@10.2.6", diff --git a/public/downloads/codetruss-cli-latest.tgz b/public/downloads/codetruss-cli-latest.tgz index 89af684..0f69844 100644 Binary files a/public/downloads/codetruss-cli-latest.tgz and b/public/downloads/codetruss-cli-latest.tgz differ diff --git a/public/downloads/codetruss-cli-latest.tgz.sha256 b/public/downloads/codetruss-cli-latest.tgz.sha256 index f2a3138..938921c 100644 --- a/public/downloads/codetruss-cli-latest.tgz.sha256 +++ b/public/downloads/codetruss-cli-latest.tgz.sha256 @@ -1 +1 @@ -1f5fc0d4633cda7d82f2c07e315496a9608bc15f01aa14cfc10aebbd88c977f5 codetruss-cli-latest.tgz +0dbd333a638376aa68e4a2f330c6d59cd0e852700104a7dd5444232a2278a862 codetruss-cli-latest.tgz diff --git a/release-reference.json b/release-reference.json index 28f7280..2b39a47 100644 --- a/release-reference.json +++ b/release-reference.json @@ -1,8 +1,8 @@ { "schemaVersion": 1, - "version": "0.2.50", - "websiteArchive": "https://codetruss.com/downloads/codetruss-cli-0.2.50.tgz", - "archiveSha256": "1f5fc0d4633cda7d82f2c07e315496a9608bc15f01aa14cfc10aebbd88c977f5", - "sbomSha256": "4c17029e967a23a77958147c43dcf21ea0d919c17c12cab70ebbf2334c16d632", - "bundleSha256": "7eaf6cb2f908dbfb4f89ef90b0de795340e37b6b6ca8051b01ebcfda403dbfae" + "version": "0.2.51", + "websiteArchive": "https://codetruss.com/downloads/codetruss-cli-0.2.51.tgz", + "archiveSha256": "0dbd333a638376aa68e4a2f330c6d59cd0e852700104a7dd5444232a2278a862", + "sbomSha256": "4f01e0111543c4d624e365d1b889377bb2a560048549eac78bb52d0eaedc9ed7", + "bundleSha256": "1dd21a129123693dc6dc3b7b6706ca1abf40b97eafc75da5e04e41c0af2722dd" }