@intentius/chant 0.0.12 → 0.0.13

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 (31) hide show
  1. package/package.json +1 -1
  2. package/src/cli/commands/__fixtures__/init-lexicon-output/docs/astro.config.mjs +3 -0
  3. package/src/cli/commands/__fixtures__/init-lexicon-output/docs/src/content/docs/getting-started.mdx +6 -0
  4. package/src/cli/commands/__fixtures__/init-lexicon-output/docs/src/content/docs/lint-rules.mdx +6 -0
  5. package/src/cli/commands/__fixtures__/init-lexicon-output/docs/src/content/docs/serialization.mdx +6 -0
  6. package/src/cli/commands/__fixtures__/init-lexicon-output/examples/getting-started/package.json +9 -0
  7. package/src/cli/commands/__fixtures__/init-lexicon-output/examples/getting-started/src/infra.ts +12 -0
  8. package/src/cli/commands/__fixtures__/init-lexicon-output/src/composites/.gitkeep +0 -0
  9. package/src/cli/commands/__fixtures__/init-lexicon-output/src/lint/post-synth/.gitkeep +0 -0
  10. package/src/cli/commands/__fixtures__/init-lexicon-output/src/plugin.ts +37 -42
  11. package/src/cli/commands/__snapshots__/init-lexicon.test.ts.snap +37 -42
  12. package/src/cli/commands/build.ts +12 -7
  13. package/src/cli/commands/check-lexicon.ts +385 -0
  14. package/src/cli/commands/import.ts +6 -3
  15. package/src/cli/commands/init-lexicon.test.ts +22 -1
  16. package/src/cli/commands/init-lexicon.ts +194 -43
  17. package/src/cli/commands/init.ts +3 -3
  18. package/src/cli/commands/onboard.test.ts +295 -0
  19. package/src/cli/commands/onboard.ts +313 -0
  20. package/src/cli/handlers/dev.ts +26 -1
  21. package/src/cli/main.ts +5 -1
  22. package/src/codegen/docs.ts +11 -5
  23. package/src/codegen/generate-registry.ts +3 -2
  24. package/src/codegen/typecheck.ts +44 -2
  25. package/src/detectLexicon.test.ts +24 -0
  26. package/src/detectLexicon.ts +4 -2
  27. package/src/runtime.ts +4 -0
  28. package/src/serializer-walker.test.ts +8 -0
  29. package/src/toml.test.ts +388 -0
  30. package/src/toml.ts +606 -0
  31. /package/src/cli/commands/__fixtures__/init-lexicon-output/{examples/getting-started → src/actions}/.gitkeep +0 -0
