@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
package/src/workspace/records.ts
CHANGED
|
@@ -22,7 +22,9 @@ import yaml from "js-yaml";
|
|
|
22
22
|
import { z } from "zod";
|
|
23
23
|
import { importLexiconModule, registerLexiconDeclarations } from "../lexicon-module";
|
|
24
24
|
import type { ReasonCode } from "./reason-codes";
|
|
25
|
+
import { checkPins, pinEntries, type AssetPin } from "./record-assets";
|
|
25
26
|
import type { RecordSource } from "./record-source";
|
|
27
|
+
import type { WorkspaceTree } from "./tree";
|
|
26
28
|
|
|
27
29
|
// ── Reason codes ─────────────────────────────────────────────────────────────
|
|
28
30
|
|
|
@@ -44,6 +46,30 @@ export const RECORD_REASON_CODES = [
|
|
|
44
46
|
] as const satisfies readonly ReasonCode[];
|
|
45
47
|
export type RecordReasonCode = (typeof RECORD_REASON_CODES)[number];
|
|
46
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Why a record carries a warning. Closed, like the reason codes. A warning
|
|
51
|
+
* never makes a record invalid, and `--current` still lists the record.
|
|
52
|
+
*/
|
|
53
|
+
export const RECORD_WARNING_CODES = [
|
|
54
|
+
/** A pinned file's bytes no longer hash to the pinned sha256 (#2549). */
|
|
55
|
+
"asset-drift",
|
|
56
|
+
/** A pinned file does not exist in the tree read (#2549). */
|
|
57
|
+
"asset-missing",
|
|
58
|
+
/**
|
|
59
|
+
* A pinned file is unchanged since a record this one supersedes pinned it at
|
|
60
|
+
* the same hash: the decision changed and the artifact did not follow (#2549).
|
|
61
|
+
*/
|
|
62
|
+
"asset-stale",
|
|
63
|
+
/** A supersedes link from a record whose state is weaker than the one it names, so it has no effect yet (#2524 D4). */
|
|
64
|
+
"record-supersedes-pending",
|
|
65
|
+
] as const satisfies readonly ReasonCode[];
|
|
66
|
+
export type RecordWarningCode = (typeof RECORD_WARNING_CODES)[number];
|
|
67
|
+
|
|
68
|
+
export interface RecordWarning {
|
|
69
|
+
code: RecordWarningCode;
|
|
70
|
+
message: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
47
73
|
/**
|
|
48
74
|
* Why the read as a whole failed. Also closed. The command exits 1 with one of
|
|
49
75
|
* these and returns no records.
|
|
@@ -114,11 +140,34 @@ export const recordKindSchema = z
|
|
|
114
140
|
closedStates: z.array(z.string().min(1)),
|
|
115
141
|
/** The front-matter list of links to superseded records, and the key in each entry that holds the target id. */
|
|
116
142
|
supersedes: z.object({ field: z.string().min(1), key: z.string().min(1) }).strict(),
|
|
143
|
+
/**
|
|
144
|
+
* How strongly each state is approved (#2524 D4). With it, a supersedes
|
|
145
|
+
* link takes effect when the new record's rank is above 0 and at least the
|
|
146
|
+
* old record's, "under an equal or stricter approval rule". A state it
|
|
147
|
+
* leaves out ranks 0. Without it, a link takes effect only from a record
|
|
148
|
+
* in a closed state.
|
|
149
|
+
*/
|
|
150
|
+
approval: z.record(z.string(), z.number().int().min(0)).optional(),
|
|
151
|
+
/**
|
|
152
|
+
* The front-matter list whose entries may pin a workspace file, as
|
|
153
|
+
* `{path, sha256}` (#2549). Optional: a kind without it pins nothing.
|
|
154
|
+
*/
|
|
155
|
+
pins: z.object({ field: z.string().min(1) }).strict().optional(),
|
|
156
|
+
/**
|
|
157
|
+
* The front-matter list of what a record governs. Its `member:<name>` and
|
|
158
|
+
* `path:<path>` entries are the record's links in `chant workspace graph`
|
|
159
|
+
* (#2549). Optional.
|
|
160
|
+
*/
|
|
161
|
+
constrains: z.object({ field: z.string().min(1) }).strict().optional(),
|
|
117
162
|
})
|
|
118
163
|
.strict()
|
|
119
164
|
.refine((k) => k.closedStates.every((s) => k.states.includes(s)), {
|
|
120
165
|
message: "every closed state must be listed in states",
|
|
121
166
|
path: ["closedStates"],
|
|
167
|
+
})
|
|
168
|
+
.refine((k) => Object.keys(k.approval ?? {}).every((s) => k.states.includes(s)), {
|
|
169
|
+
message: "every state approval ranks must be listed in states",
|
|
170
|
+
path: ["approval"],
|
|
122
171
|
});
|
|
123
172
|
|
|
124
173
|
export type RecordKind = z.infer<typeof recordKindSchema>;
|
|
@@ -255,6 +304,10 @@ export interface RecordEntry {
|
|
|
255
304
|
supersededBy: string | null;
|
|
256
305
|
/** The front matter as JSON, or null when it could not be parsed. */
|
|
257
306
|
data: Record<string, unknown> | null;
|
|
307
|
+
/** Each workspace file the record pins, checked against the tree read (#2549). Empty when nothing was checked. */
|
|
308
|
+
assets: AssetPin[];
|
|
309
|
+
/** Findings that leave the record valid, such as a pinned file that changed (#2549). */
|
|
310
|
+
warnings: RecordWarning[];
|
|
258
311
|
}
|
|
259
312
|
|
|
260
313
|
export interface ReadRecordsOptions {
|
|
@@ -263,6 +316,25 @@ export interface ReadRecordsOptions {
|
|
|
263
316
|
source: RecordSource;
|
|
264
317
|
/** Leave out records a closed record supersedes. */
|
|
265
318
|
current?: boolean;
|
|
319
|
+
/**
|
|
320
|
+
* The workspace root the kind's pins resolve in: the working tree, or the
|
|
321
|
+
* revision read (#2549). Without it no pin is checked.
|
|
322
|
+
*/
|
|
323
|
+
assets?: WorkspaceTree;
|
|
324
|
+
/**
|
|
325
|
+
* When files last changed and records were recorded, in the history of the
|
|
326
|
+
* revision read, for `asset-stale` (#2549). Without it only the hashes are
|
|
327
|
+
* compared.
|
|
328
|
+
*/
|
|
329
|
+
history?: RecordHistory;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Commit times, in seconds since the epoch, read from git. */
|
|
333
|
+
export interface RecordHistory {
|
|
334
|
+
/** The last commit that changed `path` (from the workspace root), or null when unknown. */
|
|
335
|
+
fileChanged(path: string): number | null;
|
|
336
|
+
/** The commit that added the record at `path` (from the repository root), or null when it is not committed. */
|
|
337
|
+
recorded(path: string): number | null;
|
|
266
338
|
}
|
|
267
339
|
|
|
268
340
|
export interface ReadRecordsResult {
|
|
@@ -304,7 +376,7 @@ export async function readRecords(loaded: LoadedRecordKind, options: ReadRecords
|
|
|
304
376
|
const entries: RecordEntry[] = [];
|
|
305
377
|
for (const name of names.filter((n) => match.test(n)).sort()) {
|
|
306
378
|
const path = dirRel === "." ? name : `${dirRel}/${name}`;
|
|
307
|
-
const entry: RecordEntry = { id: null, path, state: null, valid: true, reasons: [], supersededBy: null, data: null };
|
|
379
|
+
const entry: RecordEntry = { id: null, path, state: null, valid: true, reasons: [], supersededBy: null, data: null, assets: [], warnings: [] };
|
|
308
380
|
entries.push(entry);
|
|
309
381
|
const fm = parseFrontMatter(options.source.read(path));
|
|
310
382
|
if (!fm.ok) {
|
|
@@ -320,6 +392,11 @@ export async function readRecords(loaded: LoadedRecordKind, options: ReadRecords
|
|
|
320
392
|
if (!result.ok) {
|
|
321
393
|
entry.reasons.push({ code: "record-schema-invalid", message: result.errors.join("; ") });
|
|
322
394
|
}
|
|
395
|
+
if (kind.pins && options.assets) {
|
|
396
|
+
const checked = checkPins(pinEntries(fm.value, kind.pins.field), options.assets);
|
|
397
|
+
entry.assets = checked.assets;
|
|
398
|
+
entry.warnings = checked.warnings;
|
|
399
|
+
}
|
|
323
400
|
}
|
|
324
401
|
|
|
325
402
|
// Ids: the first file in path order keeps an id; later ones are flagged.
|
|
@@ -332,10 +409,14 @@ export async function readRecords(loaded: LoadedRecordKind, options: ReadRecords
|
|
|
332
409
|
}
|
|
333
410
|
|
|
334
411
|
// Supersession comes from the new record's links, never from the old record.
|
|
335
|
-
//
|
|
336
|
-
//
|
|
337
|
-
// (#2524 D4).
|
|
412
|
+
// With approval ranks, a link takes effect under an equal or stricter
|
|
413
|
+
// approval rule: from a record ranked above 0 and at least as high as the
|
|
414
|
+
// one it names (#2524 D4). Without them, only from a closed record (#2555).
|
|
415
|
+
// A record is superseded at most once.
|
|
338
416
|
const closed = new Set(kind.closedStates);
|
|
417
|
+
const rank = (state: string | null): number => (state === null ? 0 : (kind.approval?.[state] ?? 0));
|
|
418
|
+
const takesEffect = (from: RecordEntry, to: RecordEntry): boolean =>
|
|
419
|
+
kind.approval ? rank(from.state) > 0 && rank(from.state) >= rank(to.state) : from.state !== null && closed.has(from.state);
|
|
339
420
|
for (const e of entries) {
|
|
340
421
|
const links = e.data?.[kind.supersedes.field];
|
|
341
422
|
if (!Array.isArray(links)) continue;
|
|
@@ -348,7 +429,16 @@ export async function readRecords(loaded: LoadedRecordKind, options: ReadRecords
|
|
|
348
429
|
e.reasons.push({ code: "record-supersedes-unknown", message: `supersedes ${target}, which no record has` });
|
|
349
430
|
continue;
|
|
350
431
|
}
|
|
351
|
-
if (
|
|
432
|
+
if (old === e) continue;
|
|
433
|
+
if (!takesEffect(e, old)) {
|
|
434
|
+
if (kind.approval) {
|
|
435
|
+
e.warnings.push({
|
|
436
|
+
code: "record-supersedes-pending",
|
|
437
|
+
message: `supersedes ${target}, which is ${old.state ?? "stateless"}; a ${e.state ?? "stateless"} record can't supersede it, so the link takes effect once this record is approved at least as strongly`,
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
352
442
|
if (old.supersededBy !== null && old.supersededBy !== e.id) {
|
|
353
443
|
e.reasons.push({
|
|
354
444
|
code: "record-supersedes-conflict",
|
|
@@ -360,6 +450,26 @@ export async function readRecords(loaded: LoadedRecordKind, options: ReadRecords
|
|
|
360
450
|
}
|
|
361
451
|
}
|
|
362
452
|
|
|
453
|
+
// A pin that still matches, at the hash a record this one supersedes
|
|
454
|
+
// pinned, while the file has not changed since this record was recorded:
|
|
455
|
+
// the decision moved on and the artifact did not (#2549).
|
|
456
|
+
for (const e of entries) {
|
|
457
|
+
for (const a of e.assets) {
|
|
458
|
+
if (a.state !== "pinned") continue;
|
|
459
|
+
const old = entries.find((o) => o.supersededBy !== null && o.supersededBy === e.id && o.assets.some((p) => p.path === a.path && p.sha256 === a.sha256));
|
|
460
|
+
if (!old) continue;
|
|
461
|
+
const changed = options.history?.fileChanged(a.path) ?? null;
|
|
462
|
+
const recorded = options.history ? options.history.recorded(e.path) : null;
|
|
463
|
+
// A record not yet committed is recorded now, after every commit.
|
|
464
|
+
if (changed !== null && recorded !== null && changed > recorded) continue;
|
|
465
|
+
a.state = "stale";
|
|
466
|
+
e.warnings.push({
|
|
467
|
+
code: "asset-stale",
|
|
468
|
+
message: `${a.path} is pinned at the hash ${old.id} pinned, and it has not changed since: ${e.id} supersedes ${old.id}, and the artifact did not follow`,
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
363
473
|
for (const e of entries) e.valid = e.reasons.length === 0;
|
|
364
474
|
const records = options.current ? entries.filter((e) => e.supersededBy === null) : entries;
|
|
365
475
|
return {
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Re-pinning records after template substitution (#2549): a pin that held in
|
|
3
|
+
* the template follows the substituted file, one that didn't stays, and the
|
|
4
|
+
* rest of the record is kept byte for byte.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
import { describe, expect, test } from "vitest";
|
|
9
|
+
import { repinSubstituted } from "./template-pins";
|
|
10
|
+
|
|
11
|
+
const sha = (s: string) => createHash("sha256").update(s).digest("hex");
|
|
12
|
+
const buf = (s: string) => Buffer.from(s, "utf-8");
|
|
13
|
+
|
|
14
|
+
function record(pins: [string, string][]): string {
|
|
15
|
+
const evidence = pins.map(([path, hash]) => ` - title: "t"\n path: "${path}"\n sha256: "${hash}"\n`).join("");
|
|
16
|
+
return `---\nschema: 1\nid: "ref-002"\nevidence:\n - title: "a link"\n url: "https://example.com"\n${evidence}constrains:\n - "member:design"\n---\n\n# Body mentions ${pins[0]?.[1] ?? ""}\n`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("repinSubstituted", () => {
|
|
20
|
+
const before = "title {{chant:name}}\n";
|
|
21
|
+
const after = "title Acme\n";
|
|
22
|
+
const other = "unchanged\n";
|
|
23
|
+
|
|
24
|
+
test("a pin that held on a substituted file gets the new hash; nothing else in the file changes", () => {
|
|
25
|
+
const text = record([["design/home.json", sha(before)], ["design/other.json", sha(other)]]);
|
|
26
|
+
const original = new Map([
|
|
27
|
+
["chant.workspace.json", buf("{}")],
|
|
28
|
+
["design/home.json", buf(before)],
|
|
29
|
+
["design/other.json", buf(other)],
|
|
30
|
+
["decisions/ref-002-x.md", buf(text)],
|
|
31
|
+
]);
|
|
32
|
+
const substituted = new Map(original).set("design/home.json", buf(after));
|
|
33
|
+
const { files, repinned } = repinSubstituted(original, substituted, ["design/home.json"]);
|
|
34
|
+
expect(repinned).toEqual([{ record: "decisions/ref-002-x.md", paths: ["design/home.json"] }]);
|
|
35
|
+
const expected = text.replace(` sha256: "${sha(before)}"`, ` sha256: "${sha(after)}"`);
|
|
36
|
+
expect(files.get("decisions/ref-002-x.md")!.toString("utf-8")).toBe(expected);
|
|
37
|
+
// The body's copy of the old hash is not front matter, so it stays.
|
|
38
|
+
expect(expected).toContain(`# Body mentions ${sha(before)}`);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("a pin that was already drifted in the template stays drifted", () => {
|
|
42
|
+
const text = record([["design/home.json", sha("something else")]]);
|
|
43
|
+
const original = new Map([
|
|
44
|
+
["design/home.json", buf(before)],
|
|
45
|
+
["decisions/ref-002-x.md", buf(text)],
|
|
46
|
+
]);
|
|
47
|
+
const substituted = new Map(original).set("design/home.json", buf(after));
|
|
48
|
+
const { files, repinned } = repinSubstituted(original, substituted, ["design/home.json"]);
|
|
49
|
+
expect(repinned).toEqual([]);
|
|
50
|
+
expect(files.get("decisions/ref-002-x.md")!.toString("utf-8")).toBe(text);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("paths resolve from the workspace declaration nearest above the record", () => {
|
|
54
|
+
const text = record([["design/home.json", sha(before)]]);
|
|
55
|
+
const original = new Map([
|
|
56
|
+
["ws/chant.workspace.json", buf("{}")],
|
|
57
|
+
["ws/design/home.json", buf(before)],
|
|
58
|
+
["ws/decisions/ref-002-x.md", buf(text)],
|
|
59
|
+
]);
|
|
60
|
+
const substituted = new Map(original).set("ws/design/home.json", buf(after));
|
|
61
|
+
const { repinned } = repinSubstituted(original, substituted, ["ws/design/home.json"]);
|
|
62
|
+
expect(repinned).toEqual([{ record: "ws/decisions/ref-002-x.md", paths: ["design/home.json"] }]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("nothing substituted, nothing re-pinned", () => {
|
|
66
|
+
const original = new Map([["decisions/ref-002-x.md", buf(record([["design/home.json", sha(before)]]))]]);
|
|
67
|
+
expect(repinSubstituted(original, original, []).repinned).toEqual([]);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Re-pinning records after template parameters are substituted (#2549, #2627).
|
|
3
|
+
*
|
|
4
|
+
* A template's decision records may pin a file of the template by hash. When
|
|
5
|
+
* `chant init --from` or `chant workspace upgrade` fills that file's
|
|
6
|
+
* `{{chant:<name>}}` placeholders, the copy's bytes differ from the
|
|
7
|
+
* template's, and every copy would report the pin as drifted on day one. So
|
|
8
|
+
* after substitution, each pin that held in the template and names a
|
|
9
|
+
* substituted file gets the hash of the substituted content.
|
|
10
|
+
*
|
|
11
|
+
* A record is any Markdown file whose front matter has an `evidence` list
|
|
12
|
+
* with path pins. Its paths resolve from the nearest directory above it that
|
|
13
|
+
* holds a workspace declaration, as `chant workspace records` resolves them.
|
|
14
|
+
* A pin that did not hold in the template stays as it is: re-pinning follows
|
|
15
|
+
* the substitution and never hides drift the template already had. Only the
|
|
16
|
+
* `sha256` value on the pin's line changes; the rest of the file is kept byte
|
|
17
|
+
* for byte.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { sha256Hex } from "../content-digest";
|
|
21
|
+
import { pinEntries } from "./record-assets";
|
|
22
|
+
import { parseFrontMatter } from "./records";
|
|
23
|
+
|
|
24
|
+
const DECLARATIONS = ["chant.workspace.json", "chant.workspace.jsonc"];
|
|
25
|
+
const EVIDENCE = "evidence";
|
|
26
|
+
|
|
27
|
+
/** A record whose pins were rewritten: its path in the template, and the pinned paths, from its workspace root. */
|
|
28
|
+
export interface RepinnedRecord {
|
|
29
|
+
record: string;
|
|
30
|
+
paths: string[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const dirOf = (path: string) => (path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : "");
|
|
34
|
+
|
|
35
|
+
/** The nearest directory at or above `dir` holding a workspace declaration in `files`, or "" for the root. */
|
|
36
|
+
function workspaceRootOf(files: ReadonlyMap<string, Buffer>, dir: string): string {
|
|
37
|
+
for (let d = dir; ; d = dirOf(d)) {
|
|
38
|
+
if (DECLARATIONS.some((name) => files.has(d === "" ? name : `${d}/${name}`))) return d;
|
|
39
|
+
if (d === "") return "";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Rewrite, in `substituted`, the pins of every record that name a file in
|
|
45
|
+
* `changed` and held against `original`. Returns the files with the records
|
|
46
|
+
* rewritten, and which records were.
|
|
47
|
+
*/
|
|
48
|
+
export function repinSubstituted(
|
|
49
|
+
original: ReadonlyMap<string, Buffer>,
|
|
50
|
+
substituted: ReadonlyMap<string, Buffer>,
|
|
51
|
+
changed: readonly string[],
|
|
52
|
+
): { files: Map<string, Buffer>; repinned: RepinnedRecord[] } {
|
|
53
|
+
const files = new Map(substituted);
|
|
54
|
+
const repinned: RepinnedRecord[] = [];
|
|
55
|
+
if (changed.length === 0) return { files, repinned };
|
|
56
|
+
const changedSet = new Set(changed);
|
|
57
|
+
for (const [recordPath, data] of [...substituted].sort(([a], [b]) => a.localeCompare(b))) {
|
|
58
|
+
if (!recordPath.endsWith(".md")) continue;
|
|
59
|
+
const text = data.toString("utf-8");
|
|
60
|
+
const fm = parseFrontMatter(text);
|
|
61
|
+
if (!fm.ok) continue;
|
|
62
|
+
const root = workspaceRootOf(substituted, dirOf(recordPath));
|
|
63
|
+
const full = (p: string) => (root === "" ? p : `${root}/${p}`);
|
|
64
|
+
const updates = new Map<string, { from: string; to: string }>();
|
|
65
|
+
for (const pin of pinEntries(fm.value, EVIDENCE)) {
|
|
66
|
+
const file = full(pin.path);
|
|
67
|
+
if (!changedSet.has(file)) continue;
|
|
68
|
+
const before = original.get(file);
|
|
69
|
+
const after = substituted.get(file);
|
|
70
|
+
if (!before || !after || sha256Hex(before) !== pin.sha256) continue;
|
|
71
|
+
updates.set(pin.path, { from: pin.sha256, to: sha256Hex(after) });
|
|
72
|
+
}
|
|
73
|
+
if (updates.size === 0) continue;
|
|
74
|
+
const rewritten = rewritePins(text, updates);
|
|
75
|
+
// Only a rewrite that reads back with every update applied is kept.
|
|
76
|
+
const check = parseFrontMatter(rewritten);
|
|
77
|
+
const pins = check.ok ? pinEntries(check.value, EVIDENCE) : [];
|
|
78
|
+
if (![...updates].every(([path, u]) => pins.some((p) => p.path === path && p.sha256 === u.to))) continue;
|
|
79
|
+
files.set(recordPath, Buffer.from(rewritten, "utf-8"));
|
|
80
|
+
repinned.push({ record: recordPath, paths: [...updates.keys()].sort() });
|
|
81
|
+
}
|
|
82
|
+
return { files, repinned };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Replace the `sha256` of each updated pin in the front matter. A pin is a
|
|
87
|
+
* list entry, from its `- ` line to the next one at the same indentation or
|
|
88
|
+
* less, holding both `path: "<path>"` and `sha256: "<old>"`.
|
|
89
|
+
*/
|
|
90
|
+
function rewritePins(text: string, updates: ReadonlyMap<string, { from: string; to: string }>): string {
|
|
91
|
+
const eol = text.includes("\r\n") ? "\r\n" : "\n";
|
|
92
|
+
const lines = text.split(eol);
|
|
93
|
+
const end = lines.indexOf("---", 1);
|
|
94
|
+
if (lines[0] !== "---" || end < 0) return text;
|
|
95
|
+
const starts: number[] = [];
|
|
96
|
+
for (let i = 1; i < end; i++) if (/^\s*- /.test(lines[i])) starts.push(i);
|
|
97
|
+
for (let s = 0; s < starts.length; s++) {
|
|
98
|
+
const from = starts[s];
|
|
99
|
+
const indent = lines[from].search(/-/);
|
|
100
|
+
let to = end;
|
|
101
|
+
for (let i = from + 1; i < end; i++) {
|
|
102
|
+
const lead = lines[i].search(/\S/);
|
|
103
|
+
if (lead >= 0 && lead <= indent) {
|
|
104
|
+
to = i;
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const entry = lines.slice(from, to).join("\n");
|
|
109
|
+
const path = entry.match(/(?:^|\n)\s*(?:- )?path: "([^"]*)"/)?.[1];
|
|
110
|
+
const update = path !== undefined ? updates.get(path) : undefined;
|
|
111
|
+
if (!update) continue;
|
|
112
|
+
for (let i = from; i < to; i++) {
|
|
113
|
+
if (new RegExp(`^(\\s*(?:- )?sha256: )"${update.from}"\\s*$`).test(lines[i])) lines[i] = lines[i].replace(update.from, update.to);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return lines.join(eol);
|
|
117
|
+
}
|
package/src/workspace/tree.ts
CHANGED
|
@@ -19,6 +19,11 @@ export interface WorkspaceTree {
|
|
|
19
19
|
list(path: string): { name: string; type: "file" | "dir" }[] | undefined;
|
|
20
20
|
/** The text of the file at `path`. Throws when it can't be read. */
|
|
21
21
|
read(path: string): string;
|
|
22
|
+
/**
|
|
23
|
+
* The bytes of the file at `path`, for hashing (#2549). Throws when it can't
|
|
24
|
+
* be read. A tree without it is read as UTF-8 text.
|
|
25
|
+
*/
|
|
26
|
+
bytes?(path: string): Uint8Array;
|
|
22
27
|
}
|
|
23
28
|
|
|
24
29
|
/** Join tree-relative path parts, leaving out `""` and `"."`. */
|
|
@@ -63,6 +68,9 @@ export function workingTree(root: string): WorkspaceTree {
|
|
|
63
68
|
read(path) {
|
|
64
69
|
return readFileSync(join(root, path), "utf-8");
|
|
65
70
|
},
|
|
71
|
+
bytes(path) {
|
|
72
|
+
return readFileSync(join(root, path));
|
|
73
|
+
},
|
|
66
74
|
};
|
|
67
75
|
}
|
|
68
76
|
|
|
@@ -123,6 +131,10 @@ export function gitTree(top: string, commit: string, prefix = ""): WorkspaceTree
|
|
|
123
131
|
if (entries.get(path) !== "file") throw new Error(`${path} is not a file${` at ${commit.slice(0, 8)}`}`);
|
|
124
132
|
return git(top, ["cat-file", "blob", `${commit}:${joinPath(prefix, path)}`]);
|
|
125
133
|
},
|
|
134
|
+
bytes(path) {
|
|
135
|
+
if (entries.get(path) !== "file") throw new Error(`${path} is not a file${` at ${commit.slice(0, 8)}`}`);
|
|
136
|
+
return execFileSync("git", ["cat-file", "blob", `${commit}:${joinPath(prefix, path)}`], { cwd: top, stdio: ["ignore", "pipe", "pipe"], maxBuffer: 512 * 1024 * 1024 });
|
|
137
|
+
},
|
|
126
138
|
};
|
|
127
139
|
}
|
|
128
140
|
|