@intentius/chant 0.44.14 → 0.46.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 (220) 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 +14 -0
  17. package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
  18. package/dist/cli/commands/init-lexicon/templates/codegen.d.ts.map +1 -1
  19. package/dist/cli/commands/init-lexicon/templates/docs.d.ts +5 -0
  20. package/dist/cli/commands/init-lexicon/templates/docs.d.ts.map +1 -1
  21. package/dist/cli/commands/init-lexicon.d.ts.map +1 -1
  22. package/dist/cli/commands/lexicon-surface-diff.d.ts +6 -0
  23. package/dist/cli/commands/lexicon-surface-diff.d.ts.map +1 -1
  24. package/dist/cli/commands/lint.d.ts.map +1 -1
  25. package/dist/cli/commands/onboard.d.ts +28 -0
  26. package/dist/cli/commands/onboard.d.ts.map +1 -1
  27. package/dist/cli/handlers/build.d.ts.map +1 -1
  28. package/dist/cli/handlers/graph.d.ts.map +1 -1
  29. package/dist/cli/handlers/lifecycle.d.ts +13 -0
  30. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  31. package/dist/cli/handlers/run.d.ts.map +1 -1
  32. package/dist/cli/handlers/search.d.ts +27 -0
  33. package/dist/cli/handlers/search.d.ts.map +1 -1
  34. package/dist/cli/main.d.ts.map +1 -1
  35. package/dist/cli/registry.d.ts +7 -0
  36. package/dist/cli/registry.d.ts.map +1 -1
  37. package/dist/codegen/docs-pages.d.ts +39 -0
  38. package/dist/codegen/docs-pages.d.ts.map +1 -0
  39. package/dist/codegen/docs-sections.d.ts.map +1 -1
  40. package/dist/codegen/docs-sidebar.d.ts +13 -2
  41. package/dist/codegen/docs-sidebar.d.ts.map +1 -1
  42. package/dist/codegen/docs-types.d.ts +30 -10
  43. package/dist/codegen/docs-types.d.ts.map +1 -1
  44. package/dist/codegen/docs.d.ts +14 -2
  45. package/dist/codegen/docs.d.ts.map +1 -1
  46. package/dist/codegen/lexicon-regen.d.ts +11 -0
  47. package/dist/codegen/lexicon-regen.d.ts.map +1 -1
  48. package/dist/codegen/surface-snapshot.d.ts +27 -3
  49. package/dist/codegen/surface-snapshot.d.ts.map +1 -1
  50. package/dist/codegen/validate.d.ts +21 -3
  51. package/dist/codegen/validate.d.ts.map +1 -1
  52. package/dist/components/capability-plugin.d.ts +13 -2
  53. package/dist/components/capability-plugin.d.ts.map +1 -1
  54. package/dist/components/driver.d.ts +38 -0
  55. package/dist/components/driver.d.ts.map +1 -1
  56. package/dist/components/index.d.ts +1 -1
  57. package/dist/components/index.d.ts.map +1 -1
  58. package/dist/components/starter-plugin.d.ts.map +1 -1
  59. package/dist/config.d.ts +85 -5
  60. package/dist/config.d.ts.map +1 -1
  61. package/dist/discovery/fold-import.d.ts.map +1 -1
  62. package/dist/env.d.ts +12 -1
  63. package/dist/env.d.ts.map +1 -1
  64. package/dist/fold/fold.d.ts +100 -1
  65. package/dist/fold/fold.d.ts.map +1 -1
  66. package/dist/graph-ir.d.ts +12 -0
  67. package/dist/graph-ir.d.ts.map +1 -1
  68. package/dist/graph-ops.d.ts +17 -0
  69. package/dist/graph-ops.d.ts.map +1 -0
  70. package/dist/lexicon.d.ts +182 -2
  71. package/dist/lexicon.d.ts.map +1 -1
  72. package/dist/lifecycle/change-set.d.ts +23 -2
  73. package/dist/lifecycle/change-set.d.ts.map +1 -1
  74. package/dist/lifecycle/deep-observe.d.ts.map +1 -1
  75. package/dist/lifecycle/index.d.ts +1 -0
  76. package/dist/lifecycle/index.d.ts.map +1 -1
  77. package/dist/lifecycle/observe.d.ts +8 -0
  78. package/dist/lifecycle/observe.d.ts.map +1 -1
  79. package/dist/lifecycle/replay.d.ts.map +1 -1
  80. package/dist/lifecycle/snapshot.d.ts.map +1 -1
  81. package/dist/lifecycle/teardown.d.ts +130 -0
  82. package/dist/lifecycle/teardown.d.ts.map +1 -0
  83. package/dist/lifecycle/types.d.ts +7 -0
  84. package/dist/lifecycle/types.d.ts.map +1 -1
  85. package/dist/lint/engine.d.ts +6 -2
  86. package/dist/lint/engine.d.ts.map +1 -1
  87. package/dist/lint/policy.d.ts.map +1 -1
  88. package/dist/lint/rule.d.ts +31 -0
  89. package/dist/lint/rule.d.ts.map +1 -1
  90. package/dist/lint/rules/cor021-env-literal-name.d.ts +3 -0
  91. package/dist/lint/rules/cor021-env-literal-name.d.ts.map +1 -0
  92. package/dist/lint/rules/index.d.ts +2 -1
  93. package/dist/lint/rules/index.d.ts.map +1 -1
  94. package/dist/managed-fields.d.ts +33 -21
  95. package/dist/managed-fields.d.ts.map +1 -1
  96. package/dist/observation.d.ts +23 -1
  97. package/dist/observation.d.ts.map +1 -1
  98. package/dist/op/builders.d.ts +36 -7
  99. package/dist/op/builders.d.ts.map +1 -1
  100. package/dist/op/index.d.ts +1 -1
  101. package/dist/op/index.d.ts.map +1 -1
  102. package/dist/testing.d.ts +136 -0
  103. package/dist/testing.d.ts.map +1 -0
  104. package/dist/yaml.d.ts +0 -8
  105. package/dist/yaml.d.ts.map +1 -1
  106. package/package.json +6 -1
  107. package/src/__snapshots__/okf.test.ts.snap +20 -0
  108. package/src/audit/discover.test.ts +55 -1
  109. package/src/audit/discover.ts +70 -2
  110. package/src/audit/edge-init-safety.test.ts +149 -18
  111. package/src/audit/report-model.ts +12 -1
  112. package/src/audit/rules-doc.ts +1 -0
  113. package/src/build-params.test.ts +52 -0
  114. package/src/cli/build-params-cli.test.ts +25 -8
  115. package/src/cli/build-params-cli.ts +37 -23
  116. package/src/cli/commands/__fixtures__/audit-coverage/.github/workflows/ci.yml +5 -0
  117. package/src/cli/commands/__fixtures__/audit-coverage/Dockerfile +2 -0
  118. package/src/cli/commands/__fixtures__/audit-coverage/infra/main.tf +3 -0
  119. package/src/cli/commands/__fixtures__/audit-coverage/infra/stack.json +6 -0
  120. package/src/cli/commands/__fixtures__/audit-coverage/k8s/deploy.yaml +12 -0
  121. package/src/cli/commands/__fixtures__/init-lexicon-output/docs/pages/getting-started.mdx +16 -0
  122. package/src/cli/commands/__fixtures__/init-lexicon-output/docs/src/content.config.ts +10 -2
  123. package/src/cli/commands/__fixtures__/init-lexicon-output/src/codegen/generate-cli.ts +3 -1
  124. package/src/cli/commands/__fixtures__/init-lexicon-output/src/codegen/generate.ts +2 -1
  125. package/src/cli/commands/__snapshots__/init-lexicon.test.ts.snap +2 -1
  126. package/src/cli/commands/audit.test.ts +83 -1
  127. package/src/cli/commands/audit.ts +121 -28
  128. package/src/cli/commands/build.test.ts +275 -5
  129. package/src/cli/commands/build.ts +86 -14
  130. package/src/cli/commands/check-lexicon-docs.ts +30 -0
  131. package/src/cli/commands/check-lexicon-examples.test.ts +73 -7
  132. package/src/cli/commands/check-lexicon-examples.ts +66 -17
  133. package/src/cli/commands/check-lexicon.test.ts +46 -2
  134. package/src/cli/commands/check-lexicon.ts +64 -4
  135. package/src/cli/commands/init-lexicon/templates/codegen.ts +5 -2
  136. package/src/cli/commands/init-lexicon/templates/docs.ts +35 -2
  137. package/src/cli/commands/init-lexicon.test.ts +42 -1
  138. package/src/cli/commands/init-lexicon.ts +3 -1
  139. package/src/cli/commands/lexicon-rolling-upgrade.ts +1 -1
  140. package/src/cli/commands/lexicon-surface-diff.test.ts +1 -1
  141. package/src/cli/commands/lexicon-surface-diff.ts +10 -1
  142. package/src/cli/commands/lexicon-surface-diff.update.test.ts +112 -0
  143. package/src/cli/commands/lint.ts +19 -6
  144. package/src/cli/commands/onboard.test.ts +319 -225
  145. package/src/cli/commands/onboard.ts +144 -58
  146. package/src/cli/handlers/build.test.ts +3 -3
  147. package/src/cli/handlers/build.ts +2 -0
  148. package/src/cli/handlers/components.ts +2 -2
  149. package/src/cli/handlers/graph.test.ts +44 -0
  150. package/src/cli/handlers/graph.ts +24 -4
  151. package/src/cli/handlers/lifecycle.test.ts +348 -1
  152. package/src/cli/handlers/lifecycle.ts +235 -3
  153. package/src/cli/handlers/run.test.ts +5 -5
  154. package/src/cli/handlers/run.ts +1 -0
  155. package/src/cli/handlers/search-live.test.ts +217 -0
  156. package/src/cli/handlers/search.test.ts +41 -1
  157. package/src/cli/handlers/search.ts +60 -8
  158. package/src/cli/main.ts +17 -4
  159. package/src/cli/registry.ts +7 -0
  160. package/src/cli/security-boundary.test.ts +2 -1
  161. package/src/codegen/docs-pages.test.ts +194 -0
  162. package/src/codegen/docs-pages.ts +138 -0
  163. package/src/codegen/docs-sections.ts +0 -8
  164. package/src/codegen/docs-sidebar.ts +50 -37
  165. package/src/codegen/docs-types.ts +32 -4
  166. package/src/codegen/docs.ts +78 -40
  167. package/src/codegen/lexicon-regen.ts +22 -2
  168. package/src/codegen/rolling-upgrade.test.ts +1 -1
  169. package/src/codegen/rolling-upgrade.ts +1 -1
  170. package/src/codegen/surface-snapshot.test.ts +139 -0
  171. package/src/codegen/surface-snapshot.ts +297 -122
  172. package/src/codegen/validate.test.ts +49 -3
  173. package/src/codegen/validate.ts +36 -7
  174. package/src/components/auto-release.ts +1 -1
  175. package/src/components/capability-plugin.ts +23 -2
  176. package/src/components/driver.test.ts +45 -0
  177. package/src/components/driver.ts +64 -27
  178. package/src/components/index.ts +2 -0
  179. package/src/components/starter-plugin.ts +5 -2
  180. package/src/config.test.ts +40 -0
  181. package/src/config.ts +159 -8
  182. package/src/discovery/fold-import.test.ts +427 -2
  183. package/src/discovery/fold-import.ts +163 -7
  184. package/src/discovery/sandbox/driver.test.ts +11 -0
  185. package/src/env.test.ts +35 -1
  186. package/src/env.ts +17 -3
  187. package/src/fold/fold.test.ts +41 -0
  188. package/src/fold/fold.ts +372 -2
  189. package/src/graph-ir-live.test.ts +42 -0
  190. package/src/graph-ir.ts +42 -2
  191. package/src/graph-ops.test.ts +69 -0
  192. package/src/graph-ops.ts +42 -0
  193. package/src/lexicon.ts +182 -2
  194. package/src/lifecycle/change-set.test.ts +41 -0
  195. package/src/lifecycle/change-set.ts +30 -2
  196. package/src/lifecycle/deep-observe.ts +4 -0
  197. package/src/lifecycle/index.ts +1 -0
  198. package/src/lifecycle/observe.ts +29 -2
  199. package/src/lifecycle/replay.ts +3 -0
  200. package/src/lifecycle/snapshot.ts +3 -0
  201. package/src/lifecycle/teardown.test.ts +537 -0
  202. package/src/lifecycle/teardown.ts +357 -0
  203. package/src/lifecycle/types.ts +7 -0
  204. package/src/lint/engine.ts +7 -1
  205. package/src/lint/policy.ts +14 -3
  206. package/src/lint/rule.ts +23 -0
  207. package/src/lint/rules/cor021-env-literal-name.test.ts +128 -0
  208. package/src/lint/rules/cor021-env-literal-name.ts +114 -0
  209. package/src/lint/rules/index.ts +4 -1
  210. package/src/managed-fields.test.ts +15 -42
  211. package/src/managed-fields.ts +43 -26
  212. package/src/observation.test.ts +24 -8
  213. package/src/observation.ts +48 -4
  214. package/src/op/builders.ts +40 -7
  215. package/src/op/index.ts +1 -1
  216. package/src/ownership.test.ts +63 -1
  217. package/src/testing.test.ts +261 -0
  218. package/src/testing.ts +338 -0
  219. package/src/yaml.test.ts +15 -0
  220. package/src/yaml.ts +19 -2
