@intentius/chant 0.0.14 → 0.0.15

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.15",
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});`);
@@ -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
  }