@intentset/verification 0.6.1 → 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/subject.ts ADDED
@@ -0,0 +1,277 @@
1
+ /**
2
+ * The subject of a verification (Core §8, ADR 0013): a hash of what the check
3
+ * is about, so a run record can stay current while the subject is unchanged.
4
+ * Pure: it reads nothing but what it is given, so the same inputs give the
5
+ * same hash on every machine.
6
+ *
7
+ * The subject is:
8
+ *
9
+ * - the verification record and what it verifies (each verified behavior, the
10
+ * rules that govern it and the scenarios that illustrate it), each by the
11
+ * source hash of its whole record: its prose is its promise;
12
+ * - the slices that implement the verified behaviors, the transitive closure
13
+ * of their `dependsOn`, and those the verification names in
14
+ * `verification.exercises`: each by the hash of its **binding fields**
15
+ * (`implements`, `dependsOn`, `claims`, `usesResources`, `entrypoints`;
16
+ * never its prose) and the content of every file its claims resolve to;
17
+ * - the resources those slices use, and any resource `exercises` names: each
18
+ * by its registry entry and the content of the files its path matches;
19
+ * - the test file (`locator`) and the local files it imports, transitively,
20
+ * which the caller resolves (`localImports`) with the architecture
21
+ * package's resolver and stops at a package boundary;
22
+ * - the repository's global inputs, which the caller has expanded to paths.
23
+ *
24
+ * The slices' and resources' files are resolved by the architecture package
25
+ * and passed in, so this package does not depend on it. The hash is the
26
+ * SHA-256 of the canonical JSON of the sorted entries (ADR 0005), like
27
+ * `graphHash`; a file is its content's SHA-256, a missing one the word
28
+ * `absent`, so a deleted file moves the subject. `parts` carries each entry,
29
+ * so a report can say which part moved (`movedParts`).
30
+ */
31
+ import { canonicalJson, type Graph, sha256Hex } from "@intentset/core";
32
+
33
+ export const SUBJECT_PART_KINDS = [
34
+ "verification",
35
+ "verified",
36
+ "slice",
37
+ "slice-file",
38
+ "resource",
39
+ "resource-file",
40
+ "test",
41
+ "test-import",
42
+ "global-input",
43
+ ] as const;
44
+ export type SubjectPartKind = (typeof SUBJECT_PART_KINDS)[number];
45
+
46
+ /** One thing that went into the hash: `id` is a record ID or a path, `hash` its content or source hash. */
47
+ export interface SubjectPart {
48
+ kind: SubjectPartKind;
49
+ id: string;
50
+ hash: string;
51
+ /** For a slice or a resource: why it is included (`implements`, `dependsOn`, `exercises`, `usesResources`; sorted, joined by a comma). */
52
+ why?: string;
53
+ /** For a slice file or a resource file: the slice or resource it belongs to. */
54
+ owner?: string;
55
+ /** The repository-relative file the part is, or the record's file: what a `git diff` names when the part moves. */
56
+ path?: string;
57
+ }
58
+
59
+ export interface Subject {
60
+ hash: string;
61
+ /** Sorted by kind, then ID. */
62
+ parts: SubjectPart[];
63
+ }
64
+
65
+ /** A registry resource as the subject sees it: its record, and the files its path matches. */
66
+ export interface SubjectResource {
67
+ record: unknown;
68
+ files: readonly string[];
69
+ }
70
+
71
+ export interface SubjectInput {
72
+ graph: Graph;
73
+ verificationId: string;
74
+ /** Repository-relative path to its bytes; undefined when the file does not exist. */
75
+ readFile: (path: string) => Uint8Array | undefined;
76
+ /** A memoized hash of a file's content (or `absent`), for a caller hashing many subjects; default: hash `readFile`. */
77
+ fileHash?: (path: string) => string;
78
+ /** Slice ID to the repository-relative files it claims, as the architecture package resolves them. */
79
+ sliceFiles: ReadonlyMap<string, readonly string[]> | Readonly<Record<string, readonly string[]>>;
80
+ /** Registry resource ID to its record and files; absent means the repository declares none. */
81
+ resources?: ReadonlyMap<string, SubjectResource>;
82
+ /** The local files a file imports, transitively and without the file itself, stopping at a package boundary. */
83
+ localImports?: (path: string) => readonly string[];
84
+ /** Files whose change affects every check and that belong to no slice (`evidence.globalInputs`), as paths. */
85
+ globalInputs: readonly string[];
86
+ }
87
+
88
+ /** What a missing file hashes as, so a deleted file moves the subject and is told apart from an empty one. */
89
+ export const ABSENT = "absent";
90
+
91
+ const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0);
92
+
93
+ /** The verification's `verification.exercises`, or none. */
94
+ export function exercisesOf(graph: Graph, id: string): string[] {
95
+ const list = graph.artifacts.get(id)?.meta.verification?.exercises;
96
+ return Array.isArray(list) ? list.filter((x): x is string => typeof x === "string") : [];
97
+ }
98
+
99
+ /** The behaviors a verification is about: those it verifies, those governed by a rule it verifies, those a scenario it verifies illustrates. */
100
+ export function verifiedBehaviors(graph: Graph, verificationId: string): string[] {
101
+ const behaviors = new Set<string>();
102
+ for (const edge of graph.out.get(verificationId) ?? []) {
103
+ if (edge.kind !== "verifies") continue;
104
+ const target = graph.artifacts.get(edge.to);
105
+ if (target === undefined) continue;
106
+ if (target.meta.type === "behavior") behaviors.add(target.meta.id);
107
+ else if (target.meta.type === "rule") {
108
+ for (const back of graph.in.get(target.meta.id) ?? []) if (back.kind === "governedBy") behaviors.add(back.from);
109
+ } else if (target.meta.type === "scenario") {
110
+ for (const out of graph.out.get(target.meta.id) ?? []) if (out.kind === "illustrates") behaviors.add(out.to);
111
+ }
112
+ }
113
+ return [...behaviors].filter((id) => graph.artifacts.get(id)?.meta.type === "behavior").sort(cmp);
114
+ }
115
+
116
+ /** The slices a subject includes, each with why: implementers, their `dependsOn` closure, and the exercised slices. */
117
+ export function includedSlices(graph: Graph, verificationId: string): Map<string, string> {
118
+ const why = new Map<string, Set<string>>();
119
+ const add = (id: string, reason: string) => {
120
+ const set = why.get(id) ?? new Set<string>();
121
+ set.add(reason);
122
+ why.set(id, set);
123
+ };
124
+ const queue: string[] = [];
125
+ const seed = (id: string, reason: string) => {
126
+ if (graph.artifacts.get(id)?.meta.type !== "slice") return;
127
+ if (!why.has(id)) queue.push(id);
128
+ add(id, reason);
129
+ };
130
+ for (const behavior of verifiedBehaviors(graph, verificationId)) {
131
+ for (const edge of graph.in.get(behavior) ?? []) if (edge.kind === "implements") seed(edge.from, "implements");
132
+ }
133
+ for (const id of exercisesOf(graph, verificationId)) seed(id, "exercises");
134
+ while (queue.length > 0) {
135
+ const id = queue.pop() as string;
136
+ for (const edge of graph.out.get(id) ?? []) {
137
+ if (edge.kind !== "dependsOn") continue;
138
+ if (!why.has(edge.to)) queue.push(edge.to);
139
+ if (graph.artifacts.get(edge.to)?.meta.type === "slice") add(edge.to, "dependsOn");
140
+ }
141
+ }
142
+ return new Map([...why].sort(([a], [b]) => cmp(a, b)).map(([id, set]) => [id, [...set].sort(cmp).join(",")]));
143
+ }
144
+
145
+ /** The binding fields of a slice record, canonical: what the subject hashes of it instead of the whole record. */
146
+ export function sliceBinding(graph: Graph, id: string): unknown {
147
+ const artifact = graph.artifacts.get(id);
148
+ if (artifact === undefined) return null;
149
+ const links = artifact.meta.links;
150
+ const slice = artifact.meta.slice;
151
+ const sorted = (list: readonly string[] | undefined) => [...(list ?? [])].sort(cmp);
152
+ return {
153
+ implements: sorted(links.implements),
154
+ dependsOn: sorted(links.dependsOn),
155
+ claims: (slice?.claims ?? [])
156
+ .map((c) => ({ kind: c.kind, path: c.path }))
157
+ .sort((a, b) => cmp(a.kind, b.kind) || cmp(a.path, b.path)),
158
+ usesResources: sorted(slice?.usesResources),
159
+ entrypoints: sorted(slice?.entrypoints),
160
+ };
161
+ }
162
+
163
+ export function computeSubject(input: SubjectInput): Subject {
164
+ const { graph, verificationId, readFile, globalInputs } = input;
165
+ const verification = graph.artifacts.get(verificationId);
166
+ if (verification === undefined || verification.meta.type !== "verification") {
167
+ throw new Error(`${verificationId} is not a verification in the graph`);
168
+ }
169
+ const claimed = (id: string): readonly string[] =>
170
+ input.sliceFiles instanceof Map
171
+ ? (input.sliceFiles.get(id) ?? [])
172
+ : ((input.sliceFiles as Record<string, readonly string[]>)[id] ?? []);
173
+ const fileHash =
174
+ input.fileHash ??
175
+ ((path: string) => {
176
+ const bytes = readFile(path);
177
+ return bytes === undefined ? ABSENT : sha256Hex(bytes);
178
+ });
179
+ const recordPart = (kind: SubjectPartKind, id: string, extra: Partial<SubjectPart> = {}): SubjectPart => {
180
+ const artifact = graph.artifacts.get(id);
181
+ return {
182
+ kind,
183
+ id,
184
+ hash: artifact === undefined ? ABSENT : artifact.sourceHash,
185
+ ...extra,
186
+ ...(artifact === undefined ? {} : { path: artifact.path }),
187
+ };
188
+ };
189
+
190
+ const parts: SubjectPart[] = [recordPart("verification", verificationId)];
191
+
192
+ const targets = new Set<string>();
193
+ for (const edge of graph.out.get(verificationId) ?? []) if (edge.kind === "verifies") targets.add(edge.to);
194
+ for (const id of verifiedBehaviors(graph, verificationId)) {
195
+ targets.add(id);
196
+ for (const edge of graph.out.get(id) ?? []) if (edge.kind === "governedBy") targets.add(edge.to);
197
+ for (const edge of graph.in.get(id) ?? []) if (edge.kind === "illustrates") targets.add(edge.from);
198
+ }
199
+ for (const id of targets) parts.push(recordPart("verified", id));
200
+
201
+ const resourceWhy = new Map<string, Set<string>>();
202
+ const useResource = (id: string, reason: string) => {
203
+ if (input.resources?.has(id) !== true) return;
204
+ const set = resourceWhy.get(id) ?? new Set<string>();
205
+ set.add(reason);
206
+ resourceWhy.set(id, set);
207
+ };
208
+ for (const id of exercisesOf(graph, verificationId)) useResource(id, "exercises");
209
+
210
+ for (const [id, why] of includedSlices(graph, verificationId)) {
211
+ const artifact = graph.artifacts.get(id);
212
+ parts.push({
213
+ kind: "slice",
214
+ id,
215
+ hash: artifact === undefined ? ABSENT : sha256Hex(canonicalJson(sliceBinding(graph, id))),
216
+ why,
217
+ ...(artifact === undefined ? {} : { path: artifact.path }),
218
+ });
219
+ for (const path of [...new Set(claimed(id))].sort(cmp)) {
220
+ parts.push({ kind: "slice-file", id: path, hash: fileHash(path), owner: id, path });
221
+ }
222
+ for (const resource of artifact?.meta.slice?.usesResources ?? []) useResource(resource, "usesResources");
223
+ }
224
+
225
+ for (const [id, set] of resourceWhy) {
226
+ const resource = input.resources?.get(id) as SubjectResource;
227
+ parts.push({
228
+ kind: "resource",
229
+ id,
230
+ hash: sha256Hex(canonicalJson(resource.record)),
231
+ why: [...set].sort(cmp).join(","),
232
+ });
233
+ for (const path of [...new Set(resource.files)].sort(cmp)) {
234
+ parts.push({ kind: "resource-file", id: path, hash: fileHash(path), owner: id, path });
235
+ }
236
+ }
237
+
238
+ const locator = verification.meta.verification?.locator;
239
+ if (locator !== undefined && locator !== "") {
240
+ parts.push({ kind: "test", id: locator, hash: fileHash(locator), path: locator });
241
+ for (const path of [...new Set(input.localImports?.(locator) ?? [])].sort(cmp)) {
242
+ if (path !== locator) parts.push({ kind: "test-import", id: path, hash: fileHash(path), path });
243
+ }
244
+ }
245
+ for (const path of [...new Set(globalInputs)]) {
246
+ parts.push({ kind: "global-input", id: path, hash: fileHash(path), path });
247
+ }
248
+
249
+ parts.sort((a, b) => cmp(a.kind, b.kind) || cmp(a.id, b.id) || cmp(a.owner ?? "", b.owner ?? ""));
250
+ const entries = parts.map(({ kind, id, hash, why, owner }) => ({ kind, id, hash, why, owner }));
251
+ return { hash: sha256Hex(canonicalJson(entries)), parts };
252
+ }
253
+
254
+ const partKey = (p: SubjectPart) => `${p.kind} ${p.id}${p.owner === undefined ? "" : ` (${p.owner})`}`;
255
+
256
+ /** The parts that differ between two subjects, as `kind id` keys, sorted: added, removed or changed. */
257
+ export function movedParts(before: Subject, after: Subject): string[] {
258
+ const a = new Map(before.parts.map((p) => [partKey(p), p.hash]));
259
+ const b = new Map(after.parts.map((p) => [partKey(p), p.hash]));
260
+ const moved: string[] = [];
261
+ for (const [k, h] of a) if (b.get(k) !== h) moved.push(k);
262
+ for (const k of b.keys()) if (!a.has(k)) moved.push(k);
263
+ return moved.sort(cmp);
264
+ }
265
+
266
+ /**
267
+ * The parts of a subject whose file or record is among `changed`: what a
268
+ * `git diff --name-only` since a record's commit says may have moved. A record
269
+ * file that changed only in prose is named too, since the diff cannot tell it
270
+ * from a change to a binding field; the hash decided the subject moved.
271
+ */
272
+ export function partsChangedAmong(subject: Subject, changed: ReadonlySet<string>): string[] {
273
+ return subject.parts
274
+ .filter((p) => p.path !== undefined && changed.has(p.path))
275
+ .map(partKey)
276
+ .sort(cmp);
277
+ }