@intentius/chant 0.0.11 → 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 (34) 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/lsp/lexicon-providers.ts +1 -0
  28. package/src/runtime.ts +4 -0
  29. package/src/serializer-walker.test.ts +8 -0
  30. package/src/toml.test.ts +388 -0
  31. package/src/toml.ts +606 -0
  32. package/src/yaml.test.ts +192 -0
  33. package/src/yaml.ts +308 -0
  34. /package/src/cli/commands/__fixtures__/init-lexicon-output/{examples/getting-started → src/actions}/.gitkeep +0 -0
@@ -0,0 +1,385 @@
1
+ import { existsSync, readdirSync, readFileSync } from "fs";
2
+ import { join, basename } from "path";
3
+
4
+ // ── Types ────────────────────────────────────────────────────────────
5
+
6
+ export interface CheckItem {
7
+ name: string;
8
+ tier: 1 | 2 | 3;
9
+ pass: boolean;
10
+ detail?: string;
11
+ }
12
+
13
+ export interface CheckResult {
14
+ items: CheckItem[];
15
+ tier1Pass: boolean;
16
+ }
17
+
18
+ // ── Helpers ──────────────────────────────────────────────────────────
19
+
20
+ /** List .ts files in a directory, optionally excluding some basenames. */
21
+ function listTsFiles(dir: string, exclude: string[] = []): string[] {
22
+ if (!existsSync(dir)) return [];
23
+ return readdirSync(dir)
24
+ .filter((f) => f.endsWith(".ts") && !exclude.includes(f));
25
+ }
26
+
27
+ /** Recursively find files matching a predicate. */
28
+ function findFiles(dir: string, predicate: (name: string) => boolean): string[] {
29
+ if (!existsSync(dir)) return [];
30
+ const results: string[] = [];
31
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
32
+ if (entry.isDirectory()) {
33
+ results.push(...findFiles(join(dir, entry.name), predicate));
34
+ } else if (predicate(entry.name)) {
35
+ results.push(join(dir, entry.name));
36
+ }
37
+ }
38
+ return results;
39
+ }
40
+
41
+ /** Read a file's content, returning empty string if missing. */
42
+ function readOr(path: string): string {
43
+ try {
44
+ return readFileSync(path, "utf-8");
45
+ } catch {
46
+ return "";
47
+ }
48
+ }
49
+
50
+ /** Count subdirectories in a directory, ignoring .gitkeep-only dirs. */
51
+ function countSubdirs(dir: string): number {
52
+ if (!existsSync(dir)) return 0;
53
+ return readdirSync(dir, { withFileTypes: true })
54
+ .filter((e) => e.isDirectory())
55
+ .filter((e) => {
56
+ const contents = readdirSync(join(dir, e.name));
57
+ // Ignore directories that only contain .gitkeep
58
+ return contents.length > 0 && !(contents.length === 1 && contents[0] === ".gitkeep");
59
+ })
60
+ .length;
61
+ }
62
+
63
+ // ── Check runner ─────────────────────────────────────────────────────
64
+
65
+ /**
66
+ * Run all completeness checks against a lexicon directory.
67
+ */
68
+ export function checkLexicon(dir: string): CheckResult {
69
+ const items: CheckItem[] = [];
70
+
71
+ // ── Tier 1: Required ───────────────────────────────────────────
72
+
73
+ items.push({
74
+ name: "src/plugin.ts exists",
75
+ tier: 1,
76
+ pass: existsSync(join(dir, "src/plugin.ts")),
77
+ });
78
+
79
+ items.push({
80
+ name: "src/serializer.ts exists",
81
+ tier: 1,
82
+ pass: existsSync(join(dir, "src/serializer.ts")),
83
+ });
84
+
85
+ const ruleFiles = listTsFiles(join(dir, "src/lint/rules"), ["index.ts"]);
86
+ items.push({
87
+ name: "At least 1 lint rule in src/lint/rules/",
88
+ tier: 1,
89
+ pass: ruleFiles.length > 0,
90
+ detail: ruleFiles.length > 0 ? `${ruleFiles.length} rule(s)` : undefined,
91
+ });
92
+
93
+ const postSynthFiles = listTsFiles(join(dir, "src/lint/post-synth"), ["index.ts"])
94
+ .filter((f) => !f.endsWith("-helpers.ts") && f !== "helpers.ts");
95
+ items.push({
96
+ name: "At least 1 post-synth check",
97
+ tier: 1,
98
+ pass: postSynthFiles.length > 0,
99
+ detail: postSynthFiles.length > 0 ? `${postSynthFiles.length} check(s)` : undefined,
100
+ });
101
+
102
+ items.push({
103
+ name: "src/lsp/completions.ts exists",
104
+ tier: 1,
105
+ pass: existsSync(join(dir, "src/lsp/completions.ts")),
106
+ });
107
+
108
+ items.push({
109
+ name: "src/lsp/hover.ts exists",
110
+ tier: 1,
111
+ pass: existsSync(join(dir, "src/lsp/hover.ts")),
112
+ });
113
+
114
+ items.push({
115
+ name: "dist/manifest.json exists",
116
+ tier: 1,
117
+ pass: existsSync(join(dir, "dist/manifest.json")),
118
+ });
119
+
120
+ const exampleCount = countSubdirs(join(dir, "examples"));
121
+ items.push({
122
+ name: "At least 1 example in examples/",
123
+ tier: 1,
124
+ pass: exampleCount > 0,
125
+ detail: exampleCount > 0 ? `${exampleCount} example(s)` : undefined,
126
+ });
127
+
128
+ const hasPluginTest = findFiles(join(dir, "src"), (n) => n === "plugin.test.ts").length > 0;
129
+ items.push({
130
+ name: "plugin.test.ts exists",
131
+ tier: 1,
132
+ pass: hasPluginTest,
133
+ });
134
+
135
+ const hasSerializerTest = findFiles(join(dir, "src"), (n) => n === "serializer.test.ts").length > 0;
136
+ items.push({
137
+ name: "serializer.test.ts exists",
138
+ tier: 1,
139
+ pass: hasSerializerTest,
140
+ });
141
+
142
+ const mdxFiles = findFiles(join(dir, "docs"), (n) => n.endsWith(".mdx"));
143
+ items.push({
144
+ name: "At least 1 .mdx doc page",
145
+ tier: 1,
146
+ pass: mdxFiles.length > 0,
147
+ detail: mdxFiles.length > 0 ? `${mdxFiles.length} page(s)` : undefined,
148
+ });
149
+
150
+ // ── Tier 2: Recommended ────────────────────────────────────────
151
+
152
+ const pluginContent = readOr(join(dir, "src/plugin.ts"));
153
+
154
+ for (const method of ["mcpTools", "mcpResources", "skills", "detectTemplate", "initTemplates"] as const) {
155
+ // Check for uncommented method: line starts with optional whitespace, then the method name
156
+ // Exclude lines that start with // or * (comment blocks)
157
+ const lines = pluginContent.split("\n");
158
+ const hasUncommented = lines.some((line) => {
159
+ const trimmed = line.trim();
160
+ return trimmed.startsWith(`${method}(`) || trimmed.startsWith(`${method} (`);
161
+ });
162
+ items.push({
163
+ name: `plugin.ts has uncommented ${method}`,
164
+ tier: 2,
165
+ pass: hasUncommented,
166
+ });
167
+ }
168
+
169
+ const compositeFiles = listTsFiles(join(dir, "src/composites"), ["index.ts"]);
170
+ items.push({
171
+ name: "At least 1 composite in src/composites/",
172
+ tier: 2,
173
+ pass: compositeFiles.length > 0,
174
+ detail: compositeFiles.length > 0 ? `${compositeFiles.length} composite(s)` : undefined,
175
+ });
176
+
177
+ items.push({
178
+ name: "At least 3 examples",
179
+ tier: 2,
180
+ pass: exampleCount >= 3,
181
+ detail: `${exampleCount} example(s)`,
182
+ });
183
+
184
+ items.push({
185
+ name: "src/lsp/completions.test.ts exists",
186
+ tier: 2,
187
+ pass: existsSync(join(dir, "src/lsp/completions.test.ts")),
188
+ });
189
+
190
+ items.push({
191
+ name: "src/lsp/hover.test.ts exists",
192
+ tier: 2,
193
+ pass: existsSync(join(dir, "src/lsp/hover.test.ts")),
194
+ });
195
+
196
+ const coverageContent = readOr(join(dir, "src/coverage.ts"));
197
+ items.push({
198
+ name: "coverage.ts is implemented",
199
+ tier: 2,
200
+ pass: !coverageContent.includes("not yet implemented"),
201
+ detail: coverageContent.includes("not yet implemented") ? "contains 'not yet implemented'" : undefined,
202
+ });
203
+
204
+ items.push({
205
+ name: "At least 8 doc pages",
206
+ tier: 2,
207
+ pass: mdxFiles.length >= 8,
208
+ detail: `${mdxFiles.length} page(s)`,
209
+ });
210
+
211
+ // ── Tier 3: Thoroughness ───────────────────────────────────────
212
+
213
+ // Each lint rule has a test (per-file or consolidated)
214
+ const ruleDir = join(dir, "src/lint/rules");
215
+ const ruleSourceFiles = listTsFiles(ruleDir, ["index.ts"]).filter((f) => !f.endsWith(".test.ts"));
216
+ const ruleTestFiles = listTsFiles(ruleDir).filter((f) => f.endsWith(".test.ts"));
217
+ // A consolidated test file (e.g. rules.test.ts) covers all rules in the directory
218
+ const hasConsolidatedRuleTest = ruleTestFiles.length > 0;
219
+ const untestedRules = hasConsolidatedRuleTest
220
+ ? []
221
+ : ruleSourceFiles.filter(
222
+ (f) => !ruleTestFiles.includes(f.replace(".ts", ".test.ts")),
223
+ );
224
+ items.push({
225
+ name: "Each lint rule has a .test.ts",
226
+ tier: 3,
227
+ pass: ruleSourceFiles.length > 0 && untestedRules.length === 0,
228
+ detail: untestedRules.length > 0 ? `missing: ${untestedRules.join(", ")}` : undefined,
229
+ });
230
+
231
+ // Each post-synth has a test (per-file or consolidated)
232
+ const postSynthDir = join(dir, "src/lint/post-synth");
233
+ const postSynthSourceFiles = listTsFiles(postSynthDir, ["index.ts"])
234
+ .filter((f) => !f.endsWith(".test.ts") && !f.endsWith("-helpers.ts") && f !== "helpers.ts");
235
+ const postSynthTestFiles = listTsFiles(postSynthDir).filter((f) => f.endsWith(".test.ts"));
236
+ // A consolidated test file (e.g. post-synth.test.ts) covers all checks in the directory
237
+ const hasConsolidatedPostSynthTest = postSynthTestFiles.length > 0;
238
+ const untestedPostSynth = hasConsolidatedPostSynthTest
239
+ ? []
240
+ : postSynthSourceFiles.filter(
241
+ (f) => !postSynthTestFiles.includes(f.replace(".ts", ".test.ts")),
242
+ );
243
+ items.push({
244
+ name: "Each post-synth check has a .test.ts",
245
+ tier: 3,
246
+ pass: postSynthSourceFiles.length > 0 && untestedPostSynth.length === 0,
247
+ detail: untestedPostSynth.length > 0 ? `missing: ${untestedPostSynth.join(", ")}` : undefined,
248
+ });
249
+
250
+ const hasTypecheckTest = findFiles(join(dir, "src"), (n) => n === "typecheck.test.ts").length > 0;
251
+ items.push({
252
+ name: "typecheck.test.ts exists",
253
+ tier: 3,
254
+ pass: hasTypecheckTest,
255
+ });
256
+
257
+ const hasRoundtripTest = findFiles(join(dir, "src"), (n) => n === "roundtrip.test.ts").length > 0;
258
+ items.push({
259
+ name: "roundtrip.test.ts exists",
260
+ tier: 3,
261
+ pass: hasRoundtripTest,
262
+ });
263
+
264
+ items.push({
265
+ name: "At least 5 composites",
266
+ tier: 3,
267
+ pass: compositeFiles.length >= 5,
268
+ detail: `${compositeFiles.length} composite(s)`,
269
+ });
270
+
271
+ const hasActions = existsSync(join(dir, "src/actions")) &&
272
+ listTsFiles(join(dir, "src/actions"), ["index.ts"]).length > 0;
273
+ items.push({
274
+ name: "src/actions/ with at least 1 action",
275
+ tier: 3,
276
+ pass: hasActions,
277
+ });
278
+
279
+ // Examples with tests (per-example or consolidated root test file)
280
+ const examplesDir = join(dir, "examples");
281
+ let examplesWithTests = 0;
282
+ if (existsSync(examplesDir)) {
283
+ // A .test.ts in the examples root directory covers all examples
284
+ const rootTestFiles = readdirSync(examplesDir).filter((f) => f.endsWith(".test.ts"));
285
+ const hasConsolidatedExampleTest = rootTestFiles.length > 0;
286
+ const exampleDirs = readdirSync(examplesDir, { withFileTypes: true }).filter((e) => e.isDirectory());
287
+ for (const entry of exampleDirs) {
288
+ if (hasConsolidatedExampleTest) {
289
+ // Consolidated test covers all non-empty example dirs
290
+ const contents = readdirSync(join(examplesDir, entry.name));
291
+ if (contents.length > 0 && !(contents.length === 1 && contents[0] === ".gitkeep")) {
292
+ examplesWithTests++;
293
+ }
294
+ } else {
295
+ const exampleTests = findFiles(join(examplesDir, entry.name), (n) => n.endsWith(".test.ts"));
296
+ if (exampleTests.length > 0) examplesWithTests++;
297
+ }
298
+ }
299
+ }
300
+ items.push({
301
+ name: "At least 5 examples with tests",
302
+ tier: 3,
303
+ pass: examplesWithTests >= 5,
304
+ detail: `${examplesWithTests} example(s) with tests`,
305
+ });
306
+
307
+ const tier1Pass = items.filter((i) => i.tier === 1).every((i) => i.pass);
308
+
309
+ return { items, tier1Pass };
310
+ }
311
+
312
+ // ── Output formatting ────────────────────────────────────────────────
313
+
314
+ const COLORS = {
315
+ green: "\x1b[32m",
316
+ yellow: "\x1b[33m",
317
+ gray: "\x1b[90m",
318
+ red: "\x1b[31m",
319
+ reset: "\x1b[0m",
320
+ bold: "\x1b[1m",
321
+ };
322
+
323
+ function useColors(): boolean {
324
+ return !process.env.NO_COLOR && process.stdout.isTTY !== false;
325
+ }
326
+
327
+ function c(text: string, code: string): string {
328
+ return useColors() ? `${code}${text}${COLORS.reset}` : text;
329
+ }
330
+
331
+ /**
332
+ * Print the check result as a colored table or JSON.
333
+ */
334
+ export function printCheckResult(result: CheckResult, json: boolean): void {
335
+ if (json) {
336
+ console.log(JSON.stringify(result, null, 2));
337
+ return;
338
+ }
339
+
340
+ const tierLabels: Record<number, string> = {
341
+ 1: "required",
342
+ 2: "recommended",
343
+ 3: "thoroughness",
344
+ };
345
+
346
+ for (const tier of [1, 2, 3] as const) {
347
+ const tierItems = result.items.filter((i) => i.tier === tier);
348
+ if (tierItems.length === 0) continue;
349
+
350
+ const passCount = tierItems.filter((i) => i.pass).length;
351
+ const label = tierLabels[tier];
352
+ console.log("");
353
+ console.log(c(`Tier ${tier} — ${label} (${passCount}/${tierItems.length})`, COLORS.bold));
354
+
355
+ for (const item of tierItems) {
356
+ let icon: string;
357
+ let nameColor: string;
358
+
359
+ if (item.pass) {
360
+ icon = c("PASS", COLORS.green);
361
+ nameColor = COLORS.green;
362
+ } else if (tier === 1) {
363
+ icon = c("FAIL", COLORS.red);
364
+ nameColor = COLORS.red;
365
+ } else if (tier === 2) {
366
+ icon = c("WARN", COLORS.yellow);
367
+ nameColor = COLORS.yellow;
368
+ } else {
369
+ icon = c("INFO", COLORS.gray);
370
+ nameColor = COLORS.gray;
371
+ }
372
+
373
+ const detail = item.detail ? c(` (${item.detail})`, COLORS.gray) : "";
374
+ console.log(` ${icon} ${c(item.name, nameColor)}${detail}`);
375
+ }
376
+ }
377
+
378
+ console.log("");
379
+ if (result.tier1Pass) {
380
+ console.log(c("All tier-1 checks passed.", COLORS.green));
381
+ } else {
382
+ const failures = result.items.filter((i) => i.tier === 1 && !i.pass);
383
+ console.log(c(`${failures.length} tier-1 check(s) failed.`, COLORS.red));
384
+ }
385
+ }
@@ -1,5 +1,5 @@
1
1
  import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync } from "fs";