@@ -0,0 +1,313 @@
1
+ import { readFileSync, writeFileSync, existsSync } from "fs";
2
+ import { join, dirname } from "path";
3
+ import { fileURLToPath } from "url";
4
+ import { formatSuccess, formatError, formatWarning } from "../format";
5
+
6
+ export interface OnboardOptions {
7
+ name: string;
8
+ verbose?: boolean;
9
+ }
10
+
11
+ export interface OnboardResult {
12
+ success: boolean;
13
+ patched: string[];
14
+ skipped: string[];
15
+ error?: string;
16
+ }
17
+
18
+ /** Resolve the monorepo root (5 dirs up from packages/core/src/cli/commands/). */
19
+ function findRepoRoot(): string {
20
+ const here = dirname(fileURLToPath(import.meta.url)); // commands/
21
+ return dirname(dirname(dirname(dirname(dirname(here))))); // -> root
22
+ }
23
+
24
+ /**
25
+ * Patch root package.json to add a workspace dependency for the lexicon.
26
+ */
27
+ function patchRootPackageJson(root: string, name: string): { patched: boolean; reason?: string } {
28
+ const pkgPath = join(root, "package.json");
29
+ if (!existsSync(pkgPath)) return { patched: false, reason: "root package.json not found" };
30
+
31
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
32
+ const depKey = `@intentius/chant-lexicon-${name}`;
33
+
34
+ if (pkg.dependencies?.[depKey]) {
35
+ return { patched: false, reason: `${depKey} already in dependencies` };
36
+ }
37
+
38
+ pkg.dependencies = pkg.dependencies ?? {};
39
+ pkg.dependencies[depKey] = "workspace:*";
40
+
41
+ // Sort dependencies for consistency
42
+ const sorted: Record<string, string> = {};
43
+ for (const k of Object.keys(pkg.dependencies).sort()) {
44
+ sorted[k] = pkg.dependencies[k];
45
+ }
46
+ pkg.dependencies = sorted;
47
+
48
+ writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
49
+ return { patched: true };
50
+ }
51
+
52
+ /**
53
+ * Insert a new prepack line after the last prepack line in each contiguous group
54
+ * of 2+ lines. Single standalone lines (like YAML `run:` values) are ignored.
55
+ * Used for multi-line `run: |` blocks in workflows and Dockerfile RUN sequences.
56
+ */
57
+ function insertPrepackInContiguousGroups(lines: string[], name: string): boolean {
58
+ const newFragment = `lexicons/${name} prepack`;
59
+ if (lines.some((l) => l.includes(newFragment))) return false;
60
+
61
+ // Identify contiguous groups of prepack lines
62
+ const groups: { start: number; end: number }[] = [];
63
+ let groupStart = -1;
64
+ for (let i = 0; i <= lines.length; i++) {
65
+ const isPrepack =
66
+ i < lines.length &&
67
+ lines[i].includes("bun run --cwd lexicons/") &&
68
+ lines[i].includes("prepack");
69
+ if (isPrepack && groupStart === -1) {
70
+ groupStart = i;
71
+ } else if (!isPrepack && groupStart !== -1) {
72
+ groups.push({ start: groupStart, end: i - 1 });
73
+ groupStart = -1;
74
+ }
75
+ }
76
+
77
+ // Only insert into groups of 2+ lines (multi-line blocks, not standalone steps)
78
+ const insertAfter = groups.filter((g) => g.end > g.start).map((g) => g.end);
79
+
80
+ if (insertAfter.length === 0) return false;
81
+
82
+ for (const idx of insertAfter.reverse()) {
83
+ const newLine = lines[idx].replace(/lexicons\/[a-z0-9-]+/i, `lexicons/${name}`);
84
+ lines.splice(idx + 1, 0, newLine);
85
+ }
86
+
87
+ return true;
88
+ }
89
+
90
+ /**
91
+ * Insert a new prepack line after every last-in-group prepack line (groups of 1+).
92
+ * Used for Dockerfiles and publish.yml where all occurrences should get a new line.
93
+ */
94
+ function insertPrepackAfterEach(lines: string[], name: string): boolean {
95
+ const newFragment = `lexicons/${name} prepack`;
96
+ if (lines.some((l) => l.includes(newFragment))) return false;
97
+
98
+ const insertAfter: number[] = [];
99
+ for (let i = 0; i < lines.length; i++) {
100
+ if (!lines[i].includes("bun run --cwd lexicons/") || !lines[i].includes("prepack")) continue;
101
+ const nextIsAlsoPrepack =
102
+ i + 1 < lines.length &&
103
+ lines[i + 1].includes("bun run --cwd lexicons/") &&
104
+ lines[i + 1].includes("prepack");
105
+ if (!nextIsAlsoPrepack) {
106
+ insertAfter.push(i);
107
+ }
108
+ }
109
+
110
+ if (insertAfter.length === 0) return false;
111
+
112
+ for (const idx of insertAfter.reverse()) {
113
+ const newLine = lines[idx].replace(/lexicons\/[a-z0-9-]+/i, `lexicons/${name}`);
114
+ lines.splice(idx + 1, 0, newLine);
115
+ }
116
+
117
+ return true;
118
+ }
119
+
120
+ /**
121
+ * Patch chant.yml: add prepack lines in check/test multi-line `run: |` blocks,
122
+ * and add a new validate step.
123
+ *
124
+ * The file has two patterns:
125
+ * 1. Multi-line blocks (check + test jobs): `run: |\n bun run --cwd lexicons/aws prepack\n ...`
126
+ * 2. Standalone steps (validate job): `- name: Generate and validate ...\n run: bun run --cwd ...`
127
+ *
128
+ * We only insert into pattern 1 (contiguous groups) and separately add a new pattern 2 step.
129
+ */
130
+ function patchCiWorkflow(root: string, name: string): { patched: boolean; reason?: string } {
131
+ const filePath = join(root, ".github/workflows/chant.yml");
132
+ if (!existsSync(filePath)) return { patched: false, reason: "chant.yml not found" };
133
+
134
+ const content = readFileSync(filePath, "utf-8");
135
+ if (content.includes(`lexicons/${name} prepack`)) {
136
+ return { patched: false, reason: `${name} already in chant.yml` };
137
+ }
138
+
139
+ const lines = content.split("\n");
140
+
141
+ // 1. Insert into multi-line `run: |` blocks only (contiguous prepack groups of 3+).
142
+ // The validate job has standalone steps that are NOT contiguous, so they won't match.
143
+ insertPrepackInContiguousGroups(lines, name);
144
+
145
+ // 2. Add a named validate step after the last "Generate and validate" step
146
+ const displayName = name.charAt(0).toUpperCase() + name.slice(1);
147
+ const validateStepName = `Generate and validate ${displayName} lexicon`;
148
+
149
+ if (!lines.some((l) => l.includes(validateStepName))) {
150
+ let lastValidateRunIdx = -1;
151
+ for (let i = 0; i < lines.length; i++) {
152
+ if (lines[i].includes("Generate and validate")) {
153
+ if (i + 1 < lines.length && lines[i + 1].trimStart().startsWith("run:")) {
154
+ lastValidateRunIdx = i + 1;
155
+ }
156
+ }
157
+ }
158
+
159
+ if (lastValidateRunIdx > 0) {
160
+ const block = [
161
+ "",
162
+ ` - name: ${validateStepName}`,
163
+ ` run: bun run --cwd lexicons/${name} prepack`,
164
+ ];
165
+ lines.splice(lastValidateRunIdx + 1, 0, ...block);
166
+ }
167
+ }
168
+
169
+ writeFileSync(filePath, lines.join("\n"));
170
+ return { patched: true };
171
+ }
172
+
173
+ /**
174
+ * Patch publish.yml: add prepack line in test job + publish step.
175
+ */
176
+ function patchPublishWorkflow(root: string, name: string): { patched: boolean; reason?: string } {
177
+ const filePath = join(root, ".github/workflows/publish.yml");
178
+ if (!existsSync(filePath)) return { patched: false, reason: "publish.yml not found" };
179
+
180
+ const content = readFileSync(filePath, "utf-8");
181
+ if (content.includes(`working-directory: lexicons/${name}`)) {
182
+ return { patched: false, reason: `publish step for ${name} already present` };
183
+ }
184
+
185
+ const lines = content.split("\n");
186
+
187
+ // Insert prepack line in test job
188
+ insertPrepackAfterEach(lines, name);
189
+
190
+ // Add publish step after the last existing publish step
191
+ let lastPublishRunIdx = -1;
192
+ for (let i = 0; i < lines.length; i++) {
193
+ if (lines[i].includes("bun publish --access public --tolerate-republish")) {
194
+ lastPublishRunIdx = i;
195
+ }
196
+ }
197
+
198
+ if (lastPublishRunIdx > 0) {
199
+ const block = [
200
+ "",
201
+ ` - name: Publish @intentius/chant-lexicon-${name}`,
202
+ ` working-directory: lexicons/${name}`,
203
+ " run: bun publish --access public --tolerate-republish",
204
+ ];
205
+ lines.splice(lastPublishRunIdx + 1, 0, ...block);
206
+ }
207
+
208
+ writeFileSync(filePath, lines.join("\n"));
209
+ return { patched: true };
210
+ }
211
+
212
+ /**
213
+ * Patch a Dockerfile to add a prepack RUN line.
214
+ */
215
+ function patchDockerfile(filePath: string, name: string): { patched: boolean; reason?: string } {
216
+ if (!existsSync(filePath)) return { patched: false, reason: `${filePath} not found` };
217
+
218
+ const content = readFileSync(filePath, "utf-8");
219
+ if (content.includes(`lexicons/${name} prepack`)) {
220
+ return { patched: false, reason: `prepack for ${name} already in Dockerfile` };
221
+ }
222
+
223
+ const lines = content.split("\n");
224
+ insertPrepackAfterEach(lines, name);
225
+ writeFileSync(filePath, lines.join("\n"));
226
+ return { patched: true };
227
+ }
228
+
229
+ /**
230
+ * Execute the onboard command — patches monorepo infrastructure for a new lexicon.
231
+ */
232
+ export function onboardCommand(options: OnboardOptions): OnboardResult {
233
+ const root = findRepoRoot();
234
+ const patched: string[] = [];
235
+ const skipped: string[] = [];
236
+
237
+ // 1. Root package.json
238
+ const pkgResult = patchRootPackageJson(root, options.name);
239
+ if (pkgResult.patched) patched.push("package.json (root dependency)");
240
+ else skipped.push(`package.json: ${pkgResult.reason}`);
241
+
242
+ // 2. CI workflow
243
+ const ciResult = patchCiWorkflow(root, options.name);
244
+ if (ciResult.patched) patched.push("chant.yml (prepack + validate)");
245
+ else skipped.push(`chant.yml: ${ciResult.reason}`);
246
+
247
+ // 3. Publish workflow
248
+ const pubResult = patchPublishWorkflow(root, options.name);
249
+ if (pubResult.patched) patched.push("publish.yml (prepack + publish step)");
250
+ else skipped.push(`publish.yml: ${pubResult.reason}`);
251
+
252
+ // 4. Dockerfiles
253
+ const dockerBun = join(root, "test/Dockerfile.smoke");
254
+ const dockerNode = join(root, "test/Dockerfile.smoke-node");
255
+
256
+ const db = patchDockerfile(dockerBun, options.name);
257
+ if (db.patched) patched.push("Dockerfile.smoke (prepack)");
258
+ else skipped.push(`Dockerfile.smoke: ${db.reason}`);
259
+
260
+ const dn = patchDockerfile(dockerNode, options.name);
261
+ if (dn.patched) patched.push("Dockerfile.smoke-node (prepack)");
262
+ else skipped.push(`Dockerfile.smoke-node: ${dn.reason}`);
263
+
264
+ return { success: true, patched, skipped };
265
+ }
266
+
267
+ /**
268
+ * Print onboard results and remaining manual steps.
269
+ */
270
+ export async function printOnboardResult(result: OnboardResult, name: string): Promise<void> {
271
+ if (!result.success) {
272
+ console.error(formatError({ message: result.error ?? "onboard failed" }));
273
+ return;
274
+ }
275
+
276
+ if (result.patched.length > 0) {
277
+ console.log(formatSuccess("Patched:"));
278
+ for (const f of result.patched) {
279
+ console.log(` ${f}`);
280
+ }
281
+ }
282
+
283
+ if (result.skipped.length > 0) {
284
+ console.log("");
285
+ console.log("Skipped (already configured):");
286
+ for (const s of result.skipped) {
287
+ console.log(` ${s}`);
288
+ }
289
+ }
290
+
291
+ console.log("");
292
+ console.log("Remaining manual steps:");
293
+ console.log(` 1. Create an example: lexicons/${name}/examples/<example-name>/`);
294
+ console.log(` (must depend on @intentius/chant-lexicon-${name} for workspace resolution)`);
295
+ console.log(` 2. Add smoke tests to test/integration.sh`);
296
+ console.log(` 3. Run: bun install (to update workspace links)`);
297
+ console.log(` 4. First npm publish: tag with v<version> and push`);
298
+ console.log(` 5. Run: chant dev check-lexicon lexicons/${name} (to see completeness status)`);
299
+ console.log(formatWarning({
300
+ message: "First-time scoped packages may publish as private despite --access public",
301
+ }));
302
+ console.log(` Check https://www.npmjs.com/org/intentius and toggle visibility if needed`);
303
+
304
+ // Run check-lexicon if the lexicon directory exists
305
+ const lexiconDir = join(findRepoRoot(), "lexicons", name);
306
+ if (existsSync(lexiconDir)) {
307
+ console.log("");
308
+ console.log("Lexicon completeness:");
309
+ const { checkLexicon, printCheckResult } = await import("./check-lexicon");
310
+ const checkResult = checkLexicon(lexiconDir);
311
+ printCheckResult(checkResult, false);
312
+ }
313
+ }
@@ -1,3 +1,4 @@
1
+ import { resolve } from "node:path";
1
2
  import { formatError, formatSuccess } from "../format";
