@intentius/chant 0.19.1 → 0.21.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 (164) hide show
  1. package/dist/build-params.d.ts +108 -0
  2. package/dist/build-params.d.ts.map +1 -0
  3. package/dist/build.d.ts +79 -1
  4. package/dist/build.d.ts.map +1 -1
  5. package/dist/cli/commands/build.d.ts +31 -0
  6. package/dist/cli/commands/build.d.ts.map +1 -1
  7. package/dist/cli/commands/check-lexicon-examples.d.ts +41 -0
  8. package/dist/cli/commands/check-lexicon-examples.d.ts.map +1 -0
  9. package/dist/cli/commands/check-lexicon-intrinsics.d.ts +71 -0
  10. package/dist/cli/commands/check-lexicon-intrinsics.d.ts.map +1 -0
  11. package/dist/cli/commands/check-lexicon.d.ts +1 -1
  12. package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
  13. package/dist/cli/commands/lint.d.ts +27 -0
  14. package/dist/cli/commands/lint.d.ts.map +1 -1
  15. package/dist/cli/handlers/build.d.ts.map +1 -1
  16. package/dist/cli/handlers/components.d.ts.map +1 -1
  17. package/dist/cli/handlers/lint.d.ts.map +1 -1
  18. package/dist/cli/handlers/run-client.d.ts +1 -1
  19. package/dist/cli/handlers/run-client.d.ts.map +1 -1
  20. package/dist/cli/handlers/run.d.ts.map +1 -1
  21. package/dist/cli/main.d.ts.map +1 -1
  22. package/dist/cli/plugins.d.ts +16 -0
  23. package/dist/cli/plugins.d.ts.map +1 -1
  24. package/dist/cli/registry.d.ts +10 -0
  25. package/dist/cli/registry.d.ts.map +1 -1
  26. package/dist/codegen/docs-sections.d.ts.map +1 -1
  27. package/dist/codegen/docs-types.d.ts +2 -0
  28. package/dist/codegen/docs-types.d.ts.map +1 -1
  29. package/dist/components/cli-support.d.ts +12 -5
  30. package/dist/components/cli-support.d.ts.map +1 -1
  31. package/dist/components/discover.d.ts +62 -7
  32. package/dist/components/discover.d.ts.map +1 -1
  33. package/dist/components/sandbox/driver.d.ts +12 -0
  34. package/dist/components/sandbox/driver.d.ts.map +1 -0
  35. package/dist/components/sandbox/run.d.ts +42 -0
  36. package/dist/components/sandbox/run.d.ts.map +1 -0
  37. package/dist/composite.d.ts +5 -0
  38. package/dist/composite.d.ts.map +1 -1
  39. package/dist/config.d.ts +71 -0
  40. package/dist/config.d.ts.map +1 -1
  41. package/dist/declarable.d.ts +16 -0
  42. package/dist/declarable.d.ts.map +1 -1
  43. package/dist/discovery/collect.d.ts.map +1 -1
  44. package/dist/discovery/entity-wire-codec.d.ts +166 -0
  45. package/dist/discovery/entity-wire-codec.d.ts.map +1 -0
  46. package/dist/discovery/entity-wire.d.ts +50 -0
  47. package/dist/discovery/entity-wire.d.ts.map +1 -0
  48. package/dist/discovery/fold-import.d.ts +239 -0
  49. package/dist/discovery/fold-import.d.ts.map +1 -0
  50. package/dist/discovery/index.d.ts +74 -1
  51. package/dist/discovery/index.d.ts.map +1 -1
  52. package/dist/discovery/sandbox/bundle.d.ts +18 -0
  53. package/dist/discovery/sandbox/bundle.d.ts.map +1 -0
  54. package/dist/discovery/sandbox/child-errors.d.ts +15 -0
  55. package/dist/discovery/sandbox/child-errors.d.ts.map +1 -0
  56. package/dist/discovery/sandbox/driver.d.ts +13 -0
  57. package/dist/discovery/sandbox/driver.d.ts.map +1 -0
  58. package/dist/discovery/sandbox/run.d.ts +69 -0
  59. package/dist/discovery/sandbox/run.d.ts.map +1 -0
  60. package/dist/errors.d.ts +9 -1
  61. package/dist/errors.d.ts.map +1 -1
  62. package/dist/fold/fold.d.ts +299 -0
  63. package/dist/fold/fold.d.ts.map +1 -0
  64. package/dist/fold/foldable-helpers.d.ts +121 -0
  65. package/dist/fold/foldable-helpers.d.ts.map +1 -0
  66. package/dist/fold/subset.d.ts +134 -0
  67. package/dist/fold/subset.d.ts.map +1 -0
  68. package/dist/index.d.ts +2 -0
  69. package/dist/index.d.ts.map +1 -1
  70. package/dist/lexicon-output.d.ts +7 -2
  71. package/dist/lexicon-output.d.ts.map +1 -1
  72. package/dist/lexicon-schema.d.ts +4 -2
  73. package/dist/lexicon-schema.d.ts.map +1 -1
  74. package/dist/lexicon.d.ts +131 -1
  75. package/dist/lexicon.d.ts.map +1 -1
  76. package/dist/lifecycle/release-ledger.d.ts +11 -0
  77. package/dist/lifecycle/release-ledger.d.ts.map +1 -1
  78. package/dist/lint/component-checks.d.ts +7 -1
  79. package/dist/lint/component-checks.d.ts.map +1 -1
  80. package/dist/lint/rules/evl001-non-literal-expression.d.ts.map +1 -1
  81. package/dist/lint/rules/evl003-dynamic-property-access.d.ts.map +1 -1
  82. package/dist/params.d.ts +60 -0
  83. package/dist/params.d.ts.map +1 -0
  84. package/dist/provenance.d.ts +21 -0
  85. package/dist/provenance.d.ts.map +1 -1
  86. package/dist/runtime.d.ts +10 -1
  87. package/dist/runtime.d.ts.map +1 -1
  88. package/package.json +2 -1
  89. package/src/build-params.test.ts +144 -0
  90. package/src/build-params.ts +207 -0
  91. package/src/build.test.ts +38 -0
  92. package/src/build.ts +144 -7
  93. package/src/cli/commands/build.test.ts +220 -2
  94. package/src/cli/commands/build.ts +111 -3
  95. package/src/cli/commands/check-lexicon-examples.test.ts +92 -0
  96. package/src/cli/commands/check-lexicon-examples.ts +103 -0
  97. package/src/cli/commands/check-lexicon-intrinsics.test.ts +179 -0
  98. package/src/cli/commands/check-lexicon-intrinsics.ts +348 -0
  99. package/src/cli/commands/check-lexicon.test.ts +34 -0
  100. package/src/cli/commands/check-lexicon.ts +137 -1
  101. package/src/cli/commands/lint.ts +31 -3
  102. package/src/cli/commands/onboard.ts +1 -1
  103. package/src/cli/component-security-boundary.test.ts +170 -0
  104. package/src/cli/handlers/build.ts +24 -3
  105. package/src/cli/handlers/components.ts +9 -2
  106. package/src/cli/handlers/dev.ts +1 -1
  107. package/src/cli/handlers/graph.ts +7 -5
  108. package/src/cli/handlers/lifecycle.ts +2 -2
  109. package/src/cli/handlers/lint.ts +2 -0
  110. package/src/cli/handlers/misc.ts +2 -2
  111. package/src/cli/handlers/run-client.ts +1 -1
  112. package/src/cli/handlers/run.ts +20 -5
  113. package/src/cli/main.test.ts +22 -0
  114. package/src/cli/main.ts +39 -0
  115. package/src/cli/plugins.ts +20 -4
  116. package/src/cli/registry.ts +10 -0
  117. package/src/cli/security-boundary.test.ts +135 -0
  118. package/src/codegen/docs-sections.test.ts +67 -0
  119. package/src/codegen/docs-sections.ts +7 -3
  120. package/src/codegen/docs-types.ts +2 -0
  121. package/src/components/cli-support.ts +22 -10
  122. package/src/components/discover.ts +127 -25
  123. package/src/components/sandbox/driver.ts +114 -0
  124. package/src/components/sandbox/run.test.ts +185 -0
  125. package/src/components/sandbox/run.ts +177 -0
  126. package/src/composite.test.ts +21 -0
  127. package/src/composite.ts +20 -1
  128. package/src/config.ts +81 -0
  129. package/src/declarable.ts +20 -0
  130. package/src/discovery/collect.ts +17 -3
  131. package/src/discovery/entity-wire-codec.ts +487 -0
  132. package/src/discovery/entity-wire.test.ts +240 -0
  133. package/src/discovery/entity-wire.ts +67 -0
  134. package/src/discovery/fold-import.test.ts +1598 -0
  135. package/src/discovery/fold-import.ts +1998 -0
  136. package/src/discovery/index.test.ts +191 -1
  137. package/src/discovery/index.ts +242 -1
  138. package/src/discovery/sandbox/bundle.ts +218 -0
  139. package/src/discovery/sandbox/child-errors.ts +65 -0
  140. package/src/discovery/sandbox/driver.ts +147 -0
  141. package/src/discovery/sandbox/run.test.ts +179 -0
  142. package/src/discovery/sandbox/run.ts +196 -0
  143. package/src/errors.ts +9 -1
  144. package/src/fold/fold.test.ts +812 -0
  145. package/src/fold/fold.ts +805 -0
  146. package/src/fold/foldable-helpers.ts +171 -0
  147. package/src/fold/subset-doc-parity.test.ts +210 -0
  148. package/src/fold/subset.test.ts +352 -0
  149. package/src/fold/subset.ts +383 -0
  150. package/src/index.ts +2 -0
  151. package/src/lexicon-output.ts +7 -2
  152. package/src/lexicon-schema.test.ts +57 -0
  153. package/src/lexicon-schema.ts +8 -1
  154. package/src/lexicon.ts +132 -1
  155. package/src/lifecycle/git.test.ts +10 -5
  156. package/src/lifecycle/release-ledger.test.ts +28 -0
  157. package/src/lifecycle/release-ledger.ts +11 -0
  158. package/src/lint/component-checks.ts +8 -1
  159. package/src/lint/rules/evl001-non-literal-expression.ts +26 -110
  160. package/src/lint/rules/evl003-dynamic-property-access.ts +11 -2
  161. package/src/params.test.ts +22 -0
  162. package/src/params.ts +66 -0
  163. package/src/provenance.ts +22 -0
  164. package/src/runtime.ts +11 -2
