@intentius/chant 0.82.0 → 0.84.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 (73) hide show
  1. package/dist/cli/main.d.ts.map +1 -1
  2. package/dist/cli/registry.d.ts +4 -0
  3. package/dist/cli/registry.d.ts.map +1 -1
  4. package/dist/content-digest.d.ts +2 -0
  5. package/dist/content-digest.d.ts.map +1 -1
  6. package/dist/workspace/checks/records.d.ts +33 -0
  7. package/dist/workspace/checks/records.d.ts.map +1 -0
  8. package/dist/workspace/checks.d.ts +8 -0
  9. package/dist/workspace/checks.d.ts.map +1 -1
  10. package/dist/workspace/compose-graph.d.ts +22 -6
  11. package/dist/workspace/compose-graph.d.ts.map +1 -1
  12. package/dist/workspace/graph-cli.d.ts +15 -4
  13. package/dist/workspace/graph-cli.d.ts.map +1 -1
  14. package/dist/workspace/intent-cli.d.ts +17 -0
  15. package/dist/workspace/intent-cli.d.ts.map +1 -0
  16. package/dist/workspace/intent-joins.d.ts +93 -0
  17. package/dist/workspace/intent-joins.d.ts.map +1 -0
  18. package/dist/workspace/intent.d.ts +285 -0
  19. package/dist/workspace/intent.d.ts.map +1 -0
  20. package/dist/workspace/lineage-check.d.ts +5 -2
  21. package/dist/workspace/lineage-check.d.ts.map +1 -1
  22. package/dist/workspace/lineage-init.d.ts.map +1 -1
  23. package/dist/workspace/lineage-lock.d.ts +8 -0
  24. package/dist/workspace/lineage-lock.d.ts.map +1 -1
  25. package/dist/workspace/lineage-upgrade.d.ts.map +1 -1
  26. package/dist/workspace/reason-codes.d.ts +19 -0
  27. package/dist/workspace/reason-codes.d.ts.map +1 -1
  28. package/dist/workspace/record-assets.d.ts +108 -0
  29. package/dist/workspace/record-assets.d.ts.map +1 -0
  30. package/dist/workspace/records-cli.d.ts +32 -1
  31. package/dist/workspace/records-cli.d.ts.map +1 -1
  32. package/dist/workspace/records.d.ts +47 -0
  33. package/dist/workspace/records.d.ts.map +1 -1
  34. package/dist/workspace/template-pins.d.ts +33 -0
  35. package/dist/workspace/template-pins.d.ts.map +1 -0
  36. package/dist/workspace/tree.d.ts +5 -0
  37. package/dist/workspace/tree.d.ts.map +1 -1
  38. package/package.json +1 -1
  39. package/src/cli/main.test.ts +11 -0
  40. package/src/cli/main.ts +22 -6
  41. package/src/cli/registry.ts +4 -0
  42. package/src/content-digest.ts +5 -0
  43. package/src/workspace/checks/records.ts +83 -0
  44. package/src/workspace/checks.test.ts +2 -0
  45. package/src/workspace/checks.ts +13 -3
  46. package/src/workspace/compose-graph.test.ts +2 -1
  47. package/src/workspace/compose-graph.ts +23 -6
  48. package/src/workspace/graph-cli.ts +40 -6
  49. package/src/workspace/graph-contract.test.ts +2 -1
  50. package/src/workspace/graph.schema.json +131 -2
  51. package/src/workspace/intent-cli.ts +95 -0
  52. package/src/workspace/intent-joins.ts +165 -0
  53. package/src/workspace/intent.schema.json +1078 -0
  54. package/src/workspace/intent.test.ts +448 -0
  55. package/src/workspace/intent.ts +941 -0
  56. package/src/workspace/lineage-check.ts +22 -5
  57. package/src/workspace/lineage-init.ts +5 -1
  58. package/src/workspace/lineage-lock.ts +7 -0
  59. package/src/workspace/lineage-upgrade.test.ts +29 -0
  60. package/src/workspace/lineage-upgrade.ts +21 -3
  61. package/src/workspace/member-commands.ts +1 -1
  62. package/src/workspace/read-contract.test.ts +16 -1
  63. package/src/workspace/reason-codes.test.ts +7 -2
  64. package/src/workspace/reason-codes.ts +23 -0
  65. package/src/workspace/record-assets.test.ts +319 -0
  66. package/src/workspace/record-assets.ts +207 -0
  67. package/src/workspace/records-cli.ts +117 -14
  68. package/src/workspace/records.schema.json +33 -1
  69. package/src/workspace/records.test.ts +50 -0
  70. package/src/workspace/records.ts +116 -6
  71. package/src/workspace/template-pins.test.ts +69 -0
  72. package/src/workspace/template-pins.ts +117 -0
  73. package/src/workspace/tree.ts +12 -0
@@ -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