@intentset/verification 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/dist/adapters/common.d.ts +51 -0
  2. package/dist/adapters/common.d.ts.map +1 -0
  3. package/dist/adapters/common.js +95 -0
  4. package/dist/adapters/common.js.map +1 -0
  5. package/dist/adapters/node-test.d.ts +3 -0
  6. package/dist/adapters/node-test.d.ts.map +1 -0
  7. package/dist/adapters/node-test.js +119 -0
  8. package/dist/adapters/node-test.js.map +1 -0
  9. package/dist/adapters/vitest.d.ts +3 -0
  10. package/dist/adapters/vitest.d.ts.map +1 -0
  11. package/dist/adapters/vitest.js +71 -0
  12. package/dist/adapters/vitest.js.map +1 -0
  13. package/dist/check.d.ts +33 -0
  14. package/dist/check.d.ts.map +1 -0
  15. package/dist/check.js +95 -0
  16. package/dist/check.js.map +1 -0
  17. package/dist/classify.d.ts +69 -0
  18. package/dist/classify.d.ts.map +1 -0
  19. package/dist/classify.js +130 -0
  20. package/dist/classify.js.map +1 -0
  21. package/dist/coverage.d.ts +88 -0
  22. package/dist/coverage.d.ts.map +1 -0
  23. package/dist/coverage.js +114 -0
  24. package/dist/coverage.js.map +1 -0
  25. package/dist/diagnostic.d.ts +21 -0
  26. package/dist/diagnostic.d.ts.map +1 -0
  27. package/dist/diagnostic.js +20 -0
  28. package/dist/diagnostic.js.map +1 -0
  29. package/dist/fixture.d.ts +43 -0
  30. package/dist/fixture.d.ts.map +1 -0
  31. package/dist/fixture.js +42 -0
  32. package/dist/fixture.js.map +1 -0
  33. package/dist/index.d.ts +16 -0
  34. package/dist/index.d.ts.map +1 -0
  35. package/dist/index.js +16 -0
  36. package/dist/index.js.map +1 -0
  37. package/dist/placeholders.d.ts +39 -0
  38. package/dist/placeholders.d.ts.map +1 -0
  39. package/dist/placeholders.js +47 -0
  40. package/dist/placeholders.js.map +1 -0
  41. package/dist/records.d.ts +77 -0
  42. package/dist/records.d.ts.map +1 -0
  43. package/dist/records.js +354 -0
  44. package/dist/records.js.map +1 -0
  45. package/package.json +33 -0
  46. package/src/adapters/common.ts +139 -0
  47. package/src/adapters/node-test.ts +139 -0
  48. package/src/adapters/vitest.ts +82 -0
  49. package/src/check.ts +137 -0
  50. package/src/classify.ts +222 -0
  51. package/src/coverage.ts +181 -0
  52. package/src/diagnostic.ts +39 -0
  53. package/src/fixture.ts +64 -0
  54. package/src/index.ts +15 -0
  55. package/src/placeholders.ts +51 -0
  56. package/src/records.ts +482 -0
