@intentius/chant 0.0.5 → 0.0.9

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 (91) hide show
  1. package/bin/chant +20 -0
  2. package/package.json +18 -17
  3. package/src/bench.test.ts +1 -1
  4. package/src/cli/commands/__fixtures__/init-lexicon-output/src/plugin.ts +0 -25
  5. package/src/cli/commands/__snapshots__/init-lexicon.test.ts.snap +0 -25
  6. package/src/cli/commands/build.ts +1 -2
  7. package/src/cli/commands/doctor.ts +8 -3
  8. package/src/cli/commands/import.ts +2 -2
  9. package/src/cli/commands/init-lexicon.test.ts +0 -3
  10. package/src/cli/commands/init-lexicon.ts +1 -79
  11. package/src/cli/commands/init.test.ts +44 -4
  12. package/src/cli/commands/init.ts +69 -26
  13. package/src/cli/commands/lint.ts +27 -13
  14. package/src/cli/commands/list.ts +2 -2
  15. package/src/cli/commands/update.ts +5 -3
  16. package/src/cli/conflict-check.test.ts +0 -1
  17. package/src/cli/handlers/dev.ts +1 -9
  18. package/src/cli/handlers/init.ts +1 -0
  19. package/src/cli/lsp/server.ts +1 -1
  20. package/src/cli/main.ts +17 -3
  21. package/src/cli/mcp/server.test.ts +233 -4
  22. package/src/cli/mcp/server.ts +6 -0
  23. package/src/cli/mcp/tools/explain.ts +134 -0
  24. package/src/cli/mcp/tools/scaffold.ts +125 -0
  25. package/src/cli/mcp/tools/search.ts +98 -0
  26. package/src/cli/registry.ts +1 -0
  27. package/src/cli/reporters/stylish.test.ts +212 -1
  28. package/src/cli/reporters/stylish.ts +133 -36
  29. package/src/codegen/docs-rules.test.ts +112 -0
  30. package/src/codegen/docs-rules.ts +129 -0
  31. package/src/codegen/docs.ts +3 -1
  32. package/src/codegen/generate-registry.test.ts +1 -1
  33. package/src/codegen/generate-registry.ts +2 -3
  34. package/src/codegen/generate-typescript.test.ts +70 -6
  35. package/src/codegen/generate-typescript.ts +15 -9
  36. package/src/codegen/generate.ts +1 -12
  37. package/src/codegen/package.ts +1 -1
  38. package/src/codegen/typecheck.ts +6 -11
  39. package/src/composite.test.ts +83 -16
  40. package/src/composite.ts +7 -5
  41. package/src/config.ts +4 -0
  42. package/src/detectLexicon.test.ts +2 -2
  43. package/src/discovery/collect.test.ts +2 -2
  44. package/src/discovery/collect.ts +1 -1
  45. package/src/index.ts +2 -1
  46. package/src/lexicon-integrity.ts +5 -4
  47. package/src/lexicon-schema.ts +8 -0
  48. package/src/lexicon.ts +15 -7
  49. package/src/lint/config.ts +8 -6
  50. package/src/lint/declarative.ts +6 -0
  51. package/src/lint/engine.test.ts +287 -11
  52. package/src/lint/engine.ts +101 -23
  53. package/src/lint/rule-registry.test.ts +112 -0
  54. package/src/lint/rule-registry.ts +118 -0
  55. package/src/lint/rule.ts +8 -0
  56. package/src/lint/rules/cor017-composite-name-match.ts +2 -1
  57. package/src/lint/rules/cor018-composite-prefer-lexicon-type.ts +4 -3
  58. package/src/lint/rules/declarable-naming-convention.ts +1 -0
  59. package/src/lint/rules/evl001-non-literal-expression.ts +1 -0
  60. package/src/lint/rules/evl002-control-flow-resource.ts +1 -0
  61. package/src/lint/rules/evl003-dynamic-property-access.ts +1 -0
  62. package/src/lint/rules/evl004-spread-non-const.ts +1 -0
  63. package/src/lint/rules/evl005-resource-block-body.ts +1 -0
  64. package/src/lint/rules/evl007-invalid-siblings.ts +1 -0
  65. package/src/lint/rules/evl009-composite-no-constant.ts +1 -0
  66. package/src/lint/rules/evl010-composite-no-transform.ts +1 -0
  67. package/src/lint/rules/export-required.ts +1 -0
  68. package/src/lint/rules/file-declarable-limit.ts +1 -0
  69. package/src/lint/rules/flat-declarations.test.ts +8 -7
  70. package/src/lint/rules/flat-declarations.ts +2 -3
  71. package/src/lint/rules/no-cyclic-declarable-ref.ts +1 -0
  72. package/src/lint/rules/no-redundant-type-import.ts +1 -0
  73. package/src/lint/rules/no-redundant-value-cast.ts +1 -0
  74. package/src/lint/rules/no-string-ref.ts +1 -0
  75. package/src/lint/rules/no-unused-declarable-import.ts +1 -0
  76. package/src/lint/rules/no-unused-declarable.test.ts +8 -0
  77. package/src/lint/rules/no-unused-declarable.ts +4 -0
  78. package/src/lint/rules/single-concern-file.ts +1 -0
  79. package/src/lsp/lexicon-providers.ts +7 -0
  80. package/src/lsp/types.ts +1 -0
  81. package/src/resource-attributes.test.ts +79 -0
  82. package/src/resource-attributes.ts +42 -0
  83. package/src/runtime-adapter.ts +158 -0
  84. package/src/runtime.ts +4 -3
  85. package/src/serializer-walker.test.ts +0 -9
  86. package/src/serializer-walker.ts +1 -3
  87. package/src/cli/commands/__fixtures__/init-lexicon-output/src/codegen/rollback.ts +0 -45
  88. package/src/codegen/case.test.ts +0 -30
  89. package/src/codegen/case.ts +0 -11
  90. package/src/codegen/rollback.test.ts +0 -92
  91. package/src/codegen/rollback.ts +0 -115
