@intentset/verification 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/dist/adapters/common.d.ts +51 -0
  2. package/dist/adapters/common.d.ts.map +1 -0
  3. package/dist/adapters/common.js +95 -0
  4. package/dist/adapters/common.js.map +1 -0
  5. package/dist/adapters/node-test.d.ts +3 -0
  6. package/dist/adapters/node-test.d.ts.map +1 -0
  7. package/dist/adapters/node-test.js +119 -0
  8. package/dist/adapters/node-test.js.map +1 -0
  9. package/dist/adapters/vitest.d.ts +3 -0
  10. package/dist/adapters/vitest.d.ts.map +1 -0
  11. package/dist/adapters/vitest.js +71 -0
  12. package/dist/adapters/vitest.js.map +1 -0
  13. package/dist/check.d.ts +33 -0
  14. package/dist/check.d.ts.map +1 -0
  15. package/dist/check.js +95 -0
  16. package/dist/check.js.map +1 -0
  17. package/dist/classify.d.ts +69 -0
  18. package/dist/classify.d.ts.map +1 -0
  19. package/dist/classify.js +130 -0
  20. package/dist/classify.js.map +1 -0
  21. package/dist/coverage.d.ts +88 -0
  22. package/dist/coverage.d.ts.map +1 -0
  23. package/dist/coverage.js +114 -0
  24. package/dist/coverage.js.map +1 -0
  25. package/dist/diagnostic.d.ts +21 -0
  26. package/dist/diagnostic.d.ts.map +1 -0
  27. package/dist/diagnostic.js +20 -0
  28. package/dist/diagnostic.js.map +1 -0
  29. package/dist/fixture.d.ts +43 -0
  30. package/dist/fixture.d.ts.map +1 -0
  31. package/dist/fixture.js +42 -0
  32. package/dist/fixture.js.map +1 -0
  33. package/dist/index.d.ts +16 -0
  34. package/dist/index.d.ts.map +1 -0
  35. package/dist/index.js +16 -0
  36. package/dist/index.js.map +1 -0
  37. package/dist/placeholders.d.ts +39 -0
  38. package/dist/placeholders.d.ts.map +1 -0
  39. package/dist/placeholders.js +47 -0
  40. package/dist/placeholders.js.map +1 -0
  41. package/dist/records.d.ts +77 -0
  42. package/dist/records.d.ts.map +1 -0
  43. package/dist/records.js +354 -0
  44. package/dist/records.js.map +1 -0
  45. package/package.json +33 -0
  46. package/src/adapters/common.ts +139 -0
  47. package/src/adapters/node-test.ts +139 -0
  48. package/src/adapters/vitest.ts +82 -0
  49. package/src/check.ts +137 -0
  50. package/src/classify.ts +222 -0
  51. package/src/coverage.ts +181 -0
  52. package/src/diagnostic.ts +39 -0
  53. package/src/fixture.ts +64 -0
  54. package/src/index.ts +15 -0
  55. package/src/placeholders.ts +51 -0
  56. package/src/records.ts +482 -0