@@ -1,5 +1,15 @@
1
1
  import { build } from "../../build";
2
- import { loadChantConfigUpward, resolveOwnershipMarker, resolveFoldEnabled, resolveSandboxEnabled } from "../../config";
2
+ import {
3
+ loadChantConfigUpward,
4
+ resolveOwnershipMarker,
5
+ resolveOwnershipEnv,
6
+ ownershipEnvDisagreement,
7
+ isOwnershipParamRef,
8
+ resolveFoldEnabled,
9
+ resolveSandboxEnabled,
10
+ } from "../../config";
11
+ import { unknownEnvError } from "../../env";
12
+ import type { OwnershipMarker } from "../../ownership";
3
13
  import { resolveCliBuildParams } from "../build-params-cli";
4
14
  import type { Serializer, SerializerResult } from "../../serializer";
5
15
  import type { LexiconPlugin } from "../../lexicon";
@@ -141,10 +151,9 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
141
151
  // its default.
142
152
  const loaded = await loadChantConfigUpward(infraPath);
143
153
  const config = loaded.config;
144
- const ownership = resolveOwnershipMarker(config);
154
+ // The ownership marker is resolved further down, after build parameters —
155
+ // `ownership.env` may reference one (#1396).
145
156
 
146
- // Environment for policy evaluation: explicit --env wins, else ownership.env.
147
- const env = options.env ?? config.ownership?.env;
148
157
  // Project-authored organizational policy checks (lint.policies), run over the
