Skip to content

Commit 8a87b0d

Browse files
author
Panopticon Agent
committed
feat: supersede verdicts by requirement
1 parent 1873720 commit 8a87b0d

12 files changed

Lines changed: 202 additions & 49 deletions

.gitattributes

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
.2119/verdicts/*.json linguist-generated=true

src/changed.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -268,7 +268,8 @@ function scopeContext(
268268
if (
269269
parsed &&
270270
currentRequirements.has(parsed.requirementId) &&
271-
(currentReviewIds.get(parsed.requirementId) === parsed.reviewId ||
271+
(parsed.reviewId === undefined ||
272+
currentReviewIds.get(parsed.requirementId) === parsed.reviewId ||
272273
baselineReviewIds.get(parsed.requirementId) === parsed.reviewId)
273274
) {
274275
affected.add(parsed.requirementId);
@@ -294,7 +295,8 @@ function scopeContext(
294295
const assigned = Boolean(
295296
parsed &&
296297
currentRequirements.has(parsed.requirementId) &&
297-
(currentReviewIds.get(parsed.requirementId) === parsed.reviewId ||
298+
(parsed.reviewId === undefined ||
299+
currentReviewIds.get(parsed.requirementId) === parsed.reviewId ||
298300
baselineReviewIds.get(parsed.requirementId) === parsed.reviewId),
299301
);
300302
return assigned ? affected.has(parsed!.requirementId) : changedPaths.has(path);
@@ -319,6 +321,7 @@ function scopeContext(
319321
lintViolations,
320322
coverViolations,
321323
reviewViolations,
324+
migrationNotices: current.migrationNotices.filter((notice) => affected.has(notice.requirementId)),
322325
verifyViolations,
323326
notInitialized: current.notInitialized && baseline.notInitialized,
324327
scopedRequirementIds: affected,
@@ -367,8 +370,9 @@ function quotedRequirementId(message: string): string | undefined {
367370
return message.match(/requirement ID "([^"]+)"/)?.[1];
368371
}
369372

370-
function verdictReview(path: string): { reviewId: string; requirementId: string } | undefined {
373+
function verdictReview(path: string): { reviewId?: string; requirementId: string } | undefined {
371374
const name = basename(path).replace(/\.json$/, "");
372375
const parsed = splitReviewId(name);
373-
return parsed ? { reviewId: name, requirementId: parsed.requirementId } : undefined;
376+
if (parsed) return { reviewId: name, requirementId: parsed.requirementId };
377+
return /^[A-Za-z0-9.-]+$/.test(name) ? { requirementId: name } : undefined;
374378
}

src/check.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { parseSpec } from "./spec.js";
66
import { scanAnnotations } from "./annotations.js";
77
import { computeCoverage, type CoverageResult } from "./cover.js";
88
import { computeReviewTargets, verdictViolations, type ReviewTask } from "./review.js";
9-
import { scanVerdicts } from "./verdict.js";
9+
import { legacyMigrationNotices, scanVerdicts, type MigrationNotice } from "./verdict.js";
1010
import { runVerifyCommands } from "./verify.js";
1111
import { allRequirements } from "./spec.js";
1212
import type { Annotation, SpecFile, Verdict, Violation } from "./model.js";
@@ -27,6 +27,7 @@ export interface CheckContext {
2727
coverViolations: Violation[];
2828
reviewViolations: Violation[];
2929
malformedVerdictViolations: Violation[];
30+
migrationNotices: MigrationNotice[];
3031
verifyViolations: Violation[];
3132
notInitialized: boolean;
3233
/** Present for `check --changed`; limits report counts and manual output to affected requirements. */
@@ -90,8 +91,11 @@ export function buildContext(root: string, options: BuildOptions = {}): CheckCon
9091
const allReviewTargets = computeReviewTargets(config, specs, coverage, repoFiles, annotations, markerLineByFile);
9192
const reviewTargets = config.reviews ? allReviewTargets : [];
9293
// Malformed verdict files are loud violations, not silent passes or skips (REQ-003.7.2).
93-
const { verdicts, violations: malformedVerdicts } = scanVerdicts(root);
94-
const reviewViolations = [...malformedVerdicts, ...verdictViolations(reviewTargets, verdicts)];
94+
const verdictScan = scanVerdicts(root);
95+
const { verdicts, violations: malformedVerdicts, stableFiles } = verdictScan;
96+
const currentReviewIds = new Map(reviewTargets.map((target) => [target.requirement.id, target.reviewId]));
97+
const migrationNotices = legacyMigrationNotices(verdictScan, currentReviewIds);
98+
const reviewViolations = [...malformedVerdicts, ...verdictViolations(reviewTargets, verdicts, stableFiles)];
9599