@@ -0,0 +1,348 @@
1
+ /**
2
+ * Static audit of a lexicon's intrinsic registrations (chant #1067).
3
+ *
4
+ * `check-lexicon.ts`'s original 29 checks are all existence/count assertions
5
+ * — they never look at whether a registration is actually *correct*.
6
+ * Foldability (`IntrinsicDef.isTag`, ../../lexicon.ts) is the sharpest case:
7
+ * it drives both `chant build --fold` (which registered tags it recognizes)
8
+ * and the generated per-lexicon intrinsics doc page (its "Folds?" column,
9
+ * via `intrinsicFolds()`), so a wrong value doesn't just mislead a reader —
10
+ * it silently changes what `--fold` does. #1039 shipped with the flag wrong
11
+ * in both directions at once (aws's `Sub` missing `isTag` entirely; gitlab's
12
+ * `reference()` claiming `isTag: true` for a plain call) and neither was
13
+ * caught by anything until someone measured fold coverage by hand.
14
+ *
15
+ * This module answers two questions per registered intrinsic, purely by
16
+ * parsing source (no execution, no `chant generate` required first):
17
+ *
18
+ * 1. Is `name` actually exported from the package's public entry
19
+ * (`src/index.ts`)? A registration that names something the package
20
+ * doesn't export is documenting a function that doesn't exist.
21
+ * 2. Does the exported declaration's own shape agree with `isTag`? A tagged
22
+ * template is a function whose first parameter is typed
23
+ * `TemplateStringsArray` — the only shape `fold()` ever recognizes as a
24
+ * tag (see ../../fold/fold.ts). Anything else (a plain function, a class,
25
+ * a const object/proxy) cannot be invoked as `` Name`...` `` and must be
26
+ * `isTag: false`.
27
+ *
28
+ * chant #1044 adds a third question, for the same reason the first two
29
+ * exist: `foldsAsCall` opts an intrinsic's PLAIN-CALL form into folding, so
30
+ * declaring it on something authored as a tagged template claims a form that
31
+ * cannot be called that way. The two flags are mutually exclusive, and a
32
+ * registration setting both is a failure here rather than a silently ignored
33
+ * field.
34
+ */
35
+
36
+ import { existsSync, readFileSync } from "fs";
37
+ import { join, dirname } from "path";
38
+ import * as ts from "typescript";
39
+
40
+ // ── Types ────────────────────────────────────────────────────────────
41
+
42
+ export interface IntrinsicAuditItem {
43
+ name: string;
44
+ /** The `isTag` value as registered in `src/plugin.ts`'s `intrinsics()`. */
45
+ declaredIsTag: boolean | undefined;
46
+ /** The `foldsAsCall` opt-in as registered in `src/plugin.ts` (chant #1044); `undefined` when absent, which means "not opted in". */
47
+ declaredFoldsAsCall: boolean | undefined;
48
+ /** Whether `name` resolves to a real export of `src/index.ts`. */
49
+ exported: boolean;
50
+ /**
51
+ * Whether the resolved declaration is genuinely authored as a tagged
52
+ * template (first parameter typed `TemplateStringsArray`). `undefined`
53
+ * when the declaration couldn't be located or its shape can't be
54
+ * determined statically — treated as a failure, not a pass, since a
55
+ * foldability claim that can't be verified is exactly the unvalidated
56
+ * state #1067 is closing.
57
+ */
58
+ actualIsTag: boolean | undefined;
59
+ /** True only when exported AND the authored shape matches `declaredIsTag`. */
60
+ ok: boolean;
61
+ detail?: string;
62
+ /**
63
+ * chant #1044 — false only when `foldsAsCall: true` is registered on
64
+ * something authored as a tagged template. Tracked separately from
65
+ * {@link ok} so the two failures report as the two different registration
66
+ * mistakes they are, rather than one message covering both.
67
+ */
68
+ callFormOk: boolean;
69
+ callFormDetail?: string;
70
+ }
71
+
72
+ interface DeclaredIntrinsic {
73
+ name: string;
74
+ isTag: boolean | undefined;
75
+ foldsAsCall: boolean | undefined;
76
+ }
77
+
78
+ interface ExportTarget {
79
+ /** Relative module specifier as written, e.g. "./intrinsics". */
80
+ modulePath: string;
81
+ /** The name as declared in the target module (post `as` rename resolution). */
82
+ localName: string;
83
+ }
84
+
85
+ // ── Parsing helpers ──────────────────────────────────────────────────
86
+
87
+ function parseFile(path: string): ts.SourceFile | undefined {
88
+ if (!existsSync(path)) return undefined;
89
+ let text: string;
90
+ try {
91
+ text = readFileSync(path, "utf-8");
92
+ } catch {
93
+ return undefined;
94
+ }
95
+ return ts.createSourceFile(path, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
96
+ }
97
+
98
+ /**
99
+ * Find `intrinsics(): IntrinsicDef[] { return [ ... ]; }` (or the arrow-
100
+ * function-property equivalent) in a plugin.ts source file and extract each
101
+ * registered `{ name, isTag, foldsAsCall }` triple from the array literal. Returns
102
+ * `undefined` when no `intrinsics` method exists at all (lexicons with no
103
+ * intrinsics, e.g. gcp/k8s, still return `[]` — that's a real empty array,
104
+ * not `undefined`).
105
+ */
106
+ function extractDeclaredIntrinsics(pluginPath: string): DeclaredIntrinsic[] | undefined {
107
+ const sf = parseFile(pluginPath);
108
+ if (!sf) return undefined;
109
+
110
+ let arrayLiteral: ts.ArrayLiteralExpression | undefined;
111
+
112
+ const findReturnedArray = (body: ts.Block): ts.ArrayLiteralExpression | undefined => {
113
+ for (const stmt of body.statements) {
114
+ if (ts.isReturnStatement(stmt) && stmt.expression && ts.isArrayLiteralExpression(stmt.expression)) {
115
+ return stmt.expression;
116
+ }
117
+ }
118
+ return undefined;
119
+ };
120
+
121
+ const visit = (node: ts.Node): void => {
122
+ if (arrayLiteral) return;
123
+
124
+ if (ts.isMethodDeclaration(node) && node.name && ts.isIdentifier(node.name) && node.name.text === "intrinsics" && node.body) {
125
+ arrayLiteral = findReturnedArray(node.body);
126
+ } else if (ts.isPropertyAssignment(node) && ts.isIdentifier(node.name) && node.name.text === "intrinsics") {
127
+ const init = node.initializer;
128
+ if (ts.isArrowFunction(init) || ts.isFunctionExpression(init)) {
129
+ if (init.body && ts.isBlock(init.body)) {
130
+ arrayLiteral = findReturnedArray(init.body);
131
+ } else if (init.body && ts.isArrayLiteralExpression(init.body)) {
132
+ arrayLiteral = init.body;
133
+ }
134
+ }
135
+ }
136
+
137
+ if (!arrayLiteral) ts.forEachChild(node, visit);
138
+ };
139
+ visit(sf);
140
+
141
+ if (!arrayLiteral) return undefined;
142
+
143
+ const result: DeclaredIntrinsic[] = [];
144
+ for (const el of arrayLiteral.elements) {
145
+ if (!ts.isObjectLiteralExpression(el)) continue;
146
+ let name: string | undefined;
147
+ let isTag: boolean | undefined;
148
+ let foldsAsCall: boolean | undefined;
149
+
150
+ for (const prop of el.properties) {
151
+ if (!ts.isPropertyAssignment(prop) || !ts.isIdentifier(prop.name)) continue;
152
+ const key = prop.name.text;
153
+ if (key === "name" && ts.isStringLiteralLike(prop.initializer)) {
154
+ name = prop.initializer.text;
155
+ } else if (key === "isTag") {
156
+ if (prop.initializer.kind === ts.SyntaxKind.TrueKeyword) isTag = true;
157
+ else if (prop.initializer.kind === ts.SyntaxKind.FalseKeyword) isTag = false;
158
+ } else if (key === "foldsAsCall") {
159
+ if (prop.initializer.kind === ts.SyntaxKind.TrueKeyword) foldsAsCall = true;
160
+ else if (prop.initializer.kind === ts.SyntaxKind.FalseKeyword) foldsAsCall = false;
161
+ }
162
+ }
163
+
164
+ if (name !== undefined) result.push({ name, isTag, foldsAsCall });
165
+ }
166
+
167
+ return result;
168
+ }
169
+
170
+ /**
171
+ * Build a map of publicly-exported name -> where it's really declared, by
172
+ * parsing `src/index.ts`'s top-level `export { a, b as c } from "./file"`
173
+ * statements (every lexicon's intrinsics are re-exported this way) plus
174
+ * direct top-level exported declarations in index.ts itself (`modulePath:
175
+ * "."`). Wildcard re-exports (`export * from "./generated"`) are not
176
+ * expanded — none of the 8 lexicons that register intrinsics export them
177
+ * that way, so this is a deliberate, documented gap rather than a silent one.
178
+ */
179
+ function extractExportMap(indexPath: string): Map<string, ExportTarget> {
180
+ const map = new Map<string, ExportTarget>();
181
+ const sf = parseFile(indexPath);
182
+ if (!sf) return map;
183
+
184
+ const hasExportModifier = (node: ts.Node): boolean =>
185
+ !!ts.canHaveModifiers(node) && (ts.getModifiers(node) ?? []).some((m) => m.kind === ts.SyntaxKind.ExportKeyword);
186
+
187
+ for (const stmt of sf.statements) {
188
+ if (ts.isExportDeclaration(stmt) && stmt.moduleSpecifier && ts.isStringLiteralLike(stmt.moduleSpecifier)) {
189
+ const modulePath = stmt.moduleSpecifier.text;
190
+ if (stmt.exportClause && ts.isNamedExports(stmt.exportClause)) {
191
+ for (const spec of stmt.exportClause.elements) {
192
+ const exportedName = spec.name.text;
193
+ const localName = spec.propertyName ? spec.propertyName.text : exportedName;
194
+ map.set(exportedName, { modulePath, localName });
195
+ }
196
+ }
197
+ continue;
198
+ }
199
+
200
+ if (ts.isFunctionDeclaration(stmt) && stmt.name && hasExportModifier(stmt)) {
201
+ map.set(stmt.name.text, { modulePath: ".", localName: stmt.name.text });
202
+ } else if (ts.isClassDeclaration(stmt) && stmt.name && hasExportModifier(stmt)) {
203
+ map.set(stmt.name.text, { modulePath: ".", localName: stmt.name.text });
204
+ } else if (ts.isVariableStatement(stmt) && hasExportModifier(stmt)) {
205
+ for (const decl of stmt.declarationList.declarations) {
206
+ if (ts.isIdentifier(decl.name)) {
207
+ map.set(decl.name.text, { modulePath: ".", localName: decl.name.text });
208
+ }
209
+ }
210
+ }
211
+ }
212
+
213
+ return map;
214
+ }
215
+
216
+ /** True when a parameter list's first entry is typed `TemplateStringsArray`. */
217
+ function firstParamIsTemplateStringsArray(params: ts.NodeArray<ts.ParameterDeclaration>): boolean {
218
+ const first = params[0];
219
+ if (!first?.type || !ts.isTypeReferenceNode(first.type)) return false;
220
+ const typeName = first.type.typeName;
221
+ const text = ts.isIdentifier(typeName) ? typeName.text : typeName.right.text;
222
+ return text === "TemplateStringsArray";
223
+ }
224
+
225
+ /**
226
+ * Resolve whether the declaration named `target.localName` in the module
227
+ * `target.modulePath` (relative to `indexPath`) is authored as a tagged
228
+ * template. Returns `{ found: false }` when the declaration can't be
229
+ * located — a function declared but never found is treated as unverifiable,
230
+ * not as a pass.
231
+ */
232
+ function resolveActualIsTag(
233
+ indexPath: string,
234
+ target: ExportTarget,
235
+ ): { found: boolean; isTag?: boolean } {
236
+ const filePath =
237
+ target.modulePath === "."
238
+ ? indexPath
239
+ : join(dirname(indexPath), `${target.modulePath.replace(/^\.\//, "")}.ts`);
240
+
241
+ const sf = parseFile(filePath);
242
+ if (!sf) return { found: false };
243
+
244
+ let result: { found: boolean; isTag?: boolean } | undefined;
245
+
246
+ for (const stmt of sf.statements) {
247
+ if (result) break;
248
+
249
+ if (ts.isFunctionDeclaration(stmt) && stmt.name?.text === target.localName) {
250
+ result = { found: true, isTag: firstParamIsTemplateStringsArray(stmt.parameters) };
251
+ } else if (ts.isClassDeclaration(stmt) && stmt.name?.text === target.localName) {
252
+ // A class is invoked with `new X(...)`, never as a tagged template.
253
+ result = { found: true, isTag: false };
254
+ } else if (ts.isVariableStatement(stmt)) {
255
+ for (const decl of stmt.declarationList.declarations) {
256
+ if (!ts.isIdentifier(decl.name) || decl.name.text !== target.localName) continue;
257
+ const init = decl.initializer;
258
+ if (init && (ts.isArrowFunction(init) || ts.isFunctionExpression(init))) {
259
+ result = { found: true, isTag: firstParamIsTemplateStringsArray(init.parameters) };
260
+ } else {
261
+ // A const object, proxy, `new X()`, etc. — not callable as a tag.
262
+ result = { found: true, isTag: false };
263
+ }
264
+ }
265
+ }
266
+ }
267
+
268
+ return result ?? { found: false };
269
+ }
270
+
271
+ // ── Public entry point ───────────────────────────────────────────────
272
+
273
+ /**
274
+ * Audit every intrinsic a lexicon registers in `src/plugin.ts` against
275
+ * `src/index.ts`'s real export surface and each export's authored shape.
276
+ * Returns `[]` for a lexicon with no `intrinsics()` method, or one that
277
+ * registers none — nothing to audit either way.
278
+ */
279
+ export function auditIntrinsics(lexiconDir: string): IntrinsicAuditItem[] {
280
+ const pluginPath = join(lexiconDir, "src/plugin.ts");
281
+ const indexPath = join(lexiconDir, "src/index.ts");
282
+
283
+ const declared = extractDeclaredIntrinsics(pluginPath);
284
+ if (!declared || declared.length === 0) return [];
285
+
286
+ const exportMap = extractExportMap(indexPath);
287
+ const items: IntrinsicAuditItem[] = [];
288
+
289
+ for (const d of declared) {
290
+ const target = exportMap.get(d.name);
291
+
292
+ if (!target) {
293
+ items.push({
294
+ name: d.name,
295
+ declaredIsTag: d.isTag,
296
+ declaredFoldsAsCall: d.foldsAsCall,
297
+ exported: false,
298
+ actualIsTag: undefined,
299
+ ok: false,
300
+ detail: `"${d.name}" is registered in plugin.ts intrinsics() but is not exported from src/index.ts`,
301
+ callFormOk: true,
302
+ });
303
+ continue;
304
+ }
305
+
306
+ const resolved = resolveActualIsTag(indexPath, target);
307
+ if (!resolved.found) {
308
+ items.push({
309
+ name: d.name,
310
+ declaredIsTag: d.isTag,
311
+ declaredFoldsAsCall: d.foldsAsCall,
312
+ exported: true,
313
+ actualIsTag: undefined,
314
+ ok: false,
315
+ detail: `could not locate the declaration of "${d.name}" (exported from ${target.modulePath}) to verify it against isTag`,
316
+ callFormOk: true,
317
+ });
318
+ continue;
319
+ }
320
+
321
+ const declaredTag = d.isTag === true;
322
+ const ok = resolved.isTag === declaredTag;
323
+ // chant #1044 — `foldsAsCall` opts the PLAIN-CALL form in, so it is
324
+ // meaningless (and, if honored, wrong) on a tagged template. Judged
325
+ // against how the export is really authored, not against the sibling
326
+ // `isTag` claim, which may itself be the thing that's wrong.
327
+ const callFormOk = !(d.foldsAsCall === true && resolved.isTag === true);
328
+ items.push({
329
+ name: d.name,
330
+ declaredIsTag: d.isTag,
331
+ declaredFoldsAsCall: d.foldsAsCall,
332
+ exported: true,
333
+ actualIsTag: resolved.isTag,
334
+ ok,
335
+ callFormOk,
336
+ callFormDetail: callFormOk
337
+ ? undefined
338
+ : `"${d.name}" is authored as a tagged template but registered with foldsAsCall: true — the call-form opt-in applies to plain-call intrinsics only`,
339
+ detail: ok
340
+ ? undefined
341
+ : resolved.isTag
342
+ ? `"${d.name}" is authored as a tagged template (first param: TemplateStringsArray) but registered with isTag: ${d.isTag ?? "undefined"}`
343
+ : `"${d.name}" is a plain call/declaration (not a tagged template) but registered with isTag: true`,
344
+ });
345
+ }
346
+
347
+ return items;
348
+ }
@@ -0,0 +1,34 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { join, dirname } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { checkLexicon } from "./check-lexicon";
5
+
6
+ // chant #1067 — check-lexicon.ts had zero tests before this issue, despite
7
+ // being the tool meant to gate every lexicon's completeness. This locks in
8
+ // that checkLexicon() actually wires the new semantic checks (intrinsic
9
+ // foldability audit, example-build audit) into its tier-1 results — not
10
+ // just the pre-existing existence/count checks — against a real,
11
+ // already-built lexicon in this repo.
12
+
13
+ const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "..", "..");
14
+ const gitlabDir = join(repoRoot, "lexicons", "gitlab");
15
+
16
+ describe("checkLexicon", () => {
17
+ test("includes the new #1067 tier-1 checks by name", async () => {
18
+ const result = await checkLexicon(gitlabDir);
19
+ const names = result.items.map((i) => i.name);
20
+ expect(names).toContain("Every shipped example builds");
21
+ expect(names).toContain("Registered intrinsics are exported by the package");
22
+ expect(names).toContain("Registered intrinsics' isTag matches how they're authored");
23
+ expect(names).toContain("dist/manifest.json declares a chantVersion");
24
+ expect(names).toContain('package.json routes exports["."].default at ./src/index.ts and deletes emitted JS in build');
25
+ });
26
+
27
+ test("gitlab's own reference() intrinsic passes both the export and isTag audits", async () => {
28
+ const result = await checkLexicon(gitlabDir);
29
+ const exported = result.items.find((i) => i.name === "Registered intrinsics are exported by the package");
30
+ const matches = result.items.find((i) => i.name === "Registered intrinsics' isTag matches how they're authored");
31
+ expect(exported?.pass).toBe(true);
32
+ expect(matches?.pass).toBe(true);
33
+ });
34
+ });
@@ -1,5 +1,7 @@
1
1
  import { existsSync, readdirSync, readFileSync } from "fs";
