@intentset/verification 0.6.1 → 0.7.1
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.
- package/README.md +50 -0
- package/dist/adapters/common.d.ts +16 -6
- package/dist/adapters/common.d.ts.map +1 -1
- package/dist/adapters/common.js +13 -8
- package/dist/adapters/common.js.map +1 -1
- package/dist/adapters/playwright.d.ts +3 -0
- package/dist/adapters/playwright.d.ts.map +1 -0
- package/dist/adapters/playwright.js +78 -0
- package/dist/adapters/playwright.js.map +1 -0
- package/dist/binding.d.ts +31 -0
- package/dist/binding.d.ts.map +1 -0
- package/dist/binding.js +46 -0
- package/dist/binding.js.map +1 -0
- package/dist/classify.d.ts +39 -7
- package/dist/classify.d.ts.map +1 -1
- package/dist/classify.js +208 -14
- package/dist/classify.js.map +1 -1
- package/dist/fixture.d.ts +18 -2
- package/dist/fixture.d.ts.map +1 -1
- package/dist/fixture.js +23 -3
- package/dist/fixture.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/records.d.ts +8 -1
- package/dist/records.d.ts.map +1 -1
- package/dist/records.js +24 -1
- package/dist/records.js.map +1 -1
- package/dist/report.d.ts.map +1 -1
- package/dist/report.js +4 -0
- package/dist/report.js.map +1 -1
- package/dist/stale.d.ts +65 -0
- package/dist/stale.d.ts.map +1 -0
- package/dist/stale.js +95 -0
- package/dist/stale.js.map +1 -0
- package/dist/subject.d.ts +130 -0
- package/dist/subject.d.ts.map +1 -0
- package/dist/subject.js +329 -0
- package/dist/subject.js.map +1 -0
- package/package.json +9 -2
- package/src/adapters/common.ts +26 -11
- package/src/adapters/playwright.ts +94 -0
- package/src/binding.ts +68 -0
- package/src/classify.ts +288 -14
- package/src/fixture.ts +36 -3
- package/src/index.ts +4 -0
- package/src/records.ts +40 -2
- package/src/report.ts +4 -0
- package/src/stale.ts +160 -0
- package/src/subject.ts +383 -0
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
|
+
}
|
package/src/subject.ts
ADDED
|
@@ -0,0 +1,383 @@
|
|
|
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
|
+
* A slice reached only through `dependsOn` is a **dependency**: under
|
|
18
|
+
* `dependencies: "entrypoint"` (`evidence.dependencies`) it contributes its
|
|
19
|
+
* binding fields, the files of its `entrypoints` and the records of the
|
|
20
|
+
* contracts it exposes or consumes and the decisions those name (a
|
|
21
|
+
* `contract` and a `decision` part), not its other claimed files; the
|
|
22
|
+
* files left out are `dependencyInternals`, so a report can say what the
|
|
23
|
+
* subject could not see (EVID007). An implementing slice and an exercised
|
|
24
|
+
* slice always contribute every file. `dependencies: "files"` is the rule
|
|
25
|
+
* of Core §8 up to 0.7.0 (ADR 0013, amended 2026-10-15, second);
|
|
26
|
+
* - the resources the verification exercises: each by its registry entry and
|
|
27
|
+
* the content of the files its path matches. A resource counts where
|
|
28
|
+
* `verification.exercises` names it, or where a slice that implements a
|
|
29
|
+
* verified behavior lists it in `usesResources` and also claims a file it
|
|
30
|
+
* matches (the slice's own resource). A resource a slice merely lists is not
|
|
31
|
+
* hashed, so a change to a backend a web slice talks to does not move every
|
|
32
|
+
* check of that slice; `resourceMode: "used"` (`evidence.resources: used`)
|
|
33
|
+
* hashes the files of every resource an included slice lists, as Core §8
|
|
34
|
+
* did from 0.7.0 to 0.7.1 (ADR 0013, amended 2026-10-15). What a subject
|
|
35
|
+
* leaves out is `unexercised`, so a report can say what it could not see
|
|
36
|
+
* (EVID006);
|
|
37
|
+
* - the test file (`locator`) and the local files it imports, transitively,
|
|
38
|
+
* which the caller resolves (`localImports`) with the architecture
|
|
39
|
+
* package's resolver and stops at a package boundary;
|
|
40
|
+
* - the repository's global inputs, which the caller has expanded to paths.
|
|
41
|
+
*
|
|
42
|
+
* The slices' and resources' files are resolved by the architecture package
|
|
43
|
+
* and passed in, so this package does not depend on it. The hash is the
|
|
44
|
+
* SHA-256 of the canonical JSON of the sorted entries (ADR 0005), like
|
|
45
|
+
* `graphHash`; a file is its content's SHA-256, a missing one the word
|
|
46
|
+
* `absent`, so a deleted file moves the subject. `parts` carries each entry,
|
|
47
|
+
* so a report can say which part moved (`movedParts`).
|
|
48
|
+
*/
|
|
49
|
+
import {
|
|
50
|
+
canonicalJson,
|
|
51
|
+
DEFAULT_EVIDENCE_DEPENDENCIES,
|
|
52
|
+
type EvidenceDependencies,
|
|
53
|
+
type EvidenceResources,
|
|
54
|
+
type Graph,
|
|
55
|
+
sha256Hex,
|
|
56
|
+
} from "@intentset/core";
|
|
57
|
+
|
|
58
|
+
export const SUBJECT_PART_KINDS = [
|
|
59
|
+
"verification",
|
|
60
|
+
"verified",
|
|
61
|
+
"slice",
|
|
62
|
+
"slice-file",
|
|
63
|
+
"contract",
|
|
64
|
+
"decision",
|
|
65
|
+
"resource",
|
|
66
|
+
"resource-file",
|
|
67
|
+
"test",
|
|
68
|
+
"test-import",
|
|
69
|
+
"global-input",
|
|
70
|
+
] as const;
|
|
71
|
+
export type SubjectPartKind = (typeof SUBJECT_PART_KINDS)[number];
|
|
72
|
+
|
|
73
|
+
/** One thing that went into the hash: `id` is a record ID or a path, `hash` its content or source hash. */
|
|
74
|
+
export interface SubjectPart {
|
|
75
|
+
kind: SubjectPartKind;
|
|
76
|
+
id: string;
|
|
77
|
+
hash: string;
|
|
78
|
+
/** For a slice or a resource: why it is included (`implements`, `dependsOn`, `exercises`, `usesResources`; sorted, joined by a comma). */
|
|
79
|
+
why?: string;
|
|
80
|
+
/** For a slice file or a resource file: the slice or resource it belongs to. */
|
|
81
|
+
owner?: string;
|
|
82
|
+
/** The repository-relative file the part is, or the record's file: what a `git diff` names when the part moves. */
|
|
83
|
+
path?: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** A registry resource an included slice lists in `usesResources` that the subject does not hash, with the files its path matches. */
|
|
87
|
+
export interface UnexercisedResource {
|
|
88
|
+
id: string;
|
|
89
|
+
files: string[];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** A dependency slice whose claimed files the subject does not hash (`dependencies: "entrypoint"`), with those files. */
|
|
93
|
+
export interface DependencyInternals {
|
|
94
|
+
id: string;
|
|
95
|
+
files: string[];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface Subject {
|
|
99
|
+
hash: string;
|
|
100
|
+
/** Sorted by kind, then ID. */
|
|
101
|
+
parts: SubjectPart[];
|
|
102
|
+
/** The resources the included slices list that this subject leaves out, sorted by ID; always empty under `resourceMode: "used"`. Not part of the hash. */
|
|
103
|
+
unexercised: UnexercisedResource[];
|
|
104
|
+
/** The dependency slices whose internal files this subject leaves out, sorted by ID; always empty under `dependencies: "files"`. Not part of the hash. */
|
|
105
|
+
dependencyInternals: DependencyInternals[];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** A registry resource as the subject sees it: its record, and the files its path matches. */
|
|
109
|
+
export interface SubjectResource {
|
|
110
|
+
record: unknown;
|
|
111
|
+
files: readonly string[];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface SubjectInput {
|
|
115
|
+
graph: Graph;
|
|
116
|
+
verificationId: string;
|
|
117
|
+
/** Repository-relative path to its bytes; undefined when the file does not exist. */
|
|
118
|
+
readFile: (path: string) => Uint8Array | undefined;
|
|
119
|
+
/** A memoized hash of a file's content (or `absent`), for a caller hashing many subjects; default: hash `readFile`. */
|
|
120
|
+
fileHash?: (path: string) => string;
|
|
121
|
+
/** Slice ID to the repository-relative files it claims, as the architecture package resolves them. */
|
|
122
|
+
sliceFiles: ReadonlyMap<string, readonly string[]> | Readonly<Record<string, readonly string[]>>;
|
|
123
|
+
/** Registry resource ID to its record and files; absent means the repository declares none. */
|
|
124
|
+
resources?: ReadonlyMap<string, SubjectResource>;
|
|
125
|
+
/** Which resource files count (`evidence.resources`): `exercised`, the default, or `used`. */
|
|
126
|
+
resourceMode?: EvidenceResources;
|
|
127
|
+
/** What a dependency slice contributes (`evidence.dependencies`): `DEFAULT_EVIDENCE_DEPENDENCIES` when absent. */
|
|
128
|
+
dependencyMode?: EvidenceDependencies;
|
|
129
|
+
/** The local files a file imports, transitively and without the file itself, stopping at a package boundary. */
|
|
130
|
+
localImports?: (path: string) => readonly string[];
|
|
131
|
+
/** Files whose change affects every check and that belong to no slice (`evidence.globalInputs`), as paths. */
|
|
132
|
+
globalInputs: readonly string[];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** What a missing file hashes as, so a deleted file moves the subject and is told apart from an empty one. */
|
|
136
|
+
export const ABSENT = "absent";
|
|
137
|
+
|
|
138
|
+
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0);
|
|
139
|
+
|
|
140
|
+
/** The verification's `verification.exercises`, or none. */
|
|
141
|
+
export function exercisesOf(graph: Graph, id: string): string[] {
|
|
142
|
+
const list = graph.artifacts.get(id)?.meta.verification?.exercises;
|
|
143
|
+
return Array.isArray(list) ? list.filter((x): x is string => typeof x === "string") : [];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** The behaviors a verification is about: those it verifies, those governed by a rule it verifies, those a scenario it verifies illustrates. */
|
|
147
|
+
export function verifiedBehaviors(graph: Graph, verificationId: string): string[] {
|
|
148
|
+
const behaviors = new Set<string>();
|
|
149
|
+
for (const edge of graph.out.get(verificationId) ?? []) {
|
|
150
|
+
if (edge.kind !== "verifies") continue;
|
|
151
|
+
const target = graph.artifacts.get(edge.to);
|
|
152
|
+
if (target === undefined) continue;
|
|
153
|
+
if (target.meta.type === "behavior") behaviors.add(target.meta.id);
|
|
154
|
+
else if (target.meta.type === "rule") {
|
|
155
|
+
for (const back of graph.in.get(target.meta.id) ?? []) if (back.kind === "governedBy") behaviors.add(back.from);
|
|
156
|
+
} else if (target.meta.type === "scenario") {
|
|
157
|
+
for (const out of graph.out.get(target.meta.id) ?? []) if (out.kind === "illustrates") behaviors.add(out.to);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return [...behaviors].filter((id) => graph.artifacts.get(id)?.meta.type === "behavior").sort(cmp);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The slices a subject includes, each with why: implementers, their `dependsOn` closure, and the exercised slices. */
|
|
164
|
+
export function includedSlices(graph: Graph, verificationId: string): Map<string, string> {
|
|
165
|
+
const why = new Map<string, Set<string>>();
|
|
166
|
+
const add = (id: string, reason: string) => {
|
|
167
|
+
const set = why.get(id) ?? new Set<string>();
|
|
168
|
+
set.add(reason);
|
|
169
|
+
why.set(id, set);
|
|
170
|
+
};
|
|
171
|
+
const queue: string[] = [];
|
|
172
|
+
const seed = (id: string, reason: string) => {
|
|
173
|
+
if (graph.artifacts.get(id)?.meta.type !== "slice") return;
|
|
174
|
+
if (!why.has(id)) queue.push(id);
|
|
175
|
+
add(id, reason);
|
|
176
|
+
};
|
|
177
|
+
for (const behavior of verifiedBehaviors(graph, verificationId)) {
|
|
178
|
+
for (const edge of graph.in.get(behavior) ?? []) if (edge.kind === "implements") seed(edge.from, "implements");
|
|
179
|
+
}
|
|
180
|
+
for (const id of exercisesOf(graph, verificationId)) seed(id, "exercises");
|
|
181
|
+
while (queue.length > 0) {
|
|
182
|
+
const id = queue.pop() as string;
|
|
183
|
+
for (const edge of graph.out.get(id) ?? []) {
|
|
184
|
+
if (edge.kind !== "dependsOn") continue;
|
|
185
|
+
if (!why.has(edge.to)) queue.push(edge.to);
|
|
186
|
+
if (graph.artifacts.get(edge.to)?.meta.type === "slice") add(edge.to, "dependsOn");
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return new Map([...why].sort(([a], [b]) => cmp(a, b)).map(([id, set]) => [id, [...set].sort(cmp).join(",")]));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** The contracts a slice exposes or consumes: its public contract records, whose change a dependent sees. */
|
|
193
|
+
export function contractsOf(graph: Graph, id: string): string[] {
|
|
194
|
+
const out = new Set<string>();
|
|
195
|
+
for (const edge of graph.out.get(id) ?? []) {
|
|
196
|
+
if (
|
|
197
|
+
(edge.kind === "exposes" || edge.kind === "consumes") &&
|
|
198
|
+
graph.artifacts.get(edge.to)?.meta.type === "contract"
|
|
199
|
+
) {
|
|
200
|
+
out.add(edge.to);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return [...out].sort(cmp);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** The binding fields of a slice record, canonical: what the subject hashes of it instead of the whole record. */
|
|
207
|
+
export function sliceBinding(graph: Graph, id: string): unknown {
|
|
208
|
+
const artifact = graph.artifacts.get(id);
|
|
209
|
+
if (artifact === undefined) return null;
|
|
210
|
+
const links = artifact.meta.links;
|
|
211
|
+
const slice = artifact.meta.slice;
|
|
212
|
+
const sorted = (list: readonly string[] | undefined) => [...(list ?? [])].sort(cmp);
|
|
213
|
+
return {
|
|
214
|
+
implements: sorted(links.implements),
|
|
215
|
+
dependsOn: sorted(links.dependsOn),
|
|
216
|
+
claims: (slice?.claims ?? [])
|
|
217
|
+
.map((c) => ({ kind: c.kind, path: c.path }))
|
|
218
|
+
.sort((a, b) => cmp(a.kind, b.kind) || cmp(a.path, b.path)),
|
|
219
|
+
usesResources: sorted(slice?.usesResources),
|
|
220
|
+
entrypoints: sorted(slice?.entrypoints),
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function computeSubject(input: SubjectInput): Subject {
|
|
225
|
+
const { graph, verificationId, readFile, globalInputs } = input;
|
|
226
|
+
const verification = graph.artifacts.get(verificationId);
|
|
227
|
+
if (verification === undefined || verification.meta.type !== "verification") {
|
|
228
|
+
throw new Error(`${verificationId} is not a verification in the graph`);
|
|
229
|
+
}
|
|
230
|
+
const claimed = (id: string): readonly string[] =>
|
|
231
|
+
input.sliceFiles instanceof Map
|
|
232
|
+
? (input.sliceFiles.get(id) ?? [])
|
|
233
|
+
: ((input.sliceFiles as Record<string, readonly string[]>)[id] ?? []);
|
|
234
|
+
const fileHash =
|
|
235
|
+
input.fileHash ??
|
|
236
|
+
((path: string) => {
|
|
237
|
+
const bytes = readFile(path);
|
|
238
|
+
return bytes === undefined ? ABSENT : sha256Hex(bytes);
|
|
239
|
+
});
|
|
240
|
+
const recordPart = (kind: SubjectPartKind, id: string, extra: Partial<SubjectPart> = {}): SubjectPart => {
|
|
241
|
+
const artifact = graph.artifacts.get(id);
|
|
242
|
+
return {
|
|
243
|
+
kind,
|
|
244
|
+
id,
|
|
245
|
+
hash: artifact === undefined ? ABSENT : artifact.sourceHash,
|
|
246
|
+
...extra,
|
|
247
|
+
...(artifact === undefined ? {} : { path: artifact.path }),
|
|
248
|
+
};
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
const parts: SubjectPart[] = [recordPart("verification", verificationId)];
|
|
252
|
+
|
|
253
|
+
const targets = new Set<string>();
|
|
254
|
+
for (const edge of graph.out.get(verificationId) ?? []) if (edge.kind === "verifies") targets.add(edge.to);
|
|
255
|
+
for (const id of verifiedBehaviors(graph, verificationId)) {
|
|
256
|
+
targets.add(id);
|
|
257
|
+
for (const edge of graph.out.get(id) ?? []) if (edge.kind === "governedBy") targets.add(edge.to);
|
|
258
|
+
for (const edge of graph.in.get(id) ?? []) if (edge.kind === "illustrates") targets.add(edge.from);
|
|
259
|
+
}
|
|
260
|
+
for (const id of targets) parts.push(recordPart("verified", id));
|
|
261
|
+
|
|
262
|
+
const mode = input.resourceMode ?? "exercised";
|
|
263
|
+
const dependencyMode = input.dependencyMode ?? DEFAULT_EVIDENCE_DEPENDENCIES;
|
|
264
|
+
const internal = new Map<string, string[]>();
|
|
265
|
+
const contractIds = new Set<string>();
|
|
266
|
+
const resourceWhy = new Map<string, Set<string>>();
|
|
267
|
+
const useResource = (id: string, reason: string) => {
|
|
268
|
+
if (input.resources?.has(id) !== true) return;
|
|
269
|
+
const set = resourceWhy.get(id) ?? new Set<string>();
|
|
270
|
+
set.add(reason);
|
|
271
|
+
resourceWhy.set(id, set);
|
|
272
|
+
};
|
|
273
|
+
const listed = new Set<string>();
|
|
274
|
+
for (const id of exercisesOf(graph, verificationId)) useResource(id, "exercises");
|
|
275
|
+
|
|
276
|
+
for (const [id, why] of includedSlices(graph, verificationId)) {
|
|
277
|
+
const artifact = graph.artifacts.get(id);
|
|
278
|
+
parts.push({
|
|
279
|
+
kind: "slice",
|
|
280
|
+
id,
|
|
281
|
+
hash: artifact === undefined ? ABSENT : sha256Hex(canonicalJson(sliceBinding(graph, id))),
|
|
282
|
+
why,
|
|
283
|
+
...(artifact === undefined ? {} : { path: artifact.path }),
|
|
284
|
+
});
|
|
285
|
+
const dependency = why === "dependsOn";
|
|
286
|
+
if (dependency && dependencyMode === "entrypoint") {
|
|
287
|
+
const surface = new Set(artifact?.meta.slice?.entrypoints ?? []);
|
|
288
|
+
for (const path of [...surface].sort(cmp)) {
|
|
289
|
+
parts.push({ kind: "slice-file", id: path, hash: fileHash(path), owner: id, path });
|
|
290
|
+
}
|
|
291
|
+
internal.set(id, [...new Set(claimed(id))].filter((path) => !surface.has(path)).sort(cmp));
|
|
292
|
+
for (const contract of contractsOf(graph, id)) contractIds.add(contract);
|
|
293
|
+
} else {
|
|
294
|
+
for (const path of [...new Set(claimed(id))].sort(cmp)) {
|
|
295
|
+
parts.push({ kind: "slice-file", id: path, hash: fileHash(path), owner: id, path });
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
const implementing = why.split(",").includes("implements");
|
|
299
|
+
for (const resource of artifact?.meta.slice?.usesResources ?? []) {
|
|
300
|
+
if (input.resources?.has(resource) !== true) continue;
|
|
301
|
+
if (mode === "used" || (implementing && ownsResource(claimed(id), input.resources.get(resource)?.files ?? []))) {
|
|
302
|
+
useResource(resource, "usesResources");
|
|
303
|
+
} else listed.add(resource);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
const decisionIds = new Set<string>();
|
|
307
|
+
for (const id of [...contractIds].sort(cmp)) {
|
|
308
|
+
parts.push(recordPart("contract", id));
|
|
309
|
+
for (const edge of graph.out.get(id) ?? []) if (edge.kind === "informedBy") decisionIds.add(edge.to);
|
|
310
|
+
}
|
|
311
|
+
for (const id of [...decisionIds].sort(cmp)) parts.push(recordPart("decision", id));
|
|
312
|
+
const unexercised: UnexercisedResource[] = [...listed]
|
|
313
|
+
.filter((id) => !resourceWhy.has(id))
|
|
314
|
+
.sort(cmp)
|
|
315
|
+
.map((id) => ({ id, files: [...new Set(input.resources?.get(id)?.files ?? [])].sort(cmp) }));
|
|
316
|
+
|
|
317
|
+
for (const [id, set] of resourceWhy) {
|
|
318
|
+
const resource = input.resources?.get(id) as SubjectResource;
|
|
319
|
+
parts.push({
|
|
320
|
+
kind: "resource",
|
|
321
|
+
id,
|
|
322
|
+
hash: sha256Hex(canonicalJson(resource.record)),
|
|
323
|
+
why: [...set].sort(cmp).join(","),
|
|
324
|
+
});
|
|
325
|
+
for (const path of [...new Set(resource.files)].sort(cmp)) {
|
|
326
|
+
parts.push({ kind: "resource-file", id: path, hash: fileHash(path), owner: id, path });
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const locator = verification.meta.verification?.locator;
|
|
331
|
+
if (locator !== undefined && locator !== "") {
|
|
332
|
+
parts.push({ kind: "test", id: locator, hash: fileHash(locator), path: locator });
|
|
333
|
+
for (const path of [...new Set(input.localImports?.(locator) ?? [])].sort(cmp)) {
|
|
334
|
+
if (path !== locator) parts.push({ kind: "test-import", id: path, hash: fileHash(path), path });
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
for (const path of [...new Set(globalInputs)]) {
|
|
338
|
+
parts.push({ kind: "global-input", id: path, hash: fileHash(path), path });
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// A claimed file of a dependency that another part hashes (an entrypoint of another slice, the test, a global input) is seen.
|
|
342
|
+
const hashed = new Set(parts.filter((p) => p.path !== undefined).map((p) => p.path));
|
|
343
|
+
const dependencyInternals: DependencyInternals[] = [...internal]
|
|
344
|
+
.map(([id, files]) => ({ id, files: files.filter((path) => !hashed.has(path)) }))
|
|
345
|
+
.filter((entry) => entry.files.length > 0)
|
|
346
|
+
.sort((a, b) => cmp(a.id, b.id));
|
|
347
|
+
|
|
348
|
+
parts.sort((a, b) => cmp(a.kind, b.kind) || cmp(a.id, b.id) || cmp(a.owner ?? "", b.owner ?? ""));
|
|
349
|
+
const entries = parts.map(({ kind, id, hash, why, owner }) => ({ kind, id, hash, why, owner }));
|
|
350
|
+
return { hash: sha256Hex(canonicalJson(entries)), parts, unexercised, dependencyInternals };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** True when a slice claims a file a resource's path matches: the resource is the slice's own, not only one it talks to. */
|
|
354
|
+
function ownsResource(claimedFiles: readonly string[], resourceFiles: readonly string[]): boolean {
|
|
355
|
+
if (claimedFiles.length === 0 || resourceFiles.length === 0) return false;
|
|
356
|
+
const files = new Set(resourceFiles);
|
|
357
|
+
return claimedFiles.some((path) => files.has(path));
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const partKey = (p: SubjectPart) => `${p.kind} ${p.id}${p.owner === undefined ? "" : ` (${p.owner})`}`;
|
|
361
|
+
|
|
362
|
+
/** The parts that differ between two subjects, as `kind id` keys, sorted: added, removed or changed. */
|
|
363
|
+
export function movedParts(before: Subject, after: Subject): string[] {
|
|
364
|
+
const a = new Map(before.parts.map((p) => [partKey(p), p.hash]));
|
|
365
|
+
const b = new Map(after.parts.map((p) => [partKey(p), p.hash]));
|
|
366
|
+
const moved: string[] = [];
|
|
367
|
+
for (const [k, h] of a) if (b.get(k) !== h) moved.push(k);
|
|
368
|
+
for (const k of b.keys()) if (!a.has(k)) moved.push(k);
|
|
369
|
+
return moved.sort(cmp);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* The parts of a subject whose file or record is among `changed`: what a
|
|
374
|
+
* `git diff --name-only` since a record's commit says may have moved. A record
|
|
375
|
+
* file that changed only in prose is named too, since the diff cannot tell it
|
|
376
|
+
* from a change to a binding field; the hash decided the subject moved.
|
|
377
|
+
*/
|
|
378
|
+
export function partsChangedAmong(subject: Subject, changed: ReadonlySet<string>): string[] {
|
|
379
|
+
return subject.parts
|
|
380
|
+
.filter((p) => p.path !== undefined && changed.has(p.path))
|
|
381
|
+
.map(partKey)
|
|
382
|
+
.sort(cmp);
|
|
383
|
+
}
|