149
158
  // resolved resources during build. Resolve paths relative to the config dir.
150
159
  const configDir = loaded.configPath ? dirname(loaded.configPath) : infraPath;
@@ -197,11 +206,13 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
197
206
  // missing required value, a type/enum mismatch) is reported as a chant
198
207
  // build error naming the parameter, never a thrown error from inside user
199
208
  // source (which is what loomster's hand-rolled `tierFromEnv()`-style
200
- // validators did before migrating to this mechanism). Also logs every
201
- // resolved value (`[param] name = value (source)`) on success.
209
+ // validators did before migrating to this mechanism). Also logs a one-line
210
+ // count on success, or every resolved value (`[param] name = value
211
+ // (source)`) under --verbose (#1424).
202
212
  const paramsResolution = resolveCliBuildParams(config.buildParams, {
203
213
  cli: options.params,
204
214
  paramsFile: options.paramsFile,
215
+ verbose: options.verbose,
205
216
  });
206
217
  if (!paramsResolution.success) {
207
218
  errors.push(...paramsResolution.errors);
@@ -228,6 +239,45 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
228
239
  );
229
240
  }
230
241
 
242
+ // #1396 — opt-in ownership marking, resolved AFTER build parameters so
243
+ // `ownership.env: { param: "env" }` takes the value `--param env=prod` (or
244
+ // the declared env mapping, or the default) bound to params.env — one
245
+ // source for the label and the marker. A reference that cannot be
246
+ // satisfied is a build error, not a marker silently stamped without an env.
247
+ let ownership: OwnershipMarker | undefined;
248
+ // Environment for policy evaluation: explicit --env wins, else ownership.env
249
+ // (read even when marking itself is off, so policy can still branch on it).
250
+ let env: string | undefined;
251
+ try {
252
+ ownership = resolveOwnershipMarker(config, paramsResolution.provenance);
253
+ env = options.env ?? resolveOwnershipEnv(config, paramsResolution.provenance);
254
+ } catch (err) {
255
+ errors.push(formatError({ message: err instanceof Error ? err.message : String(err) }));
256
+ return { success: false, resourceCount: 0, fileCount: 0, errors, warnings };
257
+ }
258
+ // A literal `ownership.env` next to an `env` build parameter that resolved
259
+ // to something else is the silent divergence #1396 reports — say so.
260
+ const disagreement = ownershipEnvDisagreement(config, paramsResolution.provenance);
261
+ if (disagreement) warnings.push(formatWarning({ message: disagreement }));
262
+
263
+ // #1221 — dynamic-env legality. `--env` is validated against the declared
264
+ // `environments` in cli/main.ts; a param-bound `ownership.env` supplied via
265
+ // `--param env=<value>` reached here unchecked, so `--param env=pord`
266
+ // stamped a marker for an environment the project never declared. Same
267
+ // check, same site of truth: literal entries match by equality, entries
268
+ // containing `*` (e.g. `"pr-*"`) match as glob patterns, so an unbounded
269
+ // family like per-PR environments is declarable without listing each name.
270
+ if (isOwnershipParamRef(config.ownership?.env)) {
271
+ const dynamicEnvErr = unknownEnvError(env, config.environments);
272
+ if (dynamicEnvErr) {
273
+ errors.push(formatError({
274
+ message: dynamicEnvErr,
275
+ hint: 'Declare it in chant.config `environments` (a "pr-*" pattern entry covers a dynamic family), or pass a declared value.',
276
+ }));
277
+ return { success: false, resourceCount: 0, fileCount: 0, errors, warnings };
278
+ }
279
+ }
280
+
231
281
  // #1039 — thread each loaded plugin's registered intrinsics (e.g. AWS's