2
2
  import { join, basename } from "path";
3
+ import { auditIntrinsics } from "./check-lexicon-intrinsics";
4
+ import { checkExamplesBuild } from "./check-lexicon-examples";
3
5
 
4
6
  // ── Types ────────────────────────────────────────────────────────────
5
7
 
@@ -65,7 +67,7 @@ function countSubdirs(dir: string): number {
65
67
  /**
66
68
  * Run all completeness checks against a lexicon directory.
67
69
  */
68
- export function checkLexicon(dir: string): CheckResult {
70
+ export async function checkLexicon(dir: string): Promise<CheckResult> {
69
71
  const items: CheckItem[] = [];
70
72
 
71
73
  // ── Tier 1: Required ───────────────────────────────────────────
@@ -117,6 +119,70 @@ export function checkLexicon(dir: string): CheckResult {
117
119
  pass: existsSync(join(dir, "dist/manifest.json")),
118
120
  });
119
121
 
122
+ // chant #1067 — chantVersion was "optional, unchecked": a lexicon could
123
+ // declare (or omit) a core-version constraint and nothing failed. This
124
+ // does not validate compatibility against the running core version — that
125
+ // needs a live check at plugin-load time (a different tool: candidates are
126
+ // `loadPlugin`/`loadPlugins` in ../plugins.ts), which is a deliberate
127
+ // non-goal here (see PR description). It closes the narrower gap that the
128
+ // field can be silently absent or malformed in the shipped manifest.
129
+ const manifestJson = readOr(join(dir, "dist/manifest.json"));
130
+ let chantVersionOk = false;
131
+ let chantVersionDetail: string | undefined;
132
+ if (manifestJson) {
133
+ try {
134
+ const parsed = JSON.parse(manifestJson) as { chantVersion?: unknown };
135
+ const cv = parsed.chantVersion;
136
+ chantVersionOk = typeof cv === "string" && /\d/.test(cv);
137
+ chantVersionDetail = chantVersionOk ? `chantVersion: ${cv as string}` : `chantVersion: ${JSON.stringify(cv)}`;
138
+ } catch {
139
+ chantVersionDetail = "dist/manifest.json is not valid JSON";
140
+ }
141
+ } else {
142
+ chantVersionDetail = "dist/manifest.json missing or unreadable";
143
+ }
144
+ items.push({
145
+ name: "dist/manifest.json declares a chantVersion",
146
+ tier: 1,
147
+ pass: chantVersionOk,
148
+ detail: chantVersionDetail,
149
+ });
150
+
151
+ // chant #1067 — packaging shape ("all 11 lexicons route exports['.'].default
152
+ // at ./src/index.ts and delete emitted JS in build") was real but
153
+ // unenforced; a lexicon shipping differently would break consumers
154
+ // silently. Cheap structural check on package.json, not a build execution.
155
+ const packageJsonRaw = readOr(join(dir, "package.json"));
156
+ let packagingOk = false;
157
+ let packagingDetail: string | undefined;
158
+ if (packageJsonRaw) {
159
+ try {
160
+ const pkg = JSON.parse(packageJsonRaw) as {
161
+ exports?: { "."?: { default?: string } };
162
+ scripts?: { build?: string };
163
+ };
164
+ const defaultExport = pkg.exports?.["."]?.default;
165
+ const buildScript = pkg.scripts?.build ?? "";
166
+ const exportOk = defaultExport === "./src/index.ts";
167
+ const deletesEmittedJs = /-delete/.test(buildScript) && /\.js/.test(buildScript);
168
+ packagingOk = exportOk && deletesEmittedJs;
169
+ const problems: string[] = [];
170
+ if (!exportOk) problems.push(`exports["."].default is ${JSON.stringify(defaultExport)}, expected "./src/index.ts"`);
171
+ if (!deletesEmittedJs) problems.push(`build script doesn't delete emitted .js from dist/: ${JSON.stringify(buildScript)}`);
172
+ packagingDetail = problems.length > 0 ? problems.join("; ") : undefined;
173
+ } catch {
174
+ packagingDetail = "package.json is not valid JSON";
175
+ }
176
+ } else {
177
+ packagingDetail = "package.json missing or unreadable";
178
+ }
179
+ items.push({
180
+ name: 'package.json routes exports["."].default at ./src/index.ts and deletes emitted JS in build',
181
+ tier: 1,
182
+ pass: packagingOk,
183
+ detail: packagingDetail,
184
+ });
185
+
120
186
  const exampleCount = countSubdirs(join(dir, "examples"));
121
187
  items.push({
122
188
  name: "At least 1 example in examples/",
@@ -125,6 +191,76 @@ export function checkLexicon(dir: string): CheckResult {
125
191
  detail: exampleCount > 0 ? `${exampleCount} example(s)` : undefined,
126
192
  });
127
193
 
194
+ // chant #1067 — every shipped example must actually build. The prior
195
+ // checks only ever counted example directories; none tried to build one,
196
+ // so `lexicons/aws/examples/core-concepts` shipped with a discovery-time
197
+ // "Duplicate export name" error while this tool reported "All tier-1
198
+ // checks passed." See ./check-lexicon-examples.ts for exactly what
199
+ // "builds" means here (discovery + serialization, not post-synth/lint).
200
+ const exampleBuilds = await checkExamplesBuild(dir);
201
+ const brokenExamples = exampleBuilds.filter((e) => !e.ok);
202
+ items.push({
203
+ name: "Every shipped example builds",
204
+ tier: 1,
205
+ pass: brokenExamples.length === 0,
206
+ detail:
207
+ brokenExamples.length > 0
208
+ ? brokenExamples.map((e) => `${e.example}: ${e.detail}`).join(" | ")
209
+ : exampleBuilds.length > 0
210
+ ? `${exampleBuilds.length} example(s) built`
211
+ : undefined,
212
+ });
213
+
214
+ // chant #1067 — foldability (isTag) is required and validated, not an
215
+ // optional flag nobody checks. #1039 shipped with it wrong in both
216
+ // directions (aws's Sub missing isTag; gitlab's reference() claiming
217
+ // isTag: true for a plain call) and nothing caught either half. See
218
+ // ./check-lexicon-intrinsics.ts for what "exported"/"matches" mean.
219
+ const intrinsicAudit = auditIntrinsics(dir);
220
+ const unexportedIntrinsics = intrinsicAudit.filter((i) => !i.exported);
221
+ items.push({
222
+ name: "Registered intrinsics are exported by the package",
223
+ tier: 1,
224
+ pass: unexportedIntrinsics.length === 0,
225
+ detail:
226
+ unexportedIntrinsics.length > 0
227
+ ? unexportedIntrinsics.map((i) => i.detail).join(" | ")
228
+ : intrinsicAudit.length > 0
229
+ ? `${intrinsicAudit.length} intrinsic(s) checked`
230
+ : undefined,
231
+ });
232
+
233
+ const mismatchedIntrinsics = intrinsicAudit.filter((i) => !i.ok);
234
+ items.push({
235
+ name: "Registered intrinsics' isTag matches how they're authored",
236
+ tier: 1,
237
+ pass: mismatchedIntrinsics.length === 0,
238
+ detail:
239
+ mismatchedIntrinsics.length > 0
240
+ ? mismatchedIntrinsics.map((i) => i.detail).join(" | ")
241
+ : intrinsicAudit.length > 0
242
+ ? `${intrinsicAudit.length} intrinsic(s) checked`
243
+ : undefined,
244
+ });
245
+
246
+ // chant #1044 — the call-form opt-in (`foldsAsCall`) admits a plain call
247
+ // into `fold()`, which has no general CallExpression case. Declaring it on
248
+ // a tagged template claims a form the intrinsic cannot be invoked in, so
249
+ // it fails here rather than being quietly ignored — the same reasoning as
250
+ // the isTag check above.
251
+ const badCallForm = intrinsicAudit.filter((i) => !i.callFormOk);
252
+ items.push({
253
+ name: "Registered intrinsics' foldsAsCall opt-in is only on plain calls",
254
+ tier: 1,
255
+ pass: badCallForm.length === 0,
256
+ detail:
257
+ badCallForm.length > 0
258
+ ? badCallForm.map((i) => i.callFormDetail).join(" | ")
259
+ : intrinsicAudit.length > 0
260
+ ? `${intrinsicAudit.length} intrinsic(s) checked`
261
+ : undefined,
262
+ });
263
+
128
264
  const hasPluginTest = findFiles(join(dir, "src"), (n) => n === "plugin.test.ts").length > 0;
129
265
  items.push({
130
266
  name: "plugin.test.ts exists",
@@ -39,6 +39,26 @@ export function isLintRule(value: unknown): value is LintRule {
39
39
  /**
40
40
  * Load custom lint rules from plugin files.
41
41
  * Each plugin file is dynamically imported and all exports conforming to LintRule are collected.
42
+ *
43
+ * chant #1051 — this (and `loadLocalRules`, `../../lint/rule-loader.ts`, for
44
+ * `.chant/rules/*.ts`) is unconditional project-source `import()` with no
45
+ * `--sandbox` equivalent, the same class of gap #1051 closes for
46
+ * `discoverComponents`. Deliberately NOT sandboxed here, and not a "decide by
47
+ * omission": a `LintRule` is not a one-shot data producer like `Component`/
48
+ * `Declarable` — its `check(context)` is a function the rule engine invokes
49
+ * inline, once per linted file, for the whole `chant lint` run. There is no
50
+ * cheap "collect once, JSON-serialize the result, hand it back" shape the way
51
+ * there is for a `Component` (plain JSON) or an entity (a wire codec) —
52
+ * `check` has to keep running as live JS in whichever process calls it. A
53
+ * shallow fix that sandboxed only the *import* step (mirroring
54
+ * `discoverComponents`) would give a false sense of safety: it would close
55
+ * off "malicious top-level module code" but leave `check()`'s own executable
56
+ * body — the dominant part of a rule's attack surface, since it runs for
57
+ * every file, every lint invocation — entirely unsandboxed regardless. A real
58
+ * fix needs either the AST/`ts.SourceFile` itself to cross the sandbox
59
+ * boundary or the whole custom-rule evaluation to run inside the child; both
60
+ * are materially harder, separate design problems from this issue's `Component`
61
+ * fix and are tracked separately (chant #1052).
42
62
  */
43
63
  export async function loadPluginRules(
44
64
  plugins: string[],
@@ -122,6 +142,13 @@ export interface LintOptions {
122
142
  format: "stylish" | "json" | "sarif";
123
143
  /** Rules to use (defaults to all) */
124
144
  rules?: LintRule[];
145
+ /**
146
+ * chant #1051 — opt-in: discover `*.component.ts` files (for the COMP*
147
+ * composition checks) in a sandboxed child process instead of the CLI's
148
+ * own process (`chant lint --sandbox`). See `discoverComponents`'s
149
+ * `sandbox` option (../../components/discover.ts).
150
+ */
151
+ sandbox?: boolean;
125
152
  }
126
153
 
127
154
  /**
@@ -296,6 +323,7 @@ async function resolveRegistryContext(
296
323
 
297
324
  async function runComponentCheckDiagnostics(
298
325
  infraPath: string,
326
+ sandbox?: boolean,
299
327
  ): Promise<{ diagnostics: LintDiagnostic[]; suppressed: Array<LintDiagnostic & { reason?: string }> }> {
300
328
  // Discovery stays scoped to the lint arg; COMP* severity overrides come from
301
329
  // the project-root config, same as the AST-rule pass.
@@ -304,7 +332,7 @@ async function runComponentCheckDiagnostics(
304
332
  const allCheckIds = new Set(checks.map((c) => c.id));
305
333
 
306
334
  const registryContext = await resolveRegistryContext(infraPath);
307
- const raw = await runComponentChecks(infraPath, checks, registryContext);
335
+ const raw = await runComponentChecks(infraPath, checks, registryContext, sandbox);
308
336
 
309
337
  const fileDirectivesCache = new Map<string, ReturnType<typeof parseDisableComments>>();
310
338
  const fileLevelDisable = (
@@ -439,7 +467,7 @@ export async function lintCommand(options: LintOptions): Promise<LintResult> {
439
467
  // structurally distinct check family (whole-project, post-discovery,
440
468
  // see ../../lint/component-checks.ts) but the same `chant lint` output and
441
469
  // the same error-severity gating as every COR/EVL diagnostic.
442
- const componentResult = await runComponentCheckDiagnostics(infraPath);
470
+ const componentResult = await runComponentCheckDiagnostics(infraPath, options.sandbox);
443
471
  diagnostics.push(...componentResult.diagnostics);
444
472
  suppressed.push(...componentResult.suppressed);
445
473
 
@@ -487,7 +515,7 @@ export async function lintCommand(options: LintOptions): Promise<LintResult> {
487
515
  // `*.component.ts` file on their behalf), but a fix applied to another
488
516
  // rule could still be in the same file a component was discovered from —
489
517
  // re-run for consistency with the AST re-lint above.
490
- const postComponentResult = await runComponentCheckDiagnostics(infraPath);
518
+ const postComponentResult = await runComponentCheckDiagnostics(infraPath, options.sandbox);
491
519
  diagnostics.push(...postComponentResult.diagnostics);
492
520
  suppressed.push(...postComponentResult.suppressed);
493
521
  }
@@ -307,7 +307,7 @@ export async function printOnboardResult(result: OnboardResult, name: string): P
307
307
  console.log("");
308
308
  console.log("Lexicon completeness:");
309
309
  const { checkLexicon, printCheckResult } = await import("./check-lexicon");
310
- const checkResult = checkLexicon(lexiconDir);
310
+ const checkResult = await checkLexicon(lexiconDir);
311
311
  printCheckResult(checkResult, false);
312
312
  }
313
313
  }