@intentius/chant 0.82.0 → 0.83.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/dist/cli/main.d.ts.map +1 -1
- package/dist/content-digest.d.ts +2 -0
- package/dist/content-digest.d.ts.map +1 -1
- package/dist/workspace/checks/records.d.ts +33 -0
- package/dist/workspace/checks/records.d.ts.map +1 -0
- package/dist/workspace/checks.d.ts +8 -0
- package/dist/workspace/checks.d.ts.map +1 -1
- package/dist/workspace/compose-graph.d.ts +22 -6
- package/dist/workspace/compose-graph.d.ts.map +1 -1
- package/dist/workspace/graph-cli.d.ts +11 -4
- package/dist/workspace/graph-cli.d.ts.map +1 -1
- package/dist/workspace/lineage-check.d.ts +5 -2
- package/dist/workspace/lineage-check.d.ts.map +1 -1
- package/dist/workspace/lineage-init.d.ts.map +1 -1
- package/dist/workspace/lineage-lock.d.ts +8 -0
- package/dist/workspace/lineage-lock.d.ts.map +1 -1
- package/dist/workspace/lineage-upgrade.d.ts.map +1 -1
- package/dist/workspace/reason-codes.d.ts +4 -0
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/record-assets.d.ts +108 -0
- package/dist/workspace/record-assets.d.ts.map +1 -0
- package/dist/workspace/records-cli.d.ts +32 -1
- package/dist/workspace/records-cli.d.ts.map +1 -1
- package/dist/workspace/records.d.ts +41 -0
- package/dist/workspace/records.d.ts.map +1 -1
- package/dist/workspace/template-pins.d.ts +33 -0
- package/dist/workspace/template-pins.d.ts.map +1 -0
- package/dist/workspace/tree.d.ts +5 -0
- package/dist/workspace/tree.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/main.ts +12 -6
- package/src/content-digest.ts +5 -0
- package/src/workspace/checks/records.ts +83 -0
- package/src/workspace/checks.test.ts +2 -0
- package/src/workspace/checks.ts +13 -3
- package/src/workspace/compose-graph.test.ts +2 -1
- package/src/workspace/compose-graph.ts +23 -6
- package/src/workspace/graph-cli.ts +32 -6
- package/src/workspace/graph-contract.test.ts +2 -1
- package/src/workspace/graph.schema.json +131 -2
- package/src/workspace/lineage-check.ts +22 -5
- package/src/workspace/lineage-init.ts +5 -1
- package/src/workspace/lineage-lock.ts +7 -0
- package/src/workspace/lineage-upgrade.test.ts +29 -0
- package/src/workspace/lineage-upgrade.ts +21 -3
- package/src/workspace/member-commands.ts +1 -1
- package/src/workspace/reason-codes.test.ts +2 -1
- package/src/workspace/reason-codes.ts +5 -0
- package/src/workspace/record-assets.test.ts +319 -0
- package/src/workspace/record-assets.ts +207 -0
- package/src/workspace/records-cli.ts +117 -14
- package/src/workspace/records.schema.json +33 -1
- package/src/workspace/records.test.ts +50 -0
- package/src/workspace/records.ts +115 -5
- package/src/workspace/template-pins.test.ts +69 -0
- package/src/workspace/template-pins.ts +117 -0
- package/src/workspace/tree.ts +12 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `chant workspace check [--at <rev>] [--json] [--format stylish|json|sarif] [--generated]`:
|
|
2
|
+
* `chant workspace check [--at <rev>] [--json] [--format stylish|json|sarif] [--generated] [--kind <kind file>]`:
|
|
3
3
|
* the lineage checks (#2550, D9), and the declaration checks (#2535, D16)
|
|
4
4
|
* when a declaration sits between the current directory and the git root.
|
|
5
5
|
*
|
|
@@ -10,7 +10,9 @@
|
|
|
10
10
|
* check. The declaration checks report `WSP` findings through lint's
|
|
11
11
|
* reporters (`./checks.ts`), and cover member ledgers (#2538), recorded
|
|
12
12
|
* pipelines (#2542) and generated files (#2541) as well as the declaration
|
|
13
|
-
* itself (#2641). `--generated` runs each declared generator too.
|
|
13
|
+
* itself (#2641). `--generated` runs each declared generator too. `--kind`
|
|
14
|
+
* reads a record kind's records and reports the files they pin by hash that
|
|
15
|
+
* have changed since (#2549).
|
|
14
16
|
*
|
|
15
17
|
* `chant workspace upgrade` runs the same checks in its staging worktree
|
|
16
18
|
* before it reaches its gate.
|
|
@@ -23,6 +25,7 @@ import type { CommandContext } from "../cli/registry";
|
|
|
23
25
|
import type { LintDiagnostic, LintRule } from "../lint/rule";
|
|
24
26
|
import { findWorkspaceRoot } from "../project-root";
|
|
25
27
|
import type { DeclarationCheckReport } from "./checks";
|
|
28
|
+
import type { RecordFacts } from "./checks/records";
|
|
26
29
|
import { LOCK_FILE, LockError, parseLock, readLock } from "./lineage-lock";
|
|
27
30
|
import type { ReasonCode } from "./reason-codes";
|
|
28
31
|
import type { WorkspaceTree } from "./tree";
|
|
@@ -81,7 +84,7 @@ export function findingKey(f: CheckFinding): string {
|
|
|
81
84
|
return `${f.code}\0${f.scope ?? ""}\0${f.path ?? ""}`;
|
|
82
85
|
}
|
|
83
86
|
|
|
84
|
-
const USAGE = "chant workspace check [--at <rev>] [--json] [--format stylish|json|sarif] [--generated]";
|
|
87
|
+
const USAGE = "chant workspace check [--at <rev>] [--json] [--format stylish|json|sarif] [--generated] [--kind <kind file>]";
|
|
85
88
|
const FORMATS = ["stylish", "json", "sarif"] as const;
|
|
86
89
|
|
|
87
90
|
/** A lock finding as a lint diagnostic, for `--format json` and `--format sarif`. */
|
|
@@ -142,7 +145,7 @@ export type CheckDocument =
|
|
|
142
145
|
* to `cwd`. `runGenerators` is `--generated`; with `at` it does nothing,
|
|
143
146
|
* since the member facts describe the working tree, not the revision.
|
|
144
147
|
*/
|
|
145
|
-
export async function runChecks(cwd: string, at?: string, options: { runGenerators?: boolean } = {}): Promise<CheckDocument> {
|
|
148
|
+
export async function runChecks(cwd: string, at?: string, options: { runGenerators?: boolean; kind?: string } = {}): Promise<CheckDocument> {
|
|
146
149
|
// Loaded here, not at the top: `workspace upgrade` imports this module for checkLineage alone.
|
|
147
150
|
const [{ findDeclarationDir, readDeclaration, readerVersion }, { gitTop, gitTree, resolveCommit, workingTree }] = await Promise.all([
|
|
148
151
|
import("./declaration"),
|
|
@@ -184,9 +187,11 @@ export async function runChecks(cwd: string, at?: string, options: { runGenerato
|
|
|
184
187
|
if (found) {
|
|
185
188
|
const { runDeclarationChecks } = await import("./checks");
|
|
186
189
|
const { dir, tree } = found;
|
|
190
|
+
const records = options.kind !== undefined ? await readRecordFacts(options.kind, cwd, commit) : undefined;
|
|
187
191
|
declaration = await runDeclarationChecks(dir, (file) => relative(cwd, join(dir, file)).split(sep).join("/"), {
|
|
188
192
|
runGenerators: options.runGenerators === true,
|
|
189
193
|
...(commit !== null ? { tree } : {}),
|
|
194
|
+
...(records ? { records } : {}),
|
|
190
195
|
});
|
|
191
196
|
let name: string | null = null;
|
|
192
197
|
try {
|
|
@@ -200,6 +205,18 @@ export async function runChecks(cwd: string, at?: string, options: { runGenerato
|
|
|
200
205
|
return { ...head, at: commit, workspace, ...report, ok, ...(declaration ? { declaration } : {}) };
|
|
201
206
|
}
|
|
202
207
|
|
|
208
|
+
/** The records of the kind file `kind` for the record checks (#2549), at `commit` or in the working tree. */
|
|
209
|
+
async function readRecordFacts(kind: string, cwd: string, commit: string | null): Promise<RecordFacts> {
|
|
210
|
+
const [{ readRecordsFor }, { RecordReadError }] = await Promise.all([import("./records-cli"), import("./records")]);
|
|
211
|
+
try {
|
|
212
|
+
const read = await readRecordsFor({ kind, cwd, ...(commit !== null ? { at: commit } : {}) });
|
|
213
|
+
return { kind, records: read.result.records.map((r) => ({ ...r, file: join(read.root, ...r.path.split("/")) })) };
|
|
214
|
+
} catch (err) {
|
|
215
|
+
if (!(err instanceof RecordReadError)) throw err;
|
|
216
|
+
return { kind, error: { code: err.code, message: err.message } };
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
203
220
|
export async function runWorkspaceCheck(ctx: CommandContext): Promise<number> {
|
|
204
221
|
const root = process.cwd();
|
|
205
222
|
if (ctx.args.extraPositional) {
|
|
@@ -218,7 +235,7 @@ export async function runWorkspaceCheck(ctx: CommandContext): Promise<number> {
|
|
|
218
235
|
const handed = await handToRootChant(root, ctx.args.at);
|
|
219
236
|
if (handed !== undefined) return handed;
|
|
220
237
|
}
|
|
221
|
-
const doc = await runChecks(root, ctx.args.at, { runGenerators: ctx.args.generated === true });
|
|
238
|
+
const doc = await runChecks(root, ctx.args.at, { runGenerators: ctx.args.generated === true, ...(ctx.args.kind !== undefined ? { kind: ctx.args.kind } : {}) });
|
|
222
239
|
if ("error" in doc) {
|
|
223
240
|
if (ctx.args.json || format === "json") console.log(JSON.stringify(doc, null, 2));
|
|
224
241
|
else console.error(formatError({ message: `${doc.error.code}: ${doc.error.message}`, hint: USAGE }));
|
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
} from "./lineage-lock";
|
|
37
37
|
import { MIGRATIONS_DIR } from "./lineage-migrations";
|
|
38
38
|
import { TEMPLATE_MANIFEST, readManifest, resolveParameters, substituteParameters } from "./template-manifest";
|
|
39
|
+
import { repinSubstituted, type RepinnedRecord } from "./template-pins";
|
|
39
40
|
|
|
40
41
|
// ── The template spec ────────────────────────────────────────────────────────
|
|
41
42
|
|
|
@@ -413,11 +414,13 @@ export async function initFromCommand(options: InitFromOptions): Promise<InitFro
|
|
|
413
414
|
// so a refused --param leaves the target untouched.
|
|
414
415
|
let parameters: Record<string, string>;
|
|
415
416
|
let contents: Map<string, Buffer>;
|
|
417
|
+
let repinned: RepinnedRecord[];
|
|
416
418
|
try {
|
|
417
419
|
const raw = new Map([...fetched.files].map(([path, f]) => [path, f.data]));
|
|
418
420
|
const manifest = readManifest(raw);
|
|
419
421
|
parameters = resolveParameters(manifest, options.params ?? {});
|
|
420
|
-
|
|
422
|
+
// Records that pin a substituted file get its new hash, so a copy's pins hold (#2549).
|
|
423
|
+
({ files: contents, repinned } = repinSubstituted(raw, substituteParameters(raw, manifest, parameters), manifest?.files ?? []));
|
|
421
424
|
} catch (err) {
|
|
422
425
|
return { success: false, createdFiles, warnings, error: (err as Error).message };
|
|
423
426
|
}
|
|
@@ -442,6 +445,7 @@ export async function initFromCommand(options: InitFromOptions): Promise<InitFro
|
|
|
442
445
|
|
|
443
446
|
const common = {
|
|
444
447
|
parameters,
|
|
448
|
+
...(repinned.length > 0 ? { repinned: repinned.filter((r) => written.has(r.record)) } : {}),
|
|
445
449
|
migrations: [],
|
|
446
450
|
files: fileEntries(written, declaredFilesAt(targetDir)),
|
|
447
451
|
manualSteps: [],
|
|
@@ -133,6 +133,12 @@ const LineageSchema = z
|
|
|
133
133
|
address: AddressSchema.nullable(),
|
|
134
134
|
/** The parameter values the template was instantiated with. Always empty for vendor scopes. */
|
|
135
135
|
parameters: z.record(z.string(), z.unknown()),
|
|
136
|
+
/**
|
|
137
|
+
* Records whose evidence pins were re-pinned to the substituted content
|
|
138
|
+
* of a parameterised file (#2549): each record's path in the scope, and the
|
|
139
|
+
* pinned paths. An upgrade re-pins the base and the target the same way.
|
|
140
|
+
*/
|
|
141
|
+
repinned: z.array(z.object({ record: z.string().min(1), paths: z.array(z.string().min(1)) }).strict()).optional(),
|
|
136
142
|
/** Migrations applied since instantiation (#2550). */
|
|
137
143
|
migrations: z.array(z.string()),
|
|
138
144
|
/** Per file, relative to the scope directory, in sorted order. */
|
|
@@ -249,6 +255,7 @@ function canonical(lock: LineageLock): LineageLock {
|
|
|
249
255
|
...(s.ref !== undefined ? { ref: s.ref } : {}),
|
|
250
256
|
address: s.address,
|
|
251
257
|
parameters: s.parameters,
|
|
258
|
+
...(s.repinned !== undefined ? { repinned: s.repinned } : {}),
|
|
252
259
|
migrations: s.migrations,
|
|
253
260
|
files,
|
|
254
261
|
manualSteps: [...s.manualSteps].sort((a, b) => a.path.localeCompare(b.path)),
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
9
9
|
import { execFileSync } from "node:child_process";
|
|
10
|
+
import { createHash } from "node:crypto";
|
|
10
11
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
11
12
|
import { tmpdir } from "node:os";
|
|
12
13
|
import { dirname, join } from "node:path";
|
|
@@ -455,6 +456,34 @@ describe("chant workspace upgrade with template parameters (#2627)", () => {
|
|
|
455
456
|
}
|
|
456
457
|
});
|
|
457
458
|
|
|
459
|
+
test("a record that pins a substituted file is re-pinned on the base and the target, so it updates with no manual step (#2549)", async () => {
|
|
460
|
+
const sha = (s: string) => createHash("sha256").update(s).digest("hex");
|
|
461
|
+
const record = (hash: string) => `---\nschema: 1\nid: "tpl-001"\nevidence:\n - title: "readme"\n path: "README.md"\n sha256: "${hash}"\n---\n`;
|
|
462
|
+
// A second project, from a v1 whose record pins the template's README.
|
|
463
|
+
tpl = join(root, "rtpl");
|
|
464
|
+
proj = join(root, "rproj");
|
|
465
|
+
mkdirSync(tpl);
|
|
466
|
+
git(tpl, ["init", "-q", "-b", "main"]);
|
|
467
|
+
const manifest = JSON.stringify({ parameters: { name: { type: "string", default: "Starter" } }, files: ["README.md"] });
|
|
468
|
+
release("v1.0.0", { "chant.template.json": manifest, "README.md": "# {{chant:name}}\n", "decisions/tpl-001-x.md": record(sha("# {{chant:name}}\n")) });
|
|
469
|
+
const made = await initFromCommand({ from: `${tpl}@v1.0.0`, path: proj, params: { name: "Acme" } });
|
|
470
|
+
expect(made.error).toBeUndefined();
|
|
471
|
+
expect(read(proj, "decisions/tpl-001-x.md")).toBe(record(sha("# Acme\n")));
|
|
472
|
+
expect(readLock(proj)!.scopes["."].repinned).toEqual([{ record: "decisions/tpl-001-x.md", paths: ["README.md"] }]);
|
|
473
|
+
git(proj, ["init", "-q", "-b", "main"]);
|
|
474
|
+
commitProject("init");
|
|
475
|
+
|
|
476
|
+
release("v2.0.0", { "README.md": "# {{chant:name}} v2\n", "decisions/tpl-001-x.md": record(sha("# {{chant:name}} v2\n")) });
|
|
477
|
+
const staged = await stageUpgrade({ root: proj, to: "v2.0.0", runChant: passing });
|
|
478
|
+
try {
|
|
479
|
+
expect(staged.manualSteps).toEqual([]);
|
|
480
|
+
expect(read(staged.worktreeProject, "decisions/tpl-001-x.md")).toBe(record(sha("# Acme v2\n")));
|
|
481
|
+
expect(readLock(staged.worktreeProject)!.scopes["."].repinned).toEqual([{ record: "decisions/tpl-001-x.md", paths: ["README.md"] }]);
|
|
482
|
+
} finally {
|
|
483
|
+
staged.dispose();
|
|
484
|
+
}
|
|
485
|
+
});
|
|
486
|
+
|
|
458
487
|
test("an upgrade to the same version changes nothing", async () => {
|
|
459
488
|
const staged = await stageUpgrade({ root: proj, to: "v1.0.0", runChant: passing });
|
|
460
489
|
try {
|
|
@@ -59,6 +59,7 @@ import { mergeFile } from "./lineage-merge";
|
|
|
59
59
|
import { assertCodeAllowed, planMigrations, runMigration, splitMigrations, type LoadedMigration } from "./lineage-migrations";
|
|
60
60
|
import { applyUpstream, type UpdateResult } from "./lineage-update";
|
|
61
61
|
import { carryParameters, readManifest, substituteParameters } from "./template-manifest";
|
|
62
|
+
import { repinSubstituted, type RepinnedRecord } from "./template-pins";
|
|
62
63
|
|
|
63
64
|
/** The kind the patch digest is taken under, so it never collides with another kind of plan. */
|
|
64
65
|
export const UPGRADE_PLAN_KIND = "workspace-upgrade";
|
|
@@ -184,6 +185,8 @@ interface Upstream {
|
|
|
184
185
|
tree?: string;
|
|
185
186
|
/** The parameter values substituted into `files` (#2627), for a git or directory source. */
|
|
186
187
|
parameters?: Record<string, string>;
|
|
188
|
+
/** The records re-pinned to substituted files (#2549). */
|
|
189
|
+
repinned?: RepinnedRecord[];
|
|
187
190
|
/** The source the lock records after the upgrade, when it moves (a directory source). */
|
|
188
191
|
source?: LineageSource;
|
|
189
192
|
}
|
|
@@ -191,13 +194,20 @@ interface Upstream {
|
|
|
191
194
|
/**
|
|
192
195
|
* The template's files as the project would have them (#2627): the manifest
|
|
193
196
|
* removed and the parameters substituted, with the recorded values and the
|
|
194
|
-
* defaults of parameters this version adds.
|
|
197
|
+
* defaults of parameters this version adds. Records that pin a substituted
|
|
198
|
+
* file are re-pinned as init re-pinned them (#2549), for the base and the
|
|
199
|
+
* target alike, so an unedited record compares equal to its base.
|
|
195
200
|
*/
|
|
196
|
-
function instantiate(
|
|
201
|
+
function instantiate(
|
|
202
|
+
raw: Map<string, Buffer>,
|
|
203
|
+
recorded: Record<string, unknown>,
|
|
204
|
+
label: string,
|
|
205
|
+
): { files: Map<string, Buffer>; parameters: Record<string, string>; repinned: RepinnedRecord[] } {
|
|
197
206
|
try {
|
|
198
207
|
const manifest = readManifest(raw);
|
|
199
208
|
const parameters = carryParameters(manifest, recorded);
|
|
200
|
-
|
|
209
|
+
const { files, repinned } = repinSubstituted(raw, substituteParameters(raw, manifest, parameters), manifest?.files ?? []);
|
|
210
|
+
return { files, parameters, repinned };
|
|
201
211
|
} catch (err) {
|
|
202
212
|
throw new UpgradeError(`${label}: ${(err as Error).message.replace(/; pass --param .*$/, "")}`);
|
|
203
213
|
}
|
|
@@ -264,6 +274,7 @@ function fetchGit(root: string, lineage: Lineage, ref: string): Upstream {
|
|
|
264
274
|
commit,
|
|
265
275
|
tree: target.tree,
|
|
266
276
|
parameters: instantiated.parameters,
|
|
277
|
+
repinned: instantiated.repinned,
|
|
267
278
|
};
|
|
268
279
|
} finally {
|
|
269
280
|
rmSync(scratch, { recursive: true, force: true });
|
|
@@ -336,6 +347,7 @@ function readDir(root: string, lineage: Lineage, to: string | undefined): Upstre
|
|
|
336
347
|
migrations: split.migrations,
|
|
337
348
|
modules: split.modules,
|
|
338
349
|
parameters: instantiated.parameters,
|
|
350
|
+
repinned: instantiated.repinned,
|
|
339
351
|
source: { type: "dir", path: recordedDirPath(target.path, target.abs, root), ...(member ? { member } : {}) },
|
|
340
352
|
};
|
|
341
353
|
}
|
|
@@ -561,6 +573,12 @@ export async function stageUpgrade(options: UpgradeOptions): Promise<StagedUpgra
|
|
|
561
573
|
for (const path of result.written) if (upstream.executable.has(path)) chmodSync(join(scopeDir, path), 0o755);
|
|
562
574
|
if (ref !== undefined) staged.ref = ref;
|
|
563
575
|
if (upstream.parameters) staged.parameters = upstream.parameters;
|
|
576
|
+
if (upstream.repinned) {
|
|
577
|
+
// The records this version re-pins, among the files the scope keeps (#2549).
|
|
578
|
+
const kept = upstream.repinned.filter((r) => staged.files[r.record] !== undefined || existsSync(join(scopeDir, r.record)));
|
|
579
|
+
if (kept.length > 0) staged.repinned = kept;
|
|
580
|
+
else delete staged.repinned;
|
|
581
|
+
}
|
|
564
582
|
if (upstream.commit) staged.address = { ...staged.address!, commit: upstream.commit, tree: upstream.tree };
|
|
565
583
|
if (upstream.source) staged.source = upstream.source;
|
|
566
584
|
writeLock(worktreeProject, next);
|
|
@@ -379,7 +379,7 @@ const USAGE: Record<WorkspaceVerb, string> = {
|
|
|
379
379
|
build: "chant workspace build [dir] [--member <name>] [-o <dir>] [--format json|yaml] [--env <env>] [--param k=v] [--dry-run]",
|
|
380
380
|
lint: "chant workspace lint [dir] [--member <name>] [--format stylish|json|sarif] [-o <file>] [--fix] [--dry-run]",
|
|
381
381
|
audit: "chant workspace audit [dir] [--member <name>] [--format stylish|json] [-o <file>] [--tier <tier>] [--fail-on <level>] [--dry-run]",
|
|
382
|
-
graph: "chant workspace graph [dir] [--member <name>] [-o <file>] [--env <env>] [--dry-run]",
|
|
382
|
+
graph: "chant workspace graph [dir] [--member <name>] [--kind <kind file>] [-o <file>] [--env <env>] [--dry-run]",
|
|
383
383
|
};
|
|
384
384
|
|
|
385
385
|
export function describePlan(plan: MemberPlan): string {
|
|
@@ -15,7 +15,7 @@ import { GRAPH_ERROR_CODES } from "./graph-cli";
|
|
|
15
15
|
import { CHECK_CODES, CHECK_ERROR_CODES } from "./lineage-check";
|
|
16
16
|
import { GROUP_REASON_CODES, MEMBER_REASON_CODES } from "./ls";
|
|
17
17
|
import { isReasonCode, REASON_CODES, REASONS } from "./reason-codes";
|
|
18
|
-
import { READ_ERROR_CODES, RECORD_REASON_CODES } from "./records";
|
|
18
|
+
import { READ_ERROR_CODES, RECORD_REASON_CODES, RECORD_WARNING_CODES } from "./records";
|
|
19
19
|
import { STATUS_ERROR_CODES, STATUS_REASON_CODES } from "./status";
|
|
20
20
|
|
|
21
21
|
const HERE = import.meta.dirname;
|
|
@@ -31,6 +31,7 @@ const PER_COMMAND: Record<string, readonly string[]> = {
|
|
|
31
31
|
STATUS_REASON_CODES,
|
|
32
32
|
STATUS_ERROR_CODES,
|
|
33
33
|
RECORD_REASON_CODES,
|
|
34
|
+
RECORD_WARNING_CODES,
|
|
34
35
|
READ_ERROR_CODES,
|
|
35
36
|
};
|
|
36
37
|
|
|
@@ -59,6 +59,11 @@ export const REASONS = {
|
|
|
59
59
|
"record-id-duplicate": "Another record earlier in path order has the same id.",
|
|
60
60
|
"record-supersedes-unknown": "A supersedes link names an id no record has.",
|
|
61
61
|
"record-supersedes-conflict": "A second closed record supersedes a record another one already superseded.",
|
|
62
|
+
// A record that is valid but warned about (records, #2549).
|
|
63
|
+
"asset-drift": "A file the record pins by hash has changed: its bytes no longer hash to the pinned sha256.",
|
|
64
|
+
"asset-missing": "A file the record pins by hash does not exist in the tree read.",
|
|
65
|
+
"asset-stale": "A file the record pins is unchanged at the hash a record it supersedes pinned: the decision changed and the artifact did not follow.",
|
|
66
|
+
"record-supersedes-pending": "A supersedes link from a record whose state is weaker than the record it names, so the link has no effect yet.",
|
|
62
67
|
// A records read that fails (records).
|
|
63
68
|
"kind-unreadable": "The record kind file is missing or could not be imported.",
|
|
64
69
|
"kind-invalid": "The record kind file exports no recordKind, or its shape is wrong.",
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Asset pins and record links (#2549): a decision's evidence may pin a
|
|
3
|
+
* workspace file by path and hash; `records` checks the pin in the tree it
|
|
4
|
+
* reads and reports drift as a warning that leaves the record valid; `graph
|
|
5
|
+
* --kind` emits `asset` and `constrains` rows; `check --kind` reports drift
|
|
6
|
+
* as WSP111.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { execFileSync } from "node:child_process";
|
|
10
|
+
import { createHash } from "node:crypto";
|
|
11
|
+
import { readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import Ajv from "ajv";
|
|
14
|
+
import { afterAll, describe, expect, test } from "vitest";
|
|
15
|
+
import { cleanScratch, commitAll, contract, declaration, repo, REPO } from "./__fixtures__/contract-repo";
|
|
16
|
+
import { runChecks } from "./lineage-check";
|
|
17
|
+
import { constraintCovers, isWorkspacePath, memberHolding, WORKSPACE_PATH_PATTERN } from "./record-assets";
|
|
18
|
+
import { pinFile, queryRecords, type RecordsDocument } from "./records-cli";
|
|
19
|
+
import { parseFrontMatter, RECORD_WARNING_CODES } from "./records";
|
|
20
|
+
import { workspaceGraph } from "./graph-cli";
|
|
21
|
+
import graphSchema from "./graph.schema.json";
|
|
22
|
+
import recordsSchema from "./records.schema.json";
|
|
23
|
+
|
|
24
|
+
afterAll(cleanScratch);
|
|
25
|
+
|
|
26
|
+
const DECISIONS = join(REPO, "docs", "design", "decisions");
|
|
27
|
+
const SAMPLE = readFileSync(join(DECISIONS, "ws-003-seal-scope.md"), "utf-8");
|
|
28
|
+
const KIND = readFileSync(join(DECISIONS, "decision.kind.mjs"), "utf-8");
|
|
29
|
+
const SCHEMA = readFileSync(join(DECISIONS, "decision.schema.json"), "utf-8");
|
|
30
|
+
const sha = (text: string | Buffer) => createHash("sha256").update(text).digest("hex");
|
|
31
|
+
|
|
32
|
+
/** ws-003 with its id replaced, and extra evidence and constrains entries. */
|
|
33
|
+
function decision(id: string, evidence: string[], constrains: string[] = [], state = "decided", supersedes: string[] = []): string {
|
|
34
|
+
let text = SAMPLE.replace(/^id: .*$/m, `id: "${id}"`).replace(/^state: .*$/m, `state: "${state}"`);
|
|
35
|
+
text = text.replace(/^evidence:\n/m, `evidence:\n${evidence.join("")}`);
|
|
36
|
+
text = text.replace(/^constrains:(?: \[\])?\n/m, `constrains:\n${constrains.map((c) => ` - "${c}"\n`).join("")}`);
|
|
37
|
+
const links = supersedes.length === 0 ? "supersedes: []" : `supersedes:\n${supersedes.map((d) => ` - decision: "${d}"`).join("\n")}`;
|
|
38
|
+
return text.replace(/^supersedes:(?: \[\])?\n(?: .*\n)*/m, `${links}\n`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const pin = (path: string, hash: string) => ` - title: "the spec"\n path: "${path}"\n sha256: "${hash}"\n`;
|
|
42
|
+
|
|
43
|
+
const SPEC = '{ "screen": "home" }\n';
|
|
44
|
+
const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0xff, 0xfe]);
|
|
45
|
+
|
|
46
|
+
/** A git repository holding a workspace at `ws/`, whose decisions pin files in its `design` member. */
|
|
47
|
+
function workspace(extra: Record<string, string> = {}): string {
|
|
48
|
+
const root = repo({
|
|
49
|
+
"ws/chant.workspace.json": declaration([
|
|
50
|
+
{ name: "app", dir: "app", kind: "other", because: "an app" },
|
|
51
|
+
{ name: "design", dir: "design", kind: "other", because: "design data" },
|
|
52
|
+
]),
|
|
53
|
+
"ws/app/src/server.mjs": "export {};\n",
|
|
54
|
+
"ws/design/screens/home.json": SPEC,
|
|
55
|
+
"ws/decisions/decision.kind.mjs": KIND,
|
|
56
|
+
"ws/decisions/decision.schema.json": SCHEMA,
|
|
57
|
+
"ws/decisions/ws-101-spec.md": decision("ws-101", [pin("design/screens/home.json", sha(SPEC))], ["member:design", "path:app/src", "path:app/nope", "member:ghost"]),
|
|
58
|
+
...extra,
|
|
59
|
+
});
|
|
60
|
+
writeFileSync(join(root, "ws", "design", "screens", "home.png"), PNG);
|
|
61
|
+
return root;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function records(doc: RecordsDocument) {
|
|
65
|
+
if ("error" in doc) throw new Error(`${doc.error.code}: ${doc.error.message}`);
|
|
66
|
+
return doc;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
describe("the path grammar", () => {
|
|
70
|
+
test("the decision schema holds the same pattern for evidence and constrains", () => {
|
|
71
|
+
const schema = JSON.parse(SCHEMA) as { definitions: { workspacePath: { pattern: string } }; properties: { constrains: { items: { pattern: string } } } };
|
|
72
|
+
expect(schema.definitions.workspacePath.pattern).toBe(`^${WORKSPACE_PATH_PATTERN}$`);
|
|
73
|
+
expect(schema.properties.constrains.items.pattern).toContain(`|path:${WORKSPACE_PATH_PATTERN})$`);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test.each([
|
|
77
|
+
["design/screens/home.json", true],
|
|
78
|
+
[".github/workflows/ci.yml", true],
|
|
79
|
+
["app", true],
|
|
80
|
+
["/etc/passwd", false],
|
|
81
|
+
["../outside", false],
|
|
82
|
+
["a/../b", false],
|
|
83
|
+
["./a", false],
|
|
84
|
+
["a//b", false],
|
|
85
|
+
["a/", false],
|
|
86
|
+
["a\\b", false],
|
|
87
|
+
["", false],
|
|
88
|
+
])("%s is a workspace path: %s", (path, ok) => {
|
|
89
|
+
expect(isWorkspacePath(path)).toBe(ok);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("the schema takes a path pin with a hash, refuses one without, and keeps url entries as they were", () => {
|
|
93
|
+
const validate = new Ajv({ allErrors: true, strict: false }).compile(JSON.parse(SCHEMA) as object);
|
|
94
|
+
const base = { title: "t" };
|
|
95
|
+
const withEvidence = (evidence: unknown[]) => {
|
|
96
|
+
const fm = parseFrontMatter(decision("ws-101", []));
|
|
97
|
+
if (!fm.ok) throw new Error(fm.message);
|
|
98
|
+
return { ...fm.value, evidence };
|
|
99
|
+
};
|
|
100
|
+
expect(validate(withEvidence([{ ...base, path: "design/a.json", sha256: sha("x") }]))).toBe(true);
|
|
101
|
+
expect(validate(withEvidence([{ ...base, url: "https://example.com", sha256: null }]))).toBe(true);
|
|
102
|
+
expect(validate(withEvidence([{ ...base, path: "design/a.json" }]))).toBe(false);
|
|
103
|
+
expect(validate(withEvidence([{ ...base, path: "design/a.json", sha256: null }]))).toBe(false);
|
|
104
|
+
expect(validate(withEvidence([{ ...base, path: "../a.json", sha256: sha("x") }]))).toBe(false);
|
|
105
|
+
expect(validate(withEvidence([{ ...base, path: "a.json", url: "https://example.com", sha256: sha("x") }]))).toBe(false);
|
|
106
|
+
expect(validate({ ...withEvidence([{ ...base, url: "https://example.com" }]), constrains: ["path:app/src", "member:app", "INTENTIUS/chant#1"] })).toBe(true);
|
|
107
|
+
expect(validate({ ...withEvidence([{ ...base, url: "https://example.com" }]), constrains: ["path:../x"] })).toBe(false);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("a path belongs to the deepest member holding it, and a path: constraint covers what is below it", () => {
|
|
111
|
+
const members = [
|
|
112
|
+
{ name: "root", dir: "." },
|
|
113
|
+
{ name: "app", dir: "app" },
|
|
114
|
+
{ name: "api", dir: "app/api" },
|
|
115
|
+
];
|
|
116
|
+
expect(memberHolding("app/api/x.ts", members)).toBe("api");
|
|
117
|
+
expect(memberHolding("app/x.ts", members)).toBe("app");
|
|
118
|
+
expect(memberHolding("apps/x.ts", members)).toBe("root");
|
|
119
|
+
expect(memberHolding("x", [{ name: "app", dir: "app" }])).toBeNull();
|
|
120
|
+
expect(constraintCovers("app", "app/src/server.mjs")).toBe(true);
|
|
121
|
+
expect(constraintCovers("app", "apps/x")).toBe(false);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe("chant workspace records checks each pin", () => {
|
|
126
|
+
const { expectValid } = contract(recordsSchema);
|
|
127
|
+
|
|
128
|
+
test("a pin that matches is pinned, resolved from the workspace holding the kind", async () => {
|
|
129
|
+
const root = workspace();
|
|
130
|
+
const doc = records(await queryRecords({ kind: "ws/decisions/decision.kind.mjs", cwd: root }));
|
|
131
|
+
expectValid(doc);
|
|
132
|
+
expect(doc.workspaceRoot).toBe("ws");
|
|
133
|
+
const [r] = doc.records;
|
|
134
|
+
expect(r.valid).toBe(true);
|
|
135
|
+
expect(r.warnings).toEqual([]);
|
|
136
|
+
expect(r.assets).toEqual([{ path: "design/screens/home.json", sha256: sha(SPEC), actual: sha(SPEC), state: "pinned" }]);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("an edited file is asset-drift and a missing one asset-missing; the record stays valid and current", async () => {
|
|
140
|
+
const root = workspace({
|
|
141
|
+
"ws/decisions/ws-102-gone.md": decision("ws-102", [pin("design/screens/gone.json", sha("x"))]),
|
|
142
|
+
});
|
|
143
|
+
writeFileSync(join(root, "ws", "design", "screens", "home.json"), '{ "screen": "home", "edited": true }\n');
|
|
144
|
+
const doc = records(await queryRecords({ kind: "ws/decisions/decision.kind.mjs", current: true, cwd: root }));
|
|
145
|
+
expectValid(doc);
|
|
146
|
+
expect(doc.records.map((r) => [r.id, r.valid, r.warnings.map((w) => w.code), r.assets.map((a) => a.state)])).toEqual([
|
|
147
|
+
["ws-101", true, ["asset-drift"], ["drifted"]],
|
|
148
|
+
["ws-102", true, ["asset-missing"], ["missing"]],
|
|
149
|
+
]);
|
|
150
|
+
expect(doc.summary.invalid).toBe(0);
|
|
151
|
+
expect(doc.records[1].assets[0].actual).toBeNull();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("the bytes are hashed, not the text", async () => {
|
|
155
|
+
const root = workspace({ "ws/decisions/ws-103-png.md": decision("ws-103", [pin("design/screens/home.png", sha(PNG))]) });
|
|
156
|
+
const doc = records(await queryRecords({ kind: "ws/decisions/decision.kind.mjs", cwd: root }));
|
|
157
|
+
expect(doc.records.find((r) => r.id === "ws-103")!.assets[0].state).toBe("pinned");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("--at checks the pin against the file as committed at that revision", async () => {
|
|
161
|
+
const root = workspace();
|
|
162
|
+
const first = commitAll(root, "one");
|
|
163
|
+
writeFileSync(join(root, "ws", "design", "screens", "home.json"), "{}\n");
|
|
164
|
+
const second = commitAll(root, "two");
|
|
165
|
+
const at = async (rev: string) => records(await queryRecords({ kind: "ws/decisions/decision.kind.mjs", at: rev, cwd: root })).records[0].assets[0].state;
|
|
166
|
+
expect(await at(first)).toBe("pinned");
|
|
167
|
+
expect(await at(second)).toBe("drifted");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("the schema lists exactly the warning codes", () => {
|
|
171
|
+
expect(recordsSchema.$defs.warning.properties.code.enum).toEqual([...RECORD_WARNING_CODES]);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("records pin <path> prints the entry's path from the workspace root and the file's hash", () => {
|
|
175
|
+
const root = workspace();
|
|
176
|
+
expect(pinFile("screens/home.json", join(root, "ws", "design"))).toEqual({ path: "design/screens/home.json", sha256: sha(SPEC) });
|
|
177
|
+
expect(pinFile("ws/design/screens/home.png", root)).toEqual({ path: "design/screens/home.png", sha256: sha(PNG) });
|
|
178
|
+
expect(pinFile("design", join(root, "ws"))).toEqual({ error: "design is not a file" });
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe("chant workspace graph --kind", () => {
|
|
183
|
+
const { expectValid } = contract(graphSchema);
|
|
184
|
+
|
|
185
|
+
test("emits an asset row per pin and a constrains row per member or path, and the document validates", async () => {
|
|
186
|
+
const root = workspace({ "ws/decisions/ws-104-old.md": decision("ws-104", [pin("design/screens/home.json", sha("old"))]) });
|
|
187
|
+
writeFileSync(join(root, "ws", "decisions", "ws-105-new.md"), decision("ws-105", [], [], "ratified", ["ws-104"]));
|
|
188
|
+
const { doc, failed } = await workspaceGraph({ cwd: join(root, "ws"), kind: join(root, "ws", "decisions", "decision.kind.mjs") });
|
|
189
|
+
expect(failed).toBe(false);
|
|
190
|
+
expectValid(doc);
|
|
191
|
+
if ("error" in doc) throw new Error(doc.error.message);
|
|
192
|
+
expect(doc.records.map((r) => [r.kind, r.id, r.supersededBy])).toEqual([
|
|
193
|
+
["decision", "ws-101", null],
|
|
194
|
+
["decision", "ws-104", "ws-105"],
|
|
195
|
+
["decision", "ws-105", null],
|
|
196
|
+
]);
|
|
197
|
+
const rows = doc.links.map((r) => ("record" in r ? [r.kind, r.record, r.target, r.member, r.status] : []));
|
|
198
|
+
// ws-104 is superseded, so its drifted pin is no row.
|
|
199
|
+
expect(rows).toEqual([
|
|
200
|
+
["asset", "ws-101", "design/screens/home.json", "design", "pinned"],
|
|
201
|
+
["constrains", "ws-101", "member:design", "design", "resolved"],
|
|
202
|
+
["constrains", "ws-101", "path:app/src", "app", "resolved"],
|
|
203
|
+
["constrains", "ws-101", "path:app/nope", "app", "missing"],
|
|
204
|
+
["constrains", "ws-101", "member:ghost", null, "missing"],
|
|
205
|
+
]);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("a kind that can't be read fails the command and leaves the records empty", async () => {
|
|
209
|
+
const root = workspace();
|
|
210
|
+
const errors: string[] = [];
|
|
211
|
+
const { doc, failed } = await workspaceGraph({ cwd: join(root, "ws"), kind: join(root, "nope.mjs"), onStderr: (t) => errors.push(t) });
|
|
212
|
+
expect(failed).toBe(true);
|
|
213
|
+
expectValid(doc);
|
|
214
|
+
expect(errors.join("")).toContain("kind-unreadable");
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
describe("chant workspace check --kind", () => {
|
|
219
|
+
test("reports a drifted pin as a WSP111 warning on the record, and passes", async () => {
|
|
220
|
+
const root = workspace();
|
|
221
|
+
writeFileSync(join(root, "ws", "design", "screens", "home.json"), "{}\n");
|
|
222
|
+
const doc = await runChecks(join(root, "ws"), undefined, { kind: "decisions/decision.kind.mjs" });
|
|
223
|
+
if ("error" in doc) throw new Error(doc.error.message);
|
|
224
|
+
const found = doc.declaration!.diagnostics.filter((d) => d.ruleId.startsWith("WSP11"));
|
|
225
|
+
expect(found.map((d) => [d.ruleId, d.severity, d.file])).toEqual([["WSP111", "warning", "decisions/ws-101-spec.md"]]);
|
|
226
|
+
expect(doc.ok).toBe(true);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("without --kind no record is read; an unreadable kind is a WSP114 error", async () => {
|
|
230
|
+
const root = workspace();
|
|
231
|
+
writeFileSync(join(root, "ws", "design", "screens", "home.json"), "{}\n");
|
|
232
|
+
const plain = await runChecks(join(root, "ws"));
|
|
233
|
+
if ("error" in plain) throw new Error(plain.error.message);
|
|
234
|
+
expect(plain.declaration!.diagnostics.some((d) => d.ruleId.startsWith("WSP11"))).toBe(false);
|
|
235
|
+
const bad = await runChecks(join(root, "ws"), undefined, { kind: "decisions/nope.mjs" });
|
|
236
|
+
if ("error" in bad) throw new Error(bad.error.message);
|
|
237
|
+
expect(bad.declaration!.diagnostics.map((d) => d.ruleId)).toContain("WSP114");
|
|
238
|
+
expect(bad.ok).toBe(false);
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
describe("asset-stale: the decision changed and the artifact did not follow", () => {
|
|
243
|
+
/** Commit everything in `root` at `seconds` since the epoch, so commit times are ordered. */
|
|
244
|
+
const commitAt = (root: string, seconds: number) => {
|
|
245
|
+
const env = { ...process.env, GIT_AUTHOR_DATE: `@${seconds} +0000`, GIT_COMMITTER_DATE: `@${seconds} +0000`, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@example.com", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@example.com" };
|
|
246
|
+
execFileSync("git", ["add", "-A"], { cwd: root, env });
|
|
247
|
+
execFileSync("git", ["commit", "-q", "--allow-empty", "-m", String(seconds)], { cwd: root, env });
|
|
248
|
+
};
|
|
249
|
+
const kind = "ws/decisions/decision.kind.mjs";
|
|
250
|
+
const superseding = (hash: string) => decision("ws-106", [pin("design/screens/home.json", hash)], [], "decided", ["ws-101"]);
|
|
251
|
+
|
|
252
|
+
test("a superseding record that pins the old hash of an unchanged file is stale; drift and missing stay apart", async () => {
|
|
253
|
+
const root = workspace({
|
|
254
|
+
// ws-107 supersedes ws-102 and pins a file that is gone; ws-108 supersedes ws-103 and pins a file that changed.
|
|
255
|
+
"ws/decisions/ws-102-gone.md": decision("ws-102", [pin("design/screens/gone.json", sha("x"))]),
|
|
256
|
+
"ws/decisions/ws-103-other.md": decision("ws-103", [pin("design/screens/other.json", sha("one\n"))]),
|
|
257
|
+
"ws/design/screens/other.json": "one\n",
|
|
258
|
+
});
|
|
259
|
+
commitAt(root, 1_700_000_000);
|
|
260
|
+
writeFileSync(join(root, "ws", "decisions", "ws-106-new.md"), superseding(sha(SPEC)));
|
|
261
|
+
writeFileSync(join(root, "ws", "decisions", "ws-107-gone.md"), decision("ws-107", [pin("design/screens/gone.json", sha("x"))], [], "decided", ["ws-102"]));
|
|
262
|
+
writeFileSync(join(root, "ws", "decisions", "ws-108-other.md"), decision("ws-108", [pin("design/screens/other.json", sha("one\n"))], [], "decided", ["ws-103"]));
|
|
263
|
+
writeFileSync(join(root, "ws", "design", "screens", "other.json"), "two\n");
|
|
264
|
+
commitAt(root, 1_700_000_100);
|
|
265
|
+
|
|
266
|
+
const doc = records(await queryRecords({ kind, current: true, cwd: root }));
|
|
267
|
+
contract(recordsSchema).expectValid(doc);
|
|
268
|
+
expect(doc.records.map((r) => [r.id, r.valid, r.assets.map((a) => a.state), r.warnings.map((w) => w.code)])).toEqual([
|
|
269
|
+
["ws-106", true, ["stale"], ["asset-stale"]],
|
|
270
|
+
["ws-107", true, ["missing"], ["asset-missing"]],
|
|
271
|
+
["ws-108", true, ["drifted"], ["asset-drift"]],
|
|
272
|
+
]);
|
|
273
|
+
|
|
274
|
+
// The same read at the revision agrees.
|
|
275
|
+
const at = records(await queryRecords({ kind, current: true, at: "HEAD", cwd: root }));
|
|
276
|
+
expect(at.records[0].assets[0].state).toBe("stale");
|
|
277
|
+
|
|
278
|
+
const { doc: graph } = await workspaceGraph({ cwd: join(root, "ws"), kind: join(root, kind) });
|
|
279
|
+
contract(graphSchema).expectValid(graph);
|
|
280
|
+
if ("error" in graph) throw new Error(graph.error.message);
|
|
281
|
+
expect(graph.links.flatMap((r) => ("record" in r && r.kind === "asset" ? [[r.record, r.status]] : []))).toEqual([
|
|
282
|
+
["ws-106", "stale"],
|
|
283
|
+
["ws-107", "missing"],
|
|
284
|
+
["ws-108", "drifted"],
|
|
285
|
+
]);
|
|
286
|
+
|
|
287
|
+
const check = await runChecks(join(root, "ws"), undefined, { kind: "decisions/decision.kind.mjs" });
|
|
288
|
+
if ("error" in check) throw new Error(check.error.message);
|
|
289
|
+
expect(check.declaration!.diagnostics.filter((d) => d.ruleId.startsWith("WSP11")).map((d) => d.ruleId).sort()).toEqual(["WSP111", "WSP112", "WSP113"]);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test("re-pinning the new version of the file clears it", async () => {
|
|
293
|
+
const root = workspace();
|
|
294
|
+
commitAt(root, 1_700_000_000);
|
|
295
|
+
const next = '{ "screen": "home", "v": 2 }\n';
|
|
296
|
+
writeFileSync(join(root, "ws", "design", "screens", "home.json"), next);
|
|
297
|
+
writeFileSync(join(root, "ws", "decisions", "ws-106-new.md"), superseding(sha(next)));
|
|
298
|
+
const doc = records(await queryRecords({ kind, current: true, cwd: root }));
|
|
299
|
+
expect(doc.records.map((r) => [r.id, r.assets.map((a) => a.state), r.warnings])).toEqual([["ws-106", ["pinned"], []]]);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test("a file committed again after the record was recorded is not stale, even at the same hash", async () => {
|
|
303
|
+
const root = workspace();
|
|
304
|
+
commitAt(root, 1_700_000_000);
|
|
305
|
+
writeFileSync(join(root, "ws", "decisions", "ws-106-new.md"), superseding(sha(SPEC)));
|
|
306
|
+
commitAt(root, 1_700_000_100);
|
|
307
|
+
const spec = join(root, "ws", "design", "screens", "home.json");
|
|
308
|
+
rmSync(spec);
|
|
309
|
+
commitAt(root, 1_700_000_200);
|
|
310
|
+
writeFileSync(spec, SPEC);
|
|
311
|
+
commitAt(root, 1_700_000_300);
|
|
312
|
+
const doc = records(await queryRecords({ kind, current: true, cwd: root }));
|
|
313
|
+
expect(doc.records[0].assets[0].state).toBe("pinned");
|
|
314
|
+
// At the revision where the record was recorded, the file had not moved since ws-101 pinned it.
|
|
315
|
+
const log = execFileSync("git", ["log", "--format=%H"], { cwd: root, encoding: "utf-8" }).trim().split("\n");
|
|
316
|
+
const at = records(await queryRecords({ kind, current: true, at: log[2], cwd: root }));
|
|
317
|
+
expect(at.records[0].assets[0].state).toBe("stale");
|
|
318
|
+
});
|
|
319
|
+
});
|