2
3
  import type { CommandContext } from "../registry";
3
4
 
@@ -21,10 +22,34 @@ export async function runDevPublish(ctx: CommandContext): Promise<number> {
21
22
  return 0;
22
23
  }
23
24
 
25
+ export async function runDevOnboard(ctx: CommandContext): Promise<number> {
26
+ const name = ctx.args.extraPositional;
27
+ if (!name) {
28
+ console.error(formatError({
29
+ message: "Missing lexicon name",
30
+ hint: "Usage: chant dev onboard <name>",
31
+ }));
32
+ return 1;
33
+ }
34
+
35
+ const { onboardCommand, printOnboardResult } = await import("../commands/onboard");
36
+ const result = onboardCommand({ name, verbose: ctx.args.verbose });
37
+ await printOnboardResult(result, name);
38
+ return result.success ? 0 : 1;
39
+ }
40
+
41
+ export async function runDevCheckLexicon(ctx: CommandContext): Promise<number> {
42
+ const dir = ctx.args.extraPositional ?? ".";
43
+ const { checkLexicon, printCheckResult } = await import("../commands/check-lexicon");
44
+ const result = checkLexicon(resolve(dir));
45
+ printCheckResult(result, ctx.args.format === "json");
46
+ return result.tier1Pass ? 0 : 1;
47
+ }
48
+
24
49
  export async function runDevUnknown(ctx: CommandContext): Promise<number> {
25
50
  console.error(formatError({
26
51
  message: `Unknown dev subcommand: ${ctx.args.path}`,
27
- hint: "Available: chant dev generate, chant dev publish",
52
+ hint: "Available: chant dev generate, chant dev publish, chant dev onboard, chant dev check-lexicon",
28
53
  }));
29
54
  return 1;
30
55
  }