package/src/records.ts ADDED
@@ -0,0 +1,482 @@
1
+ /**
2
+ * Run records (Core §8): evidence is a separate record from the verification
3
+ * definition, because runs change more often than product semantics. This
4
+ * module is the record's shape and its reader; spec/evidence.schema.json is
5
+ * the same shape for other tools, and a test holds the two in agreement.
6
+ *
7
+ * The reader accepts the literal `@current` for `commit` and `graphHash`.
8
+ * That is the fixture placeholder (see placeholders.ts): it is bound to the
9
+ * snapshot before classification, and a record still carrying it classifies
10
+ * as unresolved, never as current.
11
+ */
12
+ import { canonicalJson, type Diagnostic, ID_PATTERN, sortDiagnostics } from "@intentset/core";
13
+ import { compareStrings, evidenceDiagnostic, pointerToken } from "./diagnostic.ts";
14
+
15
+ export const RUN_RESULTS = ["pass", "fail", "skip", "error"] as const;
16
+ export type RunResult = (typeof RUN_RESULTS)[number];
17
+
18
+ /** The literal a fixture writes for "the snapshot's commit" or "the snapshot's graph hash". */
19
+ export const CURRENT = "@current";
20
+
21
+ export interface RunScope {
22
+ /** The product's artifact ID. */
23
+ product: string;
24
+ /** The exact release label; v0.1 matches release labels exactly (Core §7). */
25
+ release: string;
26
+ }
27
+
28
+ export interface RunTool {
29
+ name: string;
30
+ version: string;
31
+ }
32
+
33
+ /** One run of one verification, Core §8. Members in this order on output. */
34
+ export interface RunRecord {
35
+ evidenceId: string;
36
+ verificationId: string;
37
+ commit: string;
38
+ /** Lowercase hex SHA-256 over the canonical graph (ADR 0005), or the `@current` fixture placeholder. */
39
+ graphHash: string;
40
+ environment: string;
41
+ scope: RunScope;
42
+ /** The tool and version that ran an automated check; null for a review performed by a person alone. */
43
+ tool: RunTool | null;
44
+ /** The reviewer's identity; set exactly when the record is a manual review, which then needs a rationale. */
45
+ reviewer: string | null;
46
+ /** UTC ISO 8601 with a `Z` suffix. */
47
+ startedAt: string;
48
+ finishedAt: string;
49
+ result: RunResult;
50
+ uri: string;
51
+ rationale?: string;
52
+ /** Namespaced extensions (`org.example/key`), preserved and never interpreted. */
53
+ extensions?: Record<string, unknown>;
54
+ }
55
+
56
+ const REQUIRED = [
57
+ "evidenceId",
58
+ "verificationId",
59
+ "commit",
60
+ "graphHash",
61
+ "environment",
62
+ "scope",
63
+ "tool",
64
+ "reviewer",
65
+ "startedAt",
66
+ "finishedAt",
67
+ "result",
68
+ "uri",
69
+ ] as const;
70
+ const KNOWN = new Set<string>([...REQUIRED, "rationale", "extensions"]);
71
+
72
+ const TIMESTAMP = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,9}))?Z$/;
73
+ const GRAPH_HASH = /^[0-9a-f]{64}$/;
74
+ const NO_SPACE = /^\S+$/;
75
+ const URI = /^[A-Za-z][A-Za-z0-9+.-]*:\S+$/;
76
+ const EXTENSION_KEY = /^[^/]+\/.+$/;
77
+
78
+ /** True for a UTC timestamp `YYYY-MM-DDTHH:MM:SS[.fraction]Z` that names a real instant. */
79
+ export function isUtcTimestamp(value: unknown): value is string {
80
+ if (typeof value !== "string") return false;
81
+ const match = TIMESTAMP.exec(value);
82
+ if (match === null) return false;
83
+ const [year, month, day, hour, minute, second] = match.slice(1, 7).map(Number);
84
+ if (month < 1 || month > 12 || hour > 23 || minute > 59 || second > 59) return false;
85
+ const days = new Date(Date.UTC(year, month, 0)).getUTCDate();
86
+ return day >= 1 && day <= days;
87
+ }
88
+
89
+ /**
90
+ * A key that orders UTC timestamps exactly, fraction included: the date and
91
+ * time, then the fraction padded to nanoseconds. Plain string comparison of
92
+ * the timestamps would put `10:00:00.5Z` before `10:00:00Z`.
93
+ */
94
+ export function timeKey(timestamp: string): string {
95
+ const match = TIMESTAMP.exec(timestamp);
96
+ if (match === null) return timestamp;
97
+ return `${timestamp.slice(0, 19)}.${(match[7] ?? "").padEnd(9, "0")}`;
98
+ }
99
+
100
+ /**
101
+ * Record order everywhere in this package: finishedAt, then evidenceId. The
102
+ * last is the latest. Two records agreeing on both (a repeated evidence ID the
103
+ * reader would have refused) fall back to their canonical JSON, so the order
104
+ * never depends on input order.
105
+ */
106
+ export function compareRecords(a: RunRecord, b: RunRecord): number {
107
+ return (
108
+ compareStrings(timeKey(a.finishedAt), timeKey(b.finishedAt)) ||
109
+ compareStrings(a.evidenceId, b.evidenceId) ||
110
+ compareStrings(canonicalJson(a), canonicalJson(b))
111
+ );
112
+ }
113
+
114
+ export interface ReadRunRecords {
115
+ /** The well-formed records, in input order; a malformed record is dropped and reported. */
116
+ records: RunRecord[];
117
+ /** EVID001 errors, sorted by core's order. */
118
+ diagnostics: Diagnostic[];
119
+ }
120
+
121
+ /**
122
+ * Read run records from parsed JSON (an array of objects). Every problem is
123
+ * one EVID001 error with a JSON pointer into the input as its field; a record
124
+ * with any problem is not returned, so it can never count as evidence. Of two
125
+ * records with one evidence ID the first is kept and the later ones reported.
126
+ */
127
+ export function readRunRecords(json: unknown, path: string): ReadRunRecords {
128
+ const diagnostics: Diagnostic[] = [];
129
+ if (!Array.isArray(json)) {
130
+ diagnostics.push(
131
+ evidenceDiagnostic({
132
+ code: "EVID001",
133
+ artifact: null,
134
+ path,
135
+ field: "",
136
+ message: `The evidence in ${path} is ${describe(json)}, not an array of run records.`,
137
+ remediation: "Write the run records as a JSON array of objects, one per run (Core §8).",
138
+ }),
139
+ );
140
+ return { records: [], diagnostics };
141
+ }
142
+
143
+ const records: RunRecord[] = [];
144
+ const seen = new Map<string, number>();
145
+ json.forEach((entry, index) => {
146
+ const problems: Problem[] = [];
147
+ const record = readRecord(entry, index, problems);
148
+ if (record !== null) {
149
+ const first = seen.get(record.evidenceId);
150
+ if (first !== undefined) {
151
+ problems.push({
152
+ field: `/${index}/evidenceId`,
153
+ message: `Evidence ID ${record.evidenceId} at entry ${index} repeats entry ${first}.`,
154
+ remediation: "Give every run its own evidence ID; tools never merge two records into one.",
155
+ });
156
+ } else {
157
+ seen.set(record.evidenceId, index);
158
+ }
159
+ }
160
+ const artifact = verificationOf(entry);
161
+ for (const problem of problems) {
162
+ diagnostics.push(evidenceDiagnostic({ code: "EVID001", artifact, path, ...problem }));
163
+ }
164
+ if (record !== null && problems.length === 0) records.push(record);
165
+ });
166
+ return { records, diagnostics: sortDiagnostics(diagnostics) };
167
+ }
168
+
169
+ interface Problem {
170
+ field: string;
171
+ message: string;
172
+ remediation: string;
173
+ }
174
+
175
+ function verificationOf(entry: unknown): string | null {
176
+ if (!isRecord(entry)) return null;
177
+ const id = entry.verificationId;
178
+ return typeof id === "string" && ID_PATTERN.test(id) ? id : null;
179
+ }
180
+
181
+ function readRecord(entry: unknown, index: number, problems: Problem[]): RunRecord | null {
182
+ const at = (key: string) => `/${index}/${pointerToken(key)}`;
183
+ if (!isRecord(entry)) {
184
+ problems.push({
185
+ field: `/${index}`,
186
+ message: `Evidence entry ${index} is ${describe(entry)}, not a run record.`,
187
+ remediation: "Write each run as an object with the fields Core §8 lists.",
188
+ });
189
+ return null;
190
+ }
191
+ const problem = (key: string, message: string, remediation: string) =>
192
+ problems.push({ field: at(key), message, remediation });
193
+
194
+ for (const key of Object.keys(entry).sort(compareStrings)) {
195
+ if (!KNOWN.has(key)) {
196
+ problem(
197
+ key,
198
+ `Run record ${index} has the unknown key \`${key}\`.`,
199
+ "Remove it, or move it under `extensions` with a namespaced key such as `org.example/build`.",
200
+ );
201
+ }
202
+ }
203
+ for (const key of REQUIRED) {
204
+ if (!Object.hasOwn(entry, key)) {
205
+ problem(
206
+ key,
207
+ `Run record ${index} has no \`${key}\`.`,
208
+ `Add \`${key}\`; Core §8 requires it on every run record${key === "tool" || key === "reviewer" ? ", as null when it does not apply" : ""}.`,
209
+ );
210
+ }
211
+ }
212
+
213
+ const text = (key: string, pattern: RegExp, what: string, remediation: string): string | undefined => {
214
+ if (!Object.hasOwn(entry, key)) return undefined;
215
+ const value = entry[key];
216
+ if (typeof value === "string" && pattern.test(value)) return value;
217
+ problem(key, `\`${key}\` of run record ${index} must be ${what}, but it is ${describe(value)}.`, remediation);
218
+ return undefined;
219
+ };
220
+
221
+ const evidenceId = text(
222
+ "evidenceId",
223
+ NO_SPACE,
224
+ "a non-empty string without spaces",
225
+ "Give the run a stable evidence ID, such as the one the adapter derives.",
226
+ );
227
+ const verificationId = text(
228
+ "verificationId",
229
+ ID_PATTERN,
230
+ "a verification artifact ID",
231
+ "Name the verification this run executed, such as TEST-ASMT-SCHEDULE.",
232
+ );
233
+ const commit = text(
234
+ "commit",
235
+ NO_SPACE,
236
+ "a non-empty commit identifier",
237
+ "Record the source commit the run executed against.",
238
+ );
239
+ const graphHash = readGraphHash(entry, problem, index);
240
+ const environment = text(
241
+ "environment",
242
+ /\S/,
243
+ "a non-empty string",
244
+ "Name the environment the run executed in, such as ci or pilot.",
245
+ );
246
+ const uri = text("uri", URI, "a URI with a scheme", "Link the run's report, such as https://ci.example/runs/42.");
247
+ const scope = readScope(entry, at, problems, index);
248
+ const tool = readTool(entry, at, problems, index);
249
+ const reviewer = readReviewer(entry, problem, index);
250
+ const startedAt = readTime(entry, "startedAt", problem, index);
251
+ const finishedAt = readTime(entry, "finishedAt", problem, index);
252
+
253
+ let result: RunResult | undefined;
254
+ if (Object.hasOwn(entry, "result")) {
255
+ if ((RUN_RESULTS as readonly unknown[]).includes(entry.result)) result = entry.result as RunResult;
256
+ else
257
+ problem(
258
+ "result",
259
+ `\`result\` of run record ${index} must be pass, fail, skip or error, but it is ${describe(entry.result)}.`,
260
+ "Record one of the four results; anything else is not evidence (Core §8).",
261
+ );
262
+ }
263
+
264
+ let rationale: string | undefined;
265
+ if (Object.hasOwn(entry, "rationale")) {
266
+ if (typeof entry.rationale === "string" && /\S/.test(entry.rationale)) rationale = entry.rationale;
267
+ else
268
+ problem(
269
+ "rationale",
270
+ `\`rationale\` of run record ${index} must be a non-empty string, but it is ${describe(entry.rationale)}.`,
271
+ "Say what the reviewer examined and why it supports the result, or remove the key.",
272
+ );
273
+ }
274
+
275
+ let extensions: Record<string, unknown> | undefined;
276
+ if (Object.hasOwn(entry, "extensions")) {
277
+ const value = entry.extensions;
278
+ if (!isRecord(value)) {
279
+ problem(
280
+ "extensions",
281
+ `\`extensions\` of run record ${index} must be a mapping.`,
282
+ "Write namespaced keys under `extensions`.",
283
+ );
284
+ } else {
285
+ for (const key of Object.keys(value).sort(compareStrings)) {
286
+ if (!EXTENSION_KEY.test(key)) {
287
+ problems.push({
288
+ field: `${at("extensions")}/${pointerToken(key)}`,
289
+ message: `Extension key \`${key}\` of run record ${index} is not namespaced.`,
290
+ remediation: "Prefix it with a namespace you control, such as `org.example/build`.",
291
+ });
292
+ }
293
+ }
294
+ extensions = value;
295
+ }
296
+ }
297
+
298
+ if (reviewer === null && Object.hasOwn(entry, "tool") && entry.tool === null) {
299
+ problem(
300
+ "tool",
301
+ `Run record ${index} names neither a tool nor a reviewer, so nothing identifies who or what produced it.`,
302
+ "Record the tool and version for an automated run, or the reviewer and rationale for a manual review (Core §8).",
303
+ );
304
+ }
305
+ if (typeof reviewer === "string" && rationale === undefined && !Object.hasOwn(entry, "rationale")) {
306
+ problem(
307
+ "rationale",
308
+ `Manual run record ${index} by ${reviewer} gives no rationale.`,
309
+ "Add the review rationale; a manual record must identify its reviewer and why the result holds (Core §8).",
310
+ );
311
+ }
312
+ if (
313
+ startedAt !== undefined &&
314
+ finishedAt !== undefined &&
315
+ compareStrings(timeKey(finishedAt), timeKey(startedAt)) < 0
316
+ ) {
317
+ problem(
318
+ "finishedAt",
319
+ `Run record ${index} finished at ${finishedAt}, before it started at ${startedAt}.`,
320
+ "Record the run's real start and end in UTC; the end cannot precede the start.",
321
+ );
322
+ }
323
+
324
+ if (problems.length > 0) return null;
325
+ const record: RunRecord = {
326
+ evidenceId: evidenceId as string,
327
+ verificationId: verificationId as string,
328
+ commit: commit as string,
329
+ graphHash: graphHash as string,
330
+ environment: environment as string,
331
+ scope: scope as RunScope,
332
+ tool: tool as RunTool | null,
333
+ reviewer: reviewer as string | null,
334
+ startedAt: startedAt as string,
335
+ finishedAt: finishedAt as string,
336
+ result: result as RunResult,
337
+ uri: uri as string,
338
+ };
339
+ if (rationale !== undefined) record.rationale = rationale;
340
+ if (extensions !== undefined) record.extensions = extensions;
341
+ return record;
342
+ }
343
+
344
+ type Report = (key: string, message: string, remediation: string) => void;
345
+
346
+ function readGraphHash(entry: Record<string, unknown>, problem: Report, index: number): string | undefined {
347
+ if (!Object.hasOwn(entry, "graphHash")) return undefined;
348
+ const value = entry.graphHash;
349
+ if (typeof value === "string" && (GRAPH_HASH.test(value) || value === CURRENT)) return value;
350
+ problem(
351
+ "graphHash",
352
+ `\`graphHash\` of run record ${index} must be 64 lowercase hex characters, but it is ${describe(value)}.`,
353
+ "Record the graph hash of the snapshot the run assessed, as `intentset export` reports it (ADR 0005).",
354
+ );
355
+ return undefined;
356
+ }
357
+
358
+ function readTime(entry: Record<string, unknown>, key: string, problem: Report, index: number): string | undefined {
359
+ if (!Object.hasOwn(entry, key)) return undefined;
360
+ const value = entry[key];
361
+ if (isUtcTimestamp(value)) return value;
362
+ problem(
363
+ key,
364
+ `\`${key}\` of run record ${index} must be a UTC timestamp such as 2026-10-02T09:30:00Z, but it is ${describe(value)}.`,
365
+ "Write the instant in UTC with a Z suffix; local offsets are not accepted.",
366
+ );
367
+ return undefined;
368
+ }
369
+
370
+ function readReviewer(entry: Record<string, unknown>, problem: Report, index: number): string | null | undefined {
371
+ if (!Object.hasOwn(entry, "reviewer")) return undefined;
372
+ const value = entry.reviewer;
373
+ if (value === null) return null;
374
+ if (typeof value === "string" && /\S/.test(value)) return value;
375
+ problem(
376
+ "reviewer",
377
+ `\`reviewer\` of run record ${index} must be a reviewer's identity or null, but it is ${describe(value)}.`,
378
+ "Name the person who performed the review, or write null for an automated run.",
379
+ );
380
+ return undefined;
381
+ }
382
+
383
+ function readScope(
384
+ entry: Record<string, unknown>,
385
+ at: (key: string) => string,
386
+ problems: Problem[],
387
+ index: number,
388
+ ): RunScope | undefined {
389
+ if (!Object.hasOwn(entry, "scope")) return undefined;
390
+ const value = entry.scope;
391
+ if (!isRecord(value)) {
392
+ problems.push({
393
+ field: at("scope"),
394
+ message: `\`scope\` of run record ${index} must be a mapping with product and release, but it is ${describe(value)}.`,
395
+ remediation: "Record the exact product and release the run assessed (Core §8).",
396
+ });
397
+ return undefined;
398
+ }
399
+ let ok = true;
400
+ for (const key of Object.keys(value).sort(compareStrings)) {
401
+ if (key !== "product" && key !== "release") {
402
+ problems.push({
403
+ field: `${at("scope")}/${pointerToken(key)}`,
404
+ message: `\`scope\` of run record ${index} has the unknown key \`${key}\`.`,
405
+ remediation: "A scope names a product and a release and nothing else.",
406
+ });
407
+ ok = false;
408
+ }
409
+ }
410
+ if (typeof value.product !== "string" || !ID_PATTERN.test(value.product)) {
411
+ problems.push({
412
+ field: `${at("scope")}/product`,
413
+ message: `\`scope.product\` of run record ${index} must be a product ID, but it is ${describe(value.product)}.`,
414
+ remediation: "Name the product artifact, such as PRD-LANTERN.",
415
+ });
416
+ ok = false;
417
+ }
418
+ if (typeof value.release !== "string" || !/\S/.test(value.release)) {
419
+ problems.push({
420
+ field: `${at("scope")}/release`,
421
+ message: `\`scope.release\` of run record ${index} must be a release label, but it is ${describe(value.release)}.`,
422
+ remediation: "Give the exact release label the run assessed, such as pilot-1.",
423
+ });
424
+ ok = false;
425
+ }
426
+ return ok ? { product: value.product as string, release: value.release as string } : undefined;
427
+ }
428
+
429
+ function readTool(
430
+ entry: Record<string, unknown>,
431
+ at: (key: string) => string,
432
+ problems: Problem[],
433
+ index: number,
434
+ ): RunTool | null | undefined {
435
+ if (!Object.hasOwn(entry, "tool")) return undefined;
436
+ const value = entry.tool;
437
+ if (value === null) return null;
438
+ if (!isRecord(value)) {
439
+ problems.push({
440
+ field: at("tool"),
441
+ message: `\`tool\` of run record ${index} must be a mapping with name and version, or null, but it is ${describe(value)}.`,
442
+ remediation: "Record the tool that ran the check and its version, such as node:test and v24.15.0.",
443
+ });
444
+ return undefined;
445
+ }
446
+ let ok = true;
447
+ for (const key of Object.keys(value).sort(compareStrings)) {
448
+ if (key !== "name" && key !== "version") {
449
+ problems.push({
450
+ field: `${at("tool")}/${pointerToken(key)}`,
451
+ message: `\`tool\` of run record ${index} has the unknown key \`${key}\`.`,
452
+ remediation: "A tool is a name and a version and nothing else.",
453
+ });
454
+ ok = false;
455
+ }
456
+ }
457
+ for (const key of ["name", "version"] as const) {
458
+ if (typeof value[key] !== "string" || !/\S/.test(value[key] as string)) {
459
+ problems.push({
460
+ field: `${at("tool")}/${key}`,
461
+ message: `\`tool.${key}\` of run record ${index} must be a non-empty string, but it is ${describe(value[key])}.`,
462
+ remediation: `Record the tool's ${key}.`,
463
+ });
464
+ ok = false;
465
+ }
466
+ }
467
+ return ok ? { name: value.name as string, version: value.version as string } : undefined;
468
+ }
469
+
470
+ function isRecord(value: unknown): value is Record<string, unknown> {
471
+ return typeof value === "object" && value !== null && !Array.isArray(value);
472
+ }
473
+
474
+ function describe(value: unknown): string {
475
+ if (value === undefined) return "absent";
476
+ if (value === null) return "null";
477
+ if (Array.isArray(value)) return "an array";
478
+ if (typeof value === "string")
479
+ return value === "" ? "an empty string" : JSON.stringify(value.length > 40 ? `${value.slice(0, 40)}…` : value);
480
+ if (typeof value === "object") return "a mapping";
481
+ return `the ${typeof value} ${String(value)}`;
482
+ }