2
- import { join, resolve, basename } from "path";
2
+ import { join, resolve, basename, dirname } from "path";
3
3
  import { formatSuccess, formatWarning, formatError } from "../format";
4
4
  import type { TemplateIR, ResourceIR, TemplateParser } from "../../import/parser";
5
5
  import type { GeneratedFile, TypeScriptGenerator } from "../../import/generator";
@@ -208,10 +208,13 @@ export async function importCommand(options: ImportOptions): Promise<ImportResul
208
208
  };
209
209
  }
210
210
 
211
- // Load plugins from project config, falling back to all installed lexicons
211
+ // Load plugins — resolve from the output directory (or CWD) so that
212
+ // project config is found relative to where the user is working, not
213
+ // an arbitrary monorepo root.
214
+ const projectDir = resolve(options.output ? dirname(options.output) : ".");
212
215
  let plugins: LexiconPlugin[];
213
216
  try {
214
- const lexiconNames = await resolveProjectLexicons(resolve("."));
217
+ const lexiconNames = await resolveProjectLexicons(projectDir);
215
218
  plugins = await loadPlugins(lexiconNames);
216
219
  } catch {
217
220
  plugins = [];
@@ -34,8 +34,10 @@ describe("initLexiconCommand", () => {
34
34
 
35
35
  const expectedFiles = [
36
36
  "src/plugin.ts",
37
+ "src/plugin.test.ts",
37
38
  "src/index.ts",
38
39
  "src/serializer.ts",
40
+ "src/serializer.test.ts",
39
41
  "src/codegen/generate.ts",
40
42
  "src/codegen/generate-cli.ts",
41
43
  "src/codegen/naming.ts",
@@ -47,6 +49,8 @@ describe("initLexiconCommand", () => {
47
49
  "src/lint/rules/index.ts",
48
50
  "src/lsp/completions.ts",
49
51
  "src/lsp/hover.ts",
52
+ "src/lsp/completions.test.ts",
53
+ "src/lsp/hover.test.ts",
50
54
  "src/import/parser.ts",
51
55
  "src/import/generator.ts",
52
56
  "src/coverage.ts",
@@ -62,8 +66,15 @@ describe("initLexiconCommand", () => {
62
66
  "docs/astro.config.mjs",
63
67
  "docs/src/content.config.ts",
64
68
  "docs/src/content/docs/index.mdx",
69
+ "docs/src/content/docs/getting-started.mdx",
70
+ "docs/src/content/docs/serialization.mdx",
71
+ "docs/src/content/docs/lint-rules.mdx",
72
+ "examples/getting-started/package.json",
73
+ "examples/getting-started/src/infra.ts",
65
74
  "src/generated/.gitkeep",
66
- "examples/getting-started/.gitkeep",
75
+ "src/composites/.gitkeep",
76
+ "src/actions/.gitkeep",
77
+ "src/lint/post-synth/.gitkeep",
67
78
  ];
68
79
 
69
80
  for (const file of expectedFiles) {
@@ -248,6 +259,16 @@ describe("init-lexicon fixture snapshot", () => {
248
259
  path: FIXTURE_DIR,
249
260
  });
250
261
 
262
+ // Remove generated .test.ts files so bun test won't try to run them as tests
263
+ for (const f of [
264
+ "src/plugin.test.ts",
265
+ "src/serializer.test.ts",
266
+ "src/lsp/completions.test.ts",
267
+ "src/lsp/hover.test.ts",
268
+ ]) {
269
+ rmSync(join(FIXTURE_DIR, f), { force: true });
270
+ }
271
+
251
272
  expect(result.success).toBe(true);
252
273
 
253
274
  // Key files that must exist