package/src/cli/main.ts CHANGED
@@ -8,7 +8,7 @@ import { loadChantConfig } from "../config";
8
8
  import { initRuntime } from "../runtime-adapter";
9
9
  import { runBuild } from "./handlers/build";
10
10
  import { runLint } from "./handlers/lint";
11
- import { runDevGenerate, runDevPublish, runDevUnknown } from "./handlers/dev";
11
+ import { runDevGenerate, runDevPublish, runDevOnboard, runDevCheckLexicon, runDevUnknown } from "./handlers/dev";
12
12
  import { runServeLsp, runServeMcp, runServeUnknown } from "./handlers/serve";
13
13
  import { runInit, runInitLexicon } from "./handlers/init";
14
14
  import { runList, runImport, runUpdate, runDoctor } from "./handlers/misc";
@@ -94,6 +94,8 @@ Commands:
94
94
  Lexicon development:
95
95
  dev generate Generate lexicon artifacts (+ validate + coverage)
96
96
  dev publish Package lexicon for distribution
97
+ dev onboard <name> Patch CI, Dockerfiles, and workflows for a new lexicon
98
+ dev check-lexicon <dir> Check lexicon completeness (tier 1/2/3)
97
99
 
98
100
  Servers:
99
101
  serve lsp Start the LSP server (stdio)
