@intentset/verification 0.6.0 → 0.7.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 (51) hide show
  1. package/README.md +50 -0
  2. package/dist/adapters/common.d.ts +6 -0
  3. package/dist/adapters/common.d.ts.map +1 -1
  4. package/dist/adapters/common.js +2 -0
  5. package/dist/adapters/common.js.map +1 -1
  6. package/dist/adapters/playwright.d.ts +3 -0
  7. package/dist/adapters/playwright.d.ts.map +1 -0
  8. package/dist/adapters/playwright.js +78 -0
  9. package/dist/adapters/playwright.js.map +1 -0
  10. package/dist/binding.d.ts +31 -0
  11. package/dist/binding.d.ts.map +1 -0
  12. package/dist/binding.js +46 -0
  13. package/dist/binding.js.map +1 -0
  14. package/dist/classify.d.ts +35 -7
  15. package/dist/classify.d.ts.map +1 -1
  16. package/dist/classify.js +104 -14
  17. package/dist/classify.js.map +1 -1
  18. package/dist/fixture.d.ts +18 -2
  19. package/dist/fixture.d.ts.map +1 -1
  20. package/dist/fixture.js +23 -3
  21. package/dist/fixture.js.map +1 -1
  22. package/dist/index.d.ts +4 -0
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +4 -0
  25. package/dist/index.js.map +1 -1
  26. package/dist/records.d.ts +8 -1
  27. package/dist/records.d.ts.map +1 -1
  28. package/dist/records.js +24 -1
  29. package/dist/records.js.map +1 -1
  30. package/dist/report.d.ts.map +1 -1
  31. package/dist/report.js +4 -0
  32. package/dist/report.js.map +1 -1
  33. package/dist/stale.d.ts +65 -0
  34. package/dist/stale.d.ts.map +1 -0
  35. package/dist/stale.js +95 -0
  36. package/dist/stale.js.map +1 -0
  37. package/dist/subject.d.ts +92 -0
  38. package/dist/subject.d.ts.map +1 -0
  39. package/dist/subject.js +247 -0
  40. package/dist/subject.js.map +1 -0
  41. package/package.json +9 -2
  42. package/src/adapters/common.ts +8 -0
  43. package/src/adapters/playwright.ts +94 -0
  44. package/src/binding.ts +68 -0
  45. package/src/classify.ts +165 -14
  46. package/src/fixture.ts +36 -3
  47. package/src/index.ts +4 -0
  48. package/src/records.ts +40 -2
  49. package/src/report.ts +4 -0
  50. package/src/stale.ts +160 -0
  51. package/src/subject.ts +277 -0
package/src/classify.ts CHANGED
@@ -1,13 +1,21 @@
1
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.
2
+ * Evidence freshness (Core §8, invariant 3). Only a `pass` whose binding holds
3
+ * is current: under commit binding, at the assessed commit and graph hash;
4
+ * under subject binding, while the subject it checked is unchanged. Everything
5
+ * else is something else, and says which. A failing current run stays visible
6
+ * however many older passes there are, because the status is decided by the
7
+ * latest current run and every run is kept in the history.
7
8
  */
8
- import { type Diagnostic, type Graph, sortDiagnostics } from "@intentset/core";
9
+ import {
10
+ type Diagnostic,
11
+ type EvidenceBinding,
12
+ type EvidenceBindingReason,
13
+ type Graph,
14
+ sortDiagnostics,
15
+ } from "@intentset/core";
9
16
  import { compareStrings, evidenceDiagnostic } from "./diagnostic.ts";
10
17
  import { CURRENT, compareRecords, type RunRecord, type RunResult, type RunScope } from "./records.ts";
18
+ import { partsChangedAmong, type Subject } from "./subject.ts";
11
19
 