@@ -0,0 +1,139 @@
1
+ /**
2
+ * `node --test --test-reporter=tap`: TAP version 13 as Node's test runner
3
+ * writes it. Each test is announced by `# Subtest: <title>` and reported by an
4
+ * `ok` or `not ok` line at the same indentation, four spaces per level of
5
+ * nesting, followed by a YAML diagnostic block. A test's full title is its
6
+ * ancestors' titles and its own, joined with " > ".
7
+ *
8
+ * Results: `ok` is pass; `# SKIP` and `# TODO` are skip; `not ok` is fail when
9
+ * an assertion failed and error when anything else went wrong (a thrown
10
+ * non-assertion, a hook failure, a timeout, a cancellation). A test with
11
+ * subtests is not counted on its own unless it failed for a reason of its
12
+ * own: its subtests are what carry the result.
13
+ */
14
+ import type { RunResult } from "../records.ts";
15
+ import type { ParsedRun, ParsedTest } from "./common.ts";
16
+
17
+ const SUBTEST = /^# Subtest: (.*)$/;
18
+ const TEST_POINT = /^(not ok|ok)\b(?:\s+\d+)?(?:\s+-)?\s*(.*)$/;
19
+ const ASSERTION_CODE = "ERR_ASSERTION";
20
+
21
+ export function parseNodeTap(text: string): ParsedRun {
22
+ const lines = text.split(/\r?\n/);
23
+ const titles: string[] = [];
24
+ const hasChild: boolean[] = [];
25
+ const tests: ParsedTest[] = [];
26
+ const problems: string[] = [];
27
+
28
+ let i = 0;
29
+ while (i < lines.length) {
30
+ const line = lines[i];
31
+ const indent = line.length - line.trimStart().length;
32
+ const body = line.slice(indent);
33
+ const depth = Math.floor(indent / 4);
34
+
35
+ const subtest = SUBTEST.exec(body);
36
+ if (subtest !== null) {
37
+ titles[depth] = unescapeTap(subtest[1]);
38
+ titles.length = depth + 1;
39
+ hasChild[depth] = false;
40
+ hasChild.length = depth + 1;
41
+ i++;
42
+ continue;
43
+ }
44
+
45
+ const point = TEST_POINT.exec(body);
46
+ if (point === null) {
47
+ if (body.startsWith("Bail out!")) problems.push(body);
48
+ i++;
49
+ continue;
50
+ }
51
+
52
+ i++;
53
+ const yaml = new Map<string, string>();
54
+ if (i < lines.length && lines[i].trim() === "---") {
55
+ const blockIndent = lines[i].length - lines[i].trimStart().length;
56
+ i++;
57
+ while (i < lines.length) {
58
+ const current = lines[i];
59
+ const currentIndent = current.length - current.trimStart().length;
60
+ i++;
61
+ if (current.trim() === "..." && currentIndent === blockIndent) break;
62
+ if (currentIndent !== blockIndent) continue;
63
+ const pair = /^([A-Za-z_][A-Za-z0-9_]*):(?:\s+(.*))?$/.exec(current.slice(blockIndent));
64
+ if (pair !== null) yaml.set(pair[1], unquote(pair[2] ?? ""));
65
+ }
66
+ }
67
+
68
+ const { title, directive } = splitDirective(point[2]);
69
+ const parent = hasChild[depth] === true;
70
+ hasChild[depth] = false;
71
+ if (depth > 0) hasChild[depth - 1] = true;
72
+
73
+ const failureType = yaml.get("failureType");
74
+ const result = resultOf(point[1] === "ok", directive, failureType, yaml);
75
+ if (parent && (result === "pass" || result === "skip" || failureType === "subtestsFailed")) continue;
76
+
77
+ const ancestors = titles.slice(0, depth).filter((t) => t !== undefined);
78
+ const duration = Number(yaml.get("duration_ms"));
79
+ tests.push({
80
+ fullName: [...ancestors, title].join(" > "),
81
+ result,
82
+ durationMs: yaml.has("duration_ms") && Number.isFinite(duration) ? duration : null,
83
+ });
84
+ }
85
+ return { tests, problems };
86
+ }
87
+
88
+ function resultOf(
89
+ ok: boolean,
90
+ directive: "skip" | "todo" | null,
91
+ failureType: string | undefined,
92
+ yaml: Map<string, string>,
93
+ ): RunResult {
94
+ if (directive !== null) return "skip";
95
+ if (ok) return "pass";
96
+ if (failureType === "subtestsFailed") return "fail";
97
+ const assertion = yaml.get("code") === ASSERTION_CODE || yaml.get("name") === "AssertionError";
98
+ if (assertion) return "fail";
99
+ if (failureType === undefined) return "fail";
100
+ return "error";
101
+ }
102
+
103
+ /** Split a test point's description at its first unescaped `#` into the title and the directive. */
104
+ function splitDirective(description: string): { title: string; directive: "skip" | "todo" | null } {
105
+ for (let i = 0; i < description.length; i++) {
106
+ const c = description[i];
107
+ if (c === "\\") {
108
+ i++;
109
+ continue;
110
+ }
111
+ if (c !== "#") continue;
112
+ const rest = description.slice(i + 1).trim();
113
+ const directive = /^skip\b/i.test(rest) ? "skip" : /^todo\b/i.test(rest) ? "todo" : null;
114
+ return { title: unescapeTap(description.slice(0, i).trimEnd()), directive };
115
+ }
116
+ return { title: unescapeTap(description.trimEnd()), directive: null };
117
+ }
118
+
119
+ const ESCAPES: Record<string, string> = { b: "\b", f: "\f", n: "\n", r: "\r", t: "\t", v: "\v" };
120
+
121
+ /** Undo Node's TAP escaping: `\\`, `\#` and the control-character escapes. */
122
+ function unescapeTap(text: string): string {
123
+ return text.replace(/\\(.)/g, (_, c: string) => ESCAPES[c] ?? c);
124
+ }
125
+
126
+ function unquote(value: string): string {
127
+ const trimmed = value.trim();
128
+ if (trimmed.length >= 2 && trimmed.startsWith("'") && trimmed.endsWith("'")) {
129
+ return trimmed.slice(1, -1).replace(/''/g, "'");
130
+ }
131
+ if (trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')) {
132
+ try {
133
+ return JSON.parse(trimmed) as string;
134
+ } catch {
135
+ return trimmed.slice(1, -1);
136
+ }
137
+ }
138
+ return trimmed;
139
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * `vitest run --reporter=json`: `testResults[]`, one per file, each with
3
+ * `assertionResults[]` carrying `ancestorTitles`, `title`, `fullName`,
4
+ * `status` and `failureMessages`. Results: passed is pass; skipped, pending,
5
+ * todo and disabled are skip; failed is fail when the failure is an
6
+ * assertion and error when the test threw anything else. A file that failed
7
+ * without reporting any test (it did not load) is listed in `problems`,
8
+ * since no test title can carry it to a selector.
9
+ */
10
+ import type { RunResult } from "../records.ts";
11
+ import type { ParsedRun, ParsedTest } from "./common.ts";
12
+
13
+ const STATUSES: Record<string, RunResult | "failed"> = {
14
+ passed: "pass",
15
+ failed: "failed",
16
+ skipped: "skip",
17
+ pending: "skip",
18
+ todo: "skip",
19
+ disabled: "skip",
20
+ };
21
+
22
+ export function parseVitestJson(json: unknown): ParsedRun {
23
+ if (!isRecord(json) || !Array.isArray(json.testResults)) {
24
+ throw new TypeError("This is not Vitest JSON reporter output: expected an object with a testResults array.");
25
+ }
26
+ const tests: ParsedTest[] = [];
27
+ const problems: string[] = [];
28
+ json.testResults.forEach((file, fileIndex) => {
29
+ if (!isRecord(file)) throw new TypeError(`testResults[${fileIndex}] is not an object.`);
30
+ const name = typeof file.name === "string" ? file.name : `testResults[${fileIndex}]`;
31
+ const assertions = Array.isArray(file.assertionResults) ? file.assertionResults : [];
32
+ if (assertions.length === 0 && file.status === "failed") {
33
+ problems.push(`${name} failed without running a test: ${firstLine(file.message) || "no message"}`);
34
+ }
35
+ assertions.forEach((assertion, index) => {
36
+ if (!isRecord(assertion))
37
+ throw new TypeError(`testResults[${fileIndex}].assertionResults[${index}] is not an object.`);
38
+ const title = typeof assertion.title === "string" ? assertion.title : "";
39
+ const ancestors = Array.isArray(assertion.ancestorTitles)
40
+ ? assertion.ancestorTitles.filter((t): t is string => typeof t === "string")
41
+ : [];
42
+ const fullName =
43
+ typeof assertion.fullName === "string" && assertion.fullName !== ""
44
+ ? assertion.fullName
45
+ : [...ancestors, title].join(" > ");
46
+ const status = typeof assertion.status === "string" ? STATUSES[assertion.status] : undefined;
47
+ let result: RunResult;
48
+ if (status === undefined) {
49
+ problems.push(`${fullName} has the unknown status ${JSON.stringify(assertion.status)}, read as error`);
50
+ result = "error";
51
+ } else if (status === "failed") {
52
+ const messages = Array.isArray(assertion.failureMessages) ? assertion.failureMessages : [];
53
+ result = failureKind(typeof messages[0] === "string" ? messages[0] : "");
54
+ } else {
55
+ result = status;
56
+ }
57
+ const duration = assertion.duration;
58
+ tests.push({
59
+ fullName,
60
+ result,
61
+ durationMs: typeof duration === "number" && Number.isFinite(duration) ? duration : null,
62
+ });
63
+ });
64
+ });
65
+ return { tests, problems };
66
+ }
67
+
68
+ /** An assertion failure is fail; any other thrown error class is error; a bare message is a failed expectation. */
69
+ function failureKind(message: string): RunResult {
70
+ const head = message.trimStart();
71
+ if (/^AssertionError\b/.test(head)) return "fail";
72
+ if (/^[A-Za-z]*Error\b/.test(head)) return "error";
73
+ return "fail";
74
+ }
75
+
76
+ function firstLine(value: unknown): string {
77
+ return typeof value === "string" ? (value.trim().split("\n")[0] ?? "") : "";
78
+ }
79
+
80
+ function isRecord(value: unknown): value is Record<string, unknown> {
81
+ return typeof value === "object" && value !== null && !Array.isArray(value);
82
+ }
package/src/check.ts ADDED
@@ -0,0 +1,137 @@
1
+ /**
2
+ * The evidence check (Core §8, §11): classification, coverage, and the
3
+ * diagnostics that follow from them. CORE007 joins at L3, where current
4
+ * passing evidence is a conformance requirement; below L3 missing evidence is
5
+ * not a diagnostic, but the coverage report still shows every gap.
6
+ */
7
+ import { type Diagnostic, type Graph, type Level, LEVELS, sortDiagnostics } from "@intentset/core";
8
+ import { type Classification, classifyEvidence, type EvidenceSnapshot, type EvidenceStatus } from "./classify.ts";
9
+ import { type CoverageReport, compareUnresolved, coverage } from "./coverage.ts";
10
+ import { compareStrings, evidenceDiagnostic } from "./diagnostic.ts";
11
+ import type { RunRecord } from "./records.ts";
12
+
13
+ export interface CheckEvidenceOptions {
14
+ level?: Level;
15
+ /**
16
+ * Every repository-relative path in the tree. When given, each
17
+ * verification's locator must name one of them (EVID003); when absent the
18
+ * locators are not checked and each is listed as unresolved.
19
+ */
20
+ files?: ReadonlySet<string>;
21
+ /** The file the records were read from, for diagnostics about a record. */
22
+ evidencePath?: string | null;
23
+ }
24
+
25
+ export interface EvidenceCheck {
26
+ /**
27
+ * EVID001 (graph-aware), EVID002, EVID003 and, at L3 and above, CORE007 (an
28
+ * error for a required claim, a warning for a draft claim with no
29
+ * verification definition); sorted, origin "evidence".
30
+ */
31
+ diagnostics: Diagnostic[];
32
+ classification: Classification;
33
+ coverage: CoverageReport;
34
+ }
35
+
36
+ const PHRASES: Record<EvidenceStatus, string> = {
37
+ "current-pass": "passed at this snapshot",
38
+ "current-fail": "failed at this snapshot",
39
+ stale: "is stale, with runs only at another commit or graph hash",
40
+ skip: "was skipped at this snapshot",
41
+ error: "errored at this snapshot",
42
+ missing: "has no run record",
43
+ unresolved: "could not be classified",
44
+ };
45
+
46
+ export function checkEvidence(
47
+ graph: Graph,
48
+ records: readonly RunRecord[],
49
+ snapshot: EvidenceSnapshot,
50
+ options: CheckEvidenceOptions = {},
51
+ ): EvidenceCheck {
52
+ const level = options.level ?? "L1";
53
+ const classification = classifyEvidence(graph, records, snapshot, { evidencePath: options.evidencePath ?? null });
54
+ const report = coverage(graph, classification, { level });
55
+ const diagnostics: Diagnostic[] = [...classification.diagnostics];
56
+
57
+ for (const id of Object.keys(classification.verifications)) {
58
+ const artifact = graph.artifacts.get(id);
59
+ const locator = artifact?.meta.verification?.locator;
60
+ if (artifact === undefined || locator === undefined || artifact.meta.status === "retired") continue;
61
+ if (options.files === undefined) {
62
+ report.unresolved.push({ subject: id, reason: `locator ${locator} not checked: no file tree was supplied` });
63
+ } else if (!options.files.has(locator)) {
64
+ diagnostics.push(
65
+ evidenceDiagnostic({
66
+ code: "EVID003",
67
+ severity: "warning",
68
+ artifact: id,
69
+ path: artifact.path,
70
+ field: "/intentset/verification/locator",
71
+ message: `Verification ${id} names the locator ${locator}, which is not a file in the repository tree.`,
72
+ remediation:
73
+ "Correct the locator to the repository-relative path of the test or review procedure, or add the file.",
74
+ }),
75
+ );
76
+ }
77
+ }
78
+ report.unresolved.sort(compareUnresolved);
79
+
80
+ if (LEVELS.indexOf(level) >= LEVELS.indexOf("L3")) {
81
+ for (const claim of report.perClaim) {
82
+ // A draft claim is not required yet, but one with no verification at all
83
+ // is a gap CI output should show (decided 2026-10-03, after the Streamlane
84
+ // pilot reported nothing at L3 while two rules had no test): a warning,
85
+ // never an error, and only for a missing definition, not a missing run.
86
+ if (!claim.required && claim.lifecycle === "draft" && claim.verifications.length === 0) {
87
+ diagnostics.push(
88
+ evidenceDiagnostic({
89
+ code: "CORE007",
90
+ severity: "warning",
91
+ artifact: claim.id,
92
+ path: claim.path,
93
+ message: `Draft ${claim.type} ${claim.id} has no verification definition naming it in verifies; it is not required while draft, and will fail L3 once approved.`,
94
+ remediation: `Add a verification whose links.verifies names ${claim.id} before the claim leaves draft (Core §8).`,
95
+ }),
96
+ );
97
+ continue;
98
+ }
99
+ if (!claim.required || claim.verified) continue;
100
+ const subject = `${capitalize(claim.lifecycle)} ${claim.type} ${claim.id}`;
101
+ if (claim.verifications.length === 0) {
102
+ diagnostics.push(
103
+ evidenceDiagnostic({
104
+ code: "CORE007",
105
+ artifact: claim.id,
106
+ path: claim.path,
107
+ message: `${subject} has no verification definition naming it in verifies, so it cannot be verified in this snapshot.`,
108
+ remediation: `Add a verification whose links.verifies names ${claim.id}, then record a passing run at the snapshot (Core §8).`,
109
+ }),
110
+ );
111
+ continue;
112
+ }
113
+ const failing = claim.verifications.filter((v) => v.status !== "current-pass");
114
+ const where = `commit ${snapshot.commit ?? "(unknown)"} and graph hash ${snapshot.graphHash.slice(0, 12)}`;
115
+ diagnostics.push(
116
+ evidenceDiagnostic({
117
+ code: "CORE007",
118
+ artifact: claim.id,
119
+ path: claim.path,
120
+ message: `${subject} is not verified in this snapshot: ${failing.map((v) => `${v.id} ${PHRASES[v.status]}`).join("; ")}.`,
121
+ remediation: `Record a passing run of ${failing
122
+ .map((v) => v.id)
123
+ .sort(compareStrings)
124
+ .join(
125
+ ", ",
126
+ )} at ${where}, fixing whatever failed first; stale, skipped and errored runs never count (Core §8).`,
127
+ }),
128
+ );
129
+ }
130
+ }
131
+
132
+ return { diagnostics: sortDiagnostics(diagnostics), classification, coverage: report };
133
+ }
134
+
135
+ function capitalize(text: string): string {
136
+ return text.charAt(0).toUpperCase() + text.slice(1);
137
+ }
@@ -0,0 +1,222 @@
1
+ /**
2
+ * Evidence freshness (Core §8, invariant 3). Only a `pass` at the assessed
3
+ * commit and graph hash is current; everything else is something else, and
4
+ * says which. A failing run at the snapshot stays visible however many older
5
+ * passes there are, because the status is decided by the latest run at the
6
+ * snapshot and every run is kept in the history.
7
+ */
8
+ import { type Diagnostic, type Graph, sortDiagnostics } from "@intentset/core";
9
+ import { compareStrings, evidenceDiagnostic } from "./diagnostic.ts";
10
+ import { CURRENT, compareRecords, type RunRecord, type RunResult, type RunScope } from "./records.ts";
11
+
12
+ export const EVIDENCE_STATUSES = [
13
+ "current-pass",
14
+ "current-fail",
15
+ "stale",
16
+ "skip",
17
+ "error",
18
+ "missing",
19
+ "unresolved",
20
+ ] as const;
21
+ export type EvidenceStatus = (typeof EVIDENCE_STATUSES)[number];
22
+
23
+ /** What evidence is assessed against: a commit and graph hash, and the product and release it is for. */
24
+ export interface EvidenceSnapshot {
25
+ /** The assessed commit, or null when none could be read: matching then uses the graph hash alone, and says so. */
26
+ commit: string | null;
27
+ /** The graph hash of the snapshot (ADR 0005). */
28
+ graphHash: string;
29
+ /** The exact product and release under assessment. Absent or null considers records of every scope, and reports that it did. */
30
+ scope?: RunScope | null;
31
+ }
32
+
33
+ export type ResultCounts = Record<RunResult, number>;
34
+
35
+ export interface VerificationEvidence {
36
+ verificationId: string;
37
+ method: "automated" | "manual";
38
+ status: EvidenceStatus;
39
+ /** Why the status is qualified or what was set aside, when anything was. */
40
+ note?: string;
41
+ /** The record that decided the status: the latest at the snapshot, else the latest in scope; null when there is none. */
42
+ latest: RunRecord | null;
43
+ /** Every in-scope record for this verification, oldest first (finishedAt, then evidenceId). */
44
+ history: RunRecord[];
45
+ /** Results of the records at the snapshot, so a failure followed by a pass is still countable. */
46
+ atSnapshot: ResultCounts;
47
+ /** Records for this verification that named another product or release and were not considered. */
48
+ outOfScope: number;
49
+ }
50
+
51
+ export interface RejectedRecord {
52
+ record: RunRecord;
53
+ reason: string;
54
+ }
55
+
56
+ export interface Classification {
57
+ snapshot: { commit: string | null; graphHash: string; scope: RunScope | null };
58
+ /** One entry per verification artifact in the graph, keyed and ordered by ID. */
59
+ verifications: Record<string, VerificationEvidence>;
60
+ /** Records whose scope named another product or release, across every verification. */
61
+ ignoredOutOfScope: number;
62
+ /** Records naming an ID that is not a verification in the graph (EVID002), by evidence ID. */
63
+ unknown: RunRecord[];
64
+ /** Records that cannot be evidence for the verification they name (EVID001), by evidence ID. */
65
+ rejected: RejectedRecord[];
66
+ /** EVID001 and EVID002, sorted. */
67
+ diagnostics: Diagnostic[];
68
+ }
69
+
70
+ export interface ClassifyOptions {
71
+ /** The file the records were read from, for diagnostics; null when they did not come from one. */
72
+ evidencePath?: string | null;
73
+ }
74
+
75
+ const NOTE_COMMIT_UNKNOWN = "commit unknown; matched on graph hash";
76
+
77
+ /** Core §8: classify every verification in the graph against the snapshot. Deterministic for identical inputs. */
78
+ export function classifyEvidence(
79
+ graph: Graph,
80
+ records: readonly RunRecord[],
81
+ snapshot: EvidenceSnapshot,
82
+ options: ClassifyOptions = {},
83
+ ): Classification {
84
+ const path = options.evidencePath ?? null;
85
+ const scope = snapshot.scope ?? null;
86
+ const diagnostics: Diagnostic[] = [];
87
+ const unknown: RunRecord[] = [];
88
+ const rejected: RejectedRecord[] = [];
89
+ const inScope = new Map<string, RunRecord[]>();
90
+ const outOfScope = new Map<string, number>();
91
+ let ignoredOutOfScope = 0;
92
+
93
+ const ids = [...graph.artifacts.keys()]
94
+ .filter((id) => graph.artifacts.get(id)?.meta.type === "verification")
95
+ .sort(compareStrings);
96
+ for (const id of ids) inScope.set(id, []);
97
+
98
+ const ordered = [...records].sort((a, b) => compareStrings(a.evidenceId, b.evidenceId) || compareRecords(a, b));
99
+ for (const record of ordered) {
100
+ const target = graph.artifacts.get(record.verificationId);
101
+ if (target === undefined || target.meta.type !== "verification") {
102
+ unknown.push(record);
103
+ diagnostics.push(
104
+ evidenceDiagnostic({
105
+ code: "EVID002",
106
+ severity: "warning",
107
+ artifact: null,
108
+ path,
109
+ message:
110
+ target === undefined
111
+ ? `Run record ${record.evidenceId} names ${record.verificationId}, which is not in the graph, so it is evidence for nothing.`
112
+ : `Run record ${record.evidenceId} names ${record.verificationId}, which is a ${target.meta.type}, not a verification.`,
113
+ remediation:
114
+ "Point the record at the verification it ran, or create that verification; runs are evidence for verifications only (Core §8).",
115
+ }),
116
+ );
117
+ continue;
118
+ }
119
+ if (target.meta.verification?.method === "manual" && record.reviewer === null) {
120
+ const reason = `${record.verificationId} is a manual review, and record ${record.evidenceId} names no reviewer.`;
121
+ rejected.push({ record, reason });
122
+ diagnostics.push(
123
+ evidenceDiagnostic({
124
+ code: "EVID001",
125
+ artifact: record.verificationId,
126
+ path,
127
+ message: `Run record ${record.evidenceId} names no reviewer, but ${record.verificationId} is a manual review.`,
128
+ remediation:
129
+ "Record who performed the review and the rationale; a manual record must identify both (Core §8).",
130
+ }),
131
+ );
132
+ continue;
133
+ }
134
+ if (scope !== null && (record.scope.product !== scope.product || record.scope.release !== scope.release)) {
135
+ ignoredOutOfScope++;
136
+ outOfScope.set(record.verificationId, (outOfScope.get(record.verificationId) ?? 0) + 1);
137
+ continue;
138
+ }
139
+ inScope.get(record.verificationId)?.push(record);
140
+ }
141
+
142
+ const verifications: Record<string, VerificationEvidence> = {};
143
+ for (const id of ids) {
144
+ const artifact = graph.artifacts.get(id);
145
+ const history = (inScope.get(id) ?? []).sort(compareRecords);
146
+ verifications[id] = classifyOne(
147
+ id,
148
+ artifact?.meta.verification?.method ?? "automated",
149
+ history,
150
+ outOfScope.get(id) ?? 0,
151
+ snapshot,
152
+ );
153
+ }
154
+
155
+ return {
156
+ snapshot: { commit: snapshot.commit, graphHash: snapshot.graphHash, scope },
157
+ verifications,
158
+ ignoredOutOfScope,
159
+ unknown,
160
+ rejected,
161
+ diagnostics: sortDiagnostics(diagnostics),
162
+ };
163
+ }
164
+
165
+ /** True when a record was made at the snapshot: same graph hash, and same commit unless the snapshot's is unknown. */
166
+ export function atSnapshot(record: RunRecord, snapshot: { commit: string | null; graphHash: string }): boolean {
167
+ return record.graphHash === snapshot.graphHash && (snapshot.commit === null || record.commit === snapshot.commit);
168
+ }
169
+
170
+ function classifyOne(
171
+ verificationId: string,
172
+ method: "automated" | "manual",
173
+ history: RunRecord[],
174
+ outOfScope: number,
175
+ snapshot: EvidenceSnapshot,
176
+ ): VerificationEvidence {
177
+ const counts: ResultCounts = { pass: 0, fail: 0, skip: 0, error: 0 };
178
+ const base = { verificationId, method, history, atSnapshot: counts, outOfScope };
179
+ const ignored =
180
+ outOfScope > 0 ? `${outOfScope} record${outOfScope === 1 ? "" : "s"} for another product or release ignored` : null;
181
+
182
+ if (history.length === 0) {
183
+ return withNote({ ...base, status: "missing", latest: null }, [ignored]);
184
+ }
185
+
186
+ const unbound = history.filter(
187
+ (record) => record.graphHash === CURRENT || (snapshot.commit !== null && record.commit === CURRENT),
188
+ );
189
+ if (unbound.length > 0) {
190
+ const names = unbound.map((record) => record.evidenceId).join(", ");
191
+ return withNote({ ...base, status: "unresolved", latest: history[history.length - 1] }, [
192
+ `unbound @current placeholder in ${names}; bind it to the snapshot before classifying`,
193
+ ignored,
194
+ ]);
195
+ }
196
+
197
+ const current = history.filter((record) => atSnapshot(record, snapshot));
198
+ for (const record of current) counts[record.result]++;
199
+ if (current.length === 0) {
200
+ return withNote({ ...base, status: "stale", latest: history[history.length - 1] }, [ignored]);
201
+ }
202
+
203
+ const latest = current[current.length - 1];
204
+ const status: EvidenceStatus =
205
+ latest.result === "pass" ? "current-pass" : latest.result === "fail" ? "current-fail" : latest.result;
206
+ return withNote({ ...base, status, latest }, [snapshot.commit === null ? NOTE_COMMIT_UNKNOWN : null, ignored]);
207
+ }
208
+
209
+ function withNote(evidence: Omit<VerificationEvidence, "note">, notes: (string | null)[]): VerificationEvidence {
210
+ const present = notes.filter((note): note is string => note !== null);
211
+ const { verificationId, method, status, latest, history, atSnapshot: counts, outOfScope } = evidence;
212
+ return {
213
+ verificationId,
214
+ method,
215
+ status,
216
+ ...(present.length > 0 ? { note: present.join("; ") } : {}),
217
+ latest,
218
+ history,
219
+ atSnapshot: counts,
220
+ outOfScope,
221
+ };
222
+ }