@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/classify.ts
CHANGED
|
@@ -1,13 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Evidence freshness (Core §8, invariant 3). Only a `pass`
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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 {
|
|
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: {
|
|
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
|
|
102
|
+
/** EVID001, EVID002, EVID004, EVID005, EVID006 and EVID007, sorted. */
|
|
67
103
|
diagnostics: Diagnostic[];
|
|
68
104
|
}
|
|
69
105
|
|
|
@@ -74,6 +110,25 @@ 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
|
+
/** The most verifications EVID006 lists, and the most files it lists for each resource; the rest are counted. */
|
|
117
|
+
export const UNEXERCISED_LIMIT = 20;
|
|
118
|
+
const UNEXERCISED_FILES = 5;
|
|
119
|
+
|
|
120
|
+
/** The most verifications EVID007 lists, and the most files it lists for each dependency slice; the rest are counted. */
|
|
121
|
+
export const DEPENDENCY_INTERNALS_LIMIT = 20;
|
|
122
|
+
const DEPENDENCY_INTERNALS_FILES = 5;
|
|
123
|
+
|
|
124
|
+
/** What EVID007 collects: per dependency slice, the changed files of it outside its entrypoints and the current verifications that depend on it. */
|
|
125
|
+
type Internals = Map<string, { files: Set<string>; verifications: Set<string> }>;
|
|
126
|
+
|
|
127
|
+
/** What EVID006 collects: per resource, the changed files of it and the current verifications that use it without exercising it. */
|
|
128
|
+
type Unexercised = Map<string, { files: Set<string>; verifications: Set<string> }>;
|
|
129
|
+
|
|
130
|
+
const short = (commit: string) => commit.slice(0, 9);
|
|
131
|
+
|
|
77
132
|
/** Core §8: classify every verification in the graph against the snapshot. Deterministic for identical inputs. */
|
|
78
133
|
export function classifyEvidence(
|
|
79
134
|
graph: Graph,
|
|
@@ -139,7 +194,26 @@ export function classifyEvidence(
|
|
|
139
194
|
inScope.get(record.verificationId)?.push(record);
|
|
140
195
|
}
|
|
141
196
|
|
|
197
|
+
const binding = snapshot.binding ?? "commit";
|
|
198
|
+
if (snapshot.bindingReason === "no-slices") {
|
|
199
|
+
diagnostics.push(
|
|
200
|
+
evidenceDiagnostic({
|
|
201
|
+
code: "EVID004",
|
|
202
|
+
severity: "warning",
|
|
203
|
+
artifact: null,
|
|
204
|
+
path: null,
|
|
205
|
+
message:
|
|
206
|
+
"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).",
|
|
207
|
+
remediation:
|
|
208
|
+
"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).",
|
|
209
|
+
}),
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
142
213
|
const verifications: Record<string, VerificationEvidence> = {};
|
|
214
|
+
const unclaimed = new Set<string>();
|
|
215
|
+
const unexercised: Unexercised = new Map();
|
|
216
|
+
const internals: Internals = new Map();
|
|
143
217
|
for (const id of ids) {
|
|
144
218
|
const artifact = graph.artifacts.get(id);
|
|
145
219
|
const history = (inScope.get(id) ?? []).sort(compareRecords);
|
|
@@ -149,11 +223,85 @@ export function classifyEvidence(
|
|
|
149
223
|
history,
|
|
150
224
|
outOfScope.get(id) ?? 0,
|
|
151
225
|
snapshot,
|
|
226
|
+
binding,
|
|
227
|
+
unclaimed,
|
|
228
|
+
unexercised,
|
|
229
|
+
internals,
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
// A file of a resource EVID006 reports is not listed here as well: EVID006 says more (which verifications use it).
|
|
233
|
+
for (const entry of unexercised.values()) for (const file of entry.files) unclaimed.delete(file);
|
|
234
|
+
if (unclaimed.size > 0) {
|
|
235
|
+
const files = [...unclaimed].sort(compareStrings);
|
|
236
|
+
const listed = files.slice(0, UNCLAIMED_LIMIT);
|
|
237
|
+
diagnostics.push(
|
|
238
|
+
evidenceDiagnostic({
|
|
239
|
+
code: "EVID005",
|
|
240
|
+
severity: "warning",
|
|
241
|
+
artifact: null,
|
|
242
|
+
path: null,
|
|
243
|
+
message: `${files.length} file${files.length === 1 ? "" : "s"} changed since the commit of a subject-bound record and no slice claims, no exercised 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` : ""}.`,
|
|
244
|
+
remediation:
|
|
245
|
+
"Claim the file in a slice (the architecture check's unclaimed files rule), name its resource in a verification's `exercises`, or list it in `evidence.globalInputs` if it affects every check (Core §8).",
|
|
246
|
+
}),
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
if (unexercised.size > 0) {
|
|
250
|
+
const resources = [...unexercised].sort(([a], [b]) => compareStrings(a, b));
|
|
251
|
+
const all = new Set<string>();
|
|
252
|
+
for (const [, entry] of resources) for (const id of entry.verifications) all.add(id);
|
|
253
|
+
const verifications = [...all].sort(compareStrings);
|
|
254
|
+
const listed = verifications.slice(0, UNEXERCISED_LIMIT);
|
|
255
|
+
const what = resources.map(([id, entry]) => {
|
|
256
|
+
const files = [...entry.files].sort(compareStrings);
|
|
257
|
+
const shown = files.slice(0, UNEXERCISED_FILES).join(", ");
|
|
258
|
+
return `${id} (${shown}${files.length > UNEXERCISED_FILES ? `, and ${files.length - UNEXERCISED_FILES} more` : ""})`;
|
|
259
|
+
});
|
|
260
|
+
diagnostics.push(
|
|
261
|
+
evidenceDiagnostic({
|
|
262
|
+
code: "EVID006",
|
|
263
|
+
severity: "warning",
|
|
264
|
+
artifact: null,
|
|
265
|
+
path: null,
|
|
266
|
+
message: `${verifications.length} verification${verifications.length === 1 ? " is" : "s are"} current though a resource ${verifications.length === 1 ? "it uses" : "they use"} and ${verifications.length === 1 ? "does" : "do"} not exercise changed since the commit of ${verifications.length === 1 ? "its" : "their"} record: ${what.join("; ")}; ${listed.join(", ")}${verifications.length > listed.length ? `, and ${verifications.length - listed.length} more` : ""}.`,
|
|
267
|
+
remediation:
|
|
268
|
+
"Where a check does depend on that resource, name it in the verification's `exercises` so a change to it makes the record stale, or set `evidence.resources: used` to hash every resource a slice lists (Core §8).",
|
|
269
|
+
}),
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (internals.size > 0) {
|
|
274
|
+
const slices = [...internals].sort(([a], [b]) => compareStrings(a, b));
|
|
275
|
+
const all = new Set<string>();
|
|
276
|
+
for (const [, entry] of slices) for (const id of entry.verifications) all.add(id);
|
|
277
|
+
const verifications = [...all].sort(compareStrings);
|
|
278
|
+
const listed = verifications.slice(0, DEPENDENCY_INTERNALS_LIMIT);
|
|
279
|
+
const what = slices.map(([id, entry]) => {
|
|
280
|
+
const files = [...entry.files].sort(compareStrings);
|
|
281
|
+
const shown = files.slice(0, DEPENDENCY_INTERNALS_FILES).join(", ");
|
|
282
|
+
return `${id} (${shown}${files.length > DEPENDENCY_INTERNALS_FILES ? `, and ${files.length - DEPENDENCY_INTERNALS_FILES} more` : ""})`;
|
|
283
|
+
});
|
|
284
|
+
diagnostics.push(
|
|
285
|
+
evidenceDiagnostic({
|
|
286
|
+
code: "EVID007",
|
|
287
|
+
severity: "warning",
|
|
288
|
+
artifact: null,
|
|
289
|
+
path: null,
|
|
290
|
+
message: `${verifications.length} verification${verifications.length === 1 ? " is" : "s are"} current though a slice ${verifications.length === 1 ? "it depends" : "they depend"} on changed outside its entrypoints and contracts since the commit of ${verifications.length === 1 ? "its" : "their"} record: ${what.join("; ")}; ${listed.join(", ")}${verifications.length > listed.length ? `, and ${verifications.length - listed.length} more` : ""}.`,
|
|
291
|
+
remediation:
|
|
292
|
+
"Where a check does depend on that slice's internals, name the slice in the verification's `exercises` so a change to it makes the record stale, or set `evidence.dependencies: files` to hash every file a dependency claims (Core §8).",
|
|
293
|
+
}),
|
|
152
294
|
);
|
|
153
295
|
}
|
|
154
296
|
|
|
155
297
|
return {
|
|
156
|
-
snapshot: {
|
|
298
|
+
snapshot: {
|
|
299
|
+
commit: snapshot.commit,
|
|
300
|
+
graphHash: snapshot.graphHash,
|
|
301
|
+
scope,
|
|
302
|
+
binding,
|
|
303
|
+
bindingReason: snapshot.bindingReason ?? "configured",
|
|
304
|
+
},
|
|
157
305
|
verifications,
|
|
158
306
|
ignoredOutOfScope,
|
|
159
307
|
unknown,
|
|
@@ -173,9 +321,14 @@ function classifyOne(
|
|
|
173
321
|
history: RunRecord[],
|
|
174
322
|
outOfScope: number,
|
|
175
323
|
snapshot: EvidenceSnapshot,
|
|
324
|
+
binding: EvidenceBinding,
|
|
325
|
+
unclaimed: Set<string>,
|
|
326
|
+
unexercised: Unexercised,
|
|
327
|
+
internals: Internals,
|
|
176
328
|
): VerificationEvidence {
|
|
177
329
|
const counts: ResultCounts = { pass: 0, fail: 0, skip: 0, error: 0 };
|
|
178
|
-
const
|
|
330
|
+
const subjectHash = binding === "subject" ? (snapshot.subjects?.get(verificationId)?.hash ?? null) : null;
|
|
331
|
+
const base = { verificationId, method, binding, subject: subjectHash, history, atSnapshot: counts, outOfScope };
|
|
179
332
|
const ignored =
|
|
180
333
|
outOfScope > 0 ? `${outOfScope} record${outOfScope === 1 ? "" : "s"} for another product or release ignored` : null;
|
|
181
334
|
|
|
@@ -194,25 +347,146 @@ function classifyOne(
|
|
|
194
347
|
]);
|
|
195
348
|
}
|
|
196
349
|
|
|
197
|
-
const
|
|
350
|
+
const subject = snapshot.subjects?.get(verificationId);
|
|
351
|
+
if (binding === "subject" && subject === undefined) {
|
|
352
|
+
return withNote({ ...base, status: "unresolved", latest: history[history.length - 1] }, [
|
|
353
|
+
"subject binding is in force but no subject could be computed for this verification",
|
|
354
|
+
ignored,
|
|
355
|
+
]);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const current =
|
|
359
|
+
binding === "subject"
|
|
360
|
+
? history.filter((record) => record.binding === "subject" && record.subjectHash === subject?.hash)
|
|
361
|
+
: history.filter((record) => record.binding !== "subject" && atSnapshot(record, snapshot));
|
|
198
362
|
for (const record of current) counts[record.result]++;
|
|
199
363
|
if (current.length === 0) {
|
|
200
|
-
|
|
364
|
+
const latest = history[history.length - 1];
|
|
365
|
+
return withNote({ ...base, status: "stale", latest }, [staleNote(latest, binding, subject, snapshot), ignored]);
|
|
201
366
|
}
|
|
202
367
|
|
|
203
368
|
const latest = current[current.length - 1];
|
|
204
369
|
const status: EvidenceStatus =
|
|
205
370
|
latest.result === "pass" ? "current-pass" : latest.result === "fail" ? "current-fail" : latest.result;
|
|
206
|
-
|
|
371
|
+
const notes: (string | null)[] = [];
|
|
372
|
+
if (binding === "subject") {
|
|
373
|
+
const unlisted = collectUnclaimed(latest, snapshot, unclaimed);
|
|
374
|
+
collectUnexercised(verificationId, latest, subject, snapshot, unexercised);
|
|
375
|
+
collectInternals(verificationId, latest, subject, snapshot, internals);
|
|
376
|
+
notes.push(`judged by subject binding; the run was at commit ${short(latest.commit)}${unlisted}`);
|
|
377
|
+
} else if (snapshot.commit === null) notes.push(NOTE_COMMIT_UNKNOWN);
|
|
378
|
+
notes.push(ignored);
|
|
379
|
+
return withNote({ ...base, status, latest }, notes);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** Why the latest record is not current under the binding in force, with what moved when that can be said. */
|
|
383
|
+
function staleNote(
|
|
384
|
+
latest: RunRecord,
|
|
385
|
+
binding: EvidenceBinding,
|
|
386
|
+
subject: Subject | undefined,
|
|
387
|
+
snapshot: EvidenceSnapshot,
|
|
388
|
+
): string | null {
|
|
389
|
+
if (binding === "commit") {
|
|
390
|
+
return latest.binding === "subject"
|
|
391
|
+
? "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"
|
|
392
|
+
: null;
|
|
393
|
+
}
|
|
394
|
+
if (latest.binding !== "subject") {
|
|
395
|
+
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`;
|
|
396
|
+
}
|
|
397
|
+
if (snapshot.changedSince === undefined || subject === undefined) return "the subject changed since the run";
|
|
398
|
+
const changed = snapshot.changedSince(latest.commit);
|
|
399
|
+
if (changed === null) {
|
|
400
|
+
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`;
|
|
401
|
+
}
|
|
402
|
+
const parts = partsChangedAmong(subject, new Set(changed));
|
|
403
|
+
return parts.length === 0
|
|
404
|
+
? `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)`
|
|
405
|
+
: `the subject changed since the run at commit ${short(latest.commit)}: ${parts.slice(0, 5).join(", ")}${parts.length > 5 ? `, and ${parts.length - 5} more` : ""}`;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** Collects what changed outside every subject since a current record's commit (EVID005), and says when that cannot be known. */
|
|
409
|
+
function collectUnclaimed(latest: RunRecord, snapshot: EvidenceSnapshot, unclaimed: Set<string>): string {
|
|
410
|
+
if (snapshot.changedSince === undefined || snapshot.covers === undefined) return "";
|
|
411
|
+
const changed = snapshot.changedSince(latest.commit);
|
|
412
|
+
if (changed === null) {
|
|
413
|
+
return `; files changed since then could not be listed, because the commit is not an ancestor of the assessed commit or git is unavailable`;
|
|
414
|
+
}
|
|
415
|
+
for (const path of changed) if (!snapshot.covers(path)) unclaimed.add(path);
|
|
416
|
+
return "";
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Collects the resources a current record's verification uses without exercising (EVID006) whose files changed since
|
|
421
|
+
* the record's commit: the subject cannot see them, so the record stays current, and the report says so. It asks for
|
|
422
|
+
* the same `changedSince` as EVID005, and has nothing to say when that cannot be answered (EVID005's note says so).
|
|
423
|
+
*/
|
|
424
|
+
function collectUnexercised(
|
|
425
|
+
verificationId: string,
|
|
426
|
+
latest: RunRecord,
|
|
427
|
+
subject: Subject | undefined,
|
|
428
|
+
snapshot: EvidenceSnapshot,
|
|
429
|
+
unexercised: Unexercised,
|
|
430
|
+
): void {
|
|
431
|
+
if (snapshot.changedSince === undefined || subject === undefined || subject.unexercised.length === 0) return;
|
|
432
|
+
const changed = snapshot.changedSince(latest.commit);
|
|
433
|
+
if (changed === null || changed.length === 0) return;
|
|
434
|
+
const moved = new Set(changed);
|
|
435
|
+
for (const resource of subject.unexercised) {
|
|
436
|
+
const files = resource.files.filter((path) => moved.has(path));
|
|
437
|
+
if (files.length === 0) continue;
|
|
438
|
+
const entry = unexercised.get(resource.id) ?? { files: new Set<string>(), verifications: new Set<string>() };
|
|
439
|
+
for (const path of files) entry.files.add(path);
|
|
440
|
+
entry.verifications.add(verificationId);
|
|
441
|
+
unexercised.set(resource.id, entry);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Collects the dependency slices a current record's verification counts only through their entrypoints and contracts
|
|
447
|
+
* (EVID007) whose other files changed since the record's commit: the subject cannot see them, so the record stays
|
|
448
|
+
* current, and the report says so. Like EVID006 it has nothing to say when git cannot answer.
|
|
449
|
+
*/
|
|
450
|
+
function collectInternals(
|
|
451
|
+
verificationId: string,
|
|
452
|
+
latest: RunRecord,
|
|
453
|
+
subject: Subject | undefined,
|
|
454
|
+
snapshot: EvidenceSnapshot,
|
|
455
|
+
internals: Internals,
|
|
456
|
+
): void {
|
|
457
|
+
if (snapshot.changedSince === undefined || subject === undefined || subject.dependencyInternals.length === 0) return;
|
|
458
|
+
const changed = snapshot.changedSince(latest.commit);
|
|
459
|
+
if (changed === null || changed.length === 0) return;
|
|
460
|
+
const moved = new Set(changed);
|
|
461
|
+
for (const slice of subject.dependencyInternals) {
|
|
462
|
+
const files = slice.files.filter((path) => moved.has(path));
|
|
463
|
+
if (files.length === 0) continue;
|
|
464
|
+
const entry = internals.get(slice.id) ?? { files: new Set<string>(), verifications: new Set<string>() };
|
|
465
|
+
for (const path of files) entry.files.add(path);
|
|
466
|
+
entry.verifications.add(verificationId);
|
|
467
|
+
internals.set(slice.id, entry);
|
|
468
|
+
}
|
|
207
469
|
}
|
|
208
470
|
|
|
209
471
|
function withNote(evidence: Omit<VerificationEvidence, "note">, notes: (string | null)[]): VerificationEvidence {
|
|
210
472
|
const present = notes.filter((note): note is string => note !== null);
|
|
211
|
-
const {
|
|
473
|
+
const {
|
|
474
|
+
verificationId,
|
|
475
|
+
method,
|
|
476
|
+
binding,
|
|
477
|
+
subject,
|
|
478
|
+
status,
|
|
479
|
+
latest,
|
|
480
|
+
history,
|
|
481
|
+
atSnapshot: counts,
|
|
482
|
+
outOfScope,
|
|
483
|
+
} = evidence;
|
|
212
484
|
return {
|
|
213
485
|
verificationId,
|
|
214
486
|
method,
|
|
215
487
|
status,
|
|
488
|
+
binding,
|
|
489
|
+
subject,
|
|
216
490
|
...(present.length > 0 ? { note: present.join("; ") } : {}),
|
|
217
491
|
latest,
|
|
218
492
|
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.
|
|
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 {
|
|
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
|
|
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.
|
|
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 },
|