@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.
Files changed (67) hide show
  1. package/dist/cli/main.d.ts.map +1 -1
  2. package/dist/content-digest.d.ts +2 -0
  3. package/dist/content-digest.d.ts.map +1 -1
  4. package/dist/workspace/checks/records.d.ts +33 -0
  5. package/dist/workspace/checks/records.d.ts.map +1 -0
  6. package/dist/workspace/checks.d.ts +8 -0
  7. package/dist/workspace/checks.d.ts.map +1 -1
  8. package/dist/workspace/compose-graph.d.ts +22 -6
  9. package/dist/workspace/compose-graph.d.ts.map +1 -1
  10. package/dist/workspace/graph-cli.d.ts +11 -4
  11. package/dist/workspace/graph-cli.d.ts.map +1 -1
  12. package/dist/workspace/lineage-check.d.ts +5 -2
  13. package/dist/workspace/lineage-check.d.ts.map +1 -1
  14. package/dist/workspace/lineage-cli.d.ts +5 -1
  15. package/dist/workspace/lineage-cli.d.ts.map +1 -1
  16. package/dist/workspace/lineage-init.d.ts +47 -4
  17. package/dist/workspace/lineage-init.d.ts.map +1 -1
  18. package/dist/workspace/lineage-lock.d.ts +23 -2
  19. package/dist/workspace/lineage-lock.d.ts.map +1 -1
  20. package/dist/workspace/lineage-upgrade-cli.d.ts +1 -1
  21. package/dist/workspace/lineage-upgrade.d.ts +8 -2
  22. package/dist/workspace/lineage-upgrade.d.ts.map +1 -1
  23. package/dist/workspace/reason-codes.d.ts +4 -0
  24. package/dist/workspace/reason-codes.d.ts.map +1 -1
  25. package/dist/workspace/record-assets.d.ts +108 -0
  26. package/dist/workspace/record-assets.d.ts.map +1 -0
  27. package/dist/workspace/records-cli.d.ts +32 -1
  28. package/dist/workspace/records-cli.d.ts.map +1 -1
  29. package/dist/workspace/records.d.ts +41 -0
  30. package/dist/workspace/records.d.ts.map +1 -1
  31. package/dist/workspace/template-pins.d.ts +33 -0
  32. package/dist/workspace/template-pins.d.ts.map +1 -0
  33. package/dist/workspace/tree.d.ts +5 -0
  34. package/dist/workspace/tree.d.ts.map +1 -1
  35. package/package.json +1 -1
  36. package/src/cli/handlers/init.ts +7 -3
  37. package/src/cli/main.ts +16 -8
  38. package/src/content-digest.ts +5 -0
  39. package/src/workspace/behold-kinds.test.ts +10 -14
  40. package/src/workspace/checks/records.ts +83 -0
  41. package/src/workspace/checks.test.ts +2 -0
  42. package/src/workspace/checks.ts +13 -3
  43. package/src/workspace/compose-graph.test.ts +2 -1
  44. package/src/workspace/compose-graph.ts +23 -6
  45. package/src/workspace/graph-cli.ts +32 -6
  46. package/src/workspace/graph-contract.test.ts +2 -1
  47. package/src/workspace/graph.schema.json +131 -2
  48. package/src/workspace/lineage-check.ts +22 -5
  49. package/src/workspace/lineage-cli.ts +21 -1
  50. package/src/workspace/lineage-init-dir.test.ts +243 -0
  51. package/src/workspace/lineage-init.ts +223 -24
  52. package/src/workspace/lineage-lock.ts +18 -3
  53. package/src/workspace/lineage-upgrade-cli.ts +2 -2
  54. package/src/workspace/lineage-upgrade.test.ts +29 -0
  55. package/src/workspace/lineage-upgrade.ts +130 -13
  56. package/src/workspace/member-commands.ts +1 -1
  57. package/src/workspace/reason-codes.test.ts +2 -1
  58. package/src/workspace/reason-codes.ts +5 -0
  59. package/src/workspace/record-assets.test.ts +319 -0
  60. package/src/workspace/record-assets.ts +207 -0
  61. package/src/workspace/records-cli.ts +117 -14
  62. package/src/workspace/records.schema.json +33 -1
  63. package/src/workspace/records.test.ts +50 -0
  64. package/src/workspace/records.ts +115 -5
  65. package/src/workspace/template-pins.test.ts +69 -0
  66. package/src/workspace/template-pins.ts +117 -0
  67. package/src/workspace/tree.ts +12 -0
@@ -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
- // A link takes effect only from a closed record (#2555: a later ratified
336
- // decision replaces an earlier one), and a record is superseded at most once
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 (e.state === null || !closed.has(e.state) || old === e) continue;
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
+ }
@@ -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