96100
// [review: instructions: <path>] pointing at a missing file (REQ-005.1.4),
97101
// and [review: <globs>] matching nothing — a typo'd glob must fail loudly
@@ -135,6 +139,7 @@ export function buildContext(root: string, options: BuildOptions = {}): CheckCon
135139
coverViolations: coverage.violations,
136140
reviewViolations,
137141
malformedVerdictViolations: malformedVerdicts,
142+
migrationNotices,
138143
verifyViolations,
139144
notInitialized,
140145
};
@@ -145,6 +150,7 @@ export interface CheckReport {
145150
violations: Violation[];
146151
uncoveredRequirements: string[];
147152
staleReviews: string[];
153+
migrationNotices: string[];
148154
manualRequirements: { id: string; text: string }[];
149155
requirementCount: number;
150156
coveredCount: number;
@@ -160,6 +166,7 @@ export function buildReport(ctx: CheckContext): CheckReport {
160166
violations,
161167
uncoveredRequirements: ctx.coverage.uncovered.map((r) => r.id),
162168
staleReviews: ctx.reviewViolations.map((v) => v.message),
169+
migrationNotices: ctx.migrationNotices.map((notice) => notice.message),
163170
manualRequirements: ctx.coverage.manual.map((r) => ({ id: r.id, text: r.text })),
164171
requirementCount: enforcedTestReqs.length,
165172
coveredCount: ctx.coverage.covered.size,

src/cli.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,7 @@ switch (command) {
229229
console.log(JSON.stringify(report, null, 2));
230230
} else {
231231
printViolations(report.violations);
232+
for (const notice of report.migrationNotices) console.log(notice);
232233
if (report.manualRequirements.length > 0) {
233234
console.log(`\nManual requirements (not automatically checked):`);
234235
for (const m of report.manualRequirements) console.log(` - ${m.id}: ${m.text}`);
@@ -247,10 +248,18 @@ switch (command) {
247248
case "prune": {
248249
const ctx = buildContext(root);
249250
requireInitialized(ctx);
250-
const current = new Set(ctx.reviewTargets.map((t) => t.reviewId));
251-
const pruned = pruneVerdicts(root, current);
252-
for (const id of pruned) console.log(`pruned .2119/verdicts/${id}.json`);
253-
console.log(`prune: removed ${pruned.length} orphaned verdict(s), kept ${ctx.verdicts.size - pruned.length}`);
251+
const current = new Map(ctx.reviewTargets.map((t) => [t.requirement.id, t.reviewId]));
252+
const result = pruneVerdicts(root, current);
253+
for (const action of result.actions) {
254+
if (action.kind === "migrated") {
255+
console.log(`migrated .2119/verdicts/${action.source} -> .2119/verdicts/${action.destination}`);
256+
} else {
257+
console.log(`pruned .2119/verdicts/${action.source}`);
258+
}
259+
}
260+
const removed = result.actions.filter((action) => action.kind === "pruned").length;
261+
const migrated = result.actions.filter((action) => action.kind === "migrated").length;
262+
console.log(`prune: removed ${removed} orphaned verdict(s), migrated ${migrated}, kept ${result.kept}`);
254263
break;
255264
}
256265

src/init.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
refreshPinnedArtifacts,
1010
type AgentName,
1111
} from "./adapters.js";
12-
import { ensureReviewStorageRules } from "./verdict.js";
12+
import { ensureReviewStorageRules, ensureVerdictAttributes } from "./verdict.js";
1313

1414
const CONFIG_TEMPLATE = `# 2119 configuration — https://github.com/Unsupervisedcom/2119
1515
# All fields optional; these are the defaults unless noted.
@@ -174,6 +174,9 @@ export function runInit(root: string, args: string[]): void {
174174
if (ensureReviewStorageRules(root)) {
175175
created.push(".gitignore (2119 review/verdict rules)");
176176
}
177+
if (ensureVerdictAttributes(root)) {
178+
created.push(".gitattributes (2119 generated verdict rule)");
179+
}
177180

178181
const agentsResult = upsertSection(join(root, "AGENTS.md"), refresh);
179182
if (agentsResult) created.push(`AGENTS.md (2119 section ${agentsResult})`);

src/review.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { computeReviewId, fileParts } from "./hash.js";
77
import { evidenceBlockParts } from "./annotations.js";
88
import { matchGlobs } from "./files.js";
99
import { allRequirements } from "./spec.js";
10+
import type { VerdictFile } from "./verdict.js";
1011

1112
export const REVIEWS_DIR = ".2119/reviews";
1213

@@ -79,9 +80,32 @@ export function computeReviewTargets(
7980
export function verdictViolations(
8081
targets: Omit<ReviewTask, "instructionPath">[],
8182
verdicts: Map<string, Verdict>,
83+
stableFiles: Map<string, VerdictFile> = new Map(),
8284
): Violation[] {
8385
const out: Violation[] = [];
8486
for (const t of targets) {
87+
const stable = stableFiles.get(t.requirement.id);
88+
if (stable) {
89+
// Malformed stable files already produce their own fail-closed scan
90+
// violation and must never fall back to a legacy pass.
91+
if (!stable.verdict) continue;
92+
if (stable.verdict.reviewId !== t.reviewId) {
93+
out.push({
94+
file: `${VERDICTS_DIR_HINT}/${stable.name}`,
95+
line: 1,
96+
rule: "REQ-003.3.1",
97+
message: `${t.requirement.id} has a stale review verdict (recorded ${stable.verdict.reviewId}, current ${t.reviewId}); run \`2119 review\``,
98+
});
99+
} else if (stable.verdict.verdict === "fail") {
100+
out.push({
101+
file: `${VERDICTS_DIR_HINT}/${stable.name}`,
102+
line: 1,
103+
rule: "REQ-003.2.4",
104+
message: `${t.requirement.id} has a failing review verdict: ${stable.verdict.summary}`,
105+
});
106+
}
107+
continue;
108+
}
85109
const v = verdicts.get(t.reviewId);
86110
if (!v) {
87111
out.push({

0 commit comments

Comments
 (0)