@intentius/chant 0.81.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-cli.d.ts +5 -1
- package/dist/workspace/lineage-cli.d.ts.map +1 -1
- package/dist/workspace/lineage-init.d.ts +47 -4
- package/dist/workspace/lineage-init.d.ts.map +1 -1
- package/dist/workspace/lineage-lock.d.ts +23 -2
- package/dist/workspace/lineage-lock.d.ts.map +1 -1
- package/dist/workspace/lineage-upgrade-cli.d.ts +1 -1
- package/dist/workspace/lineage-upgrade.d.ts +8 -2
- 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/handlers/init.ts +7 -3
- package/src/cli/main.ts +16 -8
- package/src/content-digest.ts +5 -0
- package/src/workspace/behold-kinds.test.ts +10 -14
- 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-cli.ts +21 -1
- package/src/workspace/lineage-init-dir.test.ts +243 -0
- package/src/workspace/lineage-init.ts +223 -24
- package/src/workspace/lineage-lock.ts +18 -3
- package/src/workspace/lineage-upgrade-cli.ts +2 -2
- package/src/workspace/lineage-upgrade.test.ts +29 -0
- package/src/workspace/lineage-upgrade.ts +130 -13
- 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
|
@@ -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 {
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
* 1. Fetch the target version of the template. For a git source that is one
|
|
10
10
|
* `git fetch` of the target ref, plus the commit the scope was made from.
|
|
11
11
|
* This is the command's only network step, catalogued in
|
|
12
|
-
* `test/egress-catalogue.ts`. A local repository reaches nothing
|
|
12
|
+
* `test/egress-catalogue.ts`. A local repository reaches nothing, and
|
|
13
|
+
* neither does a directory source (#2647), which reads `--to <dir>`.
|
|
13
14
|
* 2. Rebuild the merge base offline: each recorded file's content at the base
|
|
14
15
|
* commit, kept only when it has the hash the lock recorded. A file the
|
|
15
16
|
* project never edited is its own merge base. Vendor sources keep no
|
|
@@ -39,12 +40,26 @@ import { basename, join, relative, resolve, sep } from "node:path";
|
|
|
39
40
|
import { computePlanDigest } from "../lifecycle/plan-digest";
|
|
40
41
|
import type { ResolvedGateApproval } from "../op/gate-approval";
|
|
41
42
|
import { checkLineage, findingKey, type CheckFinding } from "./lineage-check";
|
|
42
|
-
import { git, readTemplateTree } from "./lineage-init";
|
|
43
|
-
import {
|
|
43
|
+
import { dirLabel, git, parseDirSpec, readTemplateDir, readTemplateTree, recordedDirPath } from "./lineage-init";
|
|
44
|
+
import {
|
|
45
|
+
LOCK_FILE,
|
|
46
|
+
LockError,
|
|
47
|
+
contentDigest,
|
|
48
|
+
fileHash,
|
|
49
|
+
readLock,
|
|
50
|
+
renderLock,
|
|
51
|
+
scopeKey,
|
|
52
|
+
writeLock,
|
|
53
|
+
type Lineage,
|
|
54
|
+
type LineageLock,
|
|
55
|
+
type LineageSource,
|
|
56
|
+
type ManualStep,
|
|
57
|
+
} from "./lineage-lock";
|
|
44
58
|
import { mergeFile } from "./lineage-merge";
|
|
45
59
|
import { assertCodeAllowed, planMigrations, runMigration, splitMigrations, type LoadedMigration } from "./lineage-migrations";
|
|
46
60
|
import { applyUpstream, type UpdateResult } from "./lineage-update";
|
|
47
61
|
import { carryParameters, readManifest, substituteParameters } from "./template-manifest";
|
|
62
|
+
import { repinSubstituted, type RepinnedRecord } from "./template-pins";
|
|
48
63
|
|
|
49
64
|
/** The kind the patch digest is taken under, so it never collides with another kind of plan. */
|
|
50
65
|
export const UPGRADE_PLAN_KIND = "workspace-upgrade";
|
|
@@ -95,7 +110,12 @@ export interface UpgradeOptions {
|
|
|
95
110
|
root: string;
|
|
96
111
|
/** The scope to upgrade: `"."` or a vendor scope's directory. */
|
|
97
112
|
scope?: string;
|
|
98
|
-
/**
|
|
113
|
+
/**
|
|
114
|
+
* The target ref (git) or label (vendor). Defaults to the ref the scope is
|
|
115
|
+
* pinned at. For a directory source, the directory holding the new version,
|
|
116
|
+
* as `<dir>[#<member>]`, resolved from the current directory; the member
|
|
117
|
+
* defaults to the recorded one.
|
|
118
|
+
*/
|
|
99
119
|
to?: string;
|
|
100
120
|
/** Run migrations whose body is code. */
|
|
101
121
|
allowCode?: boolean;
|
|
@@ -163,20 +183,31 @@ interface Upstream {
|
|
|
163
183
|
modules: Map<string, Buffer>;
|
|
164
184
|
commit?: string;
|
|
165
185
|
tree?: string;
|
|
166
|
-
/** The parameter values substituted into `files` (#2627), for a git source. */
|
|
186
|
+
/** The parameter values substituted into `files` (#2627), for a git or directory source. */
|
|
167
187
|
parameters?: Record<string, string>;
|
|
188
|
+
/** The records re-pinned to substituted files (#2549). */
|
|
189
|
+
repinned?: RepinnedRecord[];
|
|
190
|
+
/** The source the lock records after the upgrade, when it moves (a directory source). */
|
|
191
|
+
source?: LineageSource;
|
|
168
192
|
}
|
|
169
193
|
|
|
170
194
|
/**
|
|
171
195
|
* The template's files as the project would have them (#2627): the manifest
|
|
172
196
|
* removed and the parameters substituted, with the recorded values and the
|
|
173
|
-
* 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.
|
|
174
200
|
*/
|
|
175
|
-
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[] } {
|
|
176
206
|
try {
|
|
177
207
|
const manifest = readManifest(raw);
|
|
178
208
|
const parameters = carryParameters(manifest, recorded);
|
|
179
|
-
|
|
209
|
+
const { files, repinned } = repinSubstituted(raw, substituteParameters(raw, manifest, parameters), manifest?.files ?? []);
|
|
210
|
+
return { files, parameters, repinned };
|
|
180
211
|
} catch (err) {
|
|
181
212
|
throw new UpgradeError(`${label}: ${(err as Error).message.replace(/; pass --param .*$/, "")}`);
|
|
182
213
|
}
|
|
@@ -243,12 +274,84 @@ function fetchGit(root: string, lineage: Lineage, ref: string): Upstream {
|
|
|
243
274
|
commit,
|
|
244
275
|
tree: target.tree,
|
|
245
276
|
parameters: instantiated.parameters,
|
|
277
|
+
repinned: instantiated.repinned,
|
|
246
278
|
};
|
|
247
279
|
} finally {
|
|
248
280
|
rmSync(scratch, { recursive: true, force: true });
|
|
249
281
|
}
|
|
250
282
|
}
|
|
251
283
|
|
|
284
|
+
/**
|
|
285
|
+
* Why a directory scope cannot be upgraded, with the way out. A directory
|
|
286
|
+
* has no history, so the merge base exists only while the files the scope
|
|
287
|
+
* was made from are still on disk.
|
|
288
|
+
*/
|
|
289
|
+
function dirRefusal(lineage: Lineage, why: string): UpgradeError {
|
|
290
|
+
const source = lineage.source as Extract<LineageSource, { type: "dir" }>;
|
|
291
|
+
return new UpgradeError(
|
|
292
|
+
`${why}. The scope was made from the directory ${dirLabel(source.path, source.member)}, which has no git history to rebuild a merge base from. ` +
|
|
293
|
+
`Upgrade with --to <dir> while ${source.path} still holds the files the scope was made from, or adopt the scope into a git lineage with \`chant workspace adopt-lineage\` (#2551).`,
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* The upstream of a directory source (#2647): the new version from the
|
|
299
|
+
* directory `to` names, and the merge base from the recorded directory, used
|
|
300
|
+
* only when its files, instantiated with the recorded parameters, still have
|
|
301
|
+
* the recorded digest. Anything else is refused: a base that is not the one
|
|
302
|
+
* the scope was made from would turn the project's edits into upstream
|
|
303
|
+
* changes. Reaches no network.
|
|
304
|
+
*/
|
|
305
|
+
function readDir(root: string, lineage: Lineage, to: string | undefined): Upstream {
|
|
306
|
+
const source = lineage.source;
|
|
307
|
+
if (source.type !== "dir") throw new UpgradeError("not a directory source");
|
|
308
|
+
if (!to) throw dirRefusal(lineage, "no --to directory");
|
|
309
|
+
|
|
310
|
+
let target;
|
|
311
|
+
try {
|
|
312
|
+
target = parseDirSpec(to);
|
|
313
|
+
} catch {
|
|
314
|
+
target = null;
|
|
315
|
+
}
|
|
316
|
+
if (!target) throw new UpgradeError(`--to ${to}: a scope made from a directory upgrades from a directory, and ${to} is not one`);
|
|
317
|
+
const member = to.includes("#") ? target.member : source.member;
|
|
318
|
+
const label = dirLabel(target.path, member);
|
|
319
|
+
const read = readTemplateDir(target.abs, member, label);
|
|
320
|
+
const raw = new Map<string, Buffer>();
|
|
321
|
+
const executable = new Set<string>();
|
|
322
|
+
for (const [path, f] of read.files) {
|
|
323
|
+
raw.set(path, f.data);
|
|
324
|
+
if (f.executable) executable.add(path);
|
|
325
|
+
}
|
|
326
|
+
const instantiated = instantiate(raw, lineage.parameters, label);
|
|
327
|
+
|
|
328
|
+
// The merge base: the recorded directory, only while it holds exactly what the scope was made from.
|
|
329
|
+
const original = resolve(root, source.path);
|
|
330
|
+
const originalLabel = dirLabel(source.path, source.member);
|
|
331
|
+
let base: Map<string, Buffer>;
|
|
332
|
+
try {
|
|
333
|
+
const old = readTemplateDir(original, source.member, originalLabel);
|
|
334
|
+
base = splitMigrations(instantiate(new Map([...old.files].map(([path, f]) => [path, f.data])), lineage.parameters, originalLabel).files).files;
|
|
335
|
+
} catch {
|
|
336
|
+
throw dirRefusal(lineage, `${originalLabel} is gone`);
|
|
337
|
+
}
|
|
338
|
+
if (!lineage.address || contentDigest(base) !== lineage.address.digest) {
|
|
339
|
+
throw dirRefusal(lineage, `${originalLabel} no longer holds the files the scope was made from (its digest is not ${lineage.address?.digest ?? "recorded"})`);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const split = splitMigrations(instantiated.files);
|
|
343
|
+
return {
|
|
344
|
+
files: split.files,
|
|
345
|
+
executable,
|
|
346
|
+
base,
|
|
347
|
+
migrations: split.migrations,
|
|
348
|
+
modules: split.modules,
|
|
349
|
+
parameters: instantiated.parameters,
|
|
350
|
+
repinned: instantiated.repinned,
|
|
351
|
+
source: { type: "dir", path: recordedDirPath(target.path, target.abs, root), ...(member ? { member } : {}) },
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
252
355
|
async function fetchUpstream(root: string, lineage: Lineage, ref: string | undefined): Promise<Upstream> {
|
|
253
356
|
const source = lineage.source;
|
|
254
357
|
if (source.type === "git") {
|
|
@@ -260,6 +363,7 @@ async function fetchUpstream(root: string, lineage: Lineage, ref: string | undef
|
|
|
260
363
|
const files = await resolveVendorSource(source, root);
|
|
261
364
|
return { files, executable: new Set(), base: new Map(), migrations: [], modules: new Map() };
|
|
262
365
|
}
|
|
366
|
+
if (source.type === "dir") return readDir(root, lineage, ref);
|
|
263
367
|
throw new UpgradeError(
|
|
264
368
|
`scope made by \`chant init --template\` (${lineage.template}) cannot be upgraded yet: its merge base is the older lexicon's render, which is not available offline. Upgrade git-sourced and vendor scopes for now.`,
|
|
265
369
|
);
|
|
@@ -404,9 +508,10 @@ export async function stageUpgrade(options: UpgradeOptions): Promise<StagedUpgra
|
|
|
404
508
|
);
|
|
405
509
|
}
|
|
406
510
|
|
|
407
|
-
// 1. Fetch.
|
|
408
|
-
const
|
|
409
|
-
const
|
|
511
|
+
// 1. Fetch. A directory source has no ref: `--to` names the directory.
|
|
512
|
+
const fromDir = lineage.source.type === "dir";
|
|
513
|
+
const ref = fromDir ? undefined : options.to ?? lineage.ref;
|
|
514
|
+
const upstream = await fetchUpstream(root, lineage, fromDir ? options.to : ref);
|
|
410
515
|
|
|
411
516
|
// 2. The merge base, offline from here on.
|
|
412
517
|
const base = rebuildBase(join(root, scope), lineage, upstream.base);
|
|
@@ -468,7 +573,14 @@ export async function stageUpgrade(options: UpgradeOptions): Promise<StagedUpgra
|
|
|
468
573
|
for (const path of result.written) if (upstream.executable.has(path)) chmodSync(join(scopeDir, path), 0o755);
|
|
469
574
|
if (ref !== undefined) staged.ref = ref;
|
|
470
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
|
+
}
|
|
471
582
|
if (upstream.commit) staged.address = { ...staged.address!, commit: upstream.commit, tree: upstream.tree };
|
|
583
|
+
if (upstream.source) staged.source = upstream.source;
|
|
472
584
|
writeLock(worktreeProject, next);
|
|
473
585
|
const lockText = renderLock(next);
|
|
474
586
|
|
|
@@ -512,8 +624,8 @@ export async function stageUpgrade(options: UpgradeOptions): Promise<StagedUpgra
|
|
|
512
624
|
scope,
|
|
513
625
|
template: lineage.template,
|
|
514
626
|
kind: lineage.kind,
|
|
515
|
-
from: lineage.ref ?? null,
|
|
516
|
-
to: staged.ref ?? null,
|
|
627
|
+
from: fromDir ? sourcePin(lineage.source) : lineage.ref ?? null,
|
|
628
|
+
to: fromDir ? sourcePin(staged.source) : staged.ref ?? null,
|
|
517
629
|
...(upstream.commit ? { commit: { ...(lineage.address?.commit ? { from: lineage.address.commit } : {}), to: upstream.commit } } : {}),
|
|
518
630
|
migrations: plan.chain.map((m) => m.migration.id),
|
|
519
631
|
written: result.written,
|
|
@@ -542,6 +654,11 @@ export async function stageUpgrade(options: UpgradeOptions): Promise<StagedUpgra
|
|
|
542
654
|
}
|
|
543
655
|
}
|
|
544
656
|
|
|
657
|
+
/** A directory source's pin, for the summary: the directory it reads. */
|
|
658
|
+
function sourcePin(source: LineageSource): string | null {
|
|
659
|
+
return source.type === "dir" ? dirLabel(source.path, source.member) : null;
|
|
660
|
+
}
|
|
661
|
+
|
|
545
662
|
/**
|
|
546
663
|
* `chant workspace check` in the worktree. A finding already present at HEAD
|
|
547
664
|
* is not the upgrade's doing, and the upgrade's own manual steps are what the
|
|
@@ -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
|
+
});
|