@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.
- package/README.md +50 -0
- package/dist/adapters/common.d.ts +6 -0
- package/dist/adapters/common.d.ts.map +1 -1
- package/dist/adapters/common.js +2 -0
- 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 +35 -7
- package/dist/classify.d.ts.map +1 -1
- package/dist/classify.js +104 -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 +92 -0
- package/dist/subject.d.ts.map +1 -0
- package/dist/subject.js +247 -0
- package/dist/subject.js.map +1 -0
- package/package.json +9 -2
- package/src/adapters/common.ts +8 -0
- package/src/adapters/playwright.ts +94 -0
- package/src/binding.ts +68 -0
- package/src/classify.ts +165 -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 +277 -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 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: {
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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.
|
|
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 },
|
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
|
+
}
|