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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 0 additions & 45 deletions .devkit/comment-firewall-rationales.json

This file was deleted.

4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ not accumulate in this config; they remain Markdown under `decisionsDir`.
- Folder fan-out, source-size, and project-structure ratchets
- Deterministic gate checkpointing for safe ship retries
- Sentry-capture review for swallowed runtime failures
- Changed-comment firewall with staged rationales and independent exception review
- Changed-comment paragraph firewall with local rationales and batched independent review
- Optional qavis advisory routing for UI changes

Every path the engine touches resolves from the consumer repository’s working directory. devkit ships mechanisms, not a consumer’s baselines, allowlists, decision history, or `guard.config.json`.
Expand Down Expand Up @@ -163,7 +163,7 @@ The tracker separates lifecycle, evidence provenance, freshness, change type, an
| Frontend security reviewer | shipped | accepted | stale | coverage | ? unknown | first-pass FAIL recall: 11/11 (100.0%) · first-pass clean pass: 7/8 (87.5%) · block recall: 10/11 (90.9%) · clean pass: 8/8 (100.0%) |
| Frontend performance reviewer | shipped | accepted | stale | coverage | ? unknown | first-pass FAIL recall: 10/11 (90.9%) · first-pass clean pass: 7/8 (87.5%) · block recall: 10/11 (90.9%) · clean pass: 7/8 (87.5%) |
| Correctness reviewer | shipped | accepted | stale | coverage | ↕ mixed | first-pass FAIL recall: 56/69 (81.2%) · first-pass clean pass: 49/59 (83.1%) |
| Changed-comment rationale review | shipped | evidence-only | unknown | — | ? unknown | No accepted local checkpoint |
| Changed-comment paragraph rationale review | shipped | evidence-only | unknown | — | ? unknown | No accepted local checkpoint |
| Decision governance | shipped | evidence-only | unknown | quality | ↑ improved | Detect accuracy: 45/49 (91.8%) · DECISION recall: 8/9 (88.9%) |
| Sentry capture judge | shipped | evidence-only | unknown | quality | ↑ improved | Commit-message F1: 56/100 (56.0%) · Focused-diff F1: 87/100 (87.0%) |
| Edge-case autonomy | no-ship | accepted | stale | no-ship | ? unknown | Judge-free ceiling: 51.2% · Pre-registered target: 35.0% |
Expand Down
12 changes: 11 additions & 1 deletion cli/__tests__/gitignore-cache.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ describe('ensureDevkitCacheGitignore', () => {
it('manages the review run directory without ignoring tracked devkit state', () => {
expect(DEVKIT_CACHE_IGNORES).toContain('.devkit/review-runs/');
expect(DEVKIT_CACHE_IGNORES).toContain('.devkit/comment-firewall-receipts.json');
expect(DEVKIT_TRACKED_UNIGNORES).toContain('!.devkit/comment-firewall-rationales.json');
expect(DEVKIT_CACHE_IGNORES).not.toContain('.devkit/comment-firewall-rationales.json');
expect(DEVKIT_TRACKED_UNIGNORES).not.toContain('!.devkit/comment-firewall-rationales.json');
expect(DEVKIT_CACHE_IGNORES).not.toContain('.devkit/');
});

Expand Down Expand Up @@ -61,6 +62,15 @@ describe('ensureDevkitCacheGitignore', () => {
expect(lines.filter((line) => line === tracked)).toHaveLength(1);
});

it('removes the obsolete tracked-rationale exception during upgrade', () => {
const d = tmp();
writeFileSync(join(d, '.gitignore'), '!.devkit/comment-firewall-rationales.json\n');
ensureDevkitCacheGitignore(d, false);
expect(readFileSync(join(d, '.gitignore'), 'utf8')).not.toContain(
'!.devkit/comment-firewall-rationales.json',
);
});

it('dry-run writes nothing', () => {
const d = tmp();
ensureDevkitCacheGitignore(d, true);
Expand Down
15 changes: 9 additions & 6 deletions cli/lib/install/gitignore-cache.mts
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,16 @@ export const DEVKIT_CACHE_IGNORES = [
'.devkit/adhd-off',
];

export const DEVKIT_TRACKED_UNIGNORES = [
'!.devkit/agent-hook-registrations-manifest.json',
'!.devkit/comment-firewall-rationales.json',
];
export const DEVKIT_TRACKED_UNIGNORES = ['!.devkit/agent-hook-registrations-manifest.json'];
const LEGACY_GITIGNORE_LINES = ['!.devkit/comment-firewall-rationales.json'];

const DEVKIT_GITIGNORE_LINES = [...DEVKIT_CACHE_IGNORES, ...DEVKIT_TRACKED_UNIGNORES];
const DEVKIT_GITIGNORE_LINES = [
...DEVKIT_CACHE_IGNORES,
...DEVKIT_TRACKED_UNIGNORES,
...LEGACY_GITIGNORE_LINES,
];
const TRACKED_UNIGNORE_SET = new Set(DEVKIT_TRACKED_UNIGNORES);
const OBSOLETE_LINE_SET = new Set(LEGACY_GITIGNORE_LINES);

// Append cache rules and keep tracked-state negations at the effective tail (gitignore is last-match
// wins, so presence alone is insufficient when a consumer later appends a broad `.devkit/*` rule).
Expand All @@ -48,7 +51,7 @@ export function ensureDevkitCacheGitignore(cwd: string, dryRun: boolean): void {
const missingCaches = DEVKIT_CACHE_IGNORES.filter((line) => !have.has(line));
const kept = existing
.split('\n')
.filter((line) => !TRACKED_UNIGNORE_SET.has(line.trim()))
.filter((line) => !TRACKED_UNIGNORE_SET.has(line.trim()) && !OBSOLETE_LINE_SET.has(line.trim()))
.join('\n');
const additions = [...missingCaches, ...DEVKIT_TRACKED_UNIGNORES];
const separator = kept && !kept.endsWith('\n') ? '\n' : '';
Expand Down
4 changes: 2 additions & 2 deletions dist/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ not accumulate in this config; they remain Markdown under `decisionsDir`.
- Folder fan-out, source-size, and project-structure ratchets
- Deterministic gate checkpointing for safe ship retries
- Sentry-capture review for swallowed runtime failures
- Changed-comment firewall with staged rationales and independent exception review
- Changed-comment paragraph firewall with local rationales and batched independent review
- Optional qavis advisory routing for UI changes

Every path the engine touches resolves from the consumer repository’s working directory. devkit ships mechanisms, not a consumer’s baselines, allowlists, decision history, or `guard.config.json`.
Expand Down Expand Up @@ -163,7 +163,7 @@ The tracker separates lifecycle, evidence provenance, freshness, change type, an
| Frontend security reviewer | shipped | accepted | stale | coverage | ? unknown | first-pass FAIL recall: 11/11 (100.0%) · first-pass clean pass: 7/8 (87.5%) · block recall: 10/11 (90.9%) · clean pass: 8/8 (100.0%) |
| Frontend performance reviewer | shipped | accepted | stale | coverage | ? unknown | first-pass FAIL recall: 10/11 (90.9%) · first-pass clean pass: 7/8 (87.5%) · block recall: 10/11 (90.9%) · clean pass: 7/8 (87.5%) |
| Correctness reviewer | shipped | accepted | stale | coverage | ↕ mixed | first-pass FAIL recall: 56/69 (81.2%) · first-pass clean pass: 49/59 (83.1%) |
| Changed-comment rationale review | shipped | evidence-only | unknown | — | ? unknown | No accepted local checkpoint |
| Changed-comment paragraph rationale review | shipped | evidence-only | unknown | — | ? unknown | No accepted local checkpoint |
| Decision governance | shipped | evidence-only | unknown | quality | ↑ improved | Detect accuracy: 45/49 (91.8%) · DECISION recall: 8/9 (88.9%) |
| Sentry capture judge | shipped | evidence-only | unknown | quality | ↑ improved | Commit-message F1: 56/100 (56.0%) · Focused-diff F1: 87/100 (87.0%) |
| Edge-case autonomy | no-ship | accepted | stale | no-ship | ? unknown | Judge-free ceiling: 51.2% · Pre-registered target: 35.0% |
Expand Down
13 changes: 8 additions & 5 deletions dist/cli/lib/install/gitignore-cache.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,15 @@ export const DEVKIT_CACHE_IGNORES = [
// preference on everyone who clones the repo.
'.devkit/adhd-off',
];
export const DEVKIT_TRACKED_UNIGNORES = [
'!.devkit/agent-hook-registrations-manifest.json',
'!.devkit/comment-firewall-rationales.json',
export const DEVKIT_TRACKED_UNIGNORES = ['!.devkit/agent-hook-registrations-manifest.json'];
const LEGACY_GITIGNORE_LINES = ['!.devkit/comment-firewall-rationales.json'];
const DEVKIT_GITIGNORE_LINES = [
...DEVKIT_CACHE_IGNORES,
...DEVKIT_TRACKED_UNIGNORES,
...LEGACY_GITIGNORE_LINES,
];
const DEVKIT_GITIGNORE_LINES = [...DEVKIT_CACHE_IGNORES, ...DEVKIT_TRACKED_UNIGNORES];
const TRACKED_UNIGNORE_SET = new Set(DEVKIT_TRACKED_UNIGNORES);
const OBSOLETE_LINE_SET = new Set(LEGACY_GITIGNORE_LINES);
// Append cache rules and keep tracked-state negations at the effective tail (gitignore is last-match
// wins, so presence alone is insufficient when a consumer later appends a broad `.devkit/*` rule).
export function ensureDevkitCacheGitignore(cwd, dryRun) {
Expand All @@ -44,7 +47,7 @@ export function ensureDevkitCacheGitignore(cwd, dryRun) {
const missingCaches = DEVKIT_CACHE_IGNORES.filter((line) => !have.has(line));
const kept = existing
.split('\n')
.filter((line) => !TRACKED_UNIGNORE_SET.has(line.trim()))
.filter((line) => !TRACKED_UNIGNORE_SET.has(line.trim()) && !OBSOLETE_LINE_SET.has(line.trim()))
.join('\n');
const additions = [...missingCaches, ...DEVKIT_TRACKED_UNIGNORES];
const separator = kept && !kept.endsWith('\n') ? '\n' : '';
Expand Down
22 changes: 15 additions & 7 deletions dist/gate-engine/comment-firewall/cli.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import { realpathSync } from 'node:fs';
import { detectChangedComments } from "./detect.mjs";
import { runCommentFirewall } from "./gate.mjs";
import { listRationales, pruneRationales, recordRationale } from "./rationales.mjs";
import { ensureLegacyRationalesMigrated, listRationales, pruneRationales, recordRationale, } from "./rationales.mjs";
const USAGE = `Usage:
guard-comments gate
guard-comments justify <finding-id> "<specific rationale>" [--ticket SC-123|URL]
Expand All @@ -14,8 +14,16 @@ function flag(args, name) {
}
export function runCommentCli(args, cwd = process.cwd()) {
const [command, ...rest] = args;
if (command === 'gate')
return runCommentFirewall(cwd);
if (command === 'gate') {
try {
ensureLegacyRationalesMigrated(cwd);
return runCommentFirewall(cwd);
}
catch (cause) {
console.error(`guard-comments: migration — ${cause instanceof Error ? cause.message : cause}`);
return 4;
}
}
if (command === 'list') {
const entries = listRationales(cwd);
if (entries.length === 0)
Expand All @@ -29,7 +37,7 @@ export function runCommentCli(args, cwd = process.cwd()) {
try {
const current = new Set(detectChangedComments(cwd).findings.map((finding) => finding.id));
const removed = pruneRationales(cwd, current);
console.error(`guard-comments: pruned ${removed} obsolete rationale${removed === 1 ? '' : 's'}.`);
console.error(`guard-comments: released ${removed} obsolete rationale ownership${removed === 1 ? '' : 's'} for this worktree.`);
return 0;
}
catch (cause) {
Expand All @@ -48,13 +56,13 @@ export function runCommentCli(args, cwd = process.cwd()) {
return 2;
}
try {
const current = detectChangedComments(cwd).findings.some((finding) => finding.id === id);
if (!current) {
const currentIds = new Set(detectChangedComments(cwd).findings.map((finding) => finding.id));
if (!currentIds.has(id)) {
console.error(`guard-comments: [${id}] is not a current staged finding; re-run the gate and copy its ID.`);
return 2;
}
const entry = recordRationale(cwd, id, rationale, ticket);
console.error(`guard-comments: rationale staged for [${id}]${entry.ticket ? ` (${entry.ticket})` : ''}; re-run the gate for independent review.`);
console.error(`guard-comments: local rationale recorded for [${id}]${entry.ticket ? ` (${entry.ticket})` : ''}; re-run the gate for batched independent review.`);
return 0;
}
catch (cause) {
Expand Down
79 changes: 73 additions & 6 deletions dist/gate-engine/comment-firewall/detect.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,20 @@ import path from 'node:path';
import { ts } from 'ts-morph';
import { resolveGuardConfig, sourceMatchers } from "../config.mjs";
import { gitPrefix } from "../ratchets/git-index.mjs";
export const COMMENT_ADAPTER_VERSION = 'typescript-scanner-v1';
export const COMMENT_FINDING_POLICY = 'changed-comment-v1';
export const COMMENT_ADAPTER_VERSION = 'typescript-scanner-v2';
export const COMMENT_FINDING_POLICY = 'changed-comment-paragraph-v4';
const SUPPORTED_EXTENSIONS = new Set(['js', 'jsx', 'ts', 'tsx', 'mjs', 'cjs', 'mts', 'cts']);
const MAX_GIT_OUTPUT = 16 * 1024 * 1024;
const CONTEXT_LINES = 4;
const HUNK_HEADER = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/;
const LEADING_DOT_SLASH = /^\.\//;
const TRAILING_SLASH = /\/$/;
const TRAILING_CARRIAGE_RETURN = /\r$/;
const TRAILING_STRUCTURAL_PUNCTUATION = /^(?:[)\]};,.:]+|<\/(?:[A-Za-z][\w:.-]*|)>)+$/;
const LINE_COMMENT_PREFIX = /^\s*\/\/[/!]?[ \t]?/;
const BLOCK_COMMENT_PREFIX = /^\s*\/\*+!?[ \t]?/;
const BLOCK_COMMENT_SUFFIX = /[ \t]*\*\/[ \t]*$/;
const BLOCK_COMMENT_CONTINUATION = /^\s*\*[ \t]?/;
const sha12 = (value) => createHash('sha256').update(value).digest('hex').slice(0, 12);
function git(cwd, args) {
return execFileSync('git', args, {
Expand Down Expand Up @@ -179,11 +185,18 @@ export function scanCommentTokens(source, extension) {
.map((range) => {
const start = range.pos;
const end = range.end;
const kind = range.kind === ts.SyntaxKind.SingleLineCommentTrivia ? 'line' : 'block';
const startLine = lineAt(starts, start);
const endLine = lineAt(starts, Math.max(start, end - 1));
const before = source.slice(starts[startLine - 1], start).trim();
const after = source.slice(end, starts[endLine] ?? source.length).trim();
const clearAfter = after.length === 0 || TRAILING_STRUCTURAL_PUNCTUATION.test(after);
return {
kind: range.kind === ts.SyntaxKind.SingleLineCommentTrivia ? 'line' : 'block',
startLine: lineAt(starts, start),
endLine: lineAt(starts, Math.max(start, end - 1)),
kind,
startLine,
endLine,
text: source.slice(start, end),
standalone: clearAfter && (before.length === 0 || (kind === 'block' && startLine < endLine)),
};
});
}
Expand Down Expand Up @@ -216,8 +229,62 @@ function hunkIntersects(hunk, token) {
}
return false;
}
function meaningfulLine(line) {
return line
.replace(TRAILING_CARRIAGE_RETURN, '')
.replace(LINE_COMMENT_PREFIX, '')
.replace(BLOCK_COMMENT_PREFIX, '')
.replace(BLOCK_COMMENT_SUFFIX, '')
.replace(BLOCK_COMMENT_CONTINUATION, '')
.trim();
}
function requiresChallenge(token, hunks) {
const addedLines = new Set(hunks.flatMap((hunk) => [...hunk.addedLines]));
const changedTextLines = token.text.split('\n').filter((line, index) => {
const sourceLine = token.startLine + index;
return addedLines.has(sourceLine) && Boolean(meaningfulLine(line));
});
return changedTextLines.length >= 3;
}
export function paragraphCommentTokens(tokens) {
const paragraphs = [];
let run = [];
const flushRun = () => {
if (run.length > 0) {
const first = run[0];
const last = run.at(-1);
if (first && last) {
const paragraph = {
kind: first.kind,
startLine: first.startLine,
endLine: last.endLine,
text: run.map((token) => token.text).join('\n'),
standalone: true,
};
paragraphs.push(paragraph);
}
}
run = [];
};
for (const token of tokens) {
const groupable = token.kind === 'line' || token.startLine === token.endLine;
if (token.standalone && groupable) {
const previous = run.at(-1);
if (previous && (token.kind !== previous.kind || token.startLine !== previous.endLine + 1)) {
flushRun();
}
run.push(token);
continue;
}
flushRun();
if (token.standalone)
paragraphs.push(token);
}
flushRun();
return paragraphs;
}
function changedTokens(source, extension, hunks) {
return scanCommentTokens(source, extension).filter((token) => hunks.some((hunk) => hunkIntersects(hunk, token)));
return paragraphCommentTokens(scanCommentTokens(source, extension)).filter((token) => hunks.some((hunk) => hunkIntersects(hunk, token)) && requiresChallenge(token, hunks));
}
function findingFor(file, extension, source, token, hunks) {
const relevantDiff = hunks
Expand Down
Loading
Loading