@intentius/chant 0.19.1 → 0.20.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 (152) 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 +54 -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/components/cli-support.d.ts +12 -5
  28. package/dist/components/cli-support.d.ts.map +1 -1
  29. package/dist/components/discover.d.ts +62 -7
  30. package/dist/components/discover.d.ts.map +1 -1
  31. package/dist/components/sandbox/driver.d.ts +12 -0
  32. package/dist/components/sandbox/driver.d.ts.map +1 -0
  33. package/dist/components/sandbox/run.d.ts +42 -0
  34. package/dist/components/sandbox/run.d.ts.map +1 -0
  35. package/dist/composite.d.ts +5 -0
  36. package/dist/composite.d.ts.map +1 -1
  37. package/dist/config.d.ts +71 -0
  38. package/dist/config.d.ts.map +1 -1
  39. package/dist/discovery/collect.d.ts.map +1 -1
  40. package/dist/discovery/entity-wire-codec.d.ts +166 -0
  41. package/dist/discovery/entity-wire-codec.d.ts.map +1 -0
  42. package/dist/discovery/entity-wire.d.ts +50 -0
  43. package/dist/discovery/entity-wire.d.ts.map +1 -0
  44. package/dist/discovery/fold-import.d.ts +210 -0
  45. package/dist/discovery/fold-import.d.ts.map +1 -0
  46. package/dist/discovery/index.d.ts +74 -1
  47. package/dist/discovery/index.d.ts.map +1 -1
  48. package/dist/discovery/sandbox/bundle.d.ts +18 -0
  49. package/dist/discovery/sandbox/bundle.d.ts.map +1 -0
  50. package/dist/discovery/sandbox/child-errors.d.ts +15 -0
  51. package/dist/discovery/sandbox/child-errors.d.ts.map +1 -0
  52. package/dist/discovery/sandbox/driver.d.ts +13 -0
  53. package/dist/discovery/sandbox/driver.d.ts.map +1 -0
  54. package/dist/discovery/sandbox/run.d.ts +69 -0
  55. package/dist/discovery/sandbox/run.d.ts.map +1 -0
  56. package/dist/errors.d.ts +9 -1
  57. package/dist/errors.d.ts.map +1 -1
  58. package/dist/fold/fold.d.ts +226 -0
  59. package/dist/fold/fold.d.ts.map +1 -0
  60. package/dist/fold/subset.d.ts +102 -0
  61. package/dist/fold/subset.d.ts.map +1 -0
  62. package/dist/index.d.ts +2 -0
  63. package/dist/index.d.ts.map +1 -1
  64. package/dist/lexicon-output.d.ts +7 -2
  65. package/dist/lexicon-output.d.ts.map +1 -1
  66. package/dist/lexicon-schema.d.ts +2 -2
  67. package/dist/lexicon-schema.d.ts.map +1 -1
  68. package/dist/lexicon.d.ts +81 -1
  69. package/dist/lexicon.d.ts.map +1 -1
  70. package/dist/lifecycle/release-ledger.d.ts +11 -0
  71. package/dist/lifecycle/release-ledger.d.ts.map +1 -1
  72. package/dist/lint/component-checks.d.ts +7 -1
  73. package/dist/lint/component-checks.d.ts.map +1 -1
  74. package/dist/lint/rules/evl001-non-literal-expression.d.ts.map +1 -1
  75. package/dist/lint/rules/evl003-dynamic-property-access.d.ts.map +1 -1
  76. package/dist/params.d.ts +60 -0
  77. package/dist/params.d.ts.map +1 -0
  78. package/dist/provenance.d.ts +21 -0
  79. package/dist/provenance.d.ts.map +1 -1
  80. package/package.json +2 -1
  81. package/src/build-params.test.ts +144 -0
  82. package/src/build-params.ts +207 -0
  83. package/src/build.test.ts +38 -0
  84. package/src/build.ts +144 -7
  85. package/src/cli/commands/build.test.ts +220 -2
  86. package/src/cli/commands/build.ts +111 -3
  87. package/src/cli/commands/check-lexicon-examples.test.ts +92 -0
  88. package/src/cli/commands/check-lexicon-examples.ts +103 -0
  89. package/src/cli/commands/check-lexicon-intrinsics.test.ts +145 -0
  90. package/src/cli/commands/check-lexicon-intrinsics.ts +312 -0
  91. package/src/cli/commands/check-lexicon.test.ts +34 -0
  92. package/src/cli/commands/check-lexicon.ts +119 -1
  93. package/src/cli/commands/lint.ts +31 -3
  94. package/src/cli/commands/onboard.ts +1 -1
  95. package/src/cli/component-security-boundary.test.ts +170 -0
  96. package/src/cli/handlers/build.ts +24 -3
  97. package/src/cli/handlers/components.ts +9 -2
  98. package/src/cli/handlers/dev.ts +1 -1
  99. package/src/cli/handlers/graph.ts +7 -5
  100. package/src/cli/handlers/lifecycle.ts +2 -2
  101. package/src/cli/handlers/lint.ts +2 -0
  102. package/src/cli/handlers/misc.ts +2 -2
  103. package/src/cli/handlers/run-client.ts +1 -1
  104. package/src/cli/handlers/run.ts +20 -5
  105. package/src/cli/main.test.ts +22 -0
  106. package/src/cli/main.ts +39 -0
  107. package/src/cli/plugins.ts +20 -4
  108. package/src/cli/registry.ts +10 -0
  109. package/src/cli/security-boundary.test.ts +135 -0
  110. package/src/codegen/docs-sections.test.ts +61 -0
  111. package/src/codegen/docs-sections.ts +7 -3
  112. package/src/components/cli-support.ts +22 -10
  113. package/src/components/discover.ts +127 -25
  114. package/src/components/sandbox/driver.ts +114 -0
  115. package/src/components/sandbox/run.test.ts +185 -0
  116. package/src/components/sandbox/run.ts +177 -0
  117. package/src/composite.test.ts +21 -0
  118. package/src/composite.ts +20 -1
  119. package/src/config.ts +81 -0
  120. package/src/discovery/collect.ts +17 -3
  121. package/src/discovery/entity-wire-codec.ts +485 -0
  122. package/src/discovery/entity-wire.test.ts +240 -0
  123. package/src/discovery/entity-wire.ts +67 -0
  124. package/src/discovery/fold-import.test.ts +1026 -0
  125. package/src/discovery/fold-import.ts +1805 -0
  126. package/src/discovery/index.test.ts +191 -1
  127. package/src/discovery/index.ts +233 -1
  128. package/src/discovery/sandbox/bundle.ts +218 -0
  129. package/src/discovery/sandbox/child-errors.ts +65 -0
  130. package/src/discovery/sandbox/driver.ts +147 -0
  131. package/src/discovery/sandbox/run.test.ts +179 -0
  132. package/src/discovery/sandbox/run.ts +196 -0
  133. package/src/errors.ts +9 -1
  134. package/src/fold/fold.test.ts +535 -0
  135. package/src/fold/fold.ts +648 -0
  136. package/src/fold/subset-doc-parity.test.ts +183 -0
  137. package/src/fold/subset.test.ts +241 -0
  138. package/src/fold/subset.ts +302 -0
  139. package/src/index.ts +2 -0
  140. package/src/lexicon-output.ts +7 -2
  141. package/src/lexicon-schema.test.ts +14 -0
  142. package/src/lexicon-schema.ts +3 -1
  143. package/src/lexicon.ts +82 -1
  144. package/src/lifecycle/git.test.ts +10 -5
  145. package/src/lifecycle/release-ledger.test.ts +28 -0
  146. package/src/lifecycle/release-ledger.ts +11 -0
  147. package/src/lint/component-checks.ts +8 -1
  148. package/src/lint/rules/evl001-non-literal-expression.ts +26 -110
  149. package/src/lint/rules/evl003-dynamic-property-access.ts +11 -2
  150. package/src/params.test.ts +22 -0
  151. package/src/params.ts +66 -0
  152. package/src/provenance.ts +22 -0
