@intentius/chant 0.0.14 → 0.0.16

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.
package/package.json CHANGED
@@ -1,9 +1,12 @@
1
1
  {
2
2
  "name": "@intentius/chant",
3
- "version": "0.0.14",
3
+ "version": "0.0.16",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
- "files": ["src/", "bin/"],
6
+ "files": [
7
+ "src/",
8
+ "bin/"
9
+ ],
7
10
  "publishConfig": {
8
11
  "access": "public"
9
12
  },
@@ -13,13 +16,16 @@
13
16
  "exports": {
14
17
  ".": "./src/index.ts",
15
18
  "./cli": "./src/cli/index.ts",
16
- "./cli/*": "./src/cli/*",
17
- "./*": "./src/*"
19
+ "./cli/*": "./src/cli/*.ts",
20
+ "./*": "./src/*.ts"
18
21
  },
19
22
  "dependencies": {
20
23
  "fflate": "^0.8.2",
21
24
  "picomatch": "^4.0.3",
22
25
  "typescript": "^5.5.0",
23
26
  "zod": "^4.3.6"
27
+ },
28
+ "optionalDependencies": {
29
+ "tsx": "^4.0.0"
24
30
  }
25
31
  }
@@ -208,6 +208,35 @@ export function checkLexicon(dir: string): CheckResult {
208
208
  detail: `${mdxFiles.length} page(s)`,
209
209
  });
210
210
 
