diff --git a/CHANGELOG.md b/CHANGELOG.md index 87727a7..951f3e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ CodeTruss CLI follows semantic versioning. Release artifacts and their SHA-256 checksums are published at . -The current public release is [v0.2.38 on GitHub](https://github.com/DeliriumPulse/codetruss-cli/releases/tag/v0.2.38), +The current public release is [v0.2.39 on GitHub](https://github.com/DeliriumPulse/codetruss-cli/releases/tag/v0.2.39), distributed from . The npm `latest` tag is still [`@codetruss/cli@0.2.24`](https://www.npmjs.com/package/@codetruss/cli/v/0.2.24): @@ -16,6 +16,69 @@ were superseded before distribution. No unreleased changes. +## 0.2.39 — 2026-08-07 + +- **Two analyzers join the registry, which now holds 15.** Both come from a + design study that ran candidate rules against eight real repositories and + kept only what survived. Everything they emit is `INFO` or `LOW`, and + `computeVerdict` escalates only at `MEDIUM`, so **neither can turn a PASS into + a REVIEW_REQUIRED or a FAILED**. A comment that repeats the line below it is + not a reason to stop an agent mid-turn. +- **Comment Signal (`comment-slop`)** measures each file against the + repository's own commenting baseline. It reports a file carrying three or more + standalone single-line comments whose words already appear on the statement + beneath them, and a file carrying two or more comments that narrate an edit + (`// Updated to use the new auth middleware`), address the reader, claim + credit for the code, or describe the work as provisional (`// In a real app + you would verify this`) — the last of which no TODO scan can see, because it + carries no marker. Comment *density* is reported as a metric and is never a + finding: across the study the most densely commented codebase produced zero + restating comments and the sparsest produced sixty-one, so a density rule + would penalise exactly the code worth rewarding. Tests, generated and + vendored content, scaffolded config, migrations, licensed files, and files + under 25 code lines are all out of scope, and the analyzer covers only the + eleven languages it has a comment lexer for. +- **Speculative Structure (`overengineering`)** reports exported values that + appear in no other indexed file, tests included, and `catch` blocks whose + entire body logs an error and rethrows it unchanged. Export findings are + worded as candidates because a symbol reached through a dynamic import or a + path built from strings looks identical to this pass. The rule is + barrel-aware, skips ORM schema modules, framework convention exports, runner + and Pages Router paths, and does not run at all against a library, whose + exported surface is its product. +- **A Convex deployment is not a pile of exports nobody consumes.** Convex + bundles a functions directory and addresses its modules by path at runtime, so + `export` *is* the registration and the only reference is a string no static + pass can follow. Those directories are now excluded, detected from the + toolchain rather than the directory name — Convex allows renaming it, and its + modules are commonly imported through a path alias no specifier match would + catch. Two structural signals are required together: the `convex` dependency + in a manifest, and the `_generated/{api,server}` pair that `convex dev` emits + into the deployment root. A directory merely *named* `convex` is still swept. +- **Speculative Structure states what it cannot see.** Single-implementation + interfaces, options nobody overrides, and parameters never varied at any call + site need the cross-file symbol graph, which does not run locally. The + receipt's "What did not run" block now names them, because silence there would + read as "no over-engineering found". +- **Receipts disclose the new count without rewriting the old ones.** The local + analysis profile becomes `local-registry-v3`. Receipts signed under + `local-registry-v2` keep a frozen renderer that reproduces their + "13 deterministic registry analyzers" wording byte for byte, exactly as + `local-registry-v1` receipts already did, so every receipt on disk still + verifies. Only v3 receipts say 15. The hosted receipt schema accepts all three + profile versions and rejects any fourth. +- A new **Comment signal** section on the receipt reports the repository's + median comment ratio, how many comments restate or narrate, and how many + files carry enough of either shape to be reported. It counts comments and + reports files, and says which is which: a file holding two restating comments + is under the reporting threshold, and a receipt that called it clean on that + basis would be stating something untrue. It is rendered from pass metrics + rather than findings — a + repository-level "nothing restates the code" finding fingerprints identically + in the baseline and final trees, so the delta would file it under recurring + and it could never reach a hook receipt. The section emits nothing when those + metrics are absent, so receipts signed before this release render unchanged. + ## 0.2.38 — 2026-08-07 - **A lone changed file no longer seats its own directory as inferred scope diff --git a/README.md b/README.md index c5529b7..7cc7c11 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ The deterministic first-pass verification gate for AI-written code. An agent finishes a change. Something has to look at it before a human does. CodeTruss Boundary is that first pass: it captures an exact before/after Git -evidence pair, checks the change against the task contract you declared, runs 13 +evidence pair, checks the change against the task contract you declared, runs 15 deterministic analyzers, a local security pass, and your own project checks, then signs a `PASS`, `REVIEW_REQUIRED`, or `FAILED` receipt you can re-verify later. @@ -44,7 +44,7 @@ To pin an exact version, install the immutable archive directly: ```bash npm install --global --ignore-scripts --no-audit --no-fund \ - https://codetruss.com/downloads/codetruss-cli-0.2.38.tgz + https://codetruss.com/downloads/codetruss-cli-0.2.39.tgz ``` The `@codetruss/cli` package on the npm registry is published as a separate, @@ -175,7 +175,10 @@ The verdict is not a confidence score. Receipts are written as Markdown and JSON next to hashed patch evidence, and can be rechecked later with `codetruss verify latest`. Every receipt states the detection gaps in its own body, so a `PASS` is never mistaken for a security -clearance. Abridged from a real 0.2.36 run: +clearance. Abridged from a real 0.2.36 run, so it prints the `local-registry-v2` +profile and its thirteen-analyzer wording. A run on this release prints +`local-registry-v3` and fifteen; 0.2.39 keeps the v2 renderer frozen so the +receipt below still verifies byte-for-byte as signed: ```markdown # CodeTruss receipt — REVIEW_REQUIRED @@ -313,8 +316,8 @@ clean global install. Verify a downloaded release yourself: ```bash -gh attestation verify codetruss-cli-0.2.38.tgz --repo DeliriumPulse/codetruss-cli -shasum -a 256 -c codetruss-cli-0.2.38.tgz.sha256 +gh attestation verify codetruss-cli-0.2.39.tgz --repo DeliriumPulse/codetruss-cli +shasum -a 256 -c codetruss-cli-0.2.39.tgz.sha256 ``` Maintainers should follow [docs/RELEASE.md](docs/RELEASE.md). Tag-driven GitHub diff --git a/packages/analyzer-engine/src/comment-slop.ts b/packages/analyzer-engine/src/comment-slop.ts new file mode 100644 index 0000000..6e997ea --- /dev/null +++ b/packages/analyzer-engine/src/comment-slop.ts @@ -0,0 +1,435 @@ +import { + annotatedAnalyzerOutput, + incompleteAnalyzerOutput, + type Analyzer, + type AnalyzerFinding, +} from './types' +import { classifyLines, commentSyntaxFor, contentWords, dataBlockIds, type ClassifiedLine } from './comments' + +/** + * `looksGenerated` reads five lines, and codegen that leads with an + * eslint-disable banner declares itself lower down. Comment-shape rules are the + * most sensitive to that gap — a generated route tree measured as a redundant + * comment hit — so they check a wider window locally rather than widening the + * shared helper, which feeds LOC totals and every other analyzer. + */ +const GENERATED_WINDOW = 30 +const GENERATED_DECLARATION = + /auto-?generated|@generated|generated by|do not edit|this file (?:was|is) automatically generated/i + +/** Paths whose comments were written by a scaffolder, not by the author. */ +const SCAFFOLD_PATH = [ + /(?:^|\/)(?:migrations|alembic|versions|_generated|\.storybook)\//, + /\.config\.[^/]+$/, + /(?:^|\/)env\.py$/, + /\.gen\.[jt]sx?$/, + /_pb\.[jt]s$/, + /\.pb\.go$/, +] + +const LICENCE_HEADER = /Copyright|SPDX-License|Licensed under|Apache License|MIT License/i +const LICENCE_WINDOW_BYTES = 1200 + +/** A file needs enough code for its comment ratio to mean anything. */ +const MIN_CODE_LINES = 25 +/** + * Density floor. Five of eight measured repositories have a comment-to-code + * median of exactly 0.000, where `3 x median` would make every commented file + * an outlier. The floor is what stops that, not a tuning knob. + */ +const DENSITY_FLOOR = 0.25 +const DENSITY_MULTIPLE = 3 +const DENSITY_MIN_COMMENTS = 12 + +const REDUNDANT_FILE_THRESHOLD = 3 +const REDUNDANT_LOW_THRESHOLD = 5 +const NARRATION_FILE_THRESHOLD = 2 +const NARRATION_LOW_THRESHOLD = 4 + +const MAX_COMMENT_CHARS = 90 +const OVERLAP_RATIO = 0.6 + +/** Tool pragmas and tracker markers are instructions, not commentary. */ +const DIRECTIVE = + /^(?:(?:TODO|FIXME|HACK|XXX|NOTE|eslint|prettier|biome|noqa|c8|istanbul|nosec|pylint|ruff|Deprecated)\b|@ts-|type:)/i +const BANNER = /^[-=*#\s]+$/ + +/** + * A comment naming the thing declared beneath it is a section label, not a + * restatement — `// Delete API Key` above `const deleteApiKey = …` is how the + * ecosystem writes headings. With the explanatory-marker test below, this + * suppresses 25%-78% of naive matches and is what takes a heavily documented + * codebase to zero. + */ +const DECLARATION = new RegExp([ + String.raw`^(?:(?:export|default|public|private|protected|static|async|abstract|declare|pub|final)\s+)*(?:function|class|interface|type|enum|struct|impl|trait|def|func|fn|const|let|var|val)\b`, + String.raw`^(?:export\s+)?(?:const|let|var)\s+\w+\s*[:=]\s*(?:async\s*)?(?:\([^)]*\)|\w+)\s*(?::[^=]*)?=>`, + String.raw`^\w+\s*[:(]\s*(?:async\s*)?(?:function|\()`, + // A class or object method signature opening its body. `async createConnection( + // params: any) {` is the same section-label shape as a named function, and the + // control-flow lookahead keeps `if (…) {` reportable. + String.raw`^(?!(?:if|for|while|switch|catch|do|else|return|match|with|using|try|foreach|unless|elif)\b)(?:(?:public|private|protected|static|async|abstract|override|get|set)\s+)*[\w$]+\s*(?:<[^>]*>)?\s*\([^)]*\)\s*(?::\s*[^{]*)?\{$`, + String.raw`^@\w+`, + // An object-literal key opening a block is a pseudo-declaration; treating it + // as code removed a measured false positive. + String.raw`^['"]?[\w$-]+['"]?\s*:\s*\{`, +].join('|')) + +/** A comment that says why is doing its job, whatever words it shares with the code. */ +const EXPLANATORY = + /\b(?:because|so that|since|otherwise|avoid|must|cannot|only|instead|note|caution|workaround|bug|see)\b|https?:/i + +/** + * String and regex literals, blanked. + * + * A comment whose only overlap with the line below it is the CONTENT of a + * literal is naming the datum, not restating the statement. Measured on this + * codebase: `// C# Process.Start` above `if (m === 'start' && /process/.test(…))` + * is a language label on one branch of a dispatch table, and the words it + * 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, ' ') +} + +/** + * Comment shapes that describe the edit, the reader, or the author's opinion of + * the code rather than the code itself. Measured at one false positive across + * 3,371 comment lines, and that one was a missing `\s` after the verb. + */ +const NARRATION_TAGS = [ + { + tag: 'change-narration', + // Anchored: these only mean anything at the start of a sentence. + opening: [ + /^(?:added|updated|changed|modified|removed|refactored|fixed|renamed|moved|replaced|improved|introduced|switched|migrated|converted)\s/i, + /^(?:this (?:is )?(?:now|was)|no longer|previously|we (?:now|used to))\b/i, + ], + patterns: [ + /\bas requested\b/i, + /\bper (?:the )?(?:request|instructions)\b/i, + ], + }, + { + tag: 'tutorial-voice', + opening: [ + /^(?:now we|here we|first,? we|next,? we|then we|finally,? we|let'?s|we'?ll|you can (?:now|also)|notice that|as you can see)\b/i, + ], + patterns: [], + }, + { + tag: 'value-claim', + opening: [], + patterns: [ + /\bfor better (?:performance|readability|maintainability)\b/i, + /\bfor improved\b/i, + /\bto ensure (?:type safety|consistency)\b/i, + /\bbest practice\b/i, + /\bproduction[- ]ready\b/i, + /\brobustness\b/i, + /\bcomprehensive\b/i, + /\bgracefully handle\b/i, + ], + }, + { + tag: 'placeholder-deferral', + opening: [], + patterns: [ + /\bin a real (?:app|application|implementation|system|world)\b/i, + /\bfor now,/i, + /\bplaceholder\b/i, + /\bmock(?:ed)? (?:data|implementation)\b/i, + /\bimplement(?: this)? later\b/i, + /\byou (?:would|should|may) want to\b/i, + /\breplace (?:this )?with (?:your|a real)\b/i, + ], + }, +] as const + +interface RedundantHit { + line: number + comment: string + code: string +} + +interface NarrationHit { + line: number + comment: string + tag: string +} + +interface FileMeasurement { + path: string + ratio: number + commentLines: number + codeLines: number + redundant: RedundantHit[] + narration: NarrationHit[] +} + +function excluded(path: string, content: string): boolean { + if (!commentSyntaxFor(path)) return true + if (SCAFFOLD_PATH.some((pattern) => pattern.test(path))) return true + if (GENERATED_DECLARATION.test(content.split('\n', GENERATED_WINDOW).join('\n'))) return true + return LICENCE_HEADER.test(content.slice(0, LICENCE_WINDOW_BYTES)) +} + +function truncate(value: string, limit: number): string { + return value.length <= limit ? value : `${value.slice(0, limit - 1)}…` +} + +function nextCodeLine(lines: ClassifiedLine[], from: number): ClassifiedLine | null { + for (let i = from; i < lines.length; i++) { + if (lines[i].kind === 'blank') continue + return lines[i].kind === 'code' ? lines[i] : null + } + return null +} + +/** + * A single-line comment whose words are already on the statement below it. + * + * The comment must stand alone: a run of `//` lines is one wrapped paragraph, + * and its last line is a sentence fragment. Comparing a fragment against the + * code below it measures nothing — measured on this codebase, the only + * restatement candidate outside a dispatch table was the tail of a three-line + * comment explaining a transaction. The rule already refuses to look past a + * following comment line; this is the same test on the other side. + */ +function redundantHit(lines: ClassifiedLine[], raw: string[], i: number): RedundantHit | null { + const line = lines[i] + if (line.kind !== 'line') return null + const above = lines[i - 1] + if (above && above.kind !== 'code' && above.kind !== 'blank') return null + const text = line.text + if (!text || text.length > MAX_COMMENT_CHARS) return null + if (DIRECTIVE.test(text) || BANNER.test(text)) return null + if (EXPLANATORY.test(text)) return null + + const below = nextCodeLine(lines, i + 1) + if (!below || DECLARATION.test(below.code)) return null + + const commentWords = contentWords(text) + if (commentWords.size < 2) return null + const codeWords = contentWords(below.code) + let shared = 0 + for (const word of commentWords) if (codeWords.has(word)) shared++ + if (shared / commentWords.size < OVERLAP_RATIO) return null + + const identifierWords = contentWords(withoutLiterals(below.code)) + if (![...commentWords].some((word) => identifierWords.has(word))) return null + + return { line: i + 1, comment: raw[i].trim(), code: below.code } +} + +/** + * Whether a comment line begins a sentence rather than continuing a wrapped + * one. Without this, the wrapped second line of "…this turn's own task text and + * / changed files did." reads as a change-narration comment starting with + * "changed". Measured on this codebase: five such continuations, no real hits. + */ +function opensSentence(lines: ClassifiedLine[], index: number): boolean { + const previous = lines[index - 1] + if (!previous || previous.kind === 'code' || previous.kind === 'blank') return true + return previous.text.length === 0 || /[.!?:]$/.test(previous.text) +} + +function narrationHit(lines: ClassifiedLine[], raw: string, index: number): NarrationHit | null { + const line = lines[index] + if (line.kind === 'code' || line.kind === 'blank') return null + const text = line.text + if (!text || DIRECTIVE.test(text)) return null + const sentenceStart = opensSentence(lines, index) + for (const family of NARRATION_TAGS) { + const matched = family.patterns.some((pattern) => pattern.test(text)) + || (sentenceStart && family.opening.some((pattern) => pattern.test(text))) + if (matched) return { line: index + 1, comment: raw.trim(), tag: family.tag } + } + return null +} + +function measure(path: string, content: string): FileMeasurement | null { + const lines = classifyLines(path, content) + if (lines.length === 0) return null + const raw = content.split('\n') + const dataBlocks = dataBlockIds(lines) + + let commentLines = 0 + let codeLines = 0 + for (const line of lines) { + if (line.kind === 'blank') continue + if (line.kind === 'code') codeLines++ + else if (!dataBlocks.has(line.block)) commentLines++ + } + if (codeLines < MIN_CODE_LINES) return null + + const redundant: RedundantHit[] = [] + const narration: NarrationHit[] = [] + for (let i = 0; i < lines.length; i++) { + if (lines[i].block !== -1 && dataBlocks.has(lines[i].block)) continue + const restating = redundantHit(lines, raw, i) + if (restating) redundant.push(restating) + const narrating = narrationHit(lines, raw[i], i) + if (narrating) narration.push(narrating) + } + + return { path, ratio: commentLines / codeLines, commentLines, codeLines, redundant, narration } +} + +function median(values: number[]): number { + if (values.length === 0) return 0 + const sorted = [...values].sort((left, right) => left - right) + const middle = Math.floor(sorted.length / 2) + return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle] +} + +function percentile(values: number[], fraction: number): number { + if (values.length === 0) return 0 + const sorted = [...values].sort((left, right) => left - right) + return sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * fraction))] +} + +function round(value: number): number { + return Math.round(value * 1000) / 1000 +} + +/** + * Comment shape, measured against the repository's own baseline. + * + * The name is deliberate: this pass measures a property of the comments. It + * never asserts who or what wrote them, because that is not provable from the + * text, and a signed receipt may not claim more than it measured. + * + * Comment DENSITY is a metric here and never a finding. Measured across eight + * repositories, the most densely commented codebase produced zero redundant + * comments while the sparsest produced sixty-one: density and restatement are + * close to anti-correlated, so a density rule would penalise exactly the + * codebases worth rewarding. + */ +export const commentSlopAnalyzer: Analyzer = { + id: 'comment-slop', + name: 'Comment Signal', + description: + "Compares each file's commenting against this repository's own baseline and " + + 'flags comments that restate the code or narrate the edit rather than explain it.', + async run(index) { + const candidates = index.files.filter( + (file) => + file.content + && (file.kind === 'source' || file.kind === 'component' || file.kind === 'route') + && !excluded(file.path, file.content), + ) + + const candidateLimit = 2000 + const measurements: FileMeasurement[] = [] + for (const file of candidates.slice(0, candidateLimit)) { + const measurement = measure(file.path, file.content!) + if (measurement) measurements.push(measurement) + } + + const ratios = measurements.map((item) => item.ratio) + const ratioMedian = median(ratios) + const densityThreshold = Math.max(DENSITY_MULTIPLE * ratioMedian, DENSITY_FLOOR) + 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) + const narrating = measurements + .filter((item) => item.narration.length >= NARRATION_FILE_THRESHOLD) + .sort((left, right) => right.narration.length - left.narration.length) + + const findingLimit = 10 + for (const item of restating.slice(0, findingLimit)) { + const outlier = isDensityOutlier(item) + const low = item.redundant.length >= REDUNDANT_LOW_THRESHOLD && outlier + const sample = item.redundant[0] + findings.push({ + category: 'DOCUMENTATION', + severity: low ? 'LOW' : 'INFO', + title: `${item.redundant.length} comments restate the code in ${item.path}`, + description: + `${item.redundant.length} single-line comments in ${item.path} repeat words that already appear on the line ` + + `beneath them — for example line ${sample.line}, "${truncate(sample.comment, 80)}" above ` + + `${truncate(sample.code, 80)}. This file comments at ${round(item.ratio)} lines per code line against a ` + + `repository median of ${round(ratioMedian)}.` + + (outlier ? " It is also a comment-density outlier against this repository's own baseline." : '') + + ' Comments that restate the statement below them go stale silently, because nothing fails when the code' + + ' changes and the comment does not.', + filePath: item.path, + line: sample.line, + suggestion: 'Keep the comments that say why; delete the ones that repeat what.', + 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 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({ + category: 'DOCUMENTATION', + severity: low ? 'LOW' : 'INFO', + title: allPlaceholder + ? `${item.narration.length} comments in ${item.path} describe unfinished work` + : `${item.narration.length} comments in ${item.path} describe the edit, not the code`, + description: allPlaceholder + ? `${item.path} contains ${item.narration.length} comments that describe the implementation as provisional — ` + + `line ${sample.line}, "${truncate(sample.comment, 80)}"${others ? `, and ${others} other${others > 1 ? 's' : ''}` : ''}. ` + + 'Unlike a TODO marker, these are invisible to the TODO tracker and to any grep for deferred work.' + : `${item.narration.length} comments in ${item.path} narrate a change or address the reader rather than ` + + `document behaviour — line ${sample.line}, "${truncate(sample.comment, 80)}"` + + `${others ? `, and ${others} other${others > 1 ? 's' : ''}` : ''}. ` + + 'Version control already records what changed; a comment that describes an edit is wrong the moment the' + + ' next edit lands.', + filePath: item.path, + line: sample.line, + suggestion: allPlaceholder + ? 'Finish the work or mark it with a TODO so the deferred-work tracker can see it.' + : 'Move change rationale to the commit message and keep the comment describing what the code does now.', + impactScore: low ? 25 : 15, + effort: 'low', + metadata: { count: item.narration.length, sample: item.narration.slice(0, 5) }, + }) + } + + const metrics = { + eligibleFiles: measurements.length, + commentRatioMedian: round(ratioMedian), + commentRatioP90: round(percentile(ratios, 0.9)), + densityOutlierFiles: measurements.filter(isDensityOutlier).length, + redundantComments: measurements.reduce((total, item) => total + item.redundant.length, 0), + redundantCommentFiles: restating.length, + narrationComments: measurements.reduce((total, item) => total + item.narration.length, 0), + narrationCommentFiles: narrating.length, + } + + // Only the candidate-file cap is real coverage loss. The per-rule finding + // cap bounds output over an analysis that still measured every candidate. + if (candidates.length > candidateLimit) { + return incompleteAnalyzerOutput(findings, { + truncated: true, + detail: `Comment analysis hit a candidate bound (${candidates.length} candidate files).`, + metrics: { ...metrics, candidates: candidates.length, candidateLimit }, + }) + } + 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 }, + }) + } + return annotatedAnalyzerOutput(findings, { + metrics: { ...metrics, candidates: candidates.length, candidateLimit }, + }) + }, +} diff --git a/packages/analyzer-engine/src/comments.ts b/packages/analyzer-engine/src/comments.ts new file mode 100644 index 0000000..e6ba622 --- /dev/null +++ b/packages/analyzer-engine/src/comments.ts @@ -0,0 +1,330 @@ +/** + * Per-line comment classification, shared by the comment-shape analyzers. + * + * `IndexedFile.loc` counts non-blank lines and is NOT comment-stripped, so any + * rule that reasons about commenting has to compute its own code/comment split. + * A naive block tracker gets this wrong in ways that matter: a file holding six + * pasted query plans inside `/* … *\/` blocks measures as almost pure comment, + * and flagging it would punish the single most deliberate file in a repository. + * + * The classifier is a character scanner rather than a set of line regexes + * because `//` inside a URL, a template literal spanning lines, and a block + * comment opened and closed on one line all defeat line-at-a-time matching. + * + * Known limit: a regex literal containing `/*` (`/a\/*b/`) is read as opening a + * block comment. Widening the scanner to track regex-vs-division context needs + * expression state this pass does not keep, and the shape is vanishingly rare + * next to the string and template cases it does handle. + */ + +/** How a line reads once comments and strings are separated from code. */ +export type CommentLineKind = 'blank' | 'code' | 'line' | 'block' | 'doc' + +export interface ClassifiedLine { + kind: CommentLineKind + /** Comment prose with markers stripped. Empty on code and blank lines. */ + text: string + /** Code with any trailing comment removed. Empty on comment and blank lines. */ + code: string + /** Id of the block comment covering this line, or -1 outside one. */ + block: number +} + +/** The comment syntax families this pass understands. */ +type Syntax = 'c-like' | 'python' + +/** + * Extensions with a classifier. Everything else returns no lines and is + * silently out of scope — the analyzers simply do not claim coverage they lack. + */ +const SYNTAX_BY_EXTENSION: Record = { + '.ts': 'c-like', + '.tsx': 'c-like', + '.js': 'c-like', + '.jsx': 'c-like', + '.mjs': 'c-like', + '.cjs': 'c-like', + '.go': 'c-like', + '.rs': 'c-like', + '.java': 'c-like', + '.cs': 'c-like', + '.py': 'python', +} + +export function commentSyntaxFor(path: string): Syntax | null { + const dot = path.lastIndexOf('.') + if (dot === -1) return null + return SYNTAX_BY_EXTENSION[path.slice(dot).toLowerCase()] ?? null +} + +type ScanState = + | 'code' + | 'line-comment' + | 'block-comment' + | 'single' + | 'double' + | 'template' + | 'py-triple-double' + | 'py-triple-single' + +interface LineScan { + /** What the first non-whitespace character on the line belongs to. */ + first: CommentLineKind + /** The line's characters that are code, with comment spans blanked out. */ + code: string + block: number +} + +/** + * Everything the scanner carries. State that survives a newline (an open block + * comment, a template literal) sits beside per-line state, because the whole + * point of a scanner over line regexes is that the two interact. + */ +interface Scanner { + syntax: Syntax + state: ScanState + docBlock: boolean + blockId: number + blockCounter: number + /** The leading token of the current line has been classified. */ + seen: boolean + /** Code text accumulated for the current line, comment spans omitted. */ + code: string + scan: LineScan +} + +function isSpace(char: string): boolean { + return char === ' ' || char === '\t' || char === '\r' +} + +/** Record what the line's leading token is, the first time one is seen. */ +function openLine(s: Scanner, kind: CommentLineKind, block: number): void { + if (s.seen) return + s.scan.first = kind + s.scan.block = block + s.seen = true +} + +function openBlock(s: Scanner, doc: boolean): void { + s.docBlock = doc + s.blockId = s.blockCounter++ + openLine(s, doc ? 'doc' : 'block', s.blockId) +} + +function closeBlock(s: Scanner): void { + s.state = 'code' + s.docBlock = false + s.blockId = -1 +} + +/** One character in `code` state. Returns the column to resume at. */ +function stepCode(s: Scanner, line: string, col: number): number { + const char = line[col] + const next = line[col + 1] + if (isSpace(char)) { + s.code += char + return col + } + if (s.syntax === 'c-like' && char === '/' && next === '/') { + const third = line[col + 2] + openLine(s, third === '/' || third === '!' ? 'doc' : 'line', -1) + s.state = 'line-comment' + return col + } + if (s.syntax === 'c-like' && char === '/' && next === '*') { + // `/**` opens a doc block, but `/**/` is an empty ordinary comment. + openBlock(s, line[col + 2] === '*' && line[col + 3] !== '/') + s.state = 'block-comment' + return col + 1 + } + if (s.syntax === 'python' && char === '#') { + openLine(s, 'line', -1) + s.state = 'line-comment' + return col + } + if (s.syntax === 'python' && (char === '"' || char === "'") && next === char && line[col + 2] === char) { + // A triple-quoted string opening a statement is a docstring; one used as a + // value is an ordinary string and counts as code. + if (s.seen) { + s.docBlock = false + s.code += char + } else { + openBlock(s, true) + } + s.seen = true + s.state = char === '"' ? 'py-triple-double' : 'py-triple-single' + return col + 2 + } + openLine(s, 'code', -1) + s.code += char + if (char === "'") s.state = 'single' + else if (char === '"') s.state = 'double' + else if (char === '`' && s.syntax === 'c-like') s.state = 'template' + return col +} + +/** One character inside a `/* … *\/` block. */ +function stepBlock(s: Scanner, line: string, col: number): number { + const char = line[col] + if (!isSpace(char)) openLine(s, s.docBlock ? 'doc' : 'block', s.blockId) + if (char === '*' && line[col + 1] === '/') { + closeBlock(s) + return col + 1 + } + return col +} + +/** One character inside a Python triple-quoted string. */ +function stepTriple(s: Scanner, line: string, col: number): number { + const char = line[col] + const quote = s.state === 'py-triple-double' ? '"' : "'" + if (!isSpace(char) && !s.seen) { + if (s.docBlock) openLine(s, 'doc', s.blockId) + else { + openLine(s, 'code', -1) + s.code += char + } + } + if (char === quote && line[col + 1] === quote && line[col + 2] === quote) { + closeBlock(s) + return col + 2 + } + return col +} + +/** One character inside an ordinary string or template literal. */ +function stepString(s: Scanner, line: string, col: number): number { + const char = line[col] + // An escape consumes the character after it, so a `\'` never closes the literal. + if (char === '\\') { + s.code += char + (line[col + 1] ?? '') + return col + 1 + } + s.code += char + const quote = s.state === 'single' ? "'" : s.state === 'double' ? '"' : '`' + if (char === quote) s.state = 'code' + return col +} + +function scanLine(s: Scanner, line: string): void { + for (let col = 0; col < line.length; col++) { + if (s.state === 'code') col = stepCode(s, line, col) + else if (s.state === 'block-comment') col = stepBlock(s, line, col) + else if (s.state === 'py-triple-double' || s.state === 'py-triple-single') col = stepTriple(s, line, col) + else if (s.state !== 'line-comment') col = stepString(s, line, col) + // `line-comment` runs to the end of the line and contributes nothing. + } + s.scan.code = s.code + // A line comment ends at the newline; every multi-line state carries over. An + // unterminated single-line string is a syntax error, not a continuation. + if (s.state === 'line-comment' || s.state === 'single' || s.state === 'double') s.state = 'code' +} + +/** + * Split a file into classified lines. + * + * Returns an empty array for a language with no classifier, which callers read + * as "not analyzable" rather than "no comments". + */ +export function classifyLines(path: string, content: string): ClassifiedLine[] { + const syntax = commentSyntaxFor(path) + if (!syntax) return [] + + const raw = content.split('\n') + const scans: LineScan[] = raw.map(() => ({ first: 'blank' as CommentLineKind, code: '', block: -1 })) + const scanner: Scanner = { + syntax, state: 'code', docBlock: false, blockId: -1, blockCounter: 0, + seen: false, code: '', scan: scans[0], + } + + for (let row = 0; row < raw.length; row++) { + scanner.scan = scans[row] + scanner.seen = false + scanner.code = '' + scanLine(scanner, raw[row]) + } + + return raw.map((line, row) => { + const scan = scans[row] + if (line.trim().length === 0) return { kind: 'blank' as const, text: '', code: '', block: -1 } + if (scan.code.trim().length > 0) return { kind: 'code' as const, text: '', code: scan.code.trim(), block: -1 } + if (scan.first === 'code' || scan.first === 'blank') return { kind: 'code' as const, text: '', code: line.trim(), block: -1 } + return { kind: scan.first, text: stripMarkers(line), code: '', block: scan.block } + }) +} + +/** Remove comment punctuation so only the prose is compared. */ +function stripMarkers(line: string): string { + return line + .trim() + .replace(/^\/\*+!?/, '') + .replace(/^\/\/+[!]?/, '') + .replace(/^#+/, '') + .replace(/^("""|''')/, '') + .replace(/("""|''')$/, '') + .replace(/\*\/\s*$/, '') + .replace(/^\*+/, '') + .trim() +} + +/** + * A block comment holding pasted evidence rather than commentary. + * + * Measured against a real file preserving six `EXPLAIN ANALYZE` plans: at a + * comment-to-code ratio of 1.07 it is the most defensible file in its + * repository, and any rule that counts those lines as commentary flags it. + */ +const DATA_BLOCK_MIN_LINES = 3 +const DATA_BLOCK_NON_PROSE_RATIO = 0.3 + +function nonProse(text: string): boolean { + if (text.length === 0) return false + return /^(?:->|\||[-=+_*]{3,}|[^A-Za-z])/.test(text) +} + +/** Ids of block comments that read as pasted data, not prose. */ +export function dataBlockIds(lines: ClassifiedLine[]): Set { + const blocks = new Map() + for (const line of lines) { + if (line.block === -1 || (line.kind !== 'block' && line.kind !== 'doc')) continue + const bucket = blocks.get(line.block) + if (bucket) bucket.push(line.text) + else blocks.set(line.block, [line.text]) + } + const dataBlocks = new Set() + for (const [id, texts] of blocks) { + if (texts.length < DATA_BLOCK_MIN_LINES) continue + const nonProseCount = texts.filter(nonProse).length + if (nonProseCount / texts.length >= DATA_BLOCK_NON_PROSE_RATIO) dataBlocks.add(id) + } + return dataBlocks +} + +/** + * Words a comment and a line of code can be compared on: identifiers split on + * camelCase and separators, lowercased, with filler removed. Non-Latin comments + * yield fewer than two tokens and therefore never compare as redundant. + */ +const STOPWORDS = new Set([ + 'the', 'and', 'but', 'for', 'not', 'are', 'was', 'were', 'been', 'being', + 'this', 'that', 'these', 'those', 'its', 'with', 'from', 'into', 'onto', + 'they', 'them', 'their', 'our', 'your', 'you', 'we', 'it', 'is', 'be', + 'will', 'would', 'can', 'could', 'should', 'may', 'might', 'must', 'shall', + 'does', 'did', 'has', 'have', 'had', 'all', 'any', 'each', 'every', + 'when', 'while', 'then', 'else', 'than', 'there', 'here', 'over', 'under', + 'out', 'off', 'via', 'per', 'own', 'new', 'old', 'only', 'also', 'just', + 'now', 'one', 'two', 'use', 'used', 'using', 'let', 'via', +]) + +export function contentWords(text: string): Set { + const words = new Set() + for (const chunk of text.split(/[^A-Za-z0-9]+/)) { + if (!chunk) continue + for (const token of chunk.replace(/([a-z0-9])([A-Z])/g, '$1 $2').split(/\s+/)) { + const word = token.toLowerCase() + if (word.length < 3 || STOPWORDS.has(word)) continue + words.add(word) + } + } + return words +} diff --git a/packages/analyzer-engine/src/dead-code.ts b/packages/analyzer-engine/src/dead-code.ts index 46e7985..9c47f3a 100644 --- a/packages/analyzer-engine/src/dead-code.ts +++ b/packages/analyzer-engine/src/dead-code.ts @@ -5,6 +5,18 @@ import { type AnalyzerFinding, } from './types' +/** + * Entry-point-ish files that are loaded by convention, not by import + * (proxy.ts is Next 16's middleware — deleting it would drop the auth gate). + * `instrumentation-client` / `instrumentation.edge` are the suffixed variants + * Next.js and Sentry install; `*.stories.tsx` is collected by Storybook's glob. + * Neither is ever imported. + * + * Shared so the speculative-export rule reads the same convention list rather + * than deriving a second, drifting copy of it. + */ +export const CONVENTION_FILENAME = /(page|layout|route|loading|error|not-found|template|default|middleware|proxy|instrumentation|opengraph-image|twitter-image|icon|apple-icon|sitemap|robots|manifest|index|main|app|server|config|next-env|globals)\.[jt]sx?$|^instrumentation[-.][a-z]+\.[jt]sx?$|\.(stories|story)\.[jt]sx?$|\.(d|config|test|spec)\.[cm]?[jt]s$/ + /** * Dead-code candidates: JS/TS modules that are never imported anywhere. * Heuristic (static string matching), so results are labeled candidates. @@ -27,13 +39,6 @@ export const deadCodeAnalyzer: Analyzer = { ) if (jsFiles.length < 5) return findings - // Entry-point-ish files that are loaded by convention, not by import - // (proxy.ts is Next 16's middleware — deleting it would drop the auth gate) - // `instrumentation-client` / `instrumentation.edge` are the suffixed - // variants Next.js and Sentry install; `*.stories.tsx` is collected by - // Storybook's glob. Neither is ever imported. - const CONVENTION = /(page|layout|route|loading|error|not-found|template|default|middleware|proxy|instrumentation|opengraph-image|twitter-image|icon|apple-icon|sitemap|robots|manifest|index|main|app|server|config|next-env|globals)\.[jt]sx?$|^instrumentation[-.][a-z]+\.[jt]sx?$|\.(stories|story)\.[jt]sx?$|\.(d|config|test|spec)\.[cm]?[jt]s$/ - // package.json script values reference runner entrypoints ("npx tsx // lib/db/seed.ts") — raw manifest JSON satisfies the quoted-ref needle. const manifests = index.files.filter( @@ -64,7 +69,7 @@ export const deadCodeAnalyzer: Analyzer = { // Anchored at the project root so a `components/pages/` folder is unaffected. if (/^(src\/)?pages\//.test(file.path)) continue const base = file.path.split('/').pop()! - if (CONVENTION.test(base)) continue + if (CONVENTION_FILENAME.test(base)) continue // Tooling dotfiles (.prettierrc.js, .eslintrc.js) are loaded by name. if (base.startsWith('.')) continue const stem = base.replace(/\.[cm]?[jt]sx?$/, '') diff --git a/packages/analyzer-engine/src/overengineering.ts b/packages/analyzer-engine/src/overengineering.ts new file mode 100644 index 0000000..f906b59 --- /dev/null +++ b/packages/analyzer-engine/src/overengineering.ts @@ -0,0 +1,317 @@ +import { + annotatedAnalyzerOutput, + incompleteAnalyzerOutput, + type Analyzer, + type AnalyzerFinding, + type RepoIndex, +} from './types' +import { classifyLines } from './comments' +import { CONVENTION_FILENAME } from './dead-code' + +/** + * Named value exports. Types are excluded: TypeScript consumes them + * structurally and often invisibly, and excluding them halved the measured + * unreferenced rate on every repository in the corpus. + */ +const EXPORT_DECLARATION = + /^[ \t]*export\s+(?:(?:declare|async|abstract)\s+)*(?:function\s*\*|function|const|let|var|class|enum)\s+([A-Za-z_$][\w$]*)/gm + +/** `export * from './x'` publishes every symbol in `x` without naming one. */ +const BARREL_REEXPORT = /^[ \t]*export\s+\*(?:\s+as\s+[\w$]+)?\s+from\s+['"]([^'"]+)['"]/gm + +const IDENTIFIER = /[A-Za-z_$][A-Za-z0-9_$]*/g + +/** + * Exports the framework loads by name. None of them is imported anywhere, and + * every one would otherwise read as surface with no consumer. + */ +const CONVENTION_EXPORT = new Set([ + 'metadata', 'generateMetadata', 'generateStaticParams', 'generateViewport', + 'loader', 'action', 'config', 'runtime', 'dynamic', 'dynamicParams', + 'revalidate', 'fetchCache', 'preferredRegion', 'maxDuration', + 'getServerSideProps', 'getStaticProps', 'getStaticPaths', 'middleware', + 'GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS', +]) + +/** + * ORM schema modules are consumed by codegen and migrations, not by imports. + * One repository's Drizzle schema files alone drove its unreferenced-export + * rate to 46.7%; without this gate the rule measures the ORM, not the code. + */ +const ORM_SCHEMA_PATH = /(?:^|\/)(?:db|database|drizzle|prisma)\/(?:.*\/)?[^/]*schema[^/]*$/i +const ORM_TABLE_CALL = /\b(?:pgTable|mysqlTable|sqliteTable|defineTable)\s*\(/ +const ORM_RELATIONS = /^[ \t]*export\s+const\s+([A-Za-z_$][\w$]*)\s*=\s*relations\s*\(/gm + +/** Runner entry points and route files: loaded by path, never by import. */ +const RUNNER_PATH = /(?:^|\/)(?:scripts|bin|tools)\// +const PAGES_ROUTER_PATH = /^(?:src\/)?pages\// + +/** + * Convex deploys a whole functions directory and addresses its modules by path + * at runtime: `internal.stripe.PREAUTH_getUserById` names a module and an + * export, so the `export` keyword IS the registration and the only reference is + * a string this pass cannot follow. Measured on a Convex starter, every one of + * thirteen candidates in that directory would have been wrong. + * + * Detected from the toolchain, never from the directory name. Convex lets the + * functions directory be renamed, its modules are commonly imported through a + * path alias (`@cvx/_generated/server`) that no specifier match would catch, + * and a directory called `convex` that no Convex toolchain ever touched is + * ordinary code. Two independent structural signals are required: the + * dependency in a manifest, and the `_generated/{api,server}` pair that + * `convex dev` emits into the deployment root. + * + * Both are required deliberately. Over-gating on one weak signal would hide + * real unused exports in any repository that happens to have a `_generated` + * directory, and a missed gate costs one candidate finding rather than silence. + */ +const CONVEX_CODEGEN = /^(.*\/)?_generated\/(api|server)\.[^/]+$/ + +function convexDeploymentRoots(index: RepoIndex): string[] { + if (!index.dependencies.has('convex')) return [] + const codegen = new Map>() + for (const file of index.files) { + const match = CONVEX_CODEGEN.exec(file.path) + if (!match) continue + const root = match[1] ?? '' + const emitted = codegen.get(root) ?? new Set() + emitted.add(match[2]) + codegen.set(root, emitted) + } + // Only a directory holding BOTH halves of the codegen pair is a deployment root. + return [...codegen].filter(([, emitted]) => emitted.size === 2).map(([root]) => root) +} + +const JS_FILE = /\.(?:ts|tsx|js|jsx|mjs|cjs)$/ + +const CATCH_OPEN = /catch\s*\(\s*([A-Za-z_$][\w$]*)\s*(?::\s*[^)]*)?\)\s*\{$/ +const CONSOLE_CALL = /^console\.\w+\([^{}]*\)\s*;?$/ + +interface SpeculativeFile { + path: string + names: string[] +} + +interface RethrowFile { + path: string + lines: number[] +} + +function directoryOf(path: string): string { + const slash = path.lastIndexOf('/') + return slash === -1 ? '' : path.slice(0, slash + 1) +} + +/** Resolve a relative module specifier against the importing file's directory. */ +function resolveSpecifier(fromPath: string, specifier: string): string | null { + if (!specifier.startsWith('.')) return null + const segments: string[] = [] + for (const part of `${directoryOf(fromPath)}${specifier}`.split('/')) { + if (part === '' || part === '.') continue + if (part === '..') segments.pop() + else segments.push(part) + } + return segments.join('/') +} + +/** Paths whose symbols are re-exported wholesale by some barrel in the repo. */ +function barrelPublishedPaths(index: RepoIndex): Set { + const targets = new Set() + for (const file of index.files) { + if (!file.content || !JS_FILE.test(file.path)) continue + for (const match of file.content.matchAll(BARREL_REEXPORT)) { + const resolved = resolveSpecifier(file.path, match[1]) + if (resolved) targets.add(resolved) + } + } + if (targets.size === 0) return targets + const published = new Set() + for (const file of index.files) { + const stem = file.path.replace(/\.[cm]?[jt]sx?$/, '') + if (targets.has(stem) || targets.has(stem.replace(/\/index$/, ''))) published.add(file.path) + } + return published +} + +/** Identifiers that occur in more than one indexed file. */ +function crossFileIdentifiers(index: RepoIndex): Set { + const firstOwner = new Map() + const shared = new Set() + for (const file of index.files) { + if (!file.content) continue + const seen = new Set() + for (const match of file.content.matchAll(IDENTIFIER)) seen.add(match[0]) + for (const token of seen) { + const owner = firstOwner.get(token) + if (owner === undefined) firstOwner.set(token, file.path) + else if (owner !== file.path) shared.add(token) + } + } + return shared +} + +/** + * A `catch` whose whole body logs and rethrows. The nested-brace bail-out keeps + * the shape unambiguous: a handler that builds an object or branches is doing + * something, and this rule declines to guess what. + */ +function logAndRethrowLines(path: string, content: string): number[] { + const lines = classifyLines(path, content) + if (lines.length === 0) return [] + const hits: number[] = [] + for (let i = 0; i < lines.length; i++) { + const open = lines[i].code.match(CATCH_OPEN) + if (!open) continue + const binding = open[1] + const throwOnly = new RegExp(`^throw\\s+${binding}\\s*;?$`) + let logs = 0 + let rethrows = 0 + for (let j = i + 1; j < lines.length; j++) { + const code = lines[j].code + if (code.length === 0) continue + if (code === '}') { + if (logs > 0 && rethrows === 1) hits.push(i + 1) + break + } + if (code.includes('{') || code.includes('}')) break + if (CONSOLE_CALL.test(code)) logs++ + else if (throwOnly.test(code)) rethrows++ + else break + } + } + return hits +} + +/** + * Structure that exists for a consumer that does not. + * + * The disclosure in the description is load-bearing. The abstraction shapes + * this rule set does NOT check — a single-implementation interface, an option + * nobody overrides, a parameter never varied at any call site — each need the + * cross-file symbol graph, and a receipt that stayed silent about them would + * read as "no over-engineering found" when the pass structurally cannot see + * them. Measured on real repositories, the regex versions of those shapes + * returned between zero and three hits, most of them wrong. + */ +export const overengineeringAnalyzer: Analyzer = { + id: 'overengineering', + name: 'Speculative Structure', + description: + 'Flags exported surface with no consumer and error handling that adds no behaviour. ' + + 'Abstraction-shape analysis (single-implementation interfaces, unused options, ' + + 'unvaried parameters) requires the hosted symbol graph and does not run locally.', + async run(index) { + const production = index.files.filter( + (file) => + file.content + && JS_FILE.test(file.path) + && (file.kind === 'source' || file.kind === 'component' || file.kind === 'route'), + ) + + const candidateLimit = 1500 + const candidates = production.slice(0, candidateLimit) + + const rethrows: RethrowFile[] = [] + for (const file of candidates) { + const lines = logAndRethrowLines(file.path, file.content!) + if (lines.length > 0) rethrows.push({ path: file.path, lines }) + } + + // A library's exported surface IS its product. "Nothing in this repository + // uses it" says nothing about a package whose consumers are elsewhere. + const speculative: SpeculativeFile[] = [] + if (index.repoType !== 'library') { + const generatedDirs = Object.keys(index.generatedFiles ?? {}).map((path) => directoryOf(path)) + const nearGenerated = (path: string) => + generatedDirs.some((dir) => (dir === '' ? !path.includes('/') : path.startsWith(dir))) + const published = barrelPublishedPaths(index) + const shared = crossFileIdentifiers(index) + const convexRoots = convexDeploymentRoots(index) + const convexDeployed = (path: string) => + convexRoots.some((root) => root === '' || path.startsWith(root)) + + for (const file of candidates) { + if (published.has(file.path)) continue + if (nearGenerated(file.path) || RUNNER_PATH.test(file.path) || PAGES_ROUTER_PATH.test(file.path)) continue + if (convexDeployed(file.path)) continue + if (CONVENTION_FILENAME.test(file.path.split('/').pop()!)) continue + const content = file.content! + if (ORM_SCHEMA_PATH.test(file.path) || ORM_TABLE_CALL.test(content)) continue + const relationNames = new Set([...content.matchAll(ORM_RELATIONS)].map((match) => match[1])) + + const names: string[] = [] + for (const match of content.matchAll(EXPORT_DECLARATION)) { + const name = match[1] + if (CONVENTION_EXPORT.has(name) || relationNames.has(name)) continue + if (!shared.has(name)) names.push(name) + } + if (names.length > 0) speculative.push({ path: file.path, names: [...new Set(names)] }) + } + } + + const findings: AnalyzerFinding[] = [] + const findingLimit = 10 + for (const file of speculative.slice(0, findingLimit)) { + const listed = file.names.slice(0, 5).map((name) => `\`${name}\``).join(', ') + const rest = file.names.length - Math.min(file.names.length, 5) + findings.push({ + category: 'DEAD_CODE', + severity: 'INFO', + title: `${file.names.length} export(s) with no consumer in ${file.path}`, + description: + `${listed}${rest > 0 ? ` and ${rest} more` : ''} ${file.names.length === 1 ? 'is' : 'are'} exported from ` + + `${file.path} and appear in no other indexed file, tests included. Exported surface with no consumer is an ` + + 'API for nobody: it has to be read, kept compiling, and refactored around. These are candidates — a symbol ' + + 'reached only through a dynamic import or a path built from strings looks the same to this pass.', + filePath: file.path, + suggestion: 'Drop the `export` keyword where the symbol is file-local, or delete it if nothing uses it.', + 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) }, + }) + } + + // Only the candidate-file cap loses coverage; the finding cap bounds output + // over an analysis that still examined every candidate. + const metrics = { + candidates: production.length, + candidateLimit, + speculativeExportFiles: speculative.length, + speculativeExports: speculative.reduce((total, file) => total + file.names.length, 0), + logAndRethrowFiles: rethrows.length, + abstractionShapeAnalysis: 'hosted-only', + } + if (production.length > candidateLimit) { + return incompleteAnalyzerOutput(findings, { + truncated: true, + detail: `Speculative-structure analysis hit a candidate bound (${production.length} candidate files).`, + metrics, + }) + } + 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, + }) + } + return annotatedAnalyzerOutput(findings, { metrics }) + }, +} diff --git a/packages/analyzer-engine/src/registry.ts b/packages/analyzer-engine/src/registry.ts index d2a89f8..a0680a1 100644 --- a/packages/analyzer-engine/src/registry.ts +++ b/packages/analyzer-engine/src/registry.ts @@ -12,6 +12,8 @@ import { complexityAnalyzer } from './complexity' import { todosAnalyzer } from './todos' import { vulnerabilityAnalyzer } from './vulnerabilities' import { coverageAnalyzer } from './coverage' +import { commentSlopAnalyzer } from './comment-slop' +import { overengineeringAnalyzer } from './overengineering' /** * Analyzer registry — the plugin surface. Adding an analyzer means writing @@ -31,6 +33,8 @@ export const ANALYZERS: Analyzer[] = [ todosAnalyzer, vulnerabilityAnalyzer, coverageAnalyzer, + commentSlopAnalyzer, + overengineeringAnalyzer, ] export function getAnalyzers(): Analyzer[] { diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index 9b5a08b..51aa11e 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -5,6 +5,69 @@ checksums are published at pass.id === 'comment-slop')?.result.metrics + if (!metrics) return [] + const files = Number(metrics.eligibleFiles) + const median = Number(metrics.commentRatioMedian) + const restating = Number(metrics.redundantComments) + const narrating = Number(metrics.narrationComments) + const reported = Number(metrics.redundantCommentFiles) + Number(metrics.narrationCommentFiles) + if (![files, median, restating, narrating, reported].every(Number.isFinite) || files <= 0) return [] + return [ + '', + '## Comment signal', + '', + `This repository comments at a median of ${median} lines per code line. Across the ${files} file(s) measured, ` + + `${restating} comment(s) restate the code beneath them and ${narrating} narrate an edit rather than describe ` + + 'behaviour.', + '', + restating + narrating === 0 + ? 'Nothing in this repository matched either shape.' + : reported === 0 + ? 'No file carries enough of either shape to be reported, so neither appears in the findings table.' + : `${reported} file(s) carry enough of either shape to be reported. The findings table above lists only what ` + + 'this session introduced or worsened; the counts here cover the whole repository.', + ] +} + /** Which historical rendering of the analysis block to reproduce. */ type ReceiptMarkdownVariant = 'current' | 'legacy-scores' | 'prior-profile' @@ -277,6 +361,7 @@ function renderMarkdownInternal(receipt: Receipt, variant: ReceiptMarkdownVarian // Emits nothing when no finding carries a fix, so every receipt signed // before suggestions existed still renders to its original bytes. ...suggestedFixLines(receipt.analyzers.findings), + ...commentSignalLines(receipt), ...analysisLines(receipt, variant), ...(receipt.analyzers.delta ? [ `Finding delta: ${receipt.analyzers.delta.introduced} introduced, ${receipt.analyzers.delta.worsened} worsened, ${receipt.analyzers.delta.recurring} recurring, ${receipt.analyzers.delta.resolved} resolved.`, diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts index 9c8586a..3ef81f9 100644 --- a/packages/cli/src/types.ts +++ b/packages/cli/src/types.ts @@ -33,15 +33,19 @@ export const MAX_LLM_DIFF_BYTES = 2_000_000 * Honest local-analysis contract: which passes ran on this machine, which did * not, and whether scores may be inferred. * - * `local-registry-v2` supersedes `local-registry-v1`, in which SAST was omitted - * entirely. A reduced security pass now runs locally, so `omittedPasses` no - * longer names it and `localPasses` names what took its place. The id is bumped - * rather than the tuple loosened: this shape is inside signed receipts, and - * every v1 receipt must keep verifying byte-for-byte against the wording it was - * signed with. + * `local-registry-v3` supersedes `local-registry-v2`, which ran thirteen + * registry analyzers; the registry now holds fifteen, and the profile block + * states that count. `local-registry-v2` had itself superseded `v1`, in which + * SAST was omitted entirely. + * + * The id is bumped rather than the wording quietly changed, and it is bumped + * for a count as readily as for a pass: this shape sits inside signed receipts, + * the profile block is the part that says what did and did not run, and a + * receipt must keep verifying byte-for-byte against the wording it was signed + * with. Every superseded version keeps a frozen renderer in `receipt.ts`. */ export const LOCAL_ANALYSIS_PROFILE = { - id: 'local-registry-v2', + id: 'local-registry-v3', omittedPasses: ['graph'], localPasses: ['local-sast'], scoreStatus: 'not-computed', @@ -54,7 +58,17 @@ export interface LegacyLocalAnalysisProfileV1 { omittedPasses: readonly ['graph', 'sast'] scoreStatus: 'not-computed' } -export type AnyLocalAnalysisProfile = LocalAnalysisProfile | LegacyLocalAnalysisProfileV1 +/** The v2 shape, retained so thirteen-analyzer receipts still parse. */ +export interface LegacyLocalAnalysisProfileV2 { + id: 'local-registry-v2' + omittedPasses: readonly ['graph'] + localPasses: readonly ['local-sast'] + scoreStatus: 'not-computed' +} +export type AnyLocalAnalysisProfile = + | LocalAnalysisProfile + | LegacyLocalAnalysisProfileV1 + | LegacyLocalAnalysisProfileV2 export interface CliConfig { version: 1 diff --git a/packages/cli/test/analysis-profile.test.ts b/packages/cli/test/analysis-profile.test.ts index 02f5204..a727b0b 100644 --- a/packages/cli/test/analysis-profile.test.ts +++ b/packages/cli/test/analysis-profile.test.ts @@ -28,9 +28,9 @@ describe('honest local analysis profile', () => { ].join('\n')) const analysis = await analyzeRepository(root) - // 13 registry analyzers plus the local security pass, which is deliberately - // NOT in the registry so "13 deterministic analyzers" stays true. - expect(analysis.passes).toHaveLength(14) + // 15 registry analyzers plus the local security pass, which is deliberately + // NOT in the registry so the receipt's registry count stays true. + expect(analysis.passes).toHaveLength(16) expect(analysis.passes.at(-1)?.id).toBe('local-sast') const injection = analysis.findings.find((finding) => finding.metadata?.ruleId === 'sql-injection') diff --git a/packages/cli/test/local-sast.test.ts b/packages/cli/test/local-sast.test.ts index a35dd4b..9d7885f 100644 --- a/packages/cli/test/local-sast.test.ts +++ b/packages/cli/test/local-sast.test.ts @@ -124,7 +124,7 @@ describe('local security pass file selection', () => { }) describe('the local pass is a pass, not a registry analyzer', () => { - it('runs alongside the 13 registry analyzers without joining them', async () => { + it('runs alongside the 15 registry analyzers without joining them', async () => { const root = await mkdtemp(join(tmpdir(), 'codetruss-local-sast-pass-')) cleanup.push(root) await mkdir(join(root, 'src')) @@ -132,7 +132,7 @@ describe('the local pass is a pass, not a registry analyzer', () => { const analysis = await analyzeRepository(root) const ids = analysis.passes.map((pass) => pass.id) - expect(ids).toHaveLength(14) + expect(ids).toHaveLength(16) expect(ids.filter((id) => id === LOCAL_SAST_PASS_ID)).toHaveLength(1) expect(ids.at(-1)).toBe(LOCAL_SAST_PASS_ID) expect(analysis.passes.at(-1)?.result.complete).toBe(true) diff --git a/packages/cli/test/receipt.test.ts b/packages/cli/test/receipt.test.ts index d09177c..8bdebb9 100644 --- a/packages/cli/test/receipt.test.ts +++ b/packages/cli/test/receipt.test.ts @@ -41,6 +41,25 @@ function profileV1Fixture(root: string, patch = 'diff evidence'): Receipt { } } +/** A receipt as CLI 0.2.35-0.2.38 signed it, when the registry held thirteen analyzers. */ +function profileV2Fixture(root: string, patch = 'diff evidence'): Receipt { + const receipt = fixture(root, patch) + return { + ...receipt, + analyzers: { + passes: receipt.analyzers.passes, + findings: receipt.analyzers.findings, + index: receipt.analyzers.index, + analysisProfile: { + id: 'local-registry-v2', + omittedPasses: ['graph'], + localPasses: ['local-sast'], + scoreStatus: 'not-computed', + }, + }, + } +} + function legacyFixture(root: string, patch = 'diff evidence'): Receipt { const receipt = fixture(root, patch) return { @@ -72,7 +91,7 @@ describe('signed receipts', () => { await expect(verifyReceipt(dir, receipt.sessionId)).resolves.toMatchObject({ verdict: 'PASS' }) const markdown = await readFile(paths.markdown, 'utf8') expect(markdown).toContain('Policy SHA-256') - expect(markdown).toContain('Profile: `local-registry-v2`') + expect(markdown).toContain('Profile: `local-registry-v3`') expect(markdown).not.toContain('Final scores:') await writeFile(paths.markdown, `${await readFile(paths.markdown, 'utf8')}tampered`) await expect(verifyReceipt(dir, receipt.sessionId)).rejects.toThrow('Markdown receipt does not match') @@ -139,6 +158,78 @@ describe('signed receipts', () => { await expect(verifyReceipt(dir, receipt.sessionId)).resolves.toMatchObject({ verdict: 'PASS' }) }) + it('reproduces the v2 wording for a receipt signed when the registry held thirteen analyzers', async () => { + const root = await mkdtemp(join(tmpdir(), 'codetruss-profile-v2-receipt-')) + const dir = join(root, 'receipts') + process.env.CODETRUSS_SIGNING_KEY = join(root, 'signing.pem') + const receipt = profileV2Fixture(root) + const paths = await writeReceipt(dir, receipt, 'diff evidence') + + const markdown = await readFile(paths.markdown, 'utf8') + expect(markdown).toContain('Profile: `local-registry-v2`') + // The count that execution made, not the count the current registry has. + expect(markdown).toContain('The 13 deterministic registry analyzers ran locally on this machine, plus a local security pass') + expect(markdown).not.toContain('The 15 deterministic registry analyzers') + // The abstraction-shape disclosure belongs to the analyzers v2 never ran. + expect(markdown).not.toContain('**Abstraction-shape analysis.**') + await expect(verifyReceipt(dir, receipt.sessionId)).resolves.toMatchObject({ verdict: 'PASS' }) + }) + + it('states the new registry count and the abstraction-shape limit on a current receipt', () => { + const markdown = renderMarkdown(fixture('/tmp/repo')) + expect(markdown).toContain('Profile: `local-registry-v3`') + expect(markdown).toContain('The 15 deterministic registry analyzers ran locally on this machine') + expect(markdown).toContain('**Abstraction-shape analysis.**') + expect(markdown).toContain('says nothing either way about those shapes') + }) + + it('renders the comment-signal measurement from pass metrics, and nothing without them', () => { + const receipt = fixture('/tmp/repo') + expect(renderMarkdown(receipt)).not.toContain('## Comment signal') + + receipt.analyzers.passes = [{ + id: 'comment-slop', + result: { + findings: [], + complete: true, + metrics: { + eligibleFiles: 127, commentRatioMedian: 0.06, commentRatioP90: 0.21, + densityOutlierFiles: 0, redundantComments: 0, redundantCommentFiles: 0, + narrationComments: 0, narrationCommentFiles: 0, + }, + }, + }] + const healthy = renderMarkdown(receipt) + expect(healthy).toContain('## Comment signal') + expect(healthy).toContain('a median of 0.06 lines per code line') + expect(healthy).toContain('Across the 127 file(s) measured, 0 comment(s) restate the code beneath them and 0 narrate an edit') + expect(healthy).toContain('Nothing in this repository matched either shape.') + // Observation only: the receipt never names an author or a verdict on taste. + expect(healthy).not.toMatch(/\bAI[- ]generated|\bslop\b|sloppy|lazy/i) + }) + + it('counts comments, not files, so a file under the reporting threshold is never called clean', () => { + const receipt = fixture('/tmp/repo') + const metrics = { + eligibleFiles: 1, commentRatioMedian: 0.083, commentRatioP90: 0.083, + densityOutlierFiles: 0, redundantComments: 2, redundantCommentFiles: 0, + narrationComments: 0, narrationCommentFiles: 0, + } + receipt.analyzers.passes = [{ id: 'comment-slop', result: { findings: [], complete: true, metrics } }] + + const markdown = renderMarkdown(receipt) + expect(markdown).toContain('2 comment(s) restate the code beneath them and 0 narrate an edit') + expect(markdown).toContain('No file carries enough of either shape to be reported') + // The measurement found something, so the receipt must not claim it found nothing. + expect(markdown).not.toContain('Nothing in this repository matched either shape') + + receipt.analyzers.passes = [{ + id: 'comment-slop', + result: { findings: [], complete: true, metrics: { ...metrics, redundantComments: 49, redundantCommentFiles: 6, narrationComments: 3, narrationCommentFiles: 1 } }, + }] + expect(renderMarkdown(receipt)).toContain('7 file(s) carry enough of either shape to be reported') + }) + it('does not list the LLM review as omitted when a model actually reviewed the diff', () => { const receipt = fixture('/tmp/repo') receipt.llm = { diff --git a/public/downloads/codetruss-cli-0.2.39.sbom.cdx.json b/public/downloads/codetruss-cli-0.2.39.sbom.cdx.json new file mode 100644 index 0000000..ba79216 --- /dev/null +++ b/public/downloads/codetruss-cli-0.2.39.sbom.cdx.json @@ -0,0 +1,170 @@ +{ + "$schema": "https://cyclonedx.org/schema/bom-1.6.schema.json", + "bomFormat": "CycloneDX", + "serialNumber": "urn:uuid:be975d22-e2ae-5460-993a-2839a1058dab", + "specVersion": "1.6", + "version": 1, + "metadata": { + "component": { + "type": "application", + "bom-ref": "pkg:npm/%40codetruss/cli@0.2.39", + "name": "@codetruss/cli", + "version": "0.2.39", + "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.39" + }, + "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.7", + "name": "brace-expansion", + "version": "5.0.7", + "licenses": [ + { + "license": { + "id": "MIT" + } + } + ], + "purl": "pkg:npm/brace-expansion@5.0.7", + "properties": [ + { + "name": "codetruss:bundled", + "value": "true" + } + ] + }, + { + "type": "library", + "bom-ref": "pkg:npm/minimatch@10.2.5", + "name": "minimatch", + "version": "10.2.5", + "licenses": [ + { + "license": { + "id": "BlueOak-1.0.0" + } + } + ], + "purl": "pkg:npm/minimatch@10.2.5", + "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.39", + "dependsOn": [ + "pkg:npm/%40codetruss/analyzer-engine@0.1.0", + "pkg:npm/minimatch@10.2.5", + "pkg:npm/yaml@2.9.0" + ] + }, + { + "ref": "pkg:npm/balanced-match@4.0.4", + "dependsOn": [] + }, + { + "ref": "pkg:npm/brace-expansion@5.0.7", + "dependsOn": [ + "pkg:npm/balanced-match@4.0.4" + ] + }, + { + "ref": "pkg:npm/minimatch@10.2.5", + "dependsOn": [ + "pkg:npm/brace-expansion@5.0.7" + ] + }, + { + "ref": "pkg:npm/yaml@2.9.0", + "dependsOn": [] + } + ] +} diff --git a/public/downloads/codetruss-cli-0.2.39.tgz b/public/downloads/codetruss-cli-0.2.39.tgz new file mode 100644 index 0000000..f8ad92c Binary files /dev/null and b/public/downloads/codetruss-cli-0.2.39.tgz differ diff --git a/public/downloads/codetruss-cli-0.2.39.tgz.sha256 b/public/downloads/codetruss-cli-0.2.39.tgz.sha256 new file mode 100644 index 0000000..7a6a4bc --- /dev/null +++ b/public/downloads/codetruss-cli-0.2.39.tgz.sha256 @@ -0,0 +1 @@ +feb9a7454abaf2c25bdeade2a6e638137290df8c0725a447e3aca04aef2bd8f0 codetruss-cli-0.2.39.tgz diff --git a/public/downloads/codetruss-cli-latest.json b/public/downloads/codetruss-cli-latest.json index 642262e..2b23b49 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.38", - "url": "/downloads/codetruss-cli-0.2.38.tgz", + "version": "0.2.39", + "url": "/downloads/codetruss-cli-0.2.39.tgz", "latestUrl": "/downloads/codetruss-cli-latest.tgz", - "sha256": "8b6093e502e10402fa6f84cd2cac86c7db9c3e54502c7566f1635ce993b32ff7", - "sbomUrl": "/downloads/codetruss-cli-0.2.38.sbom.cdx.json", - "sbomSha256": "182f1b01d91701f0b9a453f0f656913a2e6b860a11feb0d2783f93b059db915b", + "sha256": "feb9a7454abaf2c25bdeade2a6e638137290df8c0725a447e3aca04aef2bd8f0", + "sbomUrl": "/downloads/codetruss-cli-0.2.39.sbom.cdx.json", + "sbomSha256": "8bd47df2b79d979883cb0372abfc26d380b3ad2fd10327538da8cb15e838d1b4", "node": ">=20.9.0", "repository": "https://github.com/DeliriumPulse/codetruss-cli", - "releaseUrl": "https://github.com/DeliriumPulse/codetruss-cli/releases/tag/v0.2.38", - "attestationCommand": "gh attestation verify codetruss-cli-0.2.38.tgz --repo DeliriumPulse/codetruss-cli" + "releaseUrl": "https://github.com/DeliriumPulse/codetruss-cli/releases/tag/v0.2.39", + "attestationCommand": "gh attestation verify codetruss-cli-0.2.39.tgz --repo DeliriumPulse/codetruss-cli" } diff --git a/public/downloads/codetruss-cli-latest.sbom.cdx.json b/public/downloads/codetruss-cli-latest.sbom.cdx.json index bbea2e2..ba79216 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:c2d14a99-4afc-591b-b0a6-02ca9812b1b6", + "serialNumber": "urn:uuid:be975d22-e2ae-5460-993a-2839a1058dab", "specVersion": "1.6", "version": 1, "metadata": { "component": { "type": "application", - "bom-ref": "pkg:npm/%40codetruss/cli@0.2.38", + "bom-ref": "pkg:npm/%40codetruss/cli@0.2.39", "name": "@codetruss/cli", - "version": "0.2.38", + "version": "0.2.39", "description": "Local-first scope, quality, and verification receipts for coding agents", "licenses": [ { @@ -18,7 +18,7 @@ } } ], - "purl": "pkg:npm/%40codetruss/cli@0.2.38" + "purl": "pkg:npm/%40codetruss/cli@0.2.39" }, "properties": [ { @@ -139,7 +139,7 @@ "dependsOn": [] }, { - "ref": "pkg:npm/%40codetruss/cli@0.2.38", + "ref": "pkg:npm/%40codetruss/cli@0.2.39", "dependsOn": [ "pkg:npm/%40codetruss/analyzer-engine@0.1.0", "pkg:npm/minimatch@10.2.5", diff --git a/public/downloads/codetruss-cli-latest.tgz b/public/downloads/codetruss-cli-latest.tgz index 12073db..f8ad92c 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 5fafbdc..b13f42c 100644 --- a/public/downloads/codetruss-cli-latest.tgz.sha256 +++ b/public/downloads/codetruss-cli-latest.tgz.sha256 @@ -1 +1 @@ -8b6093e502e10402fa6f84cd2cac86c7db9c3e54502c7566f1635ce993b32ff7 codetruss-cli-latest.tgz +feb9a7454abaf2c25bdeade2a6e638137290df8c0725a447e3aca04aef2bd8f0 codetruss-cli-latest.tgz diff --git a/release-reference.json b/release-reference.json index 8445009..8ccb631 100644 --- a/release-reference.json +++ b/release-reference.json @@ -1,8 +1,8 @@ { "schemaVersion": 1, - "version": "0.2.38", - "websiteArchive": "https://codetruss.com/downloads/codetruss-cli-0.2.38.tgz", - "archiveSha256": "8b6093e502e10402fa6f84cd2cac86c7db9c3e54502c7566f1635ce993b32ff7", - "sbomSha256": "182f1b01d91701f0b9a453f0f656913a2e6b860a11feb0d2783f93b059db915b", - "bundleSha256": "8d6039e227c2b959dc509bd2f4cf02d1d874203fb53b911656d15916aa4dcc03" + "version": "0.2.39", + "websiteArchive": "https://codetruss.com/downloads/codetruss-cli-0.2.39.tgz", + "archiveSha256": "feb9a7454abaf2c25bdeade2a6e638137290df8c0725a447e3aca04aef2bd8f0", + "sbomSha256": "8bd47df2b79d979883cb0372abfc26d380b3ad2fd10327538da8cb15e838d1b4", + "bundleSha256": "85f3475cb84d4ce349d43a0c81c68cbf40bd573e731c5e731eeff55ae7405c4b" }