@intentius/chant 0.44.13 → 0.45.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 (175) hide show
  1. package/dist/audit/discover.d.ts +26 -2
  2. package/dist/audit/discover.d.ts.map +1 -1
  3. package/dist/audit/report-model.d.ts +10 -0
  4. package/dist/audit/report-model.d.ts.map +1 -1
  5. package/dist/audit/rules-doc.d.ts.map +1 -1
  6. package/dist/cli/build-params-cli.d.ts +16 -7
  7. package/dist/cli/build-params-cli.d.ts.map +1 -1
  8. package/dist/cli/commands/audit.d.ts +20 -0
  9. package/dist/cli/commands/audit.d.ts.map +1 -1
  10. package/dist/cli/commands/build.d.ts +9 -0
  11. package/dist/cli/commands/build.d.ts.map +1 -1
  12. package/dist/cli/commands/check-lexicon-docs.d.ts +13 -0
  13. package/dist/cli/commands/check-lexicon-docs.d.ts.map +1 -1
  14. package/dist/cli/commands/check-lexicon-examples.d.ts +24 -14
  15. package/dist/cli/commands/check-lexicon-examples.d.ts.map +1 -1
  16. package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
  17. package/dist/cli/commands/init-lexicon/templates/codegen.d.ts.map +1 -1
  18. package/dist/cli/commands/init-lexicon/templates/docs.d.ts +5 -0
  19. package/dist/cli/commands/init-lexicon/templates/docs.d.ts.map +1 -1
  20. package/dist/cli/commands/init-lexicon.d.ts.map +1 -1
  21. package/dist/cli/commands/onboard.d.ts +28 -0
  22. package/dist/cli/commands/onboard.d.ts.map +1 -1
  23. package/dist/cli/handlers/build.d.ts.map +1 -1
  24. package/dist/cli/handlers/graph.d.ts.map +1 -1
  25. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  26. package/dist/cli/handlers/run.d.ts.map +1 -1
  27. package/dist/cli/handlers/search.d.ts +27 -0
  28. package/dist/cli/handlers/search.d.ts.map +1 -1
  29. package/dist/cli/main.d.ts.map +1 -1
  30. package/dist/codegen/docs-pages.d.ts +39 -0
  31. package/dist/codegen/docs-pages.d.ts.map +1 -0
  32. package/dist/codegen/docs-sections.d.ts.map +1 -1
  33. package/dist/codegen/docs-sidebar.d.ts +13 -2
  34. package/dist/codegen/docs-sidebar.d.ts.map +1 -1
  35. package/dist/codegen/docs-types.d.ts +30 -10
  36. package/dist/codegen/docs-types.d.ts.map +1 -1
  37. package/dist/codegen/docs.d.ts +14 -2
  38. package/dist/codegen/docs.d.ts.map +1 -1
  39. package/dist/codegen/surface-snapshot.d.ts +27 -3
  40. package/dist/codegen/surface-snapshot.d.ts.map +1 -1
  41. package/dist/codegen/validate.d.ts +11 -3
  42. package/dist/codegen/validate.d.ts.map +1 -1
  43. package/dist/components/capability-plugin.d.ts +13 -2
  44. package/dist/components/capability-plugin.d.ts.map +1 -1
  45. package/dist/components/driver.d.ts +38 -0
  46. package/dist/components/driver.d.ts.map +1 -1
  47. package/dist/components/index.d.ts +1 -1
  48. package/dist/components/index.d.ts.map +1 -1
  49. package/dist/components/starter-plugin.d.ts.map +1 -1
  50. package/dist/config.d.ts +57 -5
  51. package/dist/config.d.ts.map +1 -1
  52. package/dist/discovery/fold-import.d.ts.map +1 -1
  53. package/dist/fold/fold.d.ts +100 -1
  54. package/dist/fold/fold.d.ts.map +1 -1
  55. package/dist/graph-ir.d.ts +12 -0
  56. package/dist/graph-ir.d.ts.map +1 -1
  57. package/dist/graph-ops.d.ts +17 -0
  58. package/dist/graph-ops.d.ts.map +1 -0
  59. package/dist/lifecycle/change-set.d.ts +23 -2
  60. package/dist/lifecycle/change-set.d.ts.map +1 -1
  61. package/dist/lifecycle/deep-observe.d.ts.map +1 -1
  62. package/dist/lifecycle/observe.d.ts +8 -0
  63. package/dist/lifecycle/observe.d.ts.map +1 -1
  64. package/dist/lifecycle/replay.d.ts.map +1 -1
  65. package/dist/lifecycle/snapshot.d.ts.map +1 -1
  66. package/dist/lifecycle/types.d.ts +7 -0
  67. package/dist/lifecycle/types.d.ts.map +1 -1
  68. package/dist/lint/policy.d.ts.map +1 -1
  69. package/dist/managed-fields.d.ts +33 -21
  70. package/dist/managed-fields.d.ts.map +1 -1
  71. package/dist/observation.d.ts +23 -1
  72. package/dist/observation.d.ts.map +1 -1
  73. package/dist/op/index.d.ts +1 -1
  74. package/dist/op/index.d.ts.map +1 -1
  75. package/dist/yaml.d.ts +0 -8
  76. package/dist/yaml.d.ts.map +1 -1
  77. package/package.json +1 -1
  78. package/src/__snapshots__/okf.test.ts.snap +20 -0
  79. package/src/audit/discover.test.ts +55 -1
  80. package/src/audit/discover.ts +70 -2
  81. package/src/audit/edge-init-safety.test.ts +149 -18
  82. package/src/audit/report-model.ts +12 -1
  83. package/src/audit/rules-doc.ts +1 -0
  84. package/src/build-params.test.ts +52 -0
  85. package/src/cli/build-params-cli.test.ts +25 -8
  86. package/src/cli/build-params-cli.ts +37 -23
  87. package/src/cli/commands/__fixtures__/audit-coverage/.github/workflows/ci.yml +5 -0
  88. package/src/cli/commands/__fixtures__/audit-coverage/Dockerfile +2 -0
  89. package/src/cli/commands/__fixtures__/audit-coverage/infra/main.tf +3 -0
  90. package/src/cli/commands/__fixtures__/audit-coverage/infra/stack.json +6 -0
  91. package/src/cli/commands/__fixtures__/audit-coverage/k8s/deploy.yaml +12 -0
  92. package/src/cli/commands/__fixtures__/init-lexicon-output/docs/pages/getting-started.mdx +16 -0
  93. package/src/cli/commands/__fixtures__/init-lexicon-output/docs/src/content.config.ts +10 -2
  94. package/src/cli/commands/__fixtures__/init-lexicon-output/src/codegen/generate-cli.ts +3 -1
  95. package/src/cli/commands/__fixtures__/init-lexicon-output/src/codegen/generate.ts +2 -1
  96. package/src/cli/commands/__snapshots__/init-lexicon.test.ts.snap +2 -1
  97. package/src/cli/commands/audit.test.ts +83 -1
  98. package/src/cli/commands/audit.ts +121 -28
  99. package/src/cli/commands/build.test.ts +144 -5
  100. package/src/cli/commands/build.ts +66 -14
  101. package/src/cli/commands/check-lexicon-docs.ts +30 -0
  102. package/src/cli/commands/check-lexicon-examples.test.ts +73 -7
  103. package/src/cli/commands/check-lexicon-examples.ts +66 -17
  104. package/src/cli/commands/check-lexicon.test.ts +1 -1
  105. package/src/cli/commands/check-lexicon.ts +19 -4
  106. package/src/cli/commands/init-lexicon/templates/codegen.ts +5 -2
  107. package/src/cli/commands/init-lexicon/templates/docs.ts +35 -2
  108. package/src/cli/commands/init-lexicon.test.ts +42 -1
  109. package/src/cli/commands/init-lexicon.ts +3 -1
  110. package/src/cli/commands/lexicon-rolling-upgrade.ts +1 -1
  111. package/src/cli/commands/lexicon-surface-diff.test.ts +1 -1
  112. package/src/cli/commands/lexicon-surface-diff.ts +1 -1
  113. package/src/cli/commands/onboard.test.ts +319 -225
  114. package/src/cli/commands/onboard.ts +144 -58
  115. package/src/cli/handlers/build.test.ts +3 -3
  116. package/src/cli/handlers/build.ts +2 -0
  117. package/src/cli/handlers/components.ts +2 -2
  118. package/src/cli/handlers/graph.test.ts +44 -0
  119. package/src/cli/handlers/graph.ts +22 -2
  120. package/src/cli/handlers/lifecycle.test.ts +117 -0
  121. package/src/cli/handlers/lifecycle.ts +16 -0
  122. package/src/cli/handlers/run.test.ts +5 -5
  123. package/src/cli/handlers/run.ts +1 -0
  124. package/src/cli/handlers/search-live.test.ts +217 -0
  125. package/src/cli/handlers/search.test.ts +41 -1
  126. package/src/cli/handlers/search.ts +55 -6
  127. package/src/cli/main.ts +5 -3
  128. package/src/cli/security-boundary.test.ts +2 -1
  129. package/src/codegen/docs-pages.test.ts +194 -0
  130. package/src/codegen/docs-pages.ts +138 -0
  131. package/src/codegen/docs-sections.ts +0 -8
  132. package/src/codegen/docs-sidebar.ts +50 -37
  133. package/src/codegen/docs-types.ts +32 -4
  134. package/src/codegen/docs.ts +78 -40
  135. package/src/codegen/lexicon-regen.ts +3 -1
  136. package/src/codegen/rolling-upgrade.test.ts +1 -1
  137. package/src/codegen/rolling-upgrade.ts +1 -1
  138. package/src/codegen/surface-snapshot.test.ts +139 -0
  139. package/src/codegen/surface-snapshot.ts +297 -122
  140. package/src/codegen/validate.test.ts +16 -3
  141. package/src/codegen/validate.ts +17 -7
  142. package/src/components/auto-release.ts +1 -1
  143. package/src/components/capability-plugin.ts +23 -2
  144. package/src/components/driver.test.ts +45 -0
  145. package/src/components/driver.ts +64 -27
  146. package/src/components/index.ts +2 -0
  147. package/src/components/starter-plugin.ts +5 -2
  148. package/src/config.test.ts +56 -1
  149. package/src/config.ts +152 -8
  150. package/src/discovery/fold-import.test.ts +427 -2
  151. package/src/discovery/fold-import.ts +163 -7
  152. package/src/discovery/sandbox/driver.test.ts +11 -0
  153. package/src/fold/fold.test.ts +41 -0
  154. package/src/fold/fold.ts +372 -2
  155. package/src/graph-ir-live.test.ts +42 -0
  156. package/src/graph-ir.ts +42 -2
  157. package/src/graph-ops.test.ts +69 -0
  158. package/src/graph-ops.ts +42 -0
  159. package/src/lifecycle/change-set.test.ts +41 -0
  160. package/src/lifecycle/change-set.ts +30 -2
  161. package/src/lifecycle/deep-observe.ts +4 -0
  162. package/src/lifecycle/observe.ts +29 -2
  163. package/src/lifecycle/replay.ts +3 -0
  164. package/src/lifecycle/snapshot.ts +3 -0
  165. package/src/lifecycle/types.ts +7 -0
  166. package/src/lint/policy.ts +14 -3
  167. package/src/managed-fields.test.ts +15 -42
  168. package/src/managed-fields.ts +43 -26
  169. package/src/observation.test.ts +24 -8
  170. package/src/observation.ts +48 -4
  171. package/src/op/builders-exports.test.ts +60 -0
  172. package/src/op/index.ts +1 -1
  173. package/src/ownership.test.ts +63 -1
  174. package/src/yaml.test.ts +15 -0
  175. package/src/yaml.ts +19 -2