@@ -172,6 +174,8 @@ const registry: CommandDef[] = [
172
174
  // Dev subcommands
173
175
  { name: "dev generate", requiresPlugins: true, handler: runDevGenerate },
174
176
  { name: "dev publish", requiresPlugins: true, handler: runDevPublish },
177
+ { name: "dev onboard", handler: runDevOnboard },
178
+ { name: "dev check-lexicon", handler: runDevCheckLexicon },
175
179
 
176
180
  // Serve subcommands
177
181
  { name: "serve lsp", requiresPlugins: true, handler: runServeLsp },
@@ -10,6 +10,11 @@
10
10
  import { readFileSync, readdirSync, writeFileSync, mkdirSync, rmSync } from "fs";
11
11
  import { join } from "path";
12
12
 
13
+ /** Escape curly braces so MDX doesn't treat them as JSX expressions. */
14
+ function escapeMdx(text: string): string {
15
+ return text.replace(/\{/g, "\\{").replace(/\}/g, "\\}");
16
+ }
17
+
13
18
  // ── Types ──────────────────────────────────────────────────────────
14
19
 
15
20
  export interface DocsConfig {
@@ -398,7 +403,7 @@ function buildSidebar(
398
403
  items.push({ label: "Pseudo-Parameters", slug: "pseudo-parameters" });
399
404
  }
400
405
 
401
- if (!suppress.has("rules") && !extraSlugs.has("rules") && result.pages.has("rules.mdx")) {
406
+ if (!suppress.has("rules") && !extraSlugs.has("rules") && !extraSlugs.has("lint-rules") && result.pages.has("rules.mdx")) {
402
407
  items.push({ label: "Lint Rules", slug: "rules" });
403
408
  }
404
409
 
@@ -467,7 +472,8 @@ function generateOverview(
467
472
  `- [Pseudo-Parameters](./pseudo-parameters) — ${Object.keys(manifest.pseudoParameters).length} pseudo-parameters`,
468
473
  );
469
474
  }
470
- if (!suppress.has("rules") && rules.length > 0) {
475
+ const overviewExtraSlugs = new Set((config.extraPages ?? []).map((p) => p.slug));
476
+ if (!suppress.has("rules") && !overviewExtraSlugs.has("lint-rules") && rules.length > 0) {
471
477
  lines.push(`- [Lint Rules](./rules) — ${rules.length} rules`);
472
478
  }
473
479
  if (!suppress.has("serialization")) {
@@ -504,7 +510,7 @@ function generateIntrinsics(
504
510
  for (const fn of intrinsics) {
505
511
  const tag = fn.isTag ? "Yes" : "No";
506
512
  lines.push(
507
- `| \`${fn.name}\` | ${fn.description ?? "—"} | \`${fn.outputKey ?? fn.name}\` | ${tag} |`,
513
+ `| \`${fn.name}\` | ${escapeMdx(fn.description ?? "—")} | \`${fn.outputKey ?? fn.name}\` | ${tag} |`,
508
514
  );
509
515
  }
510
516
 
@@ -561,7 +567,7 @@ function generateRules(config: DocsConfig, rules: RuleMeta[]): string {
561
567
  );
562
568
  for (const rule of lintRules.sort((a, b) => a.id.localeCompare(b.id))) {
563
569
  lines.push(
564
- `| \`${rule.id}\` | ${rule.severity} | ${rule.category} | ${rule.description} |`,
570
+ `| \`${rule.id}\` | ${rule.severity} | ${rule.category} | ${escapeMdx(rule.description)} |`,
565
571
  );
566
572
  }
567
573
  lines.push("");
@@ -579,7 +585,7 @@ function generateRules(config: DocsConfig, rules: RuleMeta[]): string {
579
585
  for (const rule of postSynthRules.sort((a, b) =>
580
586
  a.id.localeCompare(b.id),
581
587
  )) {
582
- lines.push(`| \`${rule.id}\` | ${rule.description} |`);
588
+ lines.push(`| \`${rule.id}\` | ${escapeMdx(rule.description)} |`);
583
589
  }
584
590
  lines.push("");
585
591
  }
@@ -46,12 +46,13 @@ export function buildRegistry<E>(
46
46
  const tsName = naming.resolve(typeName);
47
47
  if (!tsName) continue;
48
48
 
49
- // Build attrs map: nameraw name (identity mapping)
49
+ // Build attrs map: TS key (underscores) CF attr name (dots)
50
50
  let attrs: Record<string, string> | undefined;
51
51
  if (r.attributes.length > 0) {
52
52
  attrs = {};
53
53
  for (const a of r.attributes) {
54
- attrs[a.name] = a.name;
54
+ const tsKey = a.name.replace(/\./g, "_");
55
+ attrs[tsKey] = a.name;
55
56
  }
56
57
  }
57
58
 
@@ -6,6 +6,39 @@ import { join } from "path";
6
6
  import { tmpdir } from "os";
7
7
  import { getRuntime } from "../runtime-adapter";
8
8
 
9
+ /**
10
+ * Minimal TypeScript lib stub — declares just enough built-in types for
11
+ * validating generated .d.ts files without needing the full standard library.
12
+ * This avoids transient failures from corrupted bunx TypeScript caches.
13
+ */
14
+ const MINIMAL_LIB = `
15
+ interface Array<T> { length: number; [n: number]: T; }
16
+ interface ReadonlyArray<T> { length: number; readonly [n: number]: T; }
17
+ interface String { length: number; }
18
+ interface Boolean {}
19
+ interface Number {}
20
+ interface Function {}
21
+ interface CallableFunction extends Function {}
22
+ interface NewableFunction extends Function {}
23
+ interface Object {}
24
+ interface RegExp {}
25
+ interface IArguments {}
26
+ interface Symbol {}
27
+ type Record<K extends string | number | symbol, V> = { [P in K]: V; };
28
+ type Partial<T> = { [P in keyof T]?: T[P]; };
29
+ type Required<T> = { [P in keyof T]-?: T[P]; };
30
+ type Readonly<T> = { readonly [P in keyof T]: T[P]; };
31
+ type Pick<T, K extends keyof T> = { [P in K]: T[P]; };
32
+ type Omit<T, K extends string | number | symbol> = Pick<T, Exclude<keyof T, K>>;
33
+ type Exclude<T, U> = T extends U ? never : T;
34
+ type Extract<T, U> = T extends U ? T : never;
35
+ type NonNullable<T> = T extends null | undefined ? never : T;
36
+ type ReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any;
37
+ type InstanceType<T extends abstract new (...args: any) => any> = T extends abstract new (...args: any) => infer R ? R : any;
38
+ type Promise<T> = { then<TResult>(onfulfilled?: (value: T) => TResult): Promise<TResult>; };
39
+ interface TemplateStringsArray extends ReadonlyArray<string> { readonly raw: readonly string[]; }
40
+ `;
41
+
9
42
  export interface TypeCheckResult {
10
43
  ok: boolean;
11
44
  diagnostics: string[];
@@ -25,17 +58,26 @@ export async function typecheckDTS(content: string): Promise<TypeCheckResult> {
25
58
  const dtsPath = join(dir, "index.d.ts");
26
59
  writeFileSync(dtsPath, content);
27
60
 
28
- // Write a minimal tsconfig
61
+ // Write a minimal lib stub so tsc doesn't depend on a full TypeScript
62
+ // standard library installation (avoids bunx cache corruption issues).
63
+ writeFileSync(
64
+ join(dir, "lib.d.ts"),
65
+ MINIMAL_LIB,
66
+ );
67
+
68
+ // Write a minimal tsconfig — noLib: true prevents tsc from looking for
69
+ // standard lib files; our lib.d.ts is included via the include array.
29
70
  const tsconfig = {
30
71
  compilerOptions: {
31
72
  strict: true,
32
73
  noEmit: true,
33
74
  skipLibCheck: false,
75
+ noLib: true,
34
76
  target: "ES2022",
35
77
  module: "ES2022",
36
78
  moduleResolution: "bundler",
37
79
  },
38
- include: ["index.d.ts"],
80
+ include: ["lib.d.ts", "index.d.ts"],
39
81
  };
40
82
  writeFileSync(join(dir, "tsconfig.json"), JSON.stringify(tsconfig, null, 2));
41
83
 
@@ -223,6 +223,30 @@ describe("detectLexicons", () => {
223
223
  expect(result).toEqual(["testdom"]);
224
224
  });
225
225
 
226
+ test("detects lexicon from multiline import", async () => {
227
+ const file = join(testDir, "infra.ts");
228
+ await writeFile(
229
+ file,
230
+ `import {\n Namespace,\n StatefulSet,\n Service,\n} from "@intentius/chant-lexicon-k8s";\n\nexport const ns = new Namespace({});`
231
+ );
232
+
233
+ const result = await detectLexicons([file]);
234
+ expect(result).toEqual(["k8s"]);
235
+ });
236
+
237
+ test("detects multiple lexicons from multiline imports", async () => {
238
+ const file = join(testDir, "infra.ts");
239
+ await writeFile(
240
+ file,
241
+ `import {\n Namespace,\n Service,\n} from "@intentius/chant-lexicon-k8s";\nimport {\n FlywayProject,\n Environment,\n} from "@intentius/chant-lexicon-flyway";\n\nexport const ns = new Namespace({});`
242
+ );
243
+
244
+ const result = await detectLexicons([file]);
245
+ expect(result).toContain("k8s");
246
+ expect(result).toContain("flyway");
247
+ expect(result).toHaveLength(2);
248
+ });
249
+
226
250
  test("detects lexicon from export with curly braces", async () => {
227
251
  const file = join(testDir, "reexport.ts");
228
252
  await writeFile(
@@ -20,8 +20,10 @@ export async function detectLexicons(files: string[]): Promise<string[]> {
20
20
  continue;
21
21
  }
22
22
 
23
- // Match @intentius/chant-lexicon-<name> in import/export statements
24
- const regex = /(?:import|export)\s+.*\s+from\s+['"]@intentius\/chant-lexicon-([a-z][\w-]*)['"]/g;
23
+ // Match @intentius/chant-lexicon-<name> in import/export statements.
24
+ // Uses [\s\S] instead of . to handle multiline imports like:
25
+ // import { Foo, Bar } from "@intentius/chant-lexicon-k8s";
26
+ const regex = /(?:import|export)\s+[\s\S]*?\s+from\s+['"]@intentius\/chant-lexicon-([a-z][\w-]*)['"]/g;
25
27
 
26
28
  for (const match of content.matchAll(regex)) {
27
29
  detectedLexicons.add(match[1]);
package/src/runtime.ts CHANGED
@@ -31,6 +31,10 @@ export function createResource(
31
31
  Object.defineProperty(this, "props", { value: props ?? {}, enumerable: false, configurable: true });
32
32
  Object.defineProperty(this, "attributes", { value: attributes ?? {}, enumerable: false, configurable: true });
33
33
 
34
+ // Ref returns the resource instance itself — the serializer walker
35
+ // detects Declarable objects and emits { Ref: logicalName }
36
+ Object.defineProperty(this, "Ref", { value: this, enumerable: false });
37
+
34
38
  // Create AttrRef instances for each attribute
35
39
  // Must be enumerable so getAttributes() can discover them for resolveAttrRefs()
36
40
  for (const [camelName, attrName] of Object.entries(attrMap)) {
@@ -3,6 +3,7 @@ import { walkValue, type SerializerVisitor } from "./serializer-walker";
3
3
  import { DECLARABLE_MARKER, type Declarable } from "./declarable";
4
4
  import { INTRINSIC_MARKER } from "./intrinsic";
5
5
  import { AttrRef } from "./attrref";
6
+ import { createResource } from "./runtime";
6
7
 
7
8
  function makeDeclarable(type: string, kind: "resource" | "property" = "resource", props?: Record<string, unknown>): Declarable & { props?: Record<string, unknown> } {
8
9
  const d: Declarable & { props?: Record<string, unknown> } = {
@@ -93,6 +94,13 @@ describe("walkValue", () => {
93
94
  expect(walkValue({ a: 1, b: { c: 2 } }, names, mockVisitor)).toEqual({ a: 1, b: { c: 2 } });
94
95
  });
95
96
 
97
+ test("resource.Ref resolves via resourceRef", () => {
98
+ const TestTable = createResource("Test::Table", "test", {});
99
+ const resource = new TestTable({}) as unknown as Declarable;
100
+ const names = new Map<Declarable, string>([[resource, "MyTable"]]);
101
+ expect(walkValue((resource as any).Ref, names, mockVisitor)).toEqual({ __ref: "MyTable" });
102
+ });
103
+
96
104
  test("complex nested structure", () => {
97
105
  const resource = makeDeclarable("Test::Role");
98
106
  const ref = new AttrRef(resource, "arn");