211
+ // Post-synth check count (excluding helpers, tests, and support files)
212
+ const postSynthCheckFiles = listTsFiles(join(dir, "src/lint/post-synth"), ["index.ts"])
213
+ .filter((f) => !f.endsWith(".test.ts") && !f.endsWith("-helpers.ts") && f !== "helpers.ts" && !f.startsWith("arm-") && !f.startsWith("k8s-"));
214
+ items.push({
215
+ name: "At least 15 post-synth checks",
216
+ tier: 2,
217
+ pass: postSynthCheckFiles.length >= 15,
218
+ detail: `${postSynthCheckFiles.length} check(s)`,
219
+ });
220
+
221
+ // Skills count
222
+ const skillsDir = join(dir, "src/skills");
223
+ const skillFiles = existsSync(skillsDir) ? readdirSync(skillsDir).filter((f) => f.endsWith(".md")) : [];
224
+ items.push({
225
+ name: "At least 3 skills",
226
+ tier: 2,
227
+ pass: skillFiles.length >= 3,
228
+ detail: `${skillFiles.length} skill(s)`,
229
+ });
230
+
231
+ // initTemplates count — check for template branches in plugin.ts
232
+ const initTemplateBranches = (pluginContent.match(/template\s*===\s*["']/g) || []).length + 1; // +1 for default
233
+ items.push({
234
+ name: "At least 3 initTemplates",
235
+ tier: 2,
236
+ pass: initTemplateBranches >= 3,
237
+ detail: `${initTemplateBranches} template(s)`,
238
+ });
239
+
211
240
  // ── Tier 3: Thoroughness ───────────────────────────────────────
212
241
 
213
242
  // Each lint rule has a test (per-file or consolidated)
@@ -276,6 +305,24 @@ export function checkLexicon(dir: string): CheckResult {
276
305
  pass: hasActions,
277
306
  });
278
307
 
308
+ // Validate required names count
309
+ const validateContent = readOr(join(dir, "src/validate.ts"));
310
+ const requiredNamesMatches = validateContent.match(/["'][A-Z][a-zA-Z]+["']/g) || [];
311
+ items.push({
312
+ name: "validate.ts checks at least 30 required names",
313
+ tier: 3,
314
+ pass: requiredNamesMatches.length >= 30,
315
+ detail: `${requiredNamesMatches.length} required name(s)`,
316
+ });
317
+
318
+ // Composite test file exists
319
+ const hasCompositeTest = existsSync(join(dir, "src/composites/composites.test.ts"));
320
+ items.push({
321
+ name: "Composite test file exists",
322
+ tier: 3,
323
+ pass: hasCompositeTest,
324
+ });
325
+
279
326
  // Examples with tests (per-example or consolidated root test file)
280
327
  const examplesDir = join(dir, "examples");
281
328
  let examplesWithTests = 0;
@@ -147,7 +147,7 @@ export async function doctorCommand(path: string): Promise<DoctorReport> {
147
147
  }
148
148
  }
149
149
 
150
- // Check 8: tsconfig.json has paths
150
+ // Check 8: tsconfig.json does NOT have paths (they break runtime resolution)
151
151
  const tsconfigPath = join(projectPath, "tsconfig.json");
152
152
  if (existsSync(tsconfigPath)) {
153
153
  try {
@@ -156,8 +156,8 @@ export async function doctorCommand(path: string): Promise<DoctorReport> {
156
156
  // Strip single-line comments for basic parsing
157
157
  const cleaned = raw.replace(/\/\/.*$/gm, "");
158
158
  const tsconfig = JSON.parse(cleaned);
159
- if (!tsconfig.compilerOptions?.paths) {
160
- checks.push({ name: "tsconfig-paths", status: "warn", message: "tsconfig.json missing compilerOptions.paths" });
159
+ if (tsconfig.compilerOptions?.paths) {
160
+ checks.push({ name: "tsconfig-paths", status: "warn", message: "tsconfig.json has compilerOptions.paths — these break runtime module resolution (bun and tsx follow them). Remove the paths block." });
161
161
  } else {
162
162
  checks.push({ name: "tsconfig-paths", status: "pass" });
163
163
  }
@@ -203,10 +203,9 @@ export async function doctorCommand(path: string): Promise<DoctorReport> {
203
203
  if (!plugin.skills) continue;
204
204
  const skills = plugin.skills();
205
205
  if (skills.length === 0) continue;
206
- const skillsDir = join(projectPath, ".chant", "skills", plugin.name);
207
206
  let missing = 0;
208
207
  for (const skill of skills) {
209
- if (!existsSync(join(skillsDir, `${skill.name}.md`))) {
208
+ if (!existsSync(join(projectPath, "skills", skill.name, "SKILL.md"))) {
210
209
  missing++;
211
210
  }
212
211
  }
@@ -94,7 +94,7 @@ describe("initCommand", () => {
94
94
  });
95
95
  });
96
96
 
97
- test("generates valid tsconfig.json with path mappings", async () => {
97
+ test("generates valid tsconfig.json without path mappings", async () => {
98
98
  await withTestDir(async (testDir) => {
99
99
  const options: InitOptions = {
100
100
  path: testDir,
@@ -114,8 +114,7 @@ describe("initCommand", () => {
114
114
  expect(tsconfig.compilerOptions.strict).toBe(true);
115
115
  expect(tsconfig.compilerOptions.rootDir).toBe("./src");
116
116
  expect(tsconfig.include).toContain("src");
117
- expect(tsconfig.compilerOptions.paths["@intentius/chant"]).toEqual(["./.chant/types/core"]);
118
- expect(tsconfig.compilerOptions.paths["@intentius/chant-lexicon-aws"]).toEqual(["./.chant/types/lexicon-aws"]);
117
+ expect(tsconfig.compilerOptions.paths).toBeUndefined();
119
118
  });
120
119
  });
121
120
 
@@ -61,6 +61,16 @@ export interface InitResult {
61
61
  error?: string;
62
62
  }
63
63
 
64
+ /**
65
+ * Detect whether the user's project uses bun or npm.
66
+ * Checks for lock files first, then falls back to runtime detection.
67
+ */
68
+ function detectPackageManager(dir?: string): "bun" | "npm" {
69
+ if (dir && (existsSync(join(dir, "bun.lockb")) || existsSync(join(dir, "bun.lock")))) return "bun";
70
+ if (typeof globalThis.Bun !== "undefined") return "bun";
71
+ return "npm";
72
+ }
73
+
64
74
  /**
65
75
  * Detect the IDE environment for MCP config
66
76
  */
@@ -142,12 +152,6 @@ function generateTsConfig(lexicon: string): string {
142
152
  declaration: true,
143
153
  outDir: "./dist",
144
154
  rootDir: "./src",
145
- paths: {
146
- "@intentius/chant": ["./.chant/types/core"],
147
- "@intentius/chant/*": ["./.chant/types/core/*"],
148
- [`@intentius/chant-lexicon-${lexicon}`]: [`./.chant/types/lexicon-${lexicon}`],
149
- [`@intentius/chant-lexicon-${lexicon}/*`]: [`./.chant/types/lexicon-${lexicon}/*`],
150
- },
151
155
  },
152
156
  include: ["src"],
153
157
  exclude: ["node_modules", "dist"],
@@ -177,7 +181,7 @@ node_modules/
177
181
  .chant/types/
178
182
  .chant/meta/
179
183
  .chant/rules/
180
- .chant/skills/
184
+ skills/
181
185
  `;
182
186
  }
183
187
 
@@ -236,11 +240,11 @@ export interface ChantConfig {
236
240
  /**
237
241
  * Generate MCP config
238
242
  */
239
- function generateMcpConfig(_ide: "claude-code" | "cursor" | "generic"): string {
243
+ function generateMcpConfig(_ide: "claude-code" | "cursor" | "generic", pm: "bun" | "npm"): string {
240
244
  const config = {
241
245
  mcpServers: {
242
246
  chant: {
243
- command: "npx",
247
+ command: pm === "bun" ? "bunx" : "npx",
244
248
  args: ["chant", "serve", "mcp"],
245
249
  },
246
250
  },
@@ -450,7 +454,7 @@ export async function initCommand(options: InitOptions): Promise<InitResult> {
450
454
 
451
455
  writeIfNotExists(
452
456
  join(mcpDir, "mcp.json"),
453
- generateMcpConfig(ide),
457
+ generateMcpConfig(ide, detectPackageManager(targetDir)),
454
458
  `~/.${ide === "generic" ? "config/mcp" : ide}/mcp.json`,
455
459
  createdFiles,
456
460
  warnings,
@@ -458,28 +462,16 @@ export async function initCommand(options: InitOptions): Promise<InitResult> {
458
462
  }
459
463
 
460
464
  // Install skills from the lexicon's plugin
461
- // Write to both .chant/skills/ (chant's own location) and .claude/skills/ (Claude Code discovery)
462
465
  try {
463
466
  const plugin = await loadPlugin(options.lexicon);
464
467
  if (plugin.skills) {
465
468
  const skills = plugin.skills();
466
469
  if (skills.length > 0) {
467
- // .chant/skills/ — chant's own skill storage
468
- const chantSkillsDir = join(targetDir, ".chant", "skills", options.lexicon);
469
- mkdirSync(chantSkillsDir, { recursive: true });
470
470
  for (const skill of skills) {
471
- const skillPath = join(chantSkillsDir, `${skill.name}.md`);
472
- writeFileSync(skillPath, skill.content);
473
- createdFiles.push(`.chant/skills/${options.lexicon}/${skill.name}.md`);
474
- }
475
-
476
- // .claude/skills/ — Claude Code skill discovery format
477
- for (const skill of skills) {
478
- const claudeSkillDir = join(targetDir, ".claude", "skills", skill.name);
479
- mkdirSync(claudeSkillDir, { recursive: true });
480
- const claudeSkillPath = join(claudeSkillDir, "SKILL.md");
481
- writeFileSync(claudeSkillPath, skill.content);
482
- createdFiles.push(`.claude/skills/${skill.name}/SKILL.md`);
471
+ const skillDir = join(targetDir, "skills", skill.name);
472
+ mkdirSync(skillDir, { recursive: true });
473
+ writeFileSync(join(skillDir, "SKILL.md"), skill.content);
474
+ createdFiles.push(`skills/${skill.name}/SKILL.md`);
483
475
  }
484
476
  }
485
477
  }
@@ -519,6 +511,8 @@ export async function printInitResult(
519
511
 
520
512
  console.log("");
521
513
 
514
+ const pm = detectPackageManager(options?.cwd);
515
+
522
516
  // Interactive install prompt
523
517
  if (!options?.skipInstall) {
524
518
  const shouldInstall = await promptInstall();
@@ -527,9 +521,9 @@ export async function printInitResult(
527
521
  const cwd = options?.cwd ?? ".";
528
522
  console.log("Installing dependencies...");
529
523
  try {
530
- execSync("npm install", { cwd, stdio: "inherit" });
524
+ execSync(`${pm} install`, { cwd, stdio: "inherit" });
531
525
  } catch {
532
- console.error(formatWarning({ message: "Install failed. Run 'npm install' manually." }));
526
+ console.error(formatWarning({ message: `Install failed. Run '${pm} install' manually.` }));
533
527
  }
534
528
  }
535
529
  }
@@ -538,6 +532,6 @@ export async function printInitResult(
538
532
  console.log("Next steps:");
539
533
  console.log(" 1. Edit src/config.ts");
540
534
  console.log(" 2. Add resources in src/");
541
- console.log(" 3. npm run build");
535
+ console.log(` 3. ${pm} run build`);
542
536
  }
543
537
 
@@ -164,10 +164,10 @@ export async function updateCommand(options: UpdateOptions): Promise<UpdateResul
164
164
  if (plugin.skills) {
165
165
  const skills = plugin.skills();
166
166
  if (skills.length > 0) {
167
- const skillsDir = join(projectDir, ".chant", "skills", plugin.name);
168
- mkdirSync(skillsDir, { recursive: true });
169
167
  for (const skill of skills) {
170
- writeFileSync(join(skillsDir, `${skill.name}.md`), skill.content);
168
+ const skillDir = join(projectDir, "skills", skill.name);
169
+ mkdirSync(skillDir, { recursive: true });
170
+ writeFileSync(join(skillDir, "SKILL.md"), skill.content);
171
171
  }
172
172
  synced.push(`${plugin.name} skills (${skills.length})`);
173
173
  }
@@ -102,9 +102,12 @@ export function writeConstructor(
102
102
  const optional = p.required ? "" : "?";
103
103
  const tsType = resolveConstructorType(p.type, remap);
104
104
  if (p.description) {
105
- lines.push(` /** ${p.description} */`);
105
+ // Sanitize: escape */ to prevent breaking JSDoc comments
106
+ const safeDesc = p.description.replace(/\*\//g, "*\\/");
107
+ lines.push(` /** ${safeDesc} */`);
106
108
  }
107
- lines.push(` ${p.name}${optional}: ${tsType};`);
109
+ const safeName = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(p.name) ? p.name : JSON.stringify(p.name);
110
+ lines.push(` ${safeName}${optional}: ${tsType};`);
108
111
  }
109
112
  if (resourceAttributesType) {
110
113
  lines.push(` }, attributes?: ${resourceAttributesType});`);
@@ -0,0 +1,111 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { generatePipeline, type GeneratePipelineConfig, type ParsedResult } from "./generate";
3
+ import { NamingStrategy } from "./naming";
4
+
5
+ interface TestResult extends ParsedResult {
6
+ name: string;
7
+ }
8
+
9
+ function makeConfig(
10
+ schemas: Map<string, Buffer>,
11
+ parseOverride?: (name: string, data: Buffer) => TestResult | TestResult[] | null,
12
+ ): GeneratePipelineConfig<TestResult> {
13
+ return {
14
+ fetchSchemas: async () => schemas,
15
+ parseSchema: parseOverride ?? ((name, _data) => ({
16
+ name,
17
+ propertyTypes: [],
18
+ enums: [],
19
+ })),
20
+ createNaming: (results) => new NamingStrategy(
21
+ results.map((r) => ({ typeName: r.name, propertyTypes: r.propertyTypes })),
22
+ {
23
+ priorityNames: {},
24
+ priorityAliases: {},
25
+ priorityPropertyAliases: {},
26
+ serviceAbbreviations: {},
27
+ shortName: (t) => t,
28
+ serviceName: () => "",
29
+ },
30
+ ),
31
+ generateRegistry: () => "{}",
32
+ generateTypes: () => "// types",
33
+ generateRuntimeIndex: () => "// index",
34
+ };
35
+ }
36
+
37
+ describe("generatePipeline", () => {
38
+ test("processes single-result parseSchema", async () => {
39
+ const schemas = new Map([
40
+ ["TypeA", Buffer.from("a")],
41
+ ["TypeB", Buffer.from("b")],
42
+ ]);
43
+ const result = await generatePipeline(makeConfig(schemas));
44
+ expect(result.resources).toBe(2);
45
+ expect(result.warnings).toHaveLength(0);
46
+ });
47
+
48
+ test("supports parseSchema returning arrays", async () => {
49
+ const schemas = new Map([
50
+ ["combined", Buffer.from("data")],
51
+ ]);
52
+
53
+ const config = makeConfig(schemas, (_name, _data) => [
54
+ { name: "TypeA", propertyTypes: [{ name: "PropA" }], enums: [] },
55
+ { name: "TypeB", propertyTypes: [{ name: "PropB" }], enums: [] },
56
+ { name: "TypeC", propertyTypes: [], enums: [{}] },
57
+ ]);
58
+
59
+ const result = await generatePipeline(config);
60
+ expect(result.resources).toBe(3);
61
+ expect(result.properties).toBe(2);
62
+ expect(result.enums).toBe(1);
63
+ });
64
+
65
+ test("handles parseSchema returning null (skip)", async () => {
66
+ const schemas = new Map([
67
+ ["skip-me", Buffer.from("x")],
68
+ ["keep-me", Buffer.from("y")],
69
+ ]);
70
+ const config = makeConfig(schemas, (name, _data) => {
71
+ if (name === "skip-me") return null;
72
+ return { name, propertyTypes: [], enums: [] };
73
+ });
74
+
75
+ const result = await generatePipeline(config);
76
+ expect(result.resources).toBe(1);
77
+ });
78
+
79
+ test("handles mixed single and array returns", async () => {
80
+ const schemas = new Map([
81
+ ["single", Buffer.from("s")],
82
+ ["multi", Buffer.from("m")],
83
+ ]);
84
+ const config = makeConfig(schemas, (name, _data) => {
85
+ if (name === "multi") {
86
+ return [
87
+ { name: "MultiA", propertyTypes: [], enums: [] },
88
+ { name: "MultiB", propertyTypes: [], enums: [] },
89
+ ];
90
+ }
91
+ return { name, propertyTypes: [], enums: [] };
92
+ });
93
+
94
+ const result = await generatePipeline(config);
95
+ expect(result.resources).toBe(3);
96
+ });
97
+
98
+ test("collects parse errors as warnings", async () => {
99
+ const schemas = new Map([
100
+ ["bad", Buffer.from("x")],
101
+ ]);
102
+ const config = makeConfig(schemas, () => {
103
+ throw new Error("parse failed");
104
+ });
105
+
106
+ const result = await generatePipeline(config);
107
+ expect(result.resources).toBe(0);
108
+ expect(result.warnings).toHaveLength(1);
109
+ expect(result.warnings[0].error).toContain("parse failed");
110
+ });
111
+ });
@@ -48,8 +48,13 @@ export interface GeneratePipelineConfig<T extends ParsedResult> {
48
48
  /** Fetch or provide raw schema data. */
49
49
  fetchSchemas: (opts: { force?: boolean }) => Promise<Map<string, Buffer>>;
50
50
 
51
- /** Parse a single schema buffer into a result. Returns null to skip. */
52
- parseSchema: (typeName: string, data: Buffer) => T | null;
51
+ /**
52
+ * Parse a single schema buffer into results. Returns null to skip.
53
+ *
54
+ * May return an array when a single schema file produces multiple results
55
+ * (e.g. K8s OpenAPI spec, GitLab CI schema).
56
+ */
57
+ parseSchema: (typeName: string, data: Buffer) => T | T[] | null;
53
58
 
54
59
  /** Create a naming strategy from the parsed results. */
55
60
  createNaming: (results: T[]) => NamingStrategy;
@@ -119,7 +124,13 @@ export async function generatePipeline<T extends ParsedResult>(
119
124
  for (const [typeName, data] of schemas) {
120
125
  try {
121
126
  const result = config.parseSchema(typeName, data);
122
- if (result) results.push(result);
127
+ if (result) {
128
+ if (Array.isArray(result)) {
129
+ results.push(...result);
130
+ } else {
131
+ results.push(result);
132
+ }
133
+ }
123
134
  } catch (err) {
124
135
  warnings.push({
125
136
  file: typeName,
package/src/index.ts CHANGED
@@ -33,6 +33,7 @@ export * from "./lint/selectors";
33
33
  export * from "./lint/named-checks";
34
34
  export * from "./lint/post-synth";
35
35
  export * from "./lint/rule-loader";
36
+ export * from "./lint/discover";
36
37
  export * from "./import/parser";
37
38
  export * from "./import/generator";
38
39
  export * from "./lexicon";
@@ -0,0 +1,69 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { join, dirname } from "path";
3
+ import { fileURLToPath } from "url";
4
+ import { discoverLintRules, discoverPostSynthChecks } from "./discover";
5
+
6
+ const thisDir = dirname(fileURLToPath(import.meta.url));
7
+
8
+ describe("discoverLintRules", () => {
9
+ test("returns empty array for non-existent directory", () => {
10
+ const rules = discoverLintRules("/nonexistent/path", import.meta.url);
11
+ expect(rules).toEqual([]);
12
+ });
13
+
14
+ test("discovers rules from a real lexicon", () => {
15
+ // Use the Helm lexicon's rules directory as a real test case
16
+ const helmRulesDir = join(thisDir, "../../../../lexicons/helm/src/lint/rules");
17
+ const rules = discoverLintRules(helmRulesDir, import.meta.url);
18
+ expect(rules.length).toBeGreaterThan(0);
19
+ // Each discovered rule should have the expected shape
20
+ for (const rule of rules) {
21
+ expect(typeof rule.id).toBe("string");
22
+ expect(typeof rule.severity).toBe("string");
23
+ expect(typeof rule.category).toBe("string");
24
+ expect(typeof rule.check).toBe("function");
25
+ }
26
+ });
27
+
28
+ test("sorts rules by id", () => {
29
+ const helmRulesDir = join(thisDir, "../../../../lexicons/helm/src/lint/rules");
30
+ const rules = discoverLintRules(helmRulesDir, import.meta.url);
31
+ for (let i = 1; i < rules.length; i++) {
32
+ expect(rules[i].id.localeCompare(rules[i - 1].id)).toBeGreaterThanOrEqual(0);
33
+ }
34
+ });
35
+ });
36
+
37
+ describe("discoverPostSynthChecks", () => {
38
+ test("returns empty array for non-existent directory", () => {
39
+ const checks = discoverPostSynthChecks("/nonexistent/path", import.meta.url);
40
+ expect(checks).toEqual([]);
41
+ });
42
+
43
+ test("discovers checks from a real lexicon", () => {
44
+ const helmPostSynthDir = join(thisDir, "../../../../lexicons/helm/src/lint/post-synth");
45
+ const checks = discoverPostSynthChecks(helmPostSynthDir, import.meta.url);
46
+ expect(checks.length).toBeGreaterThan(0);
47
+ for (const check of checks) {
48
+ expect(typeof check.id).toBe("string");
49
+ expect(typeof check.description).toBe("string");
50
+ expect(typeof check.check).toBe("function");
51
+ }
52
+ });
53
+
54
+ test("sorts checks by id", () => {
55
+ const helmPostSynthDir = join(thisDir, "../../../../lexicons/helm/src/lint/post-synth");
56
+ const checks = discoverPostSynthChecks(helmPostSynthDir, import.meta.url);
57
+ for (let i = 1; i < checks.length; i++) {
58
+ expect(checks[i].id.localeCompare(checks[i - 1].id)).toBeGreaterThanOrEqual(0);
59
+ }
60
+ });
61
+
62
+ test("does not include lint rules", () => {
63
+ // Post-synth checks should not pick up lint rules (which also have check())
64
+ const helmRulesDir = join(thisDir, "../../../../lexicons/helm/src/lint/rules");
65
+ const checks = discoverPostSynthChecks(helmRulesDir, import.meta.url);
66
+ // Lint rules have severity, post-synth checks don't — so this should be empty
67
+ expect(checks.length).toBe(0);
68
+ });
69
+ });
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Auto-discovery for lint rules and post-synth checks.
3
+ *
4
+ * Scans directories for .ts files and loads exported rules/checks,
5
+ * eliminating the need to manually list every rule in plugin.ts.
6
+ */
7
+
8
+ import { readdirSync } from "fs";
9
+ import { join } from "path";
10
+ import { createRequire } from "module";
11
+ import type { LintRule } from "./rule";
12
+ import type { PostSynthCheck } from "./post-synth";
13
+
14
+ /**
15
+ * Discover lint rules from a directory.
16
+ *
17
+ * Scans the directory for .ts files (excluding tests, helpers, and index files),
18
+ * loads each module, and collects any exported objects that look like a LintRule
19
+ * (has `id`, `severity`, `category`, and `check` function).
20
+ *
21
+ * @param dir - Absolute path to the rules directory
22
+ * @param importMetaUrl - The caller's import.meta.url (for createRequire)
23
+ */
24
+ export function discoverLintRules(dir: string, importMetaUrl: string): LintRule[] {
25
+ const require = createRequire(importMetaUrl);
26
+ const rules: LintRule[] = [];
27
+
28
+ for (const file of listRuleFiles(dir)) {
29
+ try {
30
+ // Strip .ts extension — require() resolves without it
31
+ const modPath = join(dir, file.replace(/\.ts$/, ""));
32
+ const mod = require(modPath);
33
+ for (const exp of Object.values(mod)) {
34
+ if (isLintRule(exp)) rules.push(exp);
35
+ }
36
+ } catch {
37
+ // Skip files that fail to load
38
+ }
39
+ }
40
+
41
+ return rules.sort((a, b) => a.id.localeCompare(b.id));
42
+ }
43
+
44
+ /**
45
+ * Discover post-synth checks from a directory.
46
+ *
47
+ * Scans the directory for .ts files (excluding tests, helpers, and index files),
48
+ * loads each module, and collects any exported objects that look like a PostSynthCheck
49
+ * (has `id`, `description`, and `check` function).
50
+ *
51
+ * @param dir - Absolute path to the post-synth rules directory
52
+ * @param importMetaUrl - The caller's import.meta.url (for createRequire)
53
+ */
54
+ export function discoverPostSynthChecks(dir: string, importMetaUrl: string): PostSynthCheck[] {
55
+ const require = createRequire(importMetaUrl);
56
+ const checks: PostSynthCheck[] = [];
57
+
58
+ for (const file of listRuleFiles(dir)) {
59
+ try {
60
+ // Strip .ts extension — require() resolves without it
61
+ const modPath = join(dir, file.replace(/\.ts$/, ""));
62
+ const mod = require(modPath);
63
+ for (const exp of Object.values(mod)) {
64
+ if (isPostSynthCheck(exp)) checks.push(exp);
65
+ }
66
+ } catch {
67
+ // Skip files that fail to load
68
+ }
69
+ }
70
+
71
+ return checks.sort((a, b) => a.id.localeCompare(b.id));
72
+ }
73
+
74
+ /**
75
+ * List rule files in a directory (excluding tests, helpers, index).
76
+ */
77
+ function listRuleFiles(dir: string): string[] {
78
+ try {
79
+ return readdirSync(dir).filter((f) => {
80
+ if (!f.endsWith(".ts")) return false;
81
+ if (f.endsWith(".test.ts")) return false;
82
+ if (f === "index.ts") return false;
83
+ if (f.includes("helper")) return false;
84
+ return true;
85
+ });
86
+ } catch {
87
+ return [];
88
+ }
89
+ }
90
+
91
+ function isLintRule(obj: unknown): obj is LintRule {
92
+ if (typeof obj !== "object" || obj === null) return false;
93
+ const o = obj as Record<string, unknown>;
94
+ return (
95
+ typeof o.id === "string" &&
96
+ typeof o.severity === "string" &&
97
+ typeof o.category === "string" &&
98
+ typeof o.check === "function"
99
+ );
100
+ }
101
+
102
+ function isPostSynthCheck(obj: unknown): obj is PostSynthCheck {
103
+ if (typeof obj !== "object" || obj === null) return false;
104
+ const o = obj as Record<string, unknown>;
105
+ return (
106
+ typeof o.id === "string" &&
107
+ typeof o.description === "string" &&
108
+ typeof o.check === "function" &&
109
+ // Exclude LintRules (which also have check) by checking for PostSynthCheck-specific fields
110
+ typeof o.severity !== "string"
111
+ );
112
+ }
@@ -25,6 +25,7 @@ export { evl009CompositeNoConstantRule } from "./evl009-composite-no-constant";
25
25
  export { evl010CompositeNoTransformRule } from "./evl010-composite-no-transform";
26
26
  export { cor017CompositeNameMatchRule } from "./cor017-composite-name-match";
27
27
  export { cor018CompositePreferLexiconTypeRule } from "./cor018-composite-prefer-lexicon-type";
28
+ export { isInsideCompositeFactory } from "./composite-scope";
28
29
 
29
30
  import { flatDeclarationsRule } from "./flat-declarations";
30
31
  import { exportRequiredRule } from "./export-required";
@@ -101,6 +101,29 @@ describe("walkValue", () => {
101
101
  expect(walkValue((resource as any).Ref, names, mockVisitor)).toEqual({ __ref: "MyTable" });
102
102
  });
103
103
 
104
+ test("resolves __attrRef envelope inside intrinsic toJSON output", () => {
105
+ // Simulate an intrinsic whose toJSON() produces an __attrRef envelope
106
+ // (e.g., AttrRef inside a Sub template literal)
107
+ const intrinsic = {
108
+ [INTRINSIC_MARKER]: true as const,
109
+ toJSON: () => ({
110
+ MyIntrinsic: { __attrRef: { entity: "MyResource", attribute: "Arn" } },
111
+ }),
112
+ };
113
+ const names = new Map<Declarable, string>();
114
+ expect(walkValue(intrinsic, names, mockVisitor)).toEqual({
115
+ MyIntrinsic: { __getAtt: ["MyResource", "Arn"] },
116
+ });
117
+ });
118
+
119
+ test("resolves standalone __attrRef envelope in plain object", () => {
120
+ const names = new Map<Declarable, string>();
121
+ const value = { __attrRef: { entity: "MyBucket", attribute: "DomainName" } };
122
+ expect(walkValue(value, names, mockVisitor)).toEqual({
123
+ __getAtt: ["MyBucket", "DomainName"],
124
+ });
125
+ });
126
+
104
127
  test("complex nested structure", () => {
105
128
  const resource = makeDeclarable("Test::Role");
106
129
  const ref = new AttrRef(resource, "arn");
@@ -43,10 +43,10 @@ export function walkValue(
43
43
  return visitor.attrRef(name, value.attribute);
44
44
  }
45
45
 
46
- // Handle Intrinsics
46
+ // Handle Intrinsics — walk the toJSON() result to resolve any embedded AttrRef markers
47
47
  if (typeof value === "object" && value !== null && INTRINSIC_MARKER in value) {
48
48
  if ("toJSON" in value && typeof value.toJSON === "function") {
49
- return value.toJSON();
49
+ return walkValue(value.toJSON(), entityNames, visitor);
50
50
  }
51
51
  }
52
52
 
@@ -67,6 +67,12 @@ export function walkValue(
67
67
  return value.map((item) => walkValue(item, entityNames, visitor));
68
68
  }
69
69
 
70
+ // Handle serialized AttrRef envelopes (produced by AttrRef.toJSON() inside intrinsics)
71
+ if (typeof value === "object" && "__attrRef" in value) {
72
+ const ref = (value as { __attrRef: { entity: string; attribute: string } }).__attrRef;
73
+ return visitor.attrRef(ref.entity, ref.attribute);
74
+ }
75
+
70
76
  // Handle objects
71
77
  if (typeof value === "object") {
72
78
  const result: Record<string, unknown> = {};
package/src/yaml.test.ts CHANGED
@@ -88,6 +88,27 @@ describe("emitYAML", () => {
88
88
  const result = emitYAML({ key: "val" }, 1);
89
89
  expect(result).toBe("\n key: val");
90
90
  });
91
+
92
+ test("multiline string emits as | block scalar", () => {
93
+ const result = emitYAML("line1\nline2\nline3", 0);
94
+ expect(result).toBe("|\nline1\nline2\nline3");
95
+ });
96
+
97
+ test("multiline string trims trailing empty line", () => {
98
+ // Template literals often end with \n producing a trailing empty line
99
+ const result = emitYAML("line1\nline2\n", 0);
100
+ expect(result).toBe("|\nline1\nline2");
101
+ });
102
+
103
+ test("multiline string inside object value is properly indented", () => {
104
+ const result = emitYAML({ config: "line1\nline2\nline3" }, 0);
105
+ expect(result).toContain("config: |\n line1\n line2\n line3");
106
+ });
107
+
108
+ test("multiline string inside array item is properly indented", () => {
109
+ const result = emitYAML([{ data: "a\nb" }], 0);
110
+ expect(result).toContain("data: |\n a\n b");
111
+ });
91
112
  });
92
113
 
93
114
  // ---------------------------------------------------------------------------
package/src/yaml.ts CHANGED
@@ -35,6 +35,13 @@ export function emitYAML(value: unknown, indent: number): string {
35
35
  }
36
36
 
37
37
  if (typeof value === "string") {
38
+ // Multiline strings use YAML literal block scalar (|)
39
+ if (value.includes("\n")) {
40
+ const lines = value.split("\n");
41
+ // Trim trailing empty line if present (common for template strings)
42
+ const trimmed = lines[lines.length - 1] === "" ? lines.slice(0, -1) : lines;
43
+ return "|\n" + trimmed.map((l) => `${prefix}${l}`).join("\n");
44
+ }
38
45
  // Quote strings that could be misinterpreted
39
46
  if (
40
47
  value === "" ||
@@ -225,6 +232,58 @@ export function parseYAMLLines(
225
232
  return { value: result, endIndex: i };
226
233
  }
227
234
 
235
+ /**
236
+ * Parse the value of a key inside an array item.
237
+ * If the inline value is empty, look ahead for a nested object or array.
238
+ */
239
+ function parseArrayItemValue(
240
+ inlineValue: string,
241
+ lines: string[],
242
+ currentIndex: number,
243
+ childIndent: number,
244
+ ): unknown {
245
+ if (inlineValue !== "" && !inlineValue.startsWith("#")) {
246
+ if (inlineValue.startsWith("[")) {
247
+ try { return JSON.parse(inlineValue); } catch { return inlineValue; }
248
+ }
249
+ if (inlineValue.startsWith("{")) {
250
+ try { return JSON.parse(inlineValue); } catch { return inlineValue; }
251
+ }
252
+ return parseScalar(inlineValue);
253
+ }
254
+ // Empty inline value — check for nested block
255
+ const nextIdx = currentIndex + 1;
256
+ if (nextIdx < lines.length) {
257
+ const nextLine = lines[nextIdx];
258
+ if (nextLine.trim() !== "" && !nextLine.trim().startsWith("#")) {
259
+ const ni = nextLine.search(/\S/);
260
+ if (ni >= childIndent) {
261
+ if (nextLine.trimStart().startsWith("- ")) {
262
+ return parseYAMLArray(lines, nextIdx, ni).value;
263
+ }
264
+ return parseYAMLLines(lines, nextIdx, ni).value;
265
+ }
266
+ }
267
+ }
268
+ return null;
269
+ }
270
+
271
+ /**
272
+ * Skip past a nested block (object or array) starting at startIndex with the given indent.
273
+ * Returns the index of the first line that is NOT part of the nested block.
274
+ */
275
+ function skipNestedBlock(lines: string[], startIndex: number, childIndent: number): number {
276
+ let j = startIndex;
277
+ while (j < lines.length) {
278
+ const l = lines[j];
279
+ if (l.trim() === "" || l.trim().startsWith("#")) { j++; continue; }
280
+ const ni = l.search(/\S/);
281
+ if (ni < childIndent) break;
282
+ j++;
283
+ }
284
+ return j;
285
+ }
286
+
228
287
  /**
229
288
  * Parse a block array (lines starting with `- `).
230
289
  */
@@ -253,10 +312,12 @@ export function parseYAMLArray(
253
312
  const kvMatch = itemValue.match(/^([^\s:][^:]*?):\s*(.*)$/);
254
313
  if (kvMatch) {
255
314
  const obj: Record<string, unknown> = {};
256
- obj[kvMatch[1].trim()] = parseScalar(kvMatch[2].trim());
315
+ obj[kvMatch[1].trim()] = parseArrayItemValue(kvMatch[2].trim(), lines, i, indent + 2);
257
316
  // Check for more keys at indent+2
258
317
  const nextIndent = indent + 2;
259
- let j = i + 1;
318
+ let j = kvMatch[2].trim() === "" || kvMatch[2].trim().startsWith("#")
319
+ ? skipNestedBlock(lines, i + 1, nextIndent)
320
+ : i + 1;
260
321
  while (j < lines.length) {
261
322
  const nextLine = lines[j];
262
323
  if (nextLine.trim() === "" || nextLine.trim().startsWith("#")) {
@@ -264,11 +325,17 @@ export function parseYAMLArray(
264
325
  continue;
265
326
  }
266
327
  const ni = nextLine.search(/\S/);
267
- if (ni !== nextIndent) break;
328
+ if (ni < nextIndent) break;
329
+ if (ni > nextIndent) break; // belongs to a nested block already consumed
268
330
  const nextKV = nextLine.match(/^(\s*)([^\s:][^:]*?):\s*(.*)$/);
269
331
  if (nextKV) {
270
- obj[nextKV[2].trim()] = parseScalar(nextKV[3].trim());
271
- j++;
332
+ const nextVal = nextKV[3].trim();
333
+ obj[nextKV[2].trim()] = parseArrayItemValue(nextVal, lines, j, ni + 2);
334
+ if (nextVal === "" || nextVal.startsWith("#")) {
335
+ j = skipNestedBlock(lines, j + 1, ni + 2);
336
+ } else {
337
+ j++;
338
+ }
272
339
  } else {
273
340
  break;
274
341
  }