232
282
  // `Sub`) through to the fold path, so a file using a registered intrinsic
233
283
  // tagged template folds instead of unconditionally falling back to run.
@@ -272,14 +322,22 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
272
322
  });
273
323
 
274
324
  // #1022 — report per-file fold vs run so it's visible what still runs.
275
- if (fold) {
276
- for (const decision of result.foldDecisions) {
277
- const rel = relative(infraPath, decision.file) || decision.file;
278
- const detail =
279
- decision.mode === "fold"
280
- ? `${decision.resourceCount ?? 0} resource(s), no module execution`
281
- : (decision.reason ?? "fell back to run");
282
- console.error(formatInfo(`[fold:${decision.mode}] ${rel} — ${detail}`));
325
+ // #1424 — one line by default; the per-file lines and their reasons are
326
+ // --verbose. A file that ran is the expected outcome for source that
327
+ // computes values in functions, and a dozen "is not foldable" notes read
328
+ // like errors to someone meeting the project for the first time.
329
+ if (fold && result.foldDecisions.length > 0) {
330
+ if (options.verbose) {
331
+ for (const decision of result.foldDecisions) {
332
+ const rel = relative(infraPath, decision.file) || decision.file;
333
+ const detail =
334
+ decision.mode === "fold"
335
+ ? `${decision.resourceCount ?? 0} resource(s), no module execution`
336
+ : (decision.reason ?? "fell back to run");
337
+ console.error(formatInfo(`[fold:${decision.mode}] ${rel} — ${detail}`));
338
+ }
339
+ } else {
340
+ console.error(formatInfo(summarizeFoldDecisions(result.foldDecisions)));
283
341
  }
284
342
  }
285
343
 
@@ -587,6 +645,20 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
587
645
  };
588
646
  }
589
647
 
648
+ /**
649
+ * `fold: 8 files folded, 13 ran (--verbose for reasons)` — the non-verbose
650
+ * report of a fold build's per-file decisions (#1424). The "(--verbose for
651
+ * reasons)" hint appears only when something ran, since that is the only case
652
+ * with a reason to read.
653
+ */
654
+ export function summarizeFoldDecisions(decisions: readonly { mode: string }[]): string {
655
+ const folded = decisions.filter((d) => d.mode === "fold").length;
656
+ const ran = decisions.length - folded;
657
+ const files = (n: number) => `${n} file${n === 1 ? "" : "s"}`;
658
+ const hint = ran > 0 ? " (--verbose for reasons)" : "";
659
+ return `fold: ${files(folded)} folded, ${ran} ran${hint}`;
660
+ }
661
+
590
662
  /**
591
663
  * Simple JSON to YAML converter
592
664
  */
@@ -69,3 +69,33 @@ export function auditDocsReachability(lexiconDir: string): DocsReachability {
69
69
 
70
70
  return { hasSite: true, pages, unreachable };
71
71
  }
72
+
73
+ export interface DocsClassification {
74
+ /** Whether this lexicon has a docs/pages/ directory at all. */
75
+ hasPages: boolean;
76
+ /** Authored page files under docs/pages/ that lack a valid `diataxis` field. */
77
+ unclassified: string[];
78
+ }
79
+
80
+ const QUADRANTS = new Set(["tutorial", "how-to", "reference", "explanation"]);
81
+
82
+ /**
83
+ * Audit docs/pages/ for authored pages with no Diátaxis quadrant (#1731).
84
+ *
85
+ * The docs pipeline throws on the same condition, so this is the cheap
86
+ * pre-flight that reports every offender at once instead of the first one.
87
+ */
88
+ export function auditDocsClassification(lexiconDir: string): DocsClassification {
89
+ const pagesDir = join(lexiconDir, "docs", "pages");
90
+ if (!existsSync(pagesDir)) return { hasPages: false, unclassified: [] };
91
+ const unclassified: string[] = [];
92
+ for (const file of readdirSync(pagesDir).sort()) {
93
+ if (!file.endsWith(".mdx") && !file.endsWith(".md")) continue;
94
+ const text = readFileSync(join(pagesDir, file), "utf-8");
95
+ const end = text.startsWith("---\n") ? text.indexOf("\n---", 4) : -1;
96
+ const fm = end === -1 ? "" : text.slice(4, end);
97
+ const value = fm.match(/^diataxis:\s*(.+)$/m)?.[1].trim();
98
+ if (!value || !QUADRANTS.has(value)) unclassified.push(file);
99
+ }
100
+ return { hasPages: true, unclassified };
101
+ }
@@ -1,5 +1,5 @@
1
1
  import { describe, test, expect, beforeAll, afterAll } from "vitest";
2
- import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
2
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, symlinkSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { checkExamplesBuild } from "./check-lexicon-examples";
@@ -15,7 +15,10 @@ import { checkExamplesBuild } from "./check-lexicon-examples";
15
15
  * exactly what each example's own `npm run build` does.
16
16
  *
17
17
  * These fixtures import the real, already-installed `@intentius/chant-lexicon-aws`