@@ -0,0 +1,103 @@
1
+ /**
2
+ * "Every shipped example builds" (chant #1067).
3
+ *
4
+ * `check-lexicon.ts`'s existing example checks only count directories
5
+ * ("At least 1 example", "At least 3 examples", "At least 5 examples with
6
+ * tests") — nothing ever tries to build one. `lexicons/aws/examples/core-concepts`
7
+ * shipped with a discovery-time error (two files independently exporting a
8
+ * top-level `dataBucket`) and `chant dev check-lexicon lexicons/aws` still
9
+ * reported "All tier-1 checks passed," because none of its 29 checks touch
10
+ * build output. This module closes that gap directly: for every non-empty
11
+ * `examples/<name>/src/` directory, run the same discover-and-serialize
12
+ * pipeline `chant build` runs and report whether it produced output with no
13
+ * structural error.
14
+ *
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.
28
+ */
29
+
30
+ import { existsSync, readdirSync } from "fs";
31
+ import { join } from "path";
32
+ import { build } from "../../build";
33
+ import { findInfraFiles } from "../../discovery/files";
34
+ import { detectLexicons } from "../../detectLexicon";
35
+ import { loadPlugins } from "../plugins";
36
+
37
+ export interface ExampleBuildResult {
38
+ example: string;
39
+ ok: boolean;
40
+ detail?: string;
41
+ }
42
+
43
+ /**
44
+ * Build every non-empty example under `<lexiconDir>/examples/*\/src` and
45
+ * report per-example pass/fail. Returns `[]` when the lexicon has no
46
+ * `examples/` directory at all (a separate tier-1 check already covers
47
+ * that).
48
+ */
49
+ export async function checkExamplesBuild(lexiconDir: string): Promise<ExampleBuildResult[]> {
50
+ const examplesDir = join(lexiconDir, "examples");
51
+ if (!existsSync(examplesDir)) return [];
52
+
53
+ const results: ExampleBuildResult[] = [];
54
+ const entries = readdirSync(examplesDir, { withFileTypes: true }).filter((e) => e.isDirectory());
55
+
56
+ for (const entry of entries) {
57
+ const srcDir = join(examplesDir, entry.name, "src");
58
+ if (!existsSync(srcDir)) continue;
59
+
60
+ const contents = readdirSync(srcDir);
61
+ if (contents.length === 0 || (contents.length === 1 && contents[0] === ".gitkeep")) continue;
62
+
63
+ try {
64
+ const files = await findInfraFiles(srcDir);
65
+ if (files.length === 0) continue;
66
+
67
+ const lexiconNames = await detectLexicons(files);
68
+ if (lexiconNames.length === 0) {
69
+ results.push({
70
+ example: entry.name,
71
+ ok: false,
72
+ detail: "no lexicon imports detected in src/ — cannot determine which plugin(s) to build with",
73
+ });
74
+ continue;
75
+ }
76
+
77
+ const plugins = await loadPlugins(lexiconNames);
78
+ const result = await build(srcDir, plugins.map((p) => p.serializer));
79
+
80
+ const structuralErrors = result.errors.map((e) => e.message);
81
+ const producedOutput = result.outputs.size > 0;
82
+ const ok = structuralErrors.length === 0 && producedOutput;
83
+
84
+ results.push({
85
+ example: entry.name,
86
+ ok,
87
+ detail: ok
88
+ ? undefined
89
+ : structuralErrors.length > 0
90
+ ? structuralErrors.join("; ")
91
+ : "discovered source but produced no output",
92
+ });
93
+ } catch (err) {
94
+ results.push({
95
+ example: entry.name,
96
+ ok: false,
97
+ detail: err instanceof Error ? err.message : String(err),
98
+ });
99
+ }
100
+ }
101
+
102
+ return results;
103
+ }
@@ -0,0 +1,145 @@
1
+ import { describe, test, expect, beforeAll, afterAll } from "vitest";
2
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { auditIntrinsics } from "./check-lexicon-intrinsics";
6
+
7
+ /**
8
+ * chant #1067 — proof that the intrinsic-foldability audit actually
9
+ * validates, rather than just counting. Each case here is a small,
10
+ * synthetic lexicon (plugin.ts + index.ts + intrinsics.ts) so the
11
+ * fixtures encode the exact failure shapes #1039 shipped: a genuine
12
+ * tagged template registered with the wrong `isTag`, and a plain call
13
+ * registered with the wrong `isTag`, in both directions — plus a
14
+ * registration naming something the package never exports.
15
+ */
16
+
17
+ function writeFixture(dir: string): void {
18
+ mkdirSync(join(dir, "src"), { recursive: true });
19
+
20
+ writeFileSync(
21
+ join(dir, "src/plugin.ts"),
22
+ `export const testPlugin = {
23
+ name: "test",
24
+ intrinsics() {
25
+ return [
26
+ { name: "Tag1", description: "a genuine tagged template", isTag: true },
27
+ { name: "Plain1", description: "a genuine plain call", isTag: false },
28
+ { name: "Mismatch1", description: "claims tag but is a plain call", isTag: true },
29
+ { name: "Mismatch2", description: "claims plain but is a tagged template", isTag: false },
30
+ { name: "Missing1", description: "registered but never exported", isTag: false },
31
+ ];
32
+ },
33
+ };
34
+ `,
35
+ );
36
+
37
+ writeFileSync(
38
+ join(dir, "src/index.ts"),
39
+ `export { Tag1, Plain1, Mismatch1, Mismatch2 } from "./intrinsics";
40
+ `,
41
+ );
42
+
43
+ writeFileSync(
44
+ join(dir, "src/intrinsics.ts"),
45
+ `export function Tag1(strings: TemplateStringsArray, ...values: unknown[]): string {
46
+ return strings.join("");
47
+ }
48
+
49
+ export function Plain1(x: string): string {
50
+ return x;
51
+ }
52
+
53
+ // Registered as isTag: true above — this is the #1039 "aws Sub" shape,
54
+ // inverted: a plain call wrongly claimed as a tag.
55
+ export function Mismatch1(x: string): string {
56
+ return x;
57
+ }
58
+
59
+ // Registered as isTag: false above — this is the #1039 "gitlab reference()"
60
+ // shape: a genuine tagged template wrongly claimed as a plain call.
61
+ export function Mismatch2(strings: TemplateStringsArray, ...values: unknown[]): string {
62
+ return strings.join("");
63
+ }
64
+ `,
65
+ );
66
+ }
67
+
68
+ describe("auditIntrinsics", () => {
69
+ let dir: string;
70
+
71
+ beforeAll(() => {
72
+ dir = mkdtempSync(join(tmpdir(), "chant-check-lexicon-intrinsics-"));
73
+ writeFixture(dir);
74
+ });
75
+ afterAll(() => rmSync(dir, { recursive: true, force: true }));
76
+
77
+ test("a genuine tagged template registered as isTag: true passes", () => {
78
+ const items = auditIntrinsics(dir);
79
+ const item = items.find((i) => i.name === "Tag1");
80
+ expect(item).toMatchObject({ exported: true, actualIsTag: true, ok: true });
81
+ });
82
+
83
+ test("a genuine plain call registered as isTag: false passes", () => {
84
+ const items = auditIntrinsics(dir);
85
+ const item = items.find((i) => i.name === "Plain1");
86
+ expect(item).toMatchObject({ exported: true, actualIsTag: false, ok: true });
87
+ });
88
+
89
+ test("a plain call wrongly registered as isTag: true fails (aws Sub shape, inverted)", () => {
90
+ const items = auditIntrinsics(dir);
91
+ const item = items.find((i) => i.name === "Mismatch1");
92
+ expect(item?.ok).toBe(false);
93
+ expect(item?.exported).toBe(true);
94
+ expect(item?.actualIsTag).toBe(false);
95
+ expect(item?.detail).toMatch(/plain call.*but registered with isTag: true/);
96
+ });
97
+
98
+ test("a tagged template wrongly registered as isTag: false fails (gitlab reference() shape)", () => {
99
+ const items = auditIntrinsics(dir);
100
+ const item = items.find((i) => i.name === "Mismatch2");
101
+ expect(item?.ok).toBe(false);
102
+ expect(item?.exported).toBe(true);
103
+ expect(item?.actualIsTag).toBe(true);
104
+ expect(item?.detail).toMatch(/authored as a tagged template.*isTag: false/);
105
+ });
106
+
107
+ test("a registered intrinsic that isn't exported fails, distinctly from a signature mismatch", () => {
108
+ const items = auditIntrinsics(dir);
109
+ const item = items.find((i) => i.name === "Missing1");
110
+ expect(item?.exported).toBe(false);
111
+ expect(item?.ok).toBe(false);
112
+ expect(item?.detail).toMatch(/not exported from src\/index\.ts/);
113
+ });
114
+
115
+ test("returns [] for a lexicon with no intrinsics() method", () => {
116
+ const emptyDir = mkdtempSync(join(tmpdir(), "chant-check-lexicon-intrinsics-empty-"));
117
+ try {
118
+ mkdirSync(join(emptyDir, "src"), { recursive: true });
119
+ writeFileSync(join(emptyDir, "src/plugin.ts"), `export const testPlugin = { name: "test" };\n`);
120
+ expect(auditIntrinsics(emptyDir)).toEqual([]);
121
+ } finally {
122
+ rmSync(emptyDir, { recursive: true, force: true });
123
+ }
124
+ });
125
+
126
+ test("returns [] for a lexicon whose intrinsics() legitimately registers none (gcp/k8s shape)", () => {
127
+ const emptyArrayDir = mkdtempSync(join(tmpdir(), "chant-check-lexicon-intrinsics-emptyarray-"));
128
+ try {
129
+ mkdirSync(join(emptyArrayDir, "src"), { recursive: true });
130
+ writeFileSync(
131
+ join(emptyArrayDir, "src/plugin.ts"),
132
+ `export const testPlugin = {
133
+ name: "test",
134
+ intrinsics() {
135
+ return [];
136
+ },
137
+ };
138
+ `,
139
+ );
140
+ expect(auditIntrinsics(emptyArrayDir)).toEqual([]);
141
+ } finally {
142
+ rmSync(emptyArrayDir, { recursive: true, force: true });
143
+ }
144
+ });
145
+ });
@@ -0,0 +1,312 @@
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
+
29
+ import { existsSync, readFileSync } from "fs";
30
+ import { join, dirname } from "path";
31
+ import * as ts from "typescript";
32
+
33
+ // ── Types ────────────────────────────────────────────────────────────
34
+
35
+ export interface IntrinsicAuditItem {
36
+ name: string;
37
+ /** The `isTag` value as registered in `src/plugin.ts`'s `intrinsics()`. */
38
+ declaredIsTag: boolean | undefined;
39
+ /** Whether `name` resolves to a real export of `src/index.ts`. */
40
+ exported: boolean;
41
+ /**
42
+ * Whether the resolved declaration is genuinely authored as a tagged
43
+ * template (first parameter typed `TemplateStringsArray`). `undefined`
44
+ * when the declaration couldn't be located or its shape can't be
45
+ * determined statically — treated as a failure, not a pass, since a
46
+ * foldability claim that can't be verified is exactly the unvalidated
47
+ * state #1067 is closing.
48
+ */
49
+ actualIsTag: boolean | undefined;
50
+ /** True only when exported AND the authored shape matches `declaredIsTag`. */
51
+ ok: boolean;
52
+ detail?: string;
53
+ }
54
+
55
+ interface DeclaredIntrinsic {
56
+ name: string;
57
+ isTag: boolean | undefined;
58
+ }
59
+
60
+ interface ExportTarget {
61
+ /** Relative module specifier as written, e.g. "./intrinsics". */
62
+ modulePath: string;
63
+ /** The name as declared in the target module (post `as` rename resolution). */
64
+ localName: string;
65
+ }
66
+
67
+ // ── Parsing helpers ──────────────────────────────────────────────────
68
+
69
+ function parseFile(path: string): ts.SourceFile | undefined {
70
+ if (!existsSync(path)) return undefined;
71
+ let text: string;
72
+ try {
73
+ text = readFileSync(path, "utf-8");
74
+ } catch {
75
+ return undefined;
76
+ }
77
+ return ts.createSourceFile(path, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
78
+ }
79
+
80
+ /**
81
+ * Find `intrinsics(): IntrinsicDef[] { return [ ... ]; }` (or the arrow-
82
+ * function-property equivalent) in a plugin.ts source file and extract each
83
+ * registered `{ name, isTag }` pair from the array literal. Returns
84
+ * `undefined` when no `intrinsics` method exists at all (lexicons with no
85
+ * intrinsics, e.g. gcp/k8s, still return `[]` — that's a real empty array,
86
+ * not `undefined`).
87
+ */
88
+ function extractDeclaredIntrinsics(pluginPath: string): DeclaredIntrinsic[] | undefined {
89
+ const sf = parseFile(pluginPath);
90
+ if (!sf) return undefined;
91
+
92
+ let arrayLiteral: ts.ArrayLiteralExpression | undefined;
93
+
94
+ const findReturnedArray = (body: ts.Block): ts.ArrayLiteralExpression | undefined => {
95
+ for (const stmt of body.statements) {
96
+ if (ts.isReturnStatement(stmt) && stmt.expression && ts.isArrayLiteralExpression(stmt.expression)) {
97
+ return stmt.expression;
98
+ }
99
+ }
100
+ return undefined;
101
+ };
102
+
103
+ const visit = (node: ts.Node): void => {
104
+ if (arrayLiteral) return;
105
+
106
+ if (ts.isMethodDeclaration(node) && node.name && ts.isIdentifier(node.name) && node.name.text === "intrinsics" && node.body) {
107
+ arrayLiteral = findReturnedArray(node.body);
108
+ } else if (ts.isPropertyAssignment(node) && ts.isIdentifier(node.name) && node.name.text === "intrinsics") {
109
+ const init = node.initializer;
110
+ if (ts.isArrowFunction(init) || ts.isFunctionExpression(init)) {
111
+ if (init.body && ts.isBlock(init.body)) {
112
+ arrayLiteral = findReturnedArray(init.body);
113
+ } else if (init.body && ts.isArrayLiteralExpression(init.body)) {
114
+ arrayLiteral = init.body;
115
+ }
116
+ }
117
+ }
118
+
119
+ if (!arrayLiteral) ts.forEachChild(node, visit);
120
+ };
121
+ visit(sf);
122
+
123
+ if (!arrayLiteral) return undefined;
124
+
125
+ const result: DeclaredIntrinsic[] = [];
126
+ for (const el of arrayLiteral.elements) {
127
+ if (!ts.isObjectLiteralExpression(el)) continue;
128
+ let name: string | undefined;
129
+ let isTag: boolean | undefined;
130
+
131
+ for (const prop of el.properties) {
132
+ if (!ts.isPropertyAssignment(prop) || !ts.isIdentifier(prop.name)) continue;
133
+ const key = prop.name.text;
134
+ if (key === "name" && ts.isStringLiteralLike(prop.initializer)) {
135
+ name = prop.initializer.text;
136
+ } else if (key === "isTag") {
137
+ if (prop.initializer.kind === ts.SyntaxKind.TrueKeyword) isTag = true;
138
+ else if (prop.initializer.kind === ts.SyntaxKind.FalseKeyword) isTag = false;
139
+ }
140
+ }
141
+
142
+ if (name !== undefined) result.push({ name, isTag });
143
+ }
144
+
145
+ return result;
146
+ }
147
+
148
+ /**
149
+ * Build a map of publicly-exported name -> where it's really declared, by
150
+ * parsing `src/index.ts`'s top-level `export { a, b as c } from "./file"`
151
+ * statements (every lexicon's intrinsics are re-exported this way) plus
152
+ * direct top-level exported declarations in index.ts itself (`modulePath:
153
+ * "."`). Wildcard re-exports (`export * from "./generated"`) are not
154
+ * expanded — none of the 8 lexicons that register intrinsics export them
155
+ * that way, so this is a deliberate, documented gap rather than a silent one.
156
+ */
157
+ function extractExportMap(indexPath: string): Map<string, ExportTarget> {
158
+ const map = new Map<string, ExportTarget>();
159
+ const sf = parseFile(indexPath);
160
+ if (!sf) return map;
161
+
162
+ const hasExportModifier = (node: ts.Node): boolean =>
163
+ !!ts.canHaveModifiers(node) && (ts.getModifiers(node) ?? []).some((m) => m.kind === ts.SyntaxKind.ExportKeyword);
164
+
165
+ for (const stmt of sf.statements) {
166
+ if (ts.isExportDeclaration(stmt) && stmt.moduleSpecifier && ts.isStringLiteralLike(stmt.moduleSpecifier)) {
167
+ const modulePath = stmt.moduleSpecifier.text;
168
+ if (stmt.exportClause && ts.isNamedExports(stmt.exportClause)) {
169
+ for (const spec of stmt.exportClause.elements) {
170
+ const exportedName = spec.name.text;
171
+ const localName = spec.propertyName ? spec.propertyName.text : exportedName;
172
+ map.set(exportedName, { modulePath, localName });
173
+ }
174
+ }
175
+ continue;
176
+ }
177
+
178
+ if (ts.isFunctionDeclaration(stmt) && stmt.name && hasExportModifier(stmt)) {
179
+ map.set(stmt.name.text, { modulePath: ".", localName: stmt.name.text });
180
+ } else if (ts.isClassDeclaration(stmt) && stmt.name && hasExportModifier(stmt)) {
181
+ map.set(stmt.name.text, { modulePath: ".", localName: stmt.name.text });
182
+ } else if (ts.isVariableStatement(stmt) && hasExportModifier(stmt)) {
183
+ for (const decl of stmt.declarationList.declarations) {
184
+ if (ts.isIdentifier(decl.name)) {
185
+ map.set(decl.name.text, { modulePath: ".", localName: decl.name.text });
186
+ }
187
+ }
188
+ }
189
+ }
190
+
191
+ return map;
192
+ }
193
+
194
+ /** True when a parameter list's first entry is typed `TemplateStringsArray`. */
195
+ function firstParamIsTemplateStringsArray(params: ts.NodeArray<ts.ParameterDeclaration>): boolean {
196
+ const first = params[0];
197
+ if (!first?.type || !ts.isTypeReferenceNode(first.type)) return false;
198
+ const typeName = first.type.typeName;
199
+ const text = ts.isIdentifier(typeName) ? typeName.text : typeName.right.text;
200
+ return text === "TemplateStringsArray";
201
+ }
202
+
203
+ /**
204
+ * Resolve whether the declaration named `target.localName` in the module
205
+ * `target.modulePath` (relative to `indexPath`) is authored as a tagged
206
+ * template. Returns `{ found: false }` when the declaration can't be
207
+ * located — a function declared but never found is treated as unverifiable,
208
+ * not as a pass.
209
+ */
210
+ function resolveActualIsTag(
211
+ indexPath: string,
212
+ target: ExportTarget,
213
+ ): { found: boolean; isTag?: boolean } {
214
+ const filePath =
215
+ target.modulePath === "."
216
+ ? indexPath
217
+ : join(dirname(indexPath), `${target.modulePath.replace(/^\.\//, "")}.ts`);
218
+
219
+ const sf = parseFile(filePath);
220
+ if (!sf) return { found: false };
221
+
222
+ let result: { found: boolean; isTag?: boolean } | undefined;
223
+
224
+ for (const stmt of sf.statements) {
225
+ if (result) break;
226
+
227
+ if (ts.isFunctionDeclaration(stmt) && stmt.name?.text === target.localName) {
228
+ result = { found: true, isTag: firstParamIsTemplateStringsArray(stmt.parameters) };
229
+ } else if (ts.isClassDeclaration(stmt) && stmt.name?.text === target.localName) {
230
+ // A class is invoked with `new X(...)`, never as a tagged template.
231
+ result = { found: true, isTag: false };
232
+ } else if (ts.isVariableStatement(stmt)) {
233
+ for (const decl of stmt.declarationList.declarations) {
234
+ if (!ts.isIdentifier(decl.name) || decl.name.text !== target.localName) continue;
235
+ const init = decl.initializer;
236
+ if (init && (ts.isArrowFunction(init) || ts.isFunctionExpression(init))) {
237
+ result = { found: true, isTag: firstParamIsTemplateStringsArray(init.parameters) };
238
+ } else {
239
+ // A const object, proxy, `new X()`, etc. — not callable as a tag.
240
+ result = { found: true, isTag: false };
241
+ }
242
+ }
243
+ }
244
+ }
245
+
246
+ return result ?? { found: false };
247
+ }
248
+
249
+ // ── Public entry point ───────────────────────────────────────────────
250
+
251
+ /**
252
+ * Audit every intrinsic a lexicon registers in `src/plugin.ts` against
253
+ * `src/index.ts`'s real export surface and each export's authored shape.
254
+ * Returns `[]` for a lexicon with no `intrinsics()` method, or one that
255
+ * registers none — nothing to audit either way.
256
+ */
257
+ export function auditIntrinsics(lexiconDir: string): IntrinsicAuditItem[] {
258
+ const pluginPath = join(lexiconDir, "src/plugin.ts");
259
+ const indexPath = join(lexiconDir, "src/index.ts");
260
+
261
+ const declared = extractDeclaredIntrinsics(pluginPath);
262
+ if (!declared || declared.length === 0) return [];
263
+
264
+ const exportMap = extractExportMap(indexPath);
265
+ const items: IntrinsicAuditItem[] = [];
266
+
267
+ for (const d of declared) {
268
+ const target = exportMap.get(d.name);
269
+
270
+ if (!target) {
271
+ items.push({
272
+ name: d.name,
273
+ declaredIsTag: d.isTag,
274
+ exported: false,
275
+ actualIsTag: undefined,
276
+ ok: false,
277
+ detail: `"${d.name}" is registered in plugin.ts intrinsics() but is not exported from src/index.ts`,
278
+ });
279
+ continue;
280
+ }
281
+
282
+ const resolved = resolveActualIsTag(indexPath, target);
283
+ if (!resolved.found) {
284
+ items.push({
285
+ name: d.name,
286
+ declaredIsTag: d.isTag,
287
+ exported: true,
288
+ actualIsTag: undefined,
289
+ ok: false,
290
+ detail: `could not locate the declaration of "${d.name}" (exported from ${target.modulePath}) to verify it against isTag`,
291
+ });
292
+ continue;
293
+ }
294
+
295
+ const declaredTag = d.isTag === true;
296
+ const ok = resolved.isTag === declaredTag;
297
+ items.push({
298
+ name: d.name,
299
+ declaredIsTag: d.isTag,
300
+ exported: true,
301
+ actualIsTag: resolved.isTag,
302
+ ok,
303
+ detail: ok
304
+ ? undefined
305
+ : resolved.isTag
306
+ ? `"${d.name}" is authored as a tagged template (first param: TemplateStringsArray) but registered with isTag: ${d.isTag ?? "undefined"}`
307
+ : `"${d.name}" is a plain call/declaration (not a tagged template) but registered with isTag: true`,
308
+ });
309
+ }
310
+
311
+ return items;
312
+ }
@@ -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
+ });