@@ -6,6 +6,8 @@ import { formatSuccess, formatError, formatWarning } from "../format";
6
6
  export interface OnboardOptions {
7
7
  name: string;
8
8
  verbose?: boolean;
9
+ /** Monorepo root to patch. Defaults to the root this module lives in. */
10
+ root?: string;
9
11
  }
10
12
 
11
13
  export interface OnboardResult {
@@ -15,16 +17,66 @@ export interface OnboardResult {
15
17
  error?: string;
16
18
  }
17
19
 
20
+ export interface PatchResult {
21
+ patched: boolean;
22
+ reason?: string;
23
+ }
24
+
18
25
  /** Resolve the monorepo root (5 dirs up from packages/core/src/cli/commands/). */
19
26
  function findRepoRoot(): string {
20
27
  const here = dirname(fileURLToPath(import.meta.url)); // commands/
21
28
  return dirname(dirname(dirname(dirname(dirname(here))))); // -> root
22
29
  }
23
30
 
31
+ /**
32
+ * A real prepack invocation line: a Dockerfile `RUN`, a workflow `- run:`,
33
+ * or a bare line inside a `run: |` block. Comments, `echo` messages and
34
+ * `$lex` / `<lex>` placeholders do not match. Matching those placeholder
35
+ * substrings was how onboard appended duplicates to Dockerfile.smoke on
36
+ * every run (#1678).
37
+ */
38
+ const PREPACK_LINE = /^\s*(?:RUN\s+|-\s*run:\s*)?npm run --prefix lexicons\/[a-z0-9-]+ prepack\s*$/i;
39
+
40
+ function isPrepackLine(line: string): boolean {
41
+ return PREPACK_LINE.test(line);
42
+ }
43
+
44
+ function hasPrepackLine(lines: string[], name: string): boolean {
45
+ return lines.some((l) => isPrepackLine(l) && l.includes(`lexicons/${name} prepack`));
46
+ }
47
+
48
+ /** Shell loop header over lexicon names: `for lex in aws gcp k8s; do \`. */
49
+ const LEX_LOOP = /^(\s*(?:RUN\s+)?for lex in )([a-z0-9][a-z0-9 -]*?)(\s*;\s*do\b.*)$/i;
50
+
51
+ function loopCoversLexicon(lines: string[], name: string): boolean {
52
+ return lines.some((l) => {
53
+ const m = l.match(LEX_LOOP);
54
+ return m !== null && m[2].split(/\s+/).includes(name);
55
+ });
56
+ }
57
+
58
+ /**
59
+ * Append the lexicon to every `for lex in ...` loop list that does not
60
+ * already contain it. Returns true when at least one list changed.
61
+ */
62
+ function addToLexiconLoops(lines: string[], name: string): boolean {
63
+ let changed = false;
64
+ for (let i = 0; i < lines.length; i++) {
65
+ const m = lines[i].match(LEX_LOOP);
66
+ if (!m) continue;
67
+ const names = m[2].split(/\s+/).filter(Boolean);
68
+ if (names.includes(name)) continue;
69
+ names.push(name);
70
+ lines[i] = `${m[1]}${names.join(" ")}${m[3]}`;
71
+ changed = true;
72
+ }
73
+ return changed;
74
+ }
75
+
24
76
  /**
25
77
  * Patch root package.json to add a workspace dependency for the lexicon.
26
78
  */
27
- function patchRootPackageJson(root: string, name: string): { patched: boolean; reason?: string } {
79
+ function patchRootPackageJson(root: string, name: string): PatchResult {
28
80
  const pkgPath = join(root, "package.json");
29
81
  if (!existsSync(pkgPath)) return { patched: false, reason: "root package.json not found" };
30
82
 
@@ -49,23 +101,53 @@ function patchRootPackageJson(root: string, name: string): { patched: boolean; r
49
101
  return { patched: true };
50
102
  }
51
103
 
104
+ /**
105
+ * Patch the root tsconfig.json `paths` map (#1614).
106
+ *
107
+ * The whole-repo typecheck runs with `moduleResolution: node`, which never
108
+ * reads package exports maps, so every lexicon needs a bare + subpath
109
+ * mapping here or an example importing the package fails to resolve.
110
+ */
111
+ export function patchRootTsconfigPaths(root: string, name: string): PatchResult {
112
+ const tsconfigPath = join(root, "tsconfig.json");
113
+ if (!existsSync(tsconfigPath)) return { patched: false, reason: "root tsconfig.json not found" };
114
+
115
+ const cfg = JSON.parse(readFileSync(tsconfigPath, "utf-8"));
116
+ cfg.compilerOptions = cfg.compilerOptions ?? {};
117
+ const paths: Record<string, string[]> = (cfg.compilerOptions.paths = cfg.compilerOptions.paths ?? {});
118
+
119
+ const bare = `@intentius/chant-lexicon-${name}`;
120
+ const wanted: Record<string, string[]> = {
121
+ [bare]: [`lexicons/${name}/src/index.ts`],
122
+ [`${bare}/*`]: [`lexicons/${name}/src/*`],
123
+ };
124
+
125
+ let changed = false;
126
+ for (const [key, value] of Object.entries(wanted)) {
127
+ if (Array.isArray(paths[key]) && paths[key].length > 0) continue;
128
+ paths[key] = value;
129
+ changed = true;
130
+ }
131
+
132
+ if (!changed) return { patched: false, reason: `${bare} already in paths` };
133
+
134
+ writeFileSync(tsconfigPath, JSON.stringify(cfg, null, 2) + "\n");
135
+ return { patched: true };
136
+ }
137
+
52
138
  /**
53
139
  * Insert a new prepack line after the last prepack line in each contiguous group
54
140
  * of 2+ lines. Single standalone lines (like YAML `run:` values) are ignored.
55
- * Used for multi-line `run: |` blocks in workflows and Dockerfile RUN sequences.
141
+ * Used for multi-line `run: |` blocks in workflows.
56
142
  */
57
143
  function insertPrepackInContiguousGroups(lines: string[], name: string): boolean {
58
- const newFragment = `lexicons/${name} prepack`;
59
- if (lines.some((l) => l.includes(newFragment))) return false;
144
+ if (hasPrepackLine(lines, name)) return false;
60
145
 
61
146
  // Identify contiguous groups of prepack lines
62
147
  const groups: { start: number; end: number }[] = [];
63
148
  let groupStart = -1;
64
149
  for (let i = 0; i <= lines.length; i++) {
65
- const isPrepack =
66
- i < lines.length &&
67
- lines[i].includes("npm run --prefix lexicons/") &&
68
- lines[i].includes("prepack");
150
+ const isPrepack = i < lines.length && isPrepackLine(lines[i]);
69
151
  if (isPrepack && groupStart === -1) {
70
152
  groupStart = i;
71
153
  } else if (!isPrepack && groupStart !== -1) {
@@ -89,19 +171,15 @@ function insertPrepackInContiguousGroups(lines: string[], name: string): boolean
89
171
 
90
172
  /**
91
173
  * Insert a new prepack line after every last-in-group prepack line (groups of 1+).
92
- * Used for Dockerfiles and publish.yml where all occurrences should get a new line.
174
+ * Used for publish.yml and Dockerfiles that list one RUN per lexicon.
93
175
  */
94
- function insertPrepackAfterEach(lines: string[], name: string): boolean {
95
- const newFragment = `lexicons/${name} prepack`;
96
- if (lines.some((l) => l.includes(newFragment))) return false;
176
+ export function insertPrepackAfterEach(lines: string[], name: string): boolean {
177
+ if (hasPrepackLine(lines, name)) return false;
97
178
 
98
179
  const insertAfter: number[] = [];
99
180
  for (let i = 0; i < lines.length; i++) {
100
- if (!lines[i].includes("npm run --prefix lexicons/") || !lines[i].includes("prepack")) continue;
101
- const nextIsAlsoPrepack =
102
- i + 1 < lines.length &&
103
- lines[i + 1].includes("npm run --prefix lexicons/") &&
104
- lines[i + 1].includes("prepack");
181
+ if (!isPrepackLine(lines[i])) continue;
182
+ const nextIsAlsoPrepack = i + 1 < lines.length && isPrepackLine(lines[i + 1]);
105
183
  if (!nextIsAlsoPrepack) {
106
184
  insertAfter.push(i);
107
185
  }
@@ -127,20 +205,19 @@ function insertPrepackAfterEach(lines: string[], name: string): boolean {
127
205
  *
128
206
  * We only insert into pattern 1 (contiguous groups) and separately add a new pattern 2 step.
129
207
  */
130
- function patchCiWorkflow(root: string, name: string): { patched: boolean; reason?: string } {
208
+ function patchCiWorkflow(root: string, name: string): PatchResult {
131
209
  const filePath = join(root, ".github/workflows/chant.yml");
132
210
  if (!existsSync(filePath)) return { patched: false, reason: "chant.yml not found" };
133
211
 
134
212
  const content = readFileSync(filePath, "utf-8");
135
- if (content.includes(`lexicons/${name} prepack`)) {
213
+ const lines = content.split("\n");
214
+ if (hasPrepackLine(lines, name)) {
136
215
  return { patched: false, reason: `${name} already in chant.yml` };
137
216
  }
138
217
 
139
- const lines = content.split("\n");
140
-
141
- // 1. Insert into multi-line `run: |` blocks only (contiguous prepack groups of 3+).
218
+ // 1. Insert into multi-line `run: |` blocks only (contiguous prepack groups of 2+).
142
219
  // The validate job has standalone steps that are NOT contiguous, so they won't match.
143
- insertPrepackInContiguousGroups(lines, name);
220
+ let changed = insertPrepackInContiguousGroups(lines, name);
144
221
 
145
222
  // 2. Add a named validate step after the last "Generate and validate" step
146
223
  const displayName = name.charAt(0).toUpperCase() + name.slice(1);
@@ -163,9 +240,11 @@ function patchCiWorkflow(root: string, name: string): { patched: boolean; reason
163
240
  ` run: npm run --prefix lexicons/${name} prepack`,
164
241
  ];
165
242
  lines.splice(lastValidateRunIdx + 1, 0, ...block);
243
+ changed = true;
166
244
  }
167
245
  }
168
246
 
247
+ if (!changed) return { patched: false, reason: "no prepack block found to anchor on" };
169
248
  writeFileSync(filePath, lines.join("\n"));
170
249
  return { patched: true };
171
250
  }
@@ -179,34 +258,43 @@ function patchCiWorkflow(root: string, name: string): { patched: boolean; reason
179
258
  * were how k8s-client (#1177) and fountain (#1253) each shipped a package
180
259
  * the release pipeline could not publish.
181
260
  */
182
- function patchPublishWorkflow(root: string, name: string): { patched: boolean; reason?: string } {
261
+ function patchPublishWorkflow(root: string, name: string): PatchResult {
183
262
  const filePath = join(root, ".github/workflows/publish.yml");
184
263
  if (!existsSync(filePath)) return { patched: false, reason: "publish.yml not found" };
185
264
 
186
- const content = readFileSync(filePath, "utf-8");
187
- if (content.includes(`lexicons/${name} prepack`)) {
265
+ const lines = readFileSync(filePath, "utf-8").split("\n");
266
+ if (hasPrepackLine(lines, name)) {
188
267
  return { patched: false, reason: `prepack for ${name} already present` };
189
268
  }
190
269
 
191
- const lines = content.split("\n");
192
- insertPrepackAfterEach(lines, name);
270
+ if (!insertPrepackAfterEach(lines, name)) {
271
+ return { patched: false, reason: "no prepack line found to anchor on" };
272
+ }
193
273
  writeFileSync(filePath, lines.join("\n"));
194
274
  return { patched: true };
195
275
  }
196
276
 
197
277
  /**
198
- * Patch a Dockerfile to add a prepack RUN line.
278
+ * Patch a smoke Dockerfile so it covers the lexicon.
279
+ *
280
+ * Two layouts exist. Loop-based files (`for lex in aws gcp ...; do`) get the
281
+ * name appended to every loop list. Per-lexicon files (`RUN npm run --prefix
282
+ * lexicons/<x> prepack` lines) get one more RUN line. A file that already
283
+ * covers the lexicon either way is reported as such and left untouched.
199
284
  */
200
- function patchDockerfile(filePath: string, name: string): { patched: boolean; reason?: string } {
285
+ export function patchDockerfile(filePath: string, name: string): PatchResult {
201
286
  if (!existsSync(filePath)) return { patched: false, reason: `${filePath} not found` };
202
287
 
203
- const content = readFileSync(filePath, "utf-8");
204
- if (content.includes(`lexicons/${name} prepack`)) {
205
- return { patched: false, reason: `prepack for ${name} already in Dockerfile` };
288
+ const lines = readFileSync(filePath, "utf-8").split("\n");
289
+ if (loopCoversLexicon(lines, name) || hasPrepackLine(lines, name)) {
290
+ return { patched: false, reason: `already covers ${name}` };
291
+ }
292
+
293
+ const changed = addToLexiconLoops(lines, name) || insertPrepackAfterEach(lines, name);
294
+ if (!changed) {
295
+ return { patched: false, reason: "no lexicon loop or prepack line found to anchor on" };
206
296
  }
207
297
 
208
- const lines = content.split("\n");
209
- insertPrepackAfterEach(lines, name);
210
298
  writeFileSync(filePath, lines.join("\n"));
211
299
  return { patched: true };
212
300
  }
@@ -215,36 +303,34 @@ function patchDockerfile(filePath: string, name: string): { patched: boolean; re
215
303
  * Execute the onboard command — patches monorepo infrastructure for a new lexicon.
216
304
  */
217
305
  export function onboardCommand(options: OnboardOptions): OnboardResult {
218
- const root = findRepoRoot();
306
+ const root = options.root ?? findRepoRoot();
219
307
  const patched: string[] = [];
220
308
  const skipped: string[] = [];
221
309
 
222
- // 1. Root package.json
223
- const pkgResult = patchRootPackageJson(root, options.name);
224
- if (pkgResult.patched) patched.push("package.json (root dependency)");
225
- else skipped.push(`package.json: ${pkgResult.reason}`);
310
+ const record = (label: string, summary: string, result: PatchResult) => {
311
+ if (result.patched) patched.push(`${label} (${summary})`);
312
+ else skipped.push(`${label}: ${result.reason}`);
313
+ };
226
314
 
227
- // 2. CI workflow
228
- const ciResult = patchCiWorkflow(root, options.name);
229
- if (ciResult.patched) patched.push("chant.yml (prepack + validate)");
230
- else skipped.push(`chant.yml: ${ciResult.reason}`);
315
+ // 1. Root package.json
316
+ record("package.json", "root dependency", patchRootPackageJson(root, options.name));
231
317
 
232
- // 3. Publish workflow
233
- const pubResult = patchPublishWorkflow(root, options.name);
234
- if (pubResult.patched) patched.push("publish.yml (prepack)");
235
- else skipped.push(`publish.yml: ${pubResult.reason}`);
318
+ // 2. Root tsconfig.json paths (#1614)
319
+ record("tsconfig.json", "paths mapping", patchRootTsconfigPaths(root, options.name));
236
320
 
237
- // 4. Dockerfiles
238
- const dockerBun = join(root, "test/Dockerfile.smoke");
239
- const dockerNpm = join(root, "test/Dockerfile.smoke-npm");
321
+ // 3. CI workflow
322
+ record("chant.yml", "prepack + validate", patchCiWorkflow(root, options.name));
240
323
 
241
- const db = patchDockerfile(dockerBun, options.name);
242
- if (db.patched) patched.push("Dockerfile.smoke (prepack)");
243
- else skipped.push(`Dockerfile.smoke: ${db.reason}`);
324
+ // 4. Publish workflow
325
+ record("publish.yml", "prepack", patchPublishWorkflow(root, options.name));
244
326
 
245
- const dn = patchDockerfile(dockerNpm, options.name);
246
- if (dn.patched) patched.push("Dockerfile.smoke-npm (prepack)");
247
- else skipped.push(`Dockerfile.smoke-npm: ${dn.reason}`);
327
+ // 5. Dockerfiles
328
+ record("Dockerfile.smoke", "lexicon list", patchDockerfile(join(root, "test/Dockerfile.smoke"), options.name));
329
+ record(
330
+ "Dockerfile.smoke-npm",
331
+ "lexicon list",
332
+ patchDockerfile(join(root, "test/Dockerfile.smoke-npm"), options.name),
333
+ );
248
334
 
249
335
  return { success: true, patched, skipped };
250
336
  }
@@ -267,7 +353,7 @@ export async function printOnboardResult(result: OnboardResult, name: string): P
267
353
 
268
354
  if (result.skipped.length > 0) {
269
355
  console.log("");
270
- console.log("Skipped (already configured):");
356
+ console.log("Skipped (already covered):");
271
357
  for (const s of result.skipped) {
272
358
  console.log(` ${s}`);
273
359
  }
@@ -88,9 +88,9 @@ describe("runBuild --components --generate (chant #1108 build-time parameters)",
88
88
  undefined,
89
89
  [{ name: "tier", value: "light", source: "default" }],
90
90
  );
91
- // A parameter at its default collapses into the count line now; the
92
- // provenance forwarded above is where its value is asserted.
93
- expect(stderr.join("\n")).toContain("at their defaults");
91
+ // The echo is a one-line count (#1424); the provenance forwarded above
92
+ // is where the value is asserted.
93
+ expect(stderr.join("\n")).toContain("1 build parameter resolved (1 default)");
94
94
  vi.restoreAllMocks();
95
95
  });
96
96
 
@@ -37,6 +37,7 @@ async function runGenerateComponents(ctx: CommandContext): Promise<number> {
37
37
  const paramsResolution = resolveCliBuildParams(config.buildParams, {
38
38
  cli: parseParamFlags(args.param),
39
39
  paramsFile: args.paramsFile,
40
+ verbose: args.verbose,
40
41
  });
41
42
  if (!paramsResolution.success) {
42
43
  for (const message of paramsResolution.errors) console.error(message);
@@ -125,6 +126,7 @@ export async function runBuild(ctx: CommandContext): Promise<number> {
125
126
  plugins,
126
127
  fold: args.fold,
127
128
  sandbox: args.sandbox,
129
+ verbose: args.verbose,
128
130
  params,
129
131
  paramsFile: args.paramsFile,
130
132
  });
@@ -430,14 +430,14 @@ export async function runComponentsStatus(ctx: CommandContext): Promise<number>
430
430
  // rather than "stale" (recorded, and nothing live).
431
431
  const message = err instanceof Error ? err.message : String(err);
432
432
  console.error(formatWarning({ message: `${plugin.name}: describeResources failed — ${message} (components in this lexicon report unknown, not stale)` }));
433
- observed = { resources: {}, unobserved: unobservedAll(declared, "read-failed", message, entities), queried: {} };
433
+ observed = { resources: {}, unobserved: unobservedAll(declared, "read-failed", message, entities), queried: {}, notes: [] };
434
434
  }
435
435
  const cs = buildChangeSet(environment, {
436
436
  declared,
437
437
  observedNow: observed.resources,
438
438
  observedThen: undefined,
439
439
  unobserved: observed.unobserved,
440
- });
440
+ }, { lexicon: plugin.name });
441
441
  merged.entries.push(...cs.entries);
442
442
  }
443
443
  liveEvidence = liveEvidenceFromChangeSet(merged, liveNameMapping);
@@ -145,6 +145,8 @@ describe("runGraph", () => {
145
145
  vi.spyOn(console, "log").mockImplementation((s: string) => { stdoutBuf.push(s); });
146
146
  vi.spyOn(console, "error").mockImplementation((s: string) => { stderrBuf.push(s); });
147
147
  discoverOpsMock.mockReset();
148
+ // Default: no git-root ops — the IR views join these into the entity map (#1675).
149
+ discoverOpsMock.mockResolvedValue({ ops: new Map(), errors: [] });
148
150
  discoverMock.mockReset();
149
151
  lintMock.mockReset();
150
152
  layoutMock.mockReset();
@@ -223,6 +225,48 @@ describe("runGraph", () => {
223
225
  expect(ir.edges).toContainEqual({ from: "subnet", to: "vpc", kind: "ref", viaAttr: "network" });
224
226
  });
225
227
 
228
+ // #1675 — discovery scans sourceDir, but ops live beside it (project root,
229
+ // `ops/`) and are found by discoverOps from the git root. The IR joins them.
230
+ describe("ops outside sourceDir reach the IR (#1675)", () => {
231
+ const rootOp = (file: string) => ({
232
+ config: { name: "deploy", phases: [{ name: "Apply", steps: [{ kind: "activity", fn: "build" }] }], depends: ["infra"] },
233
+ filePath: file,
234
+ });
235
+
236
+ test("an op discoverOps finds at the project root becomes a Temporal::Op node", async () => {
237
+ lintClean(); discovered();
238
+ discoverOpsMock.mockResolvedValue({ ops: new Map([["deploy", rootOp("/proj/deploy.op.ts")]]), errors: [] });
239
+ const exit = await runGraph({ args: makeArgs({ format: "ir", path: "/proj", detail: 3 }), plugins: [], serializers: [] });
240
+ expect(exit).toBe(0);
241
+ const ir = JSON.parse(stdoutBuf.join("\n"));
242
+ const node = ir.nodes.find((n: { id: string }) => n.id === "deploy");
243
+ expect(node).toMatchObject({ kind: "Temporal::Op", lexicon: "temporal", sourceLoc: { file: "deploy.op.ts" } });
244
+ expect(node.attrs.phases[0].name).toBe("Apply");
245
+ expect(node.attrs.depends).toEqual(["infra"]);
246
+ });
247
+
248
+ test("an op discovery already loaded from sourceDir is not added twice", async () => {
249
+ lintClean();
250
+ const entities = sampleEntities();
251
+ entities.set("deploy", decl({ lexicon: "temporal", entityType: "Temporal::Op", props: { name: "deploy", phases: [] } }));
252
+ discoverMock.mockResolvedValue({ entities, errors: [], dependencies: new Map(), sourceFiles: ["/proj/src/deploy.op.ts"] });
253
+ discoverOpsMock.mockResolvedValue({ ops: new Map([["deploy", rootOp("/proj/src/deploy.op.ts")]]), errors: [] });
254
+ const exit = await runGraph({ args: makeArgs({ format: "ir", path: "/proj", detail: 3 }), plugins: [], serializers: [] });
255
+ expect(exit).toBe(0);
256
+ const ir = JSON.parse(stdoutBuf.join("\n"));
257
+ expect(ir.nodes.filter((n: { id: string }) => n.id === "deploy")).toHaveLength(1);
258
+ expect(ir.nodes.find((n: { id: string }) => n.id === "deploy").attrs.phases).toEqual([]);
259
+ });
260
+
261
+ test("op-discovery errors are warnings, not a refusal", async () => {
262
+ lintClean(); discovered();
263
+ discoverOpsMock.mockResolvedValue({ ops: new Map(), errors: ["ops/bad.op.ts: boom"] });
264
+ const exit = await runGraph({ args: makeArgs({ format: "ir" }), plugins: [], serializers: [] });
265
+ expect(exit).toBe(0);
266
+ expect(stderrBuf.join("\n")).toContain("ops/bad.op.ts: boom");
267
+ });
268
+ });
269
+
226
270
  // #1339 — `chant graph` discovered source with `params.*` empty, so a
227
271
  // declaration conditioned on a build parameter always took its default:
228
272
  // the graph showed one shape whatever `--param` or a declared `env:`
@@ -4,6 +4,7 @@ import { discover } from "../../discovery/index";
4
4
  import { partitionByLexicon, computeStackGraph, build, mergeBuildRootEntities } from "../../build";
5
5
  import { buildGraphIr, buildLiveGraphIr, collectUnobserved, overlayGraphs, sourceOverlayGraphs, type GraphIR, type IRPipeline, type LiveObservation } from "../../graph-ir";
6
6
  import { buildDeclaredPerStack } from "../../graph-declared";
7
+ import { mergeProjectOps } from "../../graph-ops";
7
8
  import { reconstructEdges, mergeCatalogs, containmentGroups, type ReferenceCatalog, type ContainmentPair } from "../../graph-refs";
8
9
  import { observeResources } from "../../lifecycle/observe";
9
10
  import { replaySnapshots, hasSnapshot } from "../../lifecycle/replay";
@@ -48,6 +49,7 @@ async function graphBuildParams(
48
49
  const resolution = resolveCliBuildParams(config.buildParams, {
49
50
  cli: parseParamFlags(ctx.args.param),
50
51
  paramsFile: ctx.args.paramsFile,
52
+ verbose: ctx.args.verbose,
51
53
  });
52
54
  if (!resolution.success) {
53
55
  for (const message of resolution.errors) console.error(message);
@@ -119,6 +121,17 @@ async function mergeGraphBuildRoots(
119
121
  return true;
120
122
  }
121
123
 
124
+ /** #1675 — join git-root ops into a discovered entity map; op-discovery
125
+ * errors are warnings here (the lint gate never saw those files). */
126
+ async function mergeGraphOps(
127
+ entities: Map<string, import("../../declarable").Declarable>,
128
+ sourceFiles: readonly string[],
129
+ projectPath: string,
130
+ ): Promise<void> {
131
+ const { errors } = await mergeProjectOps(entities, sourceFiles, projectPath);
132
+ for (const e of errors) console.error(formatWarning({ message: e }));
133
+ }
134
+
122
135
  /**
123
136
  * `chant graph` — the Op dependency graph by default; `--stacks` renders the
124
137
  * cross-stack apply-ordering graph (edges, order, waves) chant computes from
@@ -307,7 +320,7 @@ async function runGraphLive(
307
320
  ...(args.namespace ? { namespace: args.namespace } : {}),
308
321
  });
309
322
  observations = observeResult.observations;
310
- const { errors, warnings } = observeResult;
323
+ const { errors, warnings, notes = [] } = observeResult;
311
324
  for (const e of errors) console.error(formatWarning({ message: e }));
312
325
  // Unobserved entities (#1089) arrive as warnings — a node missing from the
313
326
  // live graph because nobody looked is a different fact from one that isn't
@@ -320,7 +333,9 @@ async function runGraphLive(
320
333
  console.error(formatWarning({
321
334
  message: `... and ${warnings.length - WARN_CAP} more entity(ies) not observed — run \`chant lifecycle diff ${environment} --live\` for the full list`,
322
335
  }));
323
- }
336
+ }
337
+ // Run-level notes (#1265): one line each, outside the per-entity cap.
338
+ for (const n of notes) console.error(formatWarning({ message: n }));
324
339
  }
325
340
 
326
341
  // Both paths land here: a replay rejoins the live pipeline at exactly this
@@ -409,6 +424,7 @@ async function runGraphLive(
409
424
  } else if (!(await mergeGraphBuildRoots(declared.entities, buildRoots))) {
410
425
  console.error(formatWarning({ message: "overlay: a build root failed to render — showing the provisioned graph without the declared overlay" }));
411
426
  } else {
427
+ await mergeGraphOps(declared.entities, declared.sourceFiles, projectPath);
412
428
  const declaredIr = buildGraphIr(declared.entities, projectPath);
413
429
  ir =
414
430
  args.overlayAnchor === "live"
@@ -675,6 +691,10 @@ async function runGraphView(
675
691
  return 1;
676
692
  }
677
693
 
694
+ // #1675 — ops live outside sourceDir by convention; join the ones
695
+ // `discoverOps` finds from the git root so the IR carries the declared DAG.
696
+ await mergeGraphOps(result.entities, result.sourceFiles, projectPath);
697
+
678
698
  // Build the base IR, focus with a lens (declarable-level, most precise), then
679
699
  // apply the detail tier — so e.g. blast:<resource> works before any collapse.
680
700
  let ir: GraphIR = buildGraphIr(result.entities, projectPath);
@@ -2,6 +2,7 @@ import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
2
2
  import { sep } from "node:path";
3
3
  import { createMockPlugin, staticDescribeResources, staticObservation, staticDeepObservation, staticListArtifacts } from "@intentius/chant-test-utils";
4
4
  import type { LexiconPlugin, ResourceMetadata } from "../../lexicon";
5
+ import { deepObservation } from "../../deep-observation";
5
6
  import type { BuildResult } from "../../build";
6
7
  import type { ParsedArgs } from "../registry";
7
8
 
@@ -300,6 +301,81 @@ describe("runLifecycleDiff --live", () => {
300
301
  expect(readKeys).toContain("loom-agents__aws");
301
302
  });
302
303
 
304
+ // #1264 — each stack declares the region it deploys to. The diff dropped it
305
+ // on the way to the live read, so a multi-region estate compared every stack
306
+ // against the ambient region and reported the out-of-region ones as absent.
307
+ test("multi-stack: each stack's declared region reaches the thin and deep reads", async () => {
308
+ buildMock.mockResolvedValue(makeBuildResult({ aws: ["bucket"] }));
309
+ fetchLifecycleMock.mockResolvedValue(undefined);
310
+ readSnapshotMock.mockResolvedValue(null);
311
+ loadChantConfigMock.mockResolvedValue({ config: { stacks: [
312
+ { name: "app-us-east-1", src: "src/us-east-1", region: "us-east-1" },
313
+ { name: "app-us-west-2", src: "src/us-west-2", region: "us-west-2" },
314
+ ] } });
315
+
316
+ const thinReads: Array<{ stack?: string; region?: string }> = [];
317
+ const deepReads: Array<{ stack?: string; region?: string }> = [];
318
+ const plugins: LexiconPlugin[] = [
319
+ createMockPlugin({
320
+ name: "aws",
321
+ emulator: awsEmulatorStub,
322
+ describeResources: async (options: { stack?: string; region?: string }) => {
323
+ thinReads.push({ stack: options.stack, region: options.region });
324
+ return {};
325
+ },
326
+ observeResourcesDeep: async (options: { stack?: string; region?: string }) => {
327
+ deepReads.push({ stack: options.stack, region: options.region });
328
+ return deepObservation({});
329
+ },
330
+ }),
331
+ ];
332
+
333
+ const exit = await runLifecycleDiff({
334
+ args: makeArgs({ path: "diff", extraPositional: "prod", live: true }),
335
+ plugins,
336
+ serializers: plugins.map((p) => p.serializer),
337
+ });
338
+
339
+ expect(exit).toBe(0);
340
+ expect(thinReads).toEqual([
341
+ { stack: "app-us-east-1", region: "us-east-1" },
342
+ { stack: "app-us-west-2", region: "us-west-2" },
343
+ ]);
344
+ expect(deepReads).toEqual([
345
+ { stack: "app-us-east-1", region: "us-east-1" },
346
+ { stack: "app-us-west-2", region: "us-west-2" },
347
+ ]);
348
+ });
349
+
350
+ test("stack without a declared region: no region reaches the live read", async () => {
351
+ buildMock.mockResolvedValue(makeBuildResult({ aws: ["bucket"] }));
352
+ fetchLifecycleMock.mockResolvedValue(undefined);
353
+ readSnapshotMock.mockResolvedValue(null);
354
+ loadChantConfigMock.mockResolvedValue({ config: { stacks: [{ name: "app", src: "src/app" }] } });
355
+
356
+ const reads: Array<Record<string, unknown>> = [];
357
+ const plugins: LexiconPlugin[] = [
358
+ createMockPlugin({
359
+ name: "aws",
360
+ emulator: awsEmulatorStub,
361
+ describeResources: async (options: Record<string, unknown>) => {
362
+ reads.push(options);
363
+ return {};
364
+ },
365
+ }),
366
+ ];
367
+
368
+ await runLifecycleDiff({
369
+ args: makeArgs({ path: "diff", extraPositional: "prod", live: true }),
370
+ plugins,
371
+ serializers: plugins.map((p) => p.serializer),
372
+ });
373
+
374
+ expect(reads).toHaveLength(1);
375
+ expect(reads[0].stack).toBe("app");
376
+ expect("region" in reads[0]).toBe(false);
377
+ });
378
+
303
379
  test("warns and skips lexicons without describeResources", async () => {
304
380
  buildMock.mockResolvedValue(makeBuildResult({ k8s: ["pod"] }));
305
381
  fetchLifecycleMock.mockResolvedValue(undefined);
@@ -704,6 +780,47 @@ describe("runLifecyclePlan", () => {
704
780
  expect(web.queried).toBe("/apis/apps/v1/namespaces/default/deployments/web");
705
781
  });
706
782
 
783
+ // #1674 — the plan merges every lexicon's change set into one flat
784
+ // `entries[]`; each row must still say which lexicon observed it, and carry
785
+ // the provider's physical id so an adopt/delete/runtime row named by a live
786
+ // key can be told apart from a declared entity name.
787
+ test("--json carries lexicon attribution and physicalId per entry across the merge", async () => {
788
+ buildMock.mockResolvedValue(makeBuildResult({ aws: ["bucket"], k8s: ["web"] }));
789
+ const plugins: LexiconPlugin[] = [
790
+ createMockPlugin({
791
+ name: "aws",
792
+ emulator: awsEmulatorStub,
793
+ describeResources: async () => ({
794
+ observation: "v1" as const,
795
+ resources: {
796
+ bucket: meta({ type: "AWS::S3::Bucket", physicalId: "arn:aws:s3:::my-bucket" }),
797
+ "sg-0abc123": meta({ type: "AWS::EC2::SecurityGroup", physicalId: "sg-0abc123", ownership: "foreign" }),
798
+ },
799
+ }),
800
+ }),
801
+ createMockPlugin({
802
+ name: "k8s",
803
+ describeResources: async () => ({
804
+ observation: "v1" as const,
805
+ resources: { web: meta({ type: "K8s::Apps::Deployment", physicalId: "default/web" }) },
806
+ }),
807
+ }),
808
+ ];
809
+ const exit = await runLifecyclePlan({
810
+ args: makeArgs({ path: "plan", extraPositional: "prod", json: true }),
811
+ plugins,
812
+ serializers: plugins.map((p) => p.serializer),
813
+ });
814
+ expect(exit).toBe(0);
815
+ const plan = JSON.parse(stdoutBuf.join("\n"));
816
+ const byName = Object.fromEntries(plan.entries.map((e: { name: string }) => [e.name, e]));
817
+ expect(byName.bucket).toMatchObject({ lexicon: "aws", physicalId: "arn:aws:s3:::my-bucket", action: "noop" });
818
+ expect(byName["sg-0abc123"]).toMatchObject({ lexicon: "aws", physicalId: "sg-0abc123", action: "adopt" });
819
+ expect(byName.web).toMatchObject({ lexicon: "k8s", physicalId: "default/web", action: "noop" });
820
+ // `name` is untouched: the adopt row is still keyed by the live key.
821
+ expect(byName["sg-0abc123"].name).toBe("sg-0abc123");
822
+ });
823
+
707
824
  // #1166 — plan is always a live read (no `--live` flag of its own), so a
708
825
  // declared environment endpoint applies here exactly as it does for
709
826
  // `chant graph --live` / `chant lifecycle diff --live`.