18
- * package (workspace-linked), so no lexicon plugin needs to be faked.
18
+ * package (workspace-linked), so no lexicon plugin needs to be faked. The
19
+ * temp lexicon dir gets the repo's node_modules linked in so that bare
20
+ * specifier resolves from the fixture the way it does from a real lexicon
21
+ * (Vitest 4's module runner resolves from the importing file, not the root).
19
22
  */
20
23
 
21
24
  function writeLexiconDirWithExample(
@@ -35,18 +38,43 @@ describe("checkExamplesBuild", () => {
35
38
 
36
39
  beforeAll(() => {
37
40
  dir = mkdtempSync(join(tmpdir(), "chant-check-lexicon-examples-"));
41
+ symlinkSync(join(__dirname, "..", "..", "..", "..", "..", "node_modules"), join(dir, "node_modules"), "dir");
38
42
  });
39
43
  afterAll(() => rmSync(dir, { recursive: true, force: true }));
40
44
 
41
- test("a clean example builds", async () => {
42
- writeLexiconDirWithExample(dir, "clean", {
43
- "bucket.ts": `import { Bucket } from "@intentius/chant-lexicon-aws";
45
+ // A bucket that satisfies the aws lexicon's error-severity checks (#1400):
46
+ // public access blocked (WAW018) and a TLS-only bucket policy (WAW042).
47
+ const cleanBucketSource = `import { Bucket, PublicAccessBlockConfiguration, S3BucketPolicy, Ref } from "@intentius/chant-lexicon-aws";
44
48
 
45
49
  export const appBucket = new Bucket({
46
50
  BucketName: "my-app-bucket",
51
+ PublicAccessBlockConfiguration: new PublicAccessBlockConfiguration({
52
+ BlockPublicAcls: true,
53
+ BlockPublicPolicy: true,
54
+ IgnorePublicAcls: true,
55
+ RestrictPublicBuckets: true,
56
+ }),
47
57
  });
48
- `,
49
- });
58
+
59
+ export const appBucketPolicy = new S3BucketPolicy({
60
+ Bucket: Ref(appBucket),
61
+ PolicyDocument: {
62
+ Version: "2012-10-17",
63
+ Statement: [
64
+ {
65
+ Effect: "Deny",
66
+ Principal: "*",
67
+ Action: "s3:*",
68
+ Resource: [appBucket.Arn],
69
+ Condition: { Bool: { "aws:SecureTransport": "false" } },
70
+ },
71
+ ],
72
+ },
73
+ });
74
+ `;
75
+
76
+ test("a clean example builds", async () => {
77
+ writeLexiconDirWithExample(dir, "clean", { "bucket.ts": cleanBucketSource });
50
78
 
51
79
  const results = await checkExamplesBuild(dir);
52
80
  const clean = results.find((r) => r.example === "clean");
@@ -75,6 +103,44 @@ export const dataBucket = new Bucket({
75
103
  expect(broken?.detail).toMatch(/Duplicate export name "dataBucket" found/);
76
104
  });
77
105
 
106
+ // chant #1400 — a bare bucket serializes fine, which is all #1067 asked,
107
+ // but fails the aws lexicon's own WAW018/WAW042 at error severity. This is
108
+ // the exact shape lambda-api and lambda-s3 shipped in.
109
+ test("an example that fails its own lexicon's post-synth checks at error severity fails (#1400)", async () => {
110
+ writeLexiconDirWithExample(dir, "insecure", {
111
+ "bucket.ts": `import { Bucket } from "@intentius/chant-lexicon-aws";
112
+
113
+ export const plainBucket = new Bucket({
114
+ BucketName: "plain-bucket",
115
+ });
116
+ `,
117
+ });
118
+
119
+ const results = await checkExamplesBuild(dir);
120
+ const insecure = results.find((r) => r.example === "insecure");
121
+ expect(insecure?.ok).toBe(false);
122
+ expect(insecure?.detail).toMatch(/post-synth error\(s\) from the lexicon's own checks/);
123
+ expect(insecure?.detail).toMatch(/WAW042: \[plainBucket\]/);
124
+ });
125
+
126
+ test("an example's own lint.rules severity config applies to post-synth checks (#1400)", async () => {
127
+ writeLexiconDirWithExample(dir, "suppressed", {
128
+ "bucket.ts": `import { Bucket } from "@intentius/chant-lexicon-aws";
129
+
130
+ export const plainBucket = new Bucket({
131
+ BucketName: "plain-bucket",
132
+ });
133
+ `,
134
+ });
135
+ writeFileSync(
136
+ join(dir, "examples", "suppressed", "chant.config.json"),
137
+ JSON.stringify({ lint: { rules: { WAW018: "warning", WAW042: "off" } } }),
138
+ );
139
+
140
+ const results = await checkExamplesBuild(dir);
141
+ expect(results.find((r) => r.example === "suppressed")).toMatchObject({ ok: true });
142
+ });
143
+
78
144
  test("an empty src/ directory is skipped, not reported as a failure", async () => {
79
145
  writeLexiconDirWithExample(dir, "empty", {});
80
146
  const results = await checkExamplesBuild(dir);
@@ -1,5 +1,6 @@
1
1
  /**
2
- * "Every shipped example builds" (chant #1067).
2
+ * "Every shipped example builds" (chant #1067) and "every shipped example
3
+ * passes its own lexicon's post-synth checks" (chant #1400).
3
4
  *
4
5
  * `check-lexicon.ts`'s existing example checks only count directories
5
6
  * ("At least 1 example", "At least 3 examples", "At least 5 examples with
@@ -12,28 +13,29 @@
12
13
  * pipeline `chant build` runs and report whether it produced output with no
13
14
  * structural error.
14
15
  *
15
- * Scope, deliberately: "builds" here means discovery + serialization
16
- * succeed (no `DiscoveryError`/`BuildError`, real output produced) — the
17
- * same thing a `Duplicate export name` failure blocks. It does not
18
- * additionally require the output to pass every post-synth/lint check
19
- * (WAW0xx and friends). Those are a separate, already-gated contract
20
- * (`chant lint`, the post-synth pipeline) with their own severity model and
21
- * their own CI step; several existing AWS examples (docs-snippets,
22
- * lambda-api, lambda-s3, shared-alb) currently fail one or more post-synth
23
- * checks for reasons unrelated to this issue (e.g. WAW042, a TLS-only
24
- * bucket policy check added after those examples were written). Folding
25
- * that axis into "does it build" would fail this new check for all of them
26
- * on its very first run, for defects this issue never set out to fix.
27
- * Tracked separately; not silently absorbed here.
16
+ * #1067 scoped "builds" to discovery + serialization on purpose, and said
17
+ * so here: three aws examples (lambda-api, lambda-s3, shared-alb) failed
18
+ * WAW042/WAW054 at error severity at the time, for defects that issue never
19
+ * set out to fix. #1400 fixed those three and added the second axis: after
20
+ * serialization, the post-synth checks each loaded plugin ships run against
21
+ * that plugin's own output, exactly as `chant build` runs them (scoped per
22
+ * plugin, `lint.rules` from the example's chant.config applied), and any
23
+ * diagnostic left at `error` severity fails the example. Warnings do not.
24
+ * An example is what a user copies wholesale; it must not teach a pattern
25
+ * the lexicon it demonstrates flags as an error.
28
26
  */
29
27
 
30
28
  import { existsSync, readdirSync } from "fs";
31
29
  import { join } from "path";
32
- import { build } from "../../build";
30
+ import { build, type BuildResult } from "../../build";
33
31
  import { findInfraFiles } from "../../discovery/files";
34
32
  import { detectLexicons } from "../../detectLexicon";
35
33
  import { loadChantConfig } from "../../config";
36
34
  import { loadPlugins } from "../plugins";
35
+ import { runPostSynthChecks, type PostSynthDiagnostic } from "../../lint/post-synth";
36
+ import { applyConfiguredSeverity } from "../../lint/config";
37
+ import type { LexiconPlugin } from "../../lexicon";
38
+ import type { SerializerResult } from "../../serializer";
37
39
 
38
40
  export interface ExampleBuildResult {
39
41
  example: string;
@@ -41,6 +43,43 @@ export interface ExampleBuildResult {
41
43
  detail?: string;
42
44
  }
43
45
 
46
+ /**
47
+ * Run each plugin's own post-synth checks against that plugin's output —
48
+ * the same per-plugin scoping and `lint.rules` severity resolution
49
+ * `cli/commands/build.ts` applies — and return what is left at `error`
50
+ * severity. Project `lint.policies` are not run here: those are the
51
+ * example author's organizational policy, not the lexicon's contract.
52
+ */
53
+ export function postSynthErrors(
54
+ plugins: LexiconPlugin[],
55
+ result: BuildResult,
56
+ lintRules: Parameters<typeof applyConfiguredSeverity>[1],
57
+ ): PostSynthDiagnostic[] {
58
+ const errors: PostSynthDiagnostic[] = [];
59
+ for (const plugin of plugins) {
60
+ if (!plugin.postSynthChecks) continue;
61
+ const checks = plugin.postSynthChecks();
62
+ if (checks.length === 0) continue;
63
+
64
+ const outputKey = plugin.serializer.name;
65
+ const scopedOutputs = new Map<string, string | SerializerResult>();
66
+ const pluginOutput = result.outputs.get(outputKey);
67
+ if (pluginOutput !== undefined) scopedOutputs.set(outputKey, pluginOutput);
68
+
69
+ const diags = runPostSynthChecks(checks, { ...result, outputs: scopedOutputs });
70
+ const { diagnostics } = applyConfiguredSeverity(diags, lintRules);
71
+ for (const diag of diagnostics) {
72
+ if (diag.severity === "error") errors.push(diag);
73
+ }
74
+ }
75
+ return errors;
76
+ }
77
+
78
+ function formatPostSynthError(diag: PostSynthDiagnostic): string {
79
+ const prefix = diag.entity ? `[${diag.entity}] ` : "";
80
+ return `${diag.checkId}: ${prefix}${diag.message}`;
81
+ }
82
+
44
83
  /**
45
84
  * Build every non-empty example under `<lexiconDir>/examples/*\/src` and
46
85
  * report per-example pass/fail. Returns `[]` when the lexicon has no
@@ -94,7 +133,15 @@ export async function checkExamplesBuild(lexiconDir: string): Promise<ExampleBui
94
133
 
95
134
  const structuralErrors = result.errors.map((e) => e.message);
96
135
  const producedOutput = result.outputs.size > 0;
97
- const ok = structuralErrors.length === 0 && producedOutput;
136
+ const structurallyOk = structuralErrors.length === 0 && producedOutput;
137
+
138
+ // #1400 — only once the build is structurally sound. A discovery error
139
+ // already explains the failure, and partial output is not the output
140
+ // the checks are meant to see.
141
+ const postSynth = structurallyOk
142
+ ? postSynthErrors(plugins, result, exampleConfig.lint?.rules)
143
+ : [];
144
+ const ok = structurallyOk && postSynth.length === 0;
98
145
 
99
146
  results.push({
100
147
  example: entry.name,
@@ -103,7 +150,9 @@ export async function checkExamplesBuild(lexiconDir: string): Promise<ExampleBui
103
150
  ? undefined
104
151
  : structuralErrors.length > 0
105
152
  ? structuralErrors.join("; ")
106
- : "discovered source but produced no output",
153
+ : !producedOutput
154
+ ? "discovered source but produced no output"
155
+ : `post-synth error(s) from the lexicon's own checks: ${postSynth.map(formatPostSynthError).join("; ")}`,
107
156
  });
108
157
  } catch (err) {
109
158
  results.push({
@@ -1,7 +1,9 @@
1
1
  import { describe, test, expect } from "vitest";
2
2
  import { join, dirname } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
- import { checkLexicon } from "./check-lexicon";
4
+ import { checkLexicon, coverageReportCheck } from "./check-lexicon";
5
+ import { loadLexiconFromDir } from "./check-lexicon-plugin";
6
+ import type { LexiconPlugin } from "../../lexicon";
5
7
 
6
8
  // chant #1067 — check-lexicon.ts had zero tests before this issue, despite
7
9
  // being the tool meant to gate every lexicon's completeness. This locks in
@@ -17,7 +19,7 @@ describe("checkLexicon", () => {
17
19
  test("includes the new #1067 tier-1 checks by name", async () => {
18
20
  const result = await checkLexicon(gitlabDir);
19
21
  const names = result.items.map((i) => i.name);
20
- expect(names).toContain("Every shipped example builds");
22
+ expect(names).toContain("Every shipped example builds and passes its own post-synth checks");
21
23
  expect(names).toContain("Registered intrinsics are exported by the package");
22
24
  expect(names).toContain("Registered intrinsics' isTag matches how they're authored");
23
25
  expect(names).toContain("dist/manifest.json declares a chantVersion");
@@ -32,3 +34,45 @@ describe("checkLexicon", () => {
32
34
  expect(matches?.pass).toBe(true);
33
35
  });
34
36
  });
37
+
38
+ // chant #1330 — fountain's spec-coverage gate lived only in its own
39
+ // coverage.test.ts, a convention rather than a check-lexicon contract. These
40
+ // lock in the tier-1 check over the plugin's coverageReport() member: red on
41
+ // an unaccounted kind, vacuous pass without the member, red on a throw, and
42
+ // green against the real fountain lexicon in this repo.
43
+ describe("coverageReportCheck", () => {
44
+ test("fails when the report leaves a kind unaccounted", async () => {
45
+ const plugin = {
46
+ coverageReport: async () => ({ unaccountedKinds: ["SandboxRequest"] }),
47
+ } as unknown as LexiconPlugin;
48
+ const item = await coverageReportCheck(plugin);
49
+ expect(item.tier).toBe(1);
50
+ expect(item.pass).toBe(false);
51
+ expect(item.detail).toContain("SandboxRequest");
52
+ });
53
+
54
+ test("passes vacuously when the plugin has no coverageReport", async () => {
55
+ const item = await coverageReportCheck({} as LexiconPlugin);
56
+ expect(item.pass).toBe(true);
57
+ expect(item.detail).toBeUndefined();
58
+ });
59
+
60
+ test("fails when the report throws", async () => {
61
+ const plugin = {
62
+ coverageReport: async () => {
63
+ throw new Error("snapshot missing");
64
+ },
65
+ } as unknown as LexiconPlugin;
66
+ const item = await coverageReportCheck(plugin);
67
+ expect(item.pass).toBe(false);
68
+ expect(item.detail).toContain("snapshot missing");
69
+ });
70
+
71
+ test("is green on the current fountain lexicon", async () => {
72
+ const loaded = await loadLexiconFromDir(join(repoRoot, "lexicons", "fountain"));
73
+ expect(loaded.plugin).toBeDefined();
74
+ const item = await coverageReportCheck(loaded.plugin);
75
+ expect(item.pass).toBe(true);
76
+ expect(item.detail).toBe("all spec kinds accounted for");
77
+ });
78
+ });
@@ -2,10 +2,11 @@ import { existsSync, readdirSync, readFileSync } from "fs";
2
2
  import { join, basename } from "path";
3
3
  import { auditIntrinsics } from "./check-lexicon-intrinsics";
4
4
  import { checkExamplesBuild } from "./check-lexicon-examples";
5
- import { auditDocsReachability } from "./check-lexicon-docs";
5
+ import { auditDocsClassification, auditDocsReachability } from "./check-lexicon-docs";
6
6
  import { auditMcpNames, lexiconNameFor } from "./check-lexicon-mcp";
7
7
  import { loadLexiconFromDir, registers, safeList } from "./check-lexicon-plugin";
8
8
  import { RULE_CATALOG } from "../../audit/catalog";
9
+ import type { LexiconPlugin } from "../../lexicon";
9
10
 
10
11
  // ── Types ────────────────────────────────────────────────────────────
11
12
 
@@ -66,6 +67,45 @@ function countSubdirs(dir: string): number {
66
67
  .length;
67
68
  }
68
69
 
70
+ /**
71
+ * #1330 — gate on the plugin's own spec-coverage accounting.
72
+ *
73
+ * fountain's `coverage.test.ts` asserts `unaccountedKinds == []` in CI, but a
74
+ * lexicon-local test is a convention, not a check-lexicon contract — the same
75
+ * class of gap #1342 closed for LSP providers. `coverageReport()` gives core
76
+ * the one fact to gate on: which upstream spec kinds are neither modeled nor
77
+ * on the lexicon's exclusion list. A lexicon without the member passes
78
+ * vacuously, the same conditional shape as the docs-reachability and
79
+ * Diátaxis checks; a report that throws fails, for the same reason `safeList`
80
+ * treats a throw as worse than absence.
81
+ */
82
+ export async function coverageReportCheck(plugin: LexiconPlugin | undefined): Promise<CheckItem> {
83
+ const report = plugin?.coverageReport;
84
+ const hasReport = typeof report === "function";
85
+ let unaccounted: string[] = [];
86
+ let error: string | undefined;
87
+ if (hasReport) {
88
+ try {
89
+ unaccounted = (await report.call(plugin))?.unaccountedKinds ?? [];
90
+ } catch (e) {
91
+ error = e instanceof Error ? e.message : String(e);
92
+ }
93
+ }
94
+ return {
95
+ name: "coverageReport() leaves no spec kind unaccounted",
96
+ tier: 1,
97
+ pass: !hasReport || (error === undefined && unaccounted.length === 0),
98
+ detail:
99
+ error !== undefined
100
+ ? `threw: ${error}`
101
+ : unaccounted.length > 0
102
+ ? `${unaccounted.length} unaccounted: ${unaccounted.join(", ")}`
103
+ : hasReport
104
+ ? "all spec kinds accounted for"
105
+ : undefined,
106
+ };
107
+ }
108
+
69
109
  // ── Check runner ─────────────────────────────────────────────────────
70
110
 
71
111
  /**
@@ -254,12 +294,14 @@ export async function checkLexicon(dir: string): Promise<CheckResult> {
254
294
  // checks only ever counted example directories; none tried to build one,
255
295
  // so `lexicons/aws/examples/core-concepts` shipped with a discovery-time
256
296
  // "Duplicate export name" error while this tool reported "All tier-1
257
- // checks passed." See ./check-lexicon-examples.ts for exactly what
258
- // "builds" means here (discovery + serialization, not post-synth/lint).
297
+ // checks passed." chant #1400 and the built output must pass the
298
+ // lexicon's own post-synth checks at error severity; three aws examples
299
+ // taught patterns (no TLS-only bucket policy, mutable ECR tags) the
300
+ // lexicon flags as errors. See ./check-lexicon-examples.ts.
259
301
  const exampleBuilds = await checkExamplesBuild(dir);
260
302
  const brokenExamples = exampleBuilds.filter((e) => !e.ok);
261
303
  items.push({
262
- name: "Every shipped example builds",
304
+ name: "Every shipped example builds and passes its own post-synth checks",
263
305
  tier: 1,
264
306
  pass: brokenExamples.length === 0,
265
307
  detail:
@@ -374,6 +416,24 @@ export async function checkLexicon(dir: string): Promise<CheckResult> {
374
416
  : undefined,
375
417
  });
376
418
 
419
+ // Authored pages name their Diátaxis quadrant; the sidebar is built from
420
+ // it, so an untagged page cannot be placed (#1731).
421
+ const docsClass = auditDocsClassification(dir);
422
+ items.push({
423
+ name: "Every authored doc page has a diataxis quadrant",
424
+ tier: 1,
425
+ pass: !docsClass.hasPages || docsClass.unclassified.length === 0,
426
+ detail:
427
+ docsClass.unclassified.length > 0
428
+ ? `${docsClass.unclassified.length} unclassified: ${docsClass.unclassified.join(", ")}`
429
+ : undefined,
430
+ });
431
+
432
+ // #1330 — fountain's spec-coverage gate lived in a lexicon-local vitest
433
+ // assertion, a convention rather than a check-lexicon contract. The plugin
434
+ // now states the fact directly via `coverageReport()`.
435
+ items.push(await coverageReportCheck(plugin));
436
+
377
437
  // ── Tier 2: Recommended ────────────────────────────────────────
378
438
 
379
439
  const pluginContent = readOr(join(dir, "src/plugin.ts"));
@@ -67,7 +67,8 @@ export async function generate(options?: { verbose?: boolean; force?: boolean })
67
67
  * Write generated files to the package directory.
68
68
  */
69
69
  export function writeGeneratedFiles(result: GenerateResult, pkgDir?: string): void {
70
- const dir = pkgDir ?? dirname(dirname(fileURLToPath(import.meta.url)));
70
+ // This file lives in src/codegen/, so the package root is three levels up.
71
+ const dir = pkgDir ?? dirname(dirname(dirname(fileURLToPath(import.meta.url))));
71
72
  writeGeneratedArtifacts({
72
73
  baseDir: dir,
73
74
  files: {
@@ -86,7 +87,9 @@ import { generate, writeGeneratedFiles } from "./generate";
86
87
  import { dirname } from "path";
87
88
  import { fileURLToPath } from "url";
88
89
 
89
- const pkgDir = dirname(dirname(fileURLToPath(import.meta.url)));
90
+ // src/codegen/generate-cli.ts -> src/codegen -> src -> package root.
91
+ // Two dirnames land in src/ and the first generate writes src/src/generated/ (#1614).
92
+ const pkgDir = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
90
93
  const result = await generate({ verbose: true });
91
94
  writeGeneratedFiles(result, pkgDir);
92
95
  `;
@@ -57,16 +57,49 @@ export default defineConfig({
57
57
  }
58
58
 
59
59
  export function generateDocsContentConfig(): string {
60
- return `import { defineCollection } from 'astro:content';
60
+ return `import { defineCollection, z } from 'astro:content';
61
61
  import { docsLoader } from '@astrojs/starlight/loaders';
62
62
  import { docsSchema } from '@astrojs/starlight/schema';
63
63
 
64
64
  export const collections = {
65
- docs: defineCollection({ loader: docsLoader(), schema: docsSchema() }),
65
+ docs: defineCollection({
66
+ loader: docsLoader(),
67
+ schema: docsSchema({
68
+ extend: z.object({
69
+ // Diátaxis quadrant (https://diataxis.fr), chant #1731.
70
+ diataxis: z.enum(['tutorial', 'how-to', 'reference', 'explanation']).optional(),
71
+ }),
72
+ }),
73
+ }),
66
74
  };
67
75
  `;
68
76
  }
69
77
 
78
+ /**
79
+ * First authored page. Lives under docs/pages/; the docs pipeline copies it
80
+ * into src/content/docs/ and builds the sidebar from its `diataxis` field.
81
+ */
82
+ export function generateDocsGettingStartedMdx(name: string): string {
83
+ const displayName = name.charAt(0).toUpperCase() + name.slice(1);
84
+ return `---
85
+ title: Getting Started
86
+ description: First project with the ${displayName} lexicon
87
+ diataxis: tutorial
88
+ ---
89
+
90
+ Install the lexicon, declare one resource, and build it.
91
+
92
+ \`\`\`bash
93
+ npm install --save-dev @intentius/chant-lexicon-${name}
94
+ chant build
95
+ \`\`\`
96
+
97
+ Replace this with a walkthrough a new user can follow start to finish. Other
98
+ pages go beside this file in \`docs/pages/\`, each with a \`diataxis\` field
99
+ (\`tutorial\`, \`how-to\`, \`reference\` or \`explanation\`).
100
+ `;
101
+ }
102
+
70
103
  export function generateDocsIndexMdx(name: string): string {
71
104
  const displayName = name.charAt(0).toUpperCase() + name.slice(1);
72
105
  return `---