12
20
  export const EVIDENCE_STATUSES = [
13
21
  "current-pass",
@@ -28,6 +36,23 @@ export interface EvidenceSnapshot {
28
36
  graphHash: string;
29
37
  /** The exact product and release under assessment. Absent or null considers records of every scope, and reports that it did. */
30
38
  scope?: RunScope | null;
39
+ /** The binding in force (Core §8); commit when absent, as before the field existed. */
40
+ binding?: EvidenceBinding;
41
+ /**
42
+ * Why the binding is the one in force: `configured` (the repository asked), `default` (subject, because slices
43
+ * exist) or `no-slices` (commit, because the repository has no slice claims to hash and did not ask for it,
44
+ * which EVID004 says). Absent reads as `configured`.
45
+ */
46
+ bindingReason?: EvidenceBindingReason;
47
+ /** Under subject binding: the subject of each verification at the snapshot, by verification ID. */
48
+ subjects?: ReadonlyMap<string, Subject>;
49
+ /**
50
+ * The files changed between a record's commit and the assessed commit, or null when the commit is not an
51
+ * ancestor of it or git is unavailable. Optional: it only makes the notes and EVID005 more specific.
52
+ */
53
+ changedSince?: (commit: string) => readonly string[] | null;
54
+ /** True for a file that can move some subject: claimed, a used resource's, a global input, a test, a record. */
55
+ covers?: (path: string) => boolean;
31
56
  }
32
57
 
33
58
  export type ResultCounts = Record<RunResult, number>;
@@ -36,6 +61,10 @@ export interface VerificationEvidence {
36
61
  verificationId: string;
37
62
  method: "automated" | "manual";
38
63
  status: EvidenceStatus;
64
+ /** The binding that judged this verification. */
65
+ binding: EvidenceBinding;
66
+ /** Under subject binding, the hash of the subject it was judged at; null under commit binding and when none could be computed. */
67
+ subject: string | null;
39
68
  /** Why the status is qualified or what was set aside, when anything was. */
40
69
  note?: string;
41
70
  /** The record that decided the status: the latest at the snapshot, else the latest in scope; null when there is none. */
@@ -54,7 +83,14 @@ export interface RejectedRecord {
54
83
  }
55
84
 
56
85
  export interface Classification {
57
- snapshot: { commit: string | null; graphHash: string; scope: RunScope | null };
86
+ snapshot: {
87
+ commit: string | null;
88
+ graphHash: string;
89
+ scope: RunScope | null;
90
+ binding: EvidenceBinding;
91
+ /** Why this binding is in force (`configured` when the caller did not say). */
92
+ bindingReason: EvidenceBindingReason;
93
+ };
58
94
  /** One entry per verification artifact in the graph, keyed and ordered by ID. */
59
95
  verifications: Record<string, VerificationEvidence>;
60
96
  /** Records whose scope named another product or release, across every verification. */
@@ -63,7 +99,7 @@ export interface Classification {
63
99
  unknown: RunRecord[];
64
100
  /** Records that cannot be evidence for the verification they name (EVID001), by evidence ID. */
65
101
  rejected: RejectedRecord[];
66
- /** EVID001 and EVID002, sorted. */
102
+ /** EVID001, EVID002, EVID004 and EVID005, sorted. */
67
103
  diagnostics: Diagnostic[];
68
104
  }
69
105
 
@@ -74,6 +110,11 @@ export interface ClassifyOptions {
74
110
 
75
111
  const NOTE_COMMIT_UNKNOWN = "commit unknown; matched on graph hash";
76
112
 
113
+ /** The most files EVID005 lists; the rest are counted. */
114
+ export const UNCLAIMED_LIMIT = 20;
115
+
116
+ const short = (commit: string) => commit.slice(0, 9);
117
+
77
118
  /** Core §8: classify every verification in the graph against the snapshot. Deterministic for identical inputs. */
78
119
  export function classifyEvidence(
79
120
  graph: Graph,
@@ -139,7 +180,24 @@ export function classifyEvidence(
139
180
  inScope.get(record.verificationId)?.push(record);
140
181
  }
141
182
 
183
+ const binding = snapshot.binding ?? "commit";
184
+ if (snapshot.bindingReason === "no-slices") {
185
+ diagnostics.push(
186
+ evidenceDiagnostic({
187
+ code: "EVID004",
188
+ severity: "warning",
189
+ artifact: null,
190
+ path: null,
191
+ message:
192
+ "No slice claims files, so there is no subject to bind evidence to: it is judged by commit binding (a record is current only at the assessed commit and graph hash).",
193
+ remediation:
194
+ "Declare slices with claims (VSA §3) so evidence can stay current while its subject is unchanged, or set `evidence.binding: commit` in .intentset/config.yaml to choose this on purpose (Core §8).",
195
+ }),
196
+ );
197
+ }
198
+
142
199
  const verifications: Record<string, VerificationEvidence> = {};
200
+ const unclaimed = new Set<string>();
143
201
  for (const id of ids) {
144
202
  const artifact = graph.artifacts.get(id);
145
203
  const history = (inScope.get(id) ?? []).sort(compareRecords);
@@ -149,11 +207,34 @@ export function classifyEvidence(
149
207
  history,
150
208
  outOfScope.get(id) ?? 0,
151
209
  snapshot,
210
+ binding,
211
+ unclaimed,
212
+ );
213
+ }
214
+ if (unclaimed.size > 0) {
215
+ const files = [...unclaimed].sort(compareStrings);
216
+ const listed = files.slice(0, UNCLAIMED_LIMIT);
217
+ diagnostics.push(
218
+ evidenceDiagnostic({
219
+ code: "EVID005",
220
+ severity: "warning",
221
+ artifact: null,
222
+ path: null,
223
+ message: `${files.length} file${files.length === 1 ? "" : "s"} changed since the commit of a subject-bound record and no slice claims, no used resource names and no global input lists ${files.length === 1 ? "it" : "them"}, so ${files.length === 1 ? "it" : "they"} cannot make any evidence stale: ${listed.join(", ")}${files.length > listed.length ? `, and ${files.length - listed.length} more` : ""}.`,
224
+ remediation:
225
+ "Claim the file in a slice (the architecture check's unclaimed files rule), name its resource in `usesResources`, or list it in `evidence.globalInputs` if it affects every check (Core §8).",
226
+ }),
152
227
  );
153
228
  }
154
229
 
155
230
  return {
156
- snapshot: { commit: snapshot.commit, graphHash: snapshot.graphHash, scope },
231
+ snapshot: {
232
+ commit: snapshot.commit,
233
+ graphHash: snapshot.graphHash,
234
+ scope,
235
+ binding,
236
+ bindingReason: snapshot.bindingReason ?? "configured",
237
+ },
157
238
  verifications,
158
239
  ignoredOutOfScope,
159
240
  unknown,
@@ -173,9 +254,12 @@ function classifyOne(
173
254
  history: RunRecord[],
174
255
  outOfScope: number,
175
256
  snapshot: EvidenceSnapshot,
257
+ binding: EvidenceBinding,
258
+ unclaimed: Set<string>,
176
259
  ): VerificationEvidence {
177
260
  const counts: ResultCounts = { pass: 0, fail: 0, skip: 0, error: 0 };
178
- const base = { verificationId, method, history, atSnapshot: counts, outOfScope };
261
+ const subjectHash = binding === "subject" ? (snapshot.subjects?.get(verificationId)?.hash ?? null) : null;
262
+ const base = { verificationId, method, binding, subject: subjectHash, history, atSnapshot: counts, outOfScope };
179
263
  const ignored =
180
264
  outOfScope > 0 ? `${outOfScope} record${outOfScope === 1 ? "" : "s"} for another product or release ignored` : null;
181
265
 
@@ -194,25 +278,92 @@ function classifyOne(
194
278
  ]);
195
279
  }
196
280
 
197
- const current = history.filter((record) => atSnapshot(record, snapshot));
281
+ const subject = snapshot.subjects?.get(verificationId);
282
+ if (binding === "subject" && subject === undefined) {
283
+ return withNote({ ...base, status: "unresolved", latest: history[history.length - 1] }, [
284
+ "subject binding is in force but no subject could be computed for this verification",
285
+ ignored,
286
+ ]);
287
+ }
288
+
289
+ const current =
290
+ binding === "subject"
291
+ ? history.filter((record) => record.binding === "subject" && record.subjectHash === subject?.hash)
292
+ : history.filter((record) => record.binding !== "subject" && atSnapshot(record, snapshot));
198
293
  for (const record of current) counts[record.result]++;
199
294
  if (current.length === 0) {
200
- return withNote({ ...base, status: "stale", latest: history[history.length - 1] }, [ignored]);
295
+ const latest = history[history.length - 1];
296
+ return withNote({ ...base, status: "stale", latest }, [staleNote(latest, binding, subject, snapshot), ignored]);
201
297
  }
202
298
 
203
299
  const latest = current[current.length - 1];
204
300
  const status: EvidenceStatus =
205
301
  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]);
302
+ const notes: (string | null)[] = [];
303
+ if (binding === "subject") {
304
+ const unlisted = collectUnclaimed(latest, snapshot, unclaimed);
305
+ notes.push(`judged by subject binding; the run was at commit ${short(latest.commit)}${unlisted}`);
306
+ } else if (snapshot.commit === null) notes.push(NOTE_COMMIT_UNKNOWN);
307
+ notes.push(ignored);
308
+ return withNote({ ...base, status, latest }, notes);
309
+ }
310
+
311
+ /** Why the latest record is not current under the binding in force, with what moved when that can be said. */
312
+ function staleNote(
313
+ latest: RunRecord,
314
+ binding: EvidenceBinding,
315
+ subject: Subject | undefined,
316
+ snapshot: EvidenceSnapshot,
317
+ ): string | null {
318
+ if (binding === "commit") {
319
+ return latest.binding === "subject"
320
+ ? "latest record is bound to a subject, not to a commit; a gate that asks for commit binding needs a run imported at this commit with commit binding"
321
+ : null;
322
+ }
323
+ if (latest.binding !== "subject") {
324
+ return `latest record is bound to commit ${short(latest.commit)}, not to a subject, so subject binding judges it stale; it can be re-imported to bind it to the subject`;
325
+ }
326
+ if (snapshot.changedSince === undefined || subject === undefined) return "the subject changed since the run";
327
+ const changed = snapshot.changedSince(latest.commit);
328
+ if (changed === null) {
329
+ return `the subject changed since the run; which part moved is unknown, because commit ${short(latest.commit)} is not an ancestor of the assessed commit or git is unavailable`;
330
+ }
331
+ const parts = partsChangedAmong(subject, new Set(changed));
332
+ return parts.length === 0
333
+ ? `the subject changed since the run at commit ${short(latest.commit)}, in a part no changed file names (a file that left the subject, or a record moved)`
334
+ : `the subject changed since the run at commit ${short(latest.commit)}: ${parts.slice(0, 5).join(", ")}${parts.length > 5 ? `, and ${parts.length - 5} more` : ""}`;
335
+ }
336
+
337
+ /** Collects what changed outside every subject since a current record's commit (EVID005), and says when that cannot be known. */
338
+ function collectUnclaimed(latest: RunRecord, snapshot: EvidenceSnapshot, unclaimed: Set<string>): string {
339
+ if (snapshot.changedSince === undefined || snapshot.covers === undefined) return "";
340
+ const changed = snapshot.changedSince(latest.commit);
341
+ if (changed === null) {
342
+ return `; files changed since then could not be listed, because the commit is not an ancestor of the assessed commit or git is unavailable`;
343
+ }
344
+ for (const path of changed) if (!snapshot.covers(path)) unclaimed.add(path);
345
+ return "";
207
346
  }
208
347
 
209
348
  function withNote(evidence: Omit<VerificationEvidence, "note">, notes: (string | null)[]): VerificationEvidence {
210
349
  const present = notes.filter((note): note is string => note !== null);
211
- const { verificationId, method, status, latest, history, atSnapshot: counts, outOfScope } = evidence;
350
+ const {
351
+ verificationId,
352
+ method,
353
+ binding,
354
+ subject,
355
+ status,
356
+ latest,
357
+ history,
358
+ atSnapshot: counts,
359
+ outOfScope,
360
+ } = evidence;
212
361
  return {
213
362
  verificationId,
214
363
  method,
215
364
  status,
365
+ binding,
366
+ subject,
216
367
  ...(present.length > 0 ? { note: present.join("; ") } : {}),
217
368
  latest,
218
369
  history,
package/src/fixture.ts CHANGED
@@ -12,9 +12,26 @@
12
12
  * 4. Check locators against the tree's paths (documents and sources) only
13
13
  * when the case supplies `sources`; a case without them has no tree to
14
14
  * check against, and its locators are reported unresolved instead.
15
- * 5. The case's diagnostics are validation's plus the reader's plus the check's.
15
+ * 5. Decide the binding (`resolveBinding`): the case's `.intentset/config.yaml`
16
+ * `evidence.binding`, else subject where the graph has slice claims, else
17
+ * commit with EVID004. Under subject binding the driver resolves what the
18
+ * architecture package knows (claimed files, import closures) over the
19
+ * tree of documents and sources into a `SubjectSource`, and every
20
+ * verification's subject is computed from it (`subjectsFor`).
21
+ * 6. The case's `changedSince`, when it has one, answers the question git
22
+ * would: the files changed between a record's commit and the snapshot.
23
+ * A commit it does not list is one that is not an ancestor.
24
+ * 7. The case's diagnostics are validation's plus the reader's plus the check's.
16
25
  */
17
- import { type Diagnostic, type Graph, graphHash, type Level, sortDiagnostics } from "@intentset/core";
26
+ import {
27
+ type Diagnostic,
28
+ type EvidenceBinding,
29
+ type Graph,
30
+ graphHash,
31
+ type Level,
32
+ sortDiagnostics,
33
+ } from "@intentset/core";
34
+ import { hasSliceClaims, resolveBinding, type SubjectSource, subjectsFor } from "./binding.ts";
18
35
  import { type EvidenceCheck, checkEvidence } from "./check.ts";
19
36
  import type { EvidenceSnapshot } from "./classify.ts";
20
37
  import { bindPlaceholders, FIXTURE_COMMIT, requestedScope } from "./placeholders.ts";
@@ -34,6 +51,12 @@ export interface FixtureEvidenceInput {
34
51
  documents: Iterable<string>;
35
52
  /** Paths of the case's `sources`. */
36
53
  sources: Iterable<string>;
54
+ /** `evidence.binding` of the case's configuration; null or absent when it states none. */
55
+ configuredBinding?: EvidenceBinding | null;
56
+ /** What the architecture package resolved over the tree; needed only when the binding in force is subject. */
57
+ subjectSource?: SubjectSource;
58
+ /** The case's `changedSince`: for a commit, the files changed between it and the snapshot. */
59
+ changedSince?: Readonly<Record<string, readonly string[]>>;
37
60
  }
38
61
 
39
62
  export interface FixtureEvidenceRun extends EvidenceCheck {
@@ -43,11 +66,21 @@ export interface FixtureEvidenceRun extends EvidenceCheck {
43
66
  }
44
67
 
45
68
  export function checkFixtureEvidence(input: FixtureEvidenceInput): FixtureEvidenceRun {
46
- const snapshot: EvidenceSnapshot = {
69
+ const decision = resolveBinding(input.configuredBinding ?? null, hasSliceClaims(input.graph));
70
+ const base: EvidenceSnapshot = {
47
71
  commit: FIXTURE_COMMIT,
48
72
  graphHash: graphHash(input.graph),
49
73
  scope: requestedScope(input.request),
50
74
  };
75
+ const snapshot: EvidenceSnapshot = { ...base, binding: decision.binding, bindingReason: decision.reason };
76
+ if (decision.binding === "subject" && input.subjectSource !== undefined) {
77
+ snapshot.subjects = subjectsFor(input.graph, input.subjectSource);
78
+ snapshot.covers = input.subjectSource.covers;
79
+ if (input.changedSince !== undefined) {
80
+ const changed = input.changedSince;
81
+ snapshot.changedSince = (commit) => changed[commit] ?? null;
82
+ }
83
+ }
51
84
  const read = readRunRecords(bindPlaceholders(input.evidence, snapshot), FIXTURE_EVIDENCE_PATH);
52
85
  const sources = [...input.sources];
53
86
  const files = sources.length > 0 ? new Set([...input.documents, ...sources]) : undefined;
package/src/index.ts CHANGED
@@ -6,7 +6,9 @@
6
6
  */
7
7
  export * from "./adapters/common.ts";
8
8
  export * from "./adapters/node-test.ts";
9
+ export * from "./adapters/playwright.ts";
9
10
  export * from "./adapters/vitest.ts";
11
+ export * from "./binding.ts";
10
12
  export * from "./check.ts";
11
13
  export * from "./classify.ts";
12
14
  export * from "./coverage.ts";
@@ -14,3 +16,5 @@ export * from "./fixture.ts";
14
16
  export * from "./placeholders.ts";
15
17
  export * from "./records.ts";
16
18
  export * from "./report.ts";
19
+ export * from "./stale.ts";
20
+ export * from "./subject.ts";
package/src/records.ts CHANGED
@@ -15,13 +15,17 @@ import { compareStrings, evidenceDiagnostic, pointerToken } from "./diagnostic.t
15
15
  export const RUN_RESULTS = ["pass", "fail", "skip", "error"] as const;
16
16
  export type RunResult = (typeof RUN_RESULTS)[number];
17
17
 
18
+ /** What a record is bound to (Core §8): the subject the run checked, or the commit and graph hash it ran at. */
19
+ export const RUN_BINDINGS = ["subject", "commit"] as const;
20
+ export type RunBinding = (typeof RUN_BINDINGS)[number];
21
+
18
22
  /** The literal a fixture writes for "the snapshot's commit" or "the snapshot's graph hash". */
19
23
  export const CURRENT = "@current";
20
24
 
21
25
  export interface RunScope {
22
26
  /** The product's artifact ID. */
23
27
  product: string;
24
- /** The exact release label; v0.1 matches release labels exactly (Core §7). */
28
+ /** The exact release label; v0.2 matches release labels exactly (Core §7). */
25
29
  release: string;
26
30
  }
27
31
 
@@ -48,6 +52,10 @@ export interface RunRecord {
48
52
  finishedAt: string;
49
53
  result: RunResult;
50
54
  uri: string;
55
+ /** Absent means commit binding, as every record written before the field existed. */
56
+ binding?: RunBinding;
57
+ /** SHA-256 of the subject the run checked (subject.ts); present on every subject-binding record. */
58
+ subjectHash?: string;
51
59
  rationale?: string;
52
60
  /** Namespaced extensions (`org.example/key`), preserved and never interpreted. */
53
61
  extensions?: Record<string, unknown>;
@@ -67,7 +75,7 @@ const REQUIRED = [
67
75
  "result",
68
76
  "uri",
69
77
  ] as const;
70
- const KNOWN = new Set<string>([...REQUIRED, "rationale", "extensions"]);
78
+ const KNOWN = new Set<string>([...REQUIRED, "binding", "subjectHash", "rationale", "extensions"]);
71
79
 
72
80
  const TIMESTAMP = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,9}))?Z$/;
73
81
  const GRAPH_HASH = /^[0-9a-f]{64}$/;
@@ -261,6 +269,34 @@ function readRecord(entry: unknown, index: number, problems: Problem[]): RunReco
261
269
  );
262
270
  }
263
271
 
272
+ let binding: RunBinding | undefined;
273
+ if (Object.hasOwn(entry, "binding")) {
274
+ if ((RUN_BINDINGS as readonly unknown[]).includes(entry.binding)) binding = entry.binding as RunBinding;
275
+ else
276
+ problem(
277
+ "binding",
278
+ `\`binding\` of run record ${index} must be subject or commit, but it is ${describe(entry.binding)}.`,
279
+ "Write `binding: subject` for a record bound to its subject, or leave it out for a commit-binding record (Core §8).",
280
+ );
281
+ }
282
+ let subjectHash: string | undefined;
283
+ if (Object.hasOwn(entry, "subjectHash")) {
284
+ if (typeof entry.subjectHash === "string" && GRAPH_HASH.test(entry.subjectHash)) subjectHash = entry.subjectHash;
285
+ else
286
+ problem(
287
+ "subjectHash",
288
+ `\`subjectHash\` of run record ${index} must be 64 lowercase hex characters, but it is ${describe(entry.subjectHash)}.`,
289
+ "Record the subject hash `intentset evidence import` computed for the verification (Core §8).",
290
+ );
291
+ }
292
+ if (binding === "subject" && !Object.hasOwn(entry, "subjectHash")) {
293
+ problem(
294
+ "subjectHash",
295
+ `Run record ${index} has binding subject but no \`subjectHash\`, so nothing says what it was bound to.`,
296
+ "Add the subject hash, or leave `binding` out to make it a commit-binding record (Core §8).",
297
+ );
298
+ }
299
+
264
300
  let rationale: string | undefined;
265
301
  if (Object.hasOwn(entry, "rationale")) {
266
302
  if (typeof entry.rationale === "string" && /\S/.test(entry.rationale)) rationale = entry.rationale;
@@ -336,6 +372,8 @@ function readRecord(entry: unknown, index: number, problems: Problem[]): RunReco
336
372
  result: result as RunResult,
337
373
  uri: uri as string,
338
374
  };
375
+ if (binding !== undefined) record.binding = binding;
376
+ if (subjectHash !== undefined) record.subjectHash = subjectHash;
339
377
  if (rationale !== undefined) record.rationale = rationale;
340
378
  if (extensions !== undefined) record.extensions = extensions;
341
379
  return record;
package/src/report.ts CHANGED
@@ -16,10 +16,14 @@ export function evidenceReport(check: EvidenceCheck, records: number): EvidenceR
16
16
  graphHash: classification.snapshot.graphHash,
17
17
  scope: classification.snapshot.scope,
18
18
  records,
19
+ binding: classification.snapshot.binding,
20
+ bindingReason: classification.snapshot.bindingReason,
19
21
  verifications: Object.values(classification.verifications).map((v) => ({
20
22
  id: v.verificationId,
21
23
  method: v.method,
22
24
  status: v.status,
25
+ binding: v.binding,
26
+ subject: v.subject,
23
27
  note: v.note ?? null,
24
28
  latest: v.latest,
25
29
  atSnapshot: { ...v.atSnapshot },
package/src/stale.ts ADDED
@@ -0,0 +1,160 @@
1
+ /**
2
+ * What needs a run (ADR 0013, "Slow suites"): the automated verifications
3
+ * whose evidence at the snapshot is not a current pass, grouped for a person
4
+ * and written as the arguments a test runner takes to run just those tests.
5
+ * Selection reads a classification and changes nothing; the runner formats are
6
+ * strings and know nothing of the repository.
7
+ *
8
+ * A selector is matched by Intentset as a whole token (`selectorMatches`); a
9
+ * runner's own `--grep` or `-t` is a regular expression that matches a
10
+ * substring of a title. The arguments here therefore select every test the
11
+ * token rule would, and possibly a few more (a selector that is a prefix of
12
+ * another's), never fewer.
13
+ */
14
+ import type { Graph } from "@intentset/core";
15
+ import { type Classification, EVIDENCE_STATUSES, type EvidenceStatus } from "./classify.ts";
16
+ import { compareStrings } from "./diagnostic.ts";
17
+
18
+ /** The statuses that mean a run is needed: everything but a current pass. */
19
+ export const NEEDS_RUN = EVIDENCE_STATUSES.filter((status) => status !== "current-pass");
20
+ export type NeedsRun = Exclude<EvidenceStatus, "current-pass">;
21
+
22
+ export interface StaleEntry {
23
+ verification: string;
24
+ selector: string;
25
+ locator: string;
26
+ status: NeedsRun;
27
+ /** The note that names what moved, for the statuses where it says something a reader needs. */
28
+ note?: string;
29
+ }
30
+
31
+ export interface ManualEntry {
32
+ verification: string;
33
+ locator: string;
34
+ status: NeedsRun;
35
+ note?: string;
36
+ }
37
+
38
+ export interface StaleCounts {
39
+ /** Automated verifications that are not retired. */
40
+ automated: number;
41
+ /** Of those, the ones with a current pass. */
42
+ current: number;
43
+ /** Of those, the ones that need a run: the sum of `byStatus`. */
44
+ needRun: number;
45
+ byStatus: Record<NeedsRun, number>;
46
+ /** Manual verifications without a current review, which no runner re-runs. */
47
+ manual: number;
48
+ }
49
+
50
+ export interface StaleReport {
51
+ needRun: StaleEntry[];
52
+ manual: ManualEntry[];
53
+ counts: StaleCounts;
54
+ }
55
+
56
+ /** The statuses whose note says why or what moved; for the others the note only says how a pass was judged. */
57
+ const NOTED: ReadonlySet<EvidenceStatus> = new Set(["stale", "missing", "unresolved"]);
58
+
59
+ /** Every verification that is not retired, split by method, with those not currently passing listed and counted. */
60
+ export function staleReport(graph: Graph, classification: Classification): StaleReport {
61
+ const needRun: StaleEntry[] = [];
62
+ const manual: ManualEntry[] = [];
63
+ const byStatus = Object.fromEntries(NEEDS_RUN.map((status) => [status, 0])) as Record<NeedsRun, number>;
64
+ let automated = 0;
65
+ for (const [id, evidence] of Object.entries(classification.verifications)) {
66
+ const meta = graph.artifacts.get(id)?.meta;
67
+ if (meta?.type !== "verification" || meta.status === "retired" || meta.verification === undefined) continue;
68
+ const { locator, selector } = meta.verification;
69
+ const note = NOTED.has(evidence.status) && evidence.note !== undefined ? { note: evidence.note } : {};
70
+ if (meta.verification.method === "manual") {
71
+ if (evidence.status !== "current-pass") {
72
+ manual.push({ verification: id, locator, status: evidence.status as NeedsRun, ...note });
73
+ }
74
+ continue;
75
+ }
76
+ automated++;
77
+ if (evidence.status === "current-pass") continue;
78
+ byStatus[evidence.status as NeedsRun]++;
79
+ needRun.push({ verification: id, selector, locator, status: evidence.status as NeedsRun, ...note });
80
+ }
81
+ needRun.sort(
82
+ (a, b) =>
83
+ compareStrings(a.locator, b.locator) ||
84
+ compareStrings(a.selector, b.selector) ||
85
+ compareStrings(a.verification, b.verification),
86
+ );
87
+ manual.sort((a, b) => compareStrings(a.locator, b.locator) || compareStrings(a.verification, b.verification));
88
+ return {
89
+ needRun,
90
+ manual,
91
+ counts: {
92
+ automated,
93
+ current: automated - needRun.length,
94
+ needRun: needRun.length,
95
+ byStatus,
96
+ manual: manual.length,
97
+ },
98
+ };
99
+ }
100
+
101
+ /** "3 of 12 automated verifications need a run: stale 2, missing 1." */
102
+ export function staleSummary(counts: StaleCounts): string {
103
+ const parts = NEEDS_RUN.filter((status) => counts.byStatus[status] > 0).map(
104
+ (status) => `${status} ${counts.byStatus[status]}`,
105
+ );
106
+ const manual =
107
+ counts.manual === 0
108
+ ? ""
109
+ : ` ${counts.manual} manual verification${counts.manual === 1 ? " has" : "s have"} no current review, which a runner does not re-run.`;
110
+ return `${counts.needRun} of ${counts.automated} automated verification${counts.automated === 1 ? "" : "s"} need a run${parts.length === 0 ? "" : `: ${parts.join(", ")}`}.${manual}`;
111
+ }
112
+
113
+ export const RUNNER_FORMATS = ["playwright", "vitest", "node-test"] as const;
114
+ export type RunnerFormat = (typeof RUNNER_FORMATS)[number];
115
+
116
+ /** A string as a JavaScript regular expression matching it literally; `/` is escaped so a pattern never reads as `/re/flags`. */
117
+ export function escapeRegExp(text: string): string {
118
+ return text.replace(/[\\^$.*+?()[\]{}|/]/g, "\\$&");
119
+ }
120
+
121
+ /** The same, for a file filter that is a regular expression but reads as a path: a dot stays a dot, which only matches more. */
122
+ function pathPattern(path: string): string {
123
+ return path.replace(/[\\^$*+?()[\]{}|]/g, "\\$&");
124
+ }
125
+
126
+ const PLAIN = /^[A-Za-z0-9_@%+=:,./-]+$/;
127
+
128
+ /** One word for a POSIX shell: as it is when nothing in it is special, else in single quotes with each `'` closed, escaped and reopened. */
129
+ export function shellQuote(text: string): string {
130
+ if (PLAIN.test(text)) return text;
131
+ return `'${text.replaceAll("'", "'\\''")}'`;
132
+ }
133
+
134
+ const unique = (values: string[]) => [...new Set(values)].sort(compareStrings);
135
+
136
+ /**
137
+ * The arguments that run just these tests, on one line to paste after the runner's own command (`playwright test`,
138
+ * `vitest run`, `node --test`). Empty when there is nothing to run. Selectors are sorted and unique; the locator
139
+ * files are the ones the entries name.
140
+ */
141
+ export function runnerArguments(
142
+ format: RunnerFormat,
143
+ entries: readonly Pick<StaleEntry, "selector" | "locator">[],
144
+ ): string {
145
+ if (entries.length === 0) return "";
146
+ const selectors = unique(entries.map((entry) => entry.selector));
147
+ const files = unique(entries.map((entry) => entry.locator));
148
+ const alternation = selectors.map(escapeRegExp).join("|");
149
+ switch (format) {
150
+ case "playwright":
151
+ return ["--grep", shellQuote(alternation), ...files.map((file) => shellQuote(pathPattern(file)))].join(" ");
152
+ case "vitest":
153
+ return [...files.map(shellQuote), "-t", shellQuote(alternation)].join(" ");
154
+ case "node-test":
155
+ return [
156
+ ...selectors.map((selector) => `--test-name-pattern=${shellQuote(escapeRegExp(selector))}`),
157
+ ...files.map(shellQuote),
158
+ ].join(" ");
159
+ }
160
+ }