@@ -151,8 +151,8 @@ describe("expandComposite", () => {
151
151
 
152
152
  const expanded = expandComposite("storage", MyComp({}));
153
153
  expect(expanded.size).toBe(2);
154
- expect(expanded.get("storage_bucket")?.entityType).toBe("Bucket");
155
- expect(expanded.get("storage_role")?.entityType).toBe("Role");
154
+ expect(expanded.get("storageBucket")?.entityType).toBe("Bucket");
155
+ expect(expanded.get("storageRole")?.entityType).toBe("Role");
156
156
  });
157
157
 
158
158
  test("handles nested composites", () => {
@@ -167,8 +167,8 @@ describe("expandComposite", () => {
167
167
 
168
168
  const expanded = expandComposite("app", Outer({}));
169
169
  expect(expanded.size).toBe(2);
170
- expect(expanded.get("app_bucket")?.entityType).toBe("Bucket");
171
- expect(expanded.get("app_nested_table")?.entityType).toBe("Table");
170
+ expect(expanded.get("appBucket")?.entityType).toBe("Bucket");
171
+ expect(expanded.get("appNestedTable")?.entityType).toBe("Table");
172
172
  });
173
173
 
174
174
  test("preserves Declarable identity (same object reference)", () => {
@@ -176,7 +176,7 @@ describe("expandComposite", () => {
176
176
  const MyComp = Composite<{}>(() => ({ bucket }));
177
177
 
178
178
  const expanded = expandComposite("s", MyComp({}));
179
- expect(expanded.get("s_bucket")).toBe(bucket);
179
+ expect(expanded.get("sBucket")).toBe(bucket);
180
180
  });
181
181
 
182
182
  test("handles empty composite", () => {
@@ -230,6 +230,22 @@ describe("resource() helper", () => {
230
230
  const instance = resource(MockResource, {});
231
231
  expect(instance.arn).toBeInstanceOf(AttrRef);
232
232
  });
233
+
234
+ test("forwards attributes as second constructor argument", () => {
235
+ // MockResource doesn't store attributes, so use createResource which does
236
+ const { createResource } = require("./runtime");
237
+ const TestRes = createResource("Test::Resource", "test", { arn: "Arn" });
238
+ const attrs = { DependsOn: ["Other"], Condition: "IsProd" };
239
+ const instance = resource(TestRes as any, { name: "test" }, attrs);
240
+ expect((instance as any).attributes).toEqual(attrs);
241
+ });
242
+
243
+ test("without attributes, resource() creates instance with empty attributes", () => {
244
+ const { createResource } = require("./runtime");
245
+ const TestRes = createResource("Test::Resource", "test", { arn: "Arn" });
246
+ const instance = resource(TestRes as any, { name: "test" });
247
+ expect((instance as any).attributes).toEqual({});
248
+ });
233
249
  });
234
250
 
235
251
  function mockDeclarableWithProps(type: string, props: Record<string, unknown>): Declarable {
@@ -313,6 +329,57 @@ describe("withDefaults", () => {
313
329
  expect(CompositeRegistry.size).toBe(1);
314
330
  });
315
331
 
332
+ test("function-based defaults receive caller props", () => {
333
+ let receivedByFn: Partial<{ name: string; timeout: number }> | undefined;
334
+ const Base = Composite<{ name: string; timeout: number }>((props) => ({
335
+ item: mockDeclarable(props.name),
336
+ }));
337
+
338
+ const Wrapped = withDefaults(Base, (props) => {
339
+ receivedByFn = props;
340
+ return { timeout: 30 } as { timeout: number };
341
+ });
342
+ Wrapped({ name: "test" });
343
+ expect(receivedByFn).toEqual({ name: "test" });
344
+ });
345
+
346
+ test("user props override computed defaults", () => {
347
+ let received: { timeout: number } | undefined;
348
+ const Base = Composite<{ timeout: number }>((props) => {
349
+ received = props;
350
+ return { item: mockDeclarable() };
351
+ });
352
+
353
+ const Wrapped = withDefaults(Base, () => ({ timeout: 30 }) as { timeout: number });
354
+ Wrapped({ timeout: 60 });
355
+ expect(received!.timeout).toBe(60);
356
+ });
357
+
358
+ test("stacking: withDefaults(withDefaults(base, static), fn) works", () => {
359
+ let received: { a: number; b: number; c: number } | undefined;
360
+ const Base = Composite<{ a: number; b: number; c: number }>((props) => {
361
+ received = props;
362
+ return { item: mockDeclarable() };
363
+ });
364
+
365
+ const Step1 = withDefaults(Base, { a: 1 });
366
+ const Step2 = withDefaults(Step1, (props) => ({ b: (props.c ?? 0) + 10 }) as { b: number });
367
+ Step2({ c: 3 });
368
+ expect(received).toEqual({ a: 1, b: 13, c: 3 });
369
+ });
370
+
371
+ test("undefined computed values don't overwrite user props", () => {
372
+ let received: { name: string; timeout: number } | undefined;
373
+ const Base = Composite<{ name: string; timeout: number }>((props) => {
374
+ received = props;
375
+ return { item: mockDeclarable() };
376
+ });
377
+
378
+ const Wrapped = withDefaults(Base, () => ({ timeout: undefined }) as unknown as { timeout: number });
379
+ Wrapped({ name: "test", timeout: 42 });
380
+ expect(received!.timeout).toBe(42);
381
+ });
382
+
316
383
  test("expandComposite works identically on defaulted composites", () => {
317
384
  const Base = Composite<{ name: string; timeout: number }>((props) => ({
318
385
  fn: mockDeclarable(`Fn-${props.name}`),
@@ -323,8 +390,8 @@ describe("withDefaults", () => {
323
390
  const expanded = expandComposite("api", Wrapped({ name: "test" }));
324
391
 
325
392
  expect(expanded.size).toBe(2);
326
- expect(expanded.get("api_fn")?.entityType).toBe("Fn-test");
327
- expect(expanded.get("api_role")?.entityType).toBe("Role-test");
393
+ expect(expanded.get("apiFn")?.entityType).toBe("Fn-test");
394
+ expect(expanded.get("apiRole")?.entityType).toBe("Role-test");
328
395
  });
329
396
  });
330
397
 
@@ -342,8 +409,8 @@ describe("propagate", () => {
342
409
  const instance = propagate(MyComp({}), { env: "prod" });
343
410
  const expanded = expandComposite("s", instance);
344
411
 
345
- const bucketProps = (expanded.get("s_bucket") as any).props;
346
- const roleProps = (expanded.get("s_role") as any).props;
412
+ const bucketProps = (expanded.get("sBucket") as any).props;
413
+ const roleProps = (expanded.get("sRole") as any).props;
347
414
  expect(bucketProps.env).toBe("prod");
348
415
  expect(roleProps.env).toBe("prod");
349
416
  });
@@ -359,7 +426,7 @@ describe("propagate", () => {
359
426
  tags: [{ key: "env", value: "prod" }],
360
427
  });
361
428
  const expanded = expandComposite("s", instance);
362
- const tags = (expanded.get("s_bucket") as any).props.tags;
429
+ const tags = (expanded.get("sBucket") as any).props.tags;
363
430
 
364
431
  expect(tags).toEqual([
365
432
  { key: "env", value: "prod" },
@@ -374,7 +441,7 @@ describe("propagate", () => {
374
441
 
375
442
  const instance = propagate(MyComp({}), { region: "eu-west-1" });
376
443
  const expanded = expandComposite("s", instance);
377
- expect((expanded.get("s_bucket") as any).props.region).toBe("us-west-2");
444
+ expect((expanded.get("sBucket") as any).props.region).toBe("us-west-2");
378
445
  });
379
446
 
380
447
  test("undefined values in shared props are stripped", () => {
@@ -384,7 +451,7 @@ describe("propagate", () => {
384
451
 
385
452
  const instance = propagate(MyComp({}), { name: undefined, extra: "yes" });
386
453
  const expanded = expandComposite("s", instance);
387
- const props = (expanded.get("s_bucket") as any).props;
454
+ const props = (expanded.get("sBucket") as any).props;
388
455
  expect(props.name).toBe("data");
389
456
  expect(props.extra).toBe("yes");
390
457
  });
@@ -402,8 +469,8 @@ describe("propagate", () => {
402
469
  const instance = propagate(Outer({}), { env: "prod" });
403
470
  const expanded = expandComposite("app", instance);
404
471
 
405
- expect((expanded.get("app_bucket") as any).props.env).toBe("prod");
406
- expect((expanded.get("app_nested_table") as any).props.env).toBe("prod");
472
+ expect((expanded.get("appBucket") as any).props.env).toBe("prod");
473
+ expect((expanded.get("appNestedTable") as any).props.env).toBe("prod");
407
474
  });
408
475
 
409
476
  test("expanded declarables are same object references", () => {
@@ -412,7 +479,7 @@ describe("propagate", () => {
412
479
 
413
480
  const instance = propagate(MyComp({}), { env: "prod" });
414
481
  const expanded = expandComposite("s", instance);
415
- expect(expanded.get("s_bucket")).toBe(bucket);
482
+ expect(expanded.get("sBucket")).toBe(bucket);
416
483
  });
417
484
 
418
485
  test("composites without propagate work unchanged", () => {
@@ -421,6 +488,6 @@ describe("propagate", () => {
421
488
  }));
422
489
 
423
490
  const expanded = expandComposite("s", MyComp({}));
424
- expect((expanded.get("s_bucket") as any).props.name).toBe("data");
491
+ expect((expanded.get("sBucket") as any).props.name).toBe("data");
425
492
  });
426
493
  });
package/src/composite.ts CHANGED
@@ -123,7 +123,7 @@ export function expandComposite(
123
123
  const shared = (instance as any)[SHARED_PROPS] as Record<string, unknown> | undefined;
124
124
 
125
125
  for (const [memberName, member] of Object.entries(instance.members)) {
126
- const fullName = `${prefix}_${memberName}`;
126
+ const fullName = `${prefix}${memberName[0].toUpperCase()}${memberName.slice(1)}`;
127
127
 
128
128
  if (isCompositeInstance(member)) {
129
129
  const nested = expandComposite(fullName, member);
@@ -182,10 +182,11 @@ type Simplify<T> = { [K in keyof T]: T[K] };
182
182
  */
183
183
  export function withDefaults<P, M extends CompositeMembers, D extends Partial<P>>(
184
184
  definition: CompositeDefinition<P, M>,
185
- defaults: D,
185
+ defaults: D | ((props: Partial<P>) => D),
186
186
  ): CompositeDefinition<Simplify<PartialByDefault<P, D>>, M> {
187
187
  const wrapped = ((props: Simplify<PartialByDefault<P, D>>) => {
188
- return definition({ ...defaults, ...props } as P);
188
+ const resolved = typeof defaults === "function" ? defaults(props as Partial<P>) : defaults;
189
+ return definition({ ...resolved, ...props } as P);
189
190
  }) as CompositeDefinition<Simplify<PartialByDefault<P, D>>, M>;
190
191
 
191
192
  Object.defineProperty(wrapped, "compositeName", {
@@ -236,8 +237,9 @@ export function propagate<M extends CompositeMembers>(
236
237
  * Exists so lint tooling can validate composite member construction (EVL005).
237
238
  */
238
239
  export function resource<T extends Declarable, P>(
239
- Type: new (props: P) => T,
240
+ Type: new (props: P, attributes?: Record<string, unknown>) => T,
240
241
  props: P,
242
+ attributes?: Record<string, unknown>,
241
243
  ): T {
242
- return new Type(props);
244
+ return new Type(props, attributes);
243
245
  }
package/src/config.ts CHANGED
@@ -7,6 +7,7 @@ import type { LintConfig } from "./lint/config";
7
7
  * Zod schema for ChantConfig validation.
8
8
  */
9
9
  export const ChantConfigSchema = z.object({
10
+ runtime: z.enum(["bun", "node"]).optional(),
10
11
  lexicons: z.array(z.string().min(1)).optional(),
11
12
  lint: z.record(z.string(), z.unknown()).optional(),
12
13
  }).passthrough();
@@ -17,6 +18,9 @@ export const ChantConfigSchema = z.object({
17
18
  * Loaded from `chant.config.ts` (preferred) or `chant.config.json`.
18
19
  */
19
20
  export interface ChantConfig {
21
+ /** JS runtime to use for spawned commands: "bun" (default) or "node" */
22
+ runtime?: "bun" | "node";
23
+
20
24
  /** Lexicon package names to load (e.g. ["aws"]) */
21
25
  lexicons?: string[];
22
26
 
@@ -213,7 +213,7 @@ describe("detectLexicons", () => {
213
213
  });
214
214
 
215
215
  test("detects lexicon from export statement", async () => {
216
- const file = join(testDir, "barrel.ts");
216
+ const file = join(testDir, "reexport.ts");
217
217
  await writeFile(
218
218
  file,
219
219
  'export * from "@intentius/chant-lexicon-testdom";\nimport * as core from "@intentius/chant";'
@@ -224,7 +224,7 @@ describe("detectLexicons", () => {
224
224
  });
225
225
 
226
226
  test("detects lexicon from export with curly braces", async () => {
227
- const file = join(testDir, "barrel.ts");
227
+ const file = join(testDir, "reexport.ts");
228
228
  await writeFile(
229
229
  file,
230
230
  'export { Bucket, Interpolate } from "@intentius/chant-lexicon-testdom";'
@@ -262,8 +262,8 @@ describe("collectEntities with composites", () => {
262
262
  { file: "test.ts", exports: { myComp: instance } },
263
263
  ]);
264
264
 
265
- expect(entities.has("myComp_a")).toBe(true);
266
- expect(entities.has("myComp_b")).toBe(true);
265
+ expect(entities.has("myCompA")).toBe(true);
266
+ expect(entities.has("myCompB")).toBe(true);
267
267
  expect(entities.has("myComp")).toBe(false);
268
268
  });
269
269
  });
@@ -23,7 +23,7 @@ export function collectEntities(
23
23
  for (const [name, value] of Object.entries(exports)) {
24
24
  if (isDeclarable(value)) {
25
25
  if (entities.has(name)) {
26
- // Same object re-exported from multiple files (e.g. barrel re-exports) is fine
26
+ // Same object re-exported from multiple files (e.g. re-exports from multiple files) is fine
27
27
  if (entities.get(name) !== value) {
28
28
  throw new DiscoveryError(
29
29
  file,
package/src/index.ts CHANGED
@@ -47,11 +47,12 @@ export * from "./codegen/fetch";
47
47
  export * from "./codegen/generate";
48
48
  export * from "./codegen/package";
49
49
  export * from "./codegen/typecheck";
50
- export * from "./codegen/rollback";
51
50
  export * from "./codegen/coverage";
52
51
  export * from "./codegen/validate";
53
52
  export * from "./codegen/docs";
54
53
  export * from "./runtime";
54
+ export * from "./runtime-adapter";
55
+ export * from "./resource-attributes";
55
56
  export * from "./stack-output";
56
57
  export * from "./child-project";
57
58
  export * from "./lsp/types";
@@ -2,18 +2,19 @@
2
2
  * Content hashing and integrity verification for lexicon artifacts.
3
3
  */
4
4
  import type { BundleSpec } from "./lexicon";
5
+ import { getRuntime } from "./runtime-adapter";
5
6
 
6
7
  export interface ArtifactIntegrity {
7
- algorithm: "xxhash64";
8
+ algorithm: string;
8
9
  artifacts: Record<string, string>;
9
10
  composite: string;
10
11
  }
11
12
 
12
13
  /**
13
- * Hash a single artifact's content using xxhash64.
14
+ * Hash a single artifact's content using the runtime's hash algorithm.
14
15
  */
15
16
  export function hashArtifact(content: string): string {
16
- return Bun.hash(content).toString(16);
17
+ return getRuntime().hash(content);
17
18
  }
18
19
 
19
20
  /**
@@ -39,7 +40,7 @@ export function computeIntegrity(spec: BundleSpec): ArtifactIntegrity {
39
40
  const compositeInput = sorted.map(([k, v]) => `${k}:${v}`).join("\n");
40
41
  const composite = hashArtifact(compositeInput);
41
42
 
42
- return { algorithm: "xxhash64", artifacts, composite };
43
+ return { algorithm: getRuntime().hashAlgorithm, artifacts, composite };
43
44
  }
44
45
 
45
46
  /**
@@ -72,6 +72,14 @@ export const LexiconEntrySchema = z.object({
72
72
  createOnly: z.array(z.string()).optional(),
73
73
  writeOnly: z.array(z.string()).optional(),
74
74
  primaryIdentifier: z.array(z.string()).optional(),
75
+ deprecatedProperties: z.array(z.string()).optional(),
76
+ conditionalCreateOnly: z.array(z.string()).optional(),
77
+ replacementStrategy: z.enum(["delete_then_create", "create_then_delete"]).optional(),
78
+ tagging: z.object({
79
+ taggable: z.boolean(),
80
+ tagOnCreate: z.boolean(),
81
+ tagUpdatable: z.boolean(),
82
+ }).optional(),
75
83
  runtimeDeprecations: z.record(z.string(), z.string()).optional(),
76
84
  });
77
85
 
package/src/lexicon.ts CHANGED
@@ -97,11 +97,23 @@ export interface IntrinsicDef {
97
97
  readonly isTag?: boolean;
98
98
  }
99
99
 
100
+ /**
101
+ * Structured init template output from a lexicon plugin.
102
+ */
103
+ export interface InitTemplateSet {
104
+ /** Source files written to src/ */
105
+ src: Record<string, string>;
106
+ /** Application scaffold files written to project root */
107
+ root?: Record<string, string>;
108
+ /** Scripts merged into generated package.json */
109
+ scripts?: Record<string, string>;
110
+ }
111
+
100
112
  /**
101
113
  * Plugin interface for lexicon packages.
102
114
  *
103
115
  * Required lifecycle methods enforce consistency: every lexicon must support
104
- * generate, validate, coverage, package, and rollback operations.
116
+ * generate, validate, coverage, and package operations.
105
117
  */
106
118
  export interface LexiconPlugin {
107
119
  // ── Required ──────────────────────────────────────────────
@@ -123,9 +135,6 @@ export interface LexiconPlugin {
123
135
  /** Package lexicon into distributable tarball */
124
136
  package(options?: { verbose?: boolean; force?: boolean }): Promise<void>;
125
137
 
126
- /** List or restore generation snapshots */
127
- rollback(options?: { restore?: string; verbose?: boolean }): Promise<void>;
128
-
129
138
  // ── Optional extensions ───────────────────────────────────
130
139
  /** Return lint rules provided by this lexicon */
131
140
  lintRules?(): LintRule[];
@@ -159,7 +168,7 @@ export interface LexiconPlugin {
159
168
  skills?(): SkillDefinition[];
160
169
 
161
170
  /** Return source file templates for `chant init` project scaffolding */
162
- initTemplates?(): Record<string, string>;
171
+ initTemplates?(template?: string): InitTemplateSet;
163
172
 
164
173
  /** Optional initialization hook */
165
174
  init?(): void | Promise<void>;
@@ -206,7 +215,6 @@ export function isLexiconPlugin(value: unknown): value is LexiconPlugin {
206
215
  typeof obj.generate === "function" &&
207
216
  typeof obj.validate === "function" &&
208
217
  typeof obj.coverage === "function" &&
209
- typeof obj.package === "function" &&
210
- typeof obj.rollback === "function"
218
+ typeof obj.package === "function"
211
219
  );
212
220
  }
@@ -1,13 +1,15 @@
1
1
  import { readFileSync, existsSync } from "fs";
2
2
  import { join, dirname, resolve } from "path";
3
+ import { createRequire } from "module";
3
4
  import { z } from "zod";
4
5
  import type { Severity, RuleConfig } from "./rule";
6
+ import { moduleDir, getRuntime } from "../runtime-adapter";
5
7
  import strictPreset from "./presets/strict.json";
6
8
 
7
9
  /** Mapping of built-in preset names to their file paths */
8
10
  const BUILTIN_PRESETS: Record<string, string> = {
9
- "@intentius/chant/lint/presets/strict": resolve(import.meta.dir, "presets/strict.json"),
10
- "@intentius/chant/lint/presets/relaxed": resolve(import.meta.dir, "presets/relaxed.json"),
11
+ "@intentius/chant/lint/presets/strict": resolve(moduleDir(import.meta.url), "presets/strict.json"),
12
+ "@intentius/chant/lint/presets/relaxed": resolve(moduleDir(import.meta.url), "presets/relaxed.json"),
11
13
  };
12
14
 
13
15
  // ── Zod schemas for lint config validation ─────────────────────────
@@ -307,11 +309,12 @@ function loadConfigFile(configPath: string, visited: Set<string> = new Set()): L
307
309
  * @returns Loaded and merged configuration, or default config if not found
308
310
  */
309
311
  export function loadConfig(dir: string): LintConfig {
310
- // Try chant.config.ts first — Bun supports synchronous require() for .ts
312
+ // Try chant.config.ts first — Bun has native require() for .ts, Node uses tsx's loader
311
313
  const tsConfigPath = join(dir, "chant.config.ts");
312
314
  if (existsSync(tsConfigPath)) {
313
315
  try {
314
- const mod = require(tsConfigPath);
316
+ const _require = createRequire(join(dir, "package.json"));
317
+ const mod = _require(tsConfigPath);
315
318
  const config = mod.default ?? mod.config ?? mod;
316
319
  if (typeof config === "object" && config !== null) {
317
320
  // ChantConfig format: extract lint property
@@ -362,8 +365,7 @@ export function resolveRulesForFile(config: LintConfig, filePath: string): Recor
362
365
 
363
366
  for (const override of config.overrides) {
364
367
  const matches = override.files.some((pattern) => {
365
- const glob = new Bun.Glob(pattern);
366
- return glob.match(filePath);
368
+ return getRuntime().globMatch(pattern, filePath);
367
369
  });
368
370
 
369
371
  if (matches) {
@@ -39,6 +39,10 @@ export interface RuleSpec {
39
39
  severity: Severity;
40
40
  /** Category for grouping */
41
41
  category: Category;
42
+ /** Human-readable description of what this rule checks */
43
+ description?: string;
44
+ /** Link to rule documentation */
45
+ helpUri?: string;
42
46
  /** Selector name (or compound) to find target nodes */
43
47
  selector: string;
44
48
  /** Optional match condition to further filter nodes */
@@ -61,6 +65,8 @@ export function rule(spec: RuleSpec): LintRule {
61
65
  id: spec.id,
62
66
  severity: spec.severity,
63
67
  category: spec.category,
68
+ description: spec.description,
69
+ helpUri: spec.helpUri,
64
70
  check(context: LintContext): LintDiagnostic[] {
65
71
  const diagnostics: LintDiagnostic[] = [];
66
72
  const sf = context.sourceFile;