@intentius/chant 0.79.0 → 0.80.0

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 (162) hide show
  1. package/dist/audit/core.d.ts.map +1 -1
  2. package/dist/audit/discover.d.ts +38 -0
  3. package/dist/audit/discover.d.ts.map +1 -1
  4. package/dist/audit/terraform-state.d.ts +14 -0
  5. package/dist/audit/terraform-state.d.ts.map +1 -1
  6. package/dist/cli/command-group.d.ts +6 -0
  7. package/dist/cli/command-group.d.ts.map +1 -1
  8. package/dist/cli/commands/audit.d.ts +27 -2
  9. package/dist/cli/commands/audit.d.ts.map +1 -1
  10. package/dist/cli/commands/check-lexicon-examples.d.ts.map +1 -1
  11. package/dist/cli/commands/doctor.d.ts.map +1 -1
  12. package/dist/cli/commands/import-agents.d.ts.map +1 -1
  13. package/dist/cli/commands/init.d.ts +6 -0
  14. package/dist/cli/commands/init.d.ts.map +1 -1
  15. package/dist/cli/commands/lint.d.ts.map +1 -1
  16. package/dist/cli/commands/onboard.d.ts.map +1 -1
  17. package/dist/cli/commands/update.d.ts.map +1 -1
  18. package/dist/cli/conflict-check.d.ts +6 -0
  19. package/dist/cli/conflict-check.d.ts.map +1 -1
  20. package/dist/cli/handlers/dev.d.ts.map +1 -1
  21. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  22. package/dist/cli/handlers/misc.d.ts.map +1 -1
  23. package/dist/cli/handlers/operator.d.ts.map +1 -1
  24. package/dist/cli/handlers/promote.d.ts +17 -0
  25. package/dist/cli/handlers/promote.d.ts.map +1 -0
  26. package/dist/cli/handlers/run-generate.d.ts +39 -0
  27. package/dist/cli/handlers/run-generate.d.ts.map +1 -0
  28. package/dist/cli/handlers/run.d.ts.map +1 -1
  29. package/dist/cli/main.d.ts.map +1 -1
  30. package/dist/cli/mcp/op-tools.d.ts.map +1 -1
  31. package/dist/cli/plugins.d.ts +9 -0
  32. package/dist/cli/plugins.d.ts.map +1 -1
  33. package/dist/cli/registry.d.ts +14 -1
  34. package/dist/cli/registry.d.ts.map +1 -1
  35. package/dist/components/capability-plugin-loader.d.ts.map +1 -1
  36. package/dist/components/cli-support.d.ts.map +1 -1
  37. package/dist/components/discover.d.ts.map +1 -1
  38. package/dist/components/driver.d.ts.map +1 -1
  39. package/dist/components/fan-out-support.d.ts.map +1 -1
  40. package/dist/components/pilots/alb-ecs.pilot.d.ts +7 -1
  41. package/dist/components/pilots/alb-ecs.pilot.d.ts.map +1 -1
  42. package/dist/components/presets/ecs-fargate.d.ts +10 -4
  43. package/dist/components/presets/ecs-fargate.d.ts.map +1 -1
  44. package/dist/components/promote.d.ts +211 -0
  45. package/dist/components/promote.d.ts.map +1 -0
  46. package/dist/components/unbound-gate-approval.d.ts +24 -0
  47. package/dist/components/unbound-gate-approval.d.ts.map +1 -0
  48. package/dist/config.d.ts +82 -5
  49. package/dist/config.d.ts.map +1 -1
  50. package/dist/discovery/convergence.d.ts +97 -0
  51. package/dist/discovery/convergence.d.ts.map +1 -0
  52. package/dist/discovery/files.d.ts +41 -2
  53. package/dist/discovery/files.d.ts.map +1 -1
  54. package/dist/discovery/fold-import.d.ts +15 -0
  55. package/dist/discovery/fold-import.d.ts.map +1 -1
  56. package/dist/graph-ir.d.ts +13 -0
  57. package/dist/graph-ir.d.ts.map +1 -1
  58. package/dist/lexicon-module.d.ts +65 -0
  59. package/dist/lexicon-module.d.ts.map +1 -0
  60. package/dist/lexicon.d.ts +24 -0
  61. package/dist/lexicon.d.ts.map +1 -1
  62. package/dist/lifecycle/build-ledger-store.d.ts.map +1 -1
  63. package/dist/lifecycle/legacy-digest.d.ts +26 -0
  64. package/dist/lifecycle/legacy-digest.d.ts.map +1 -0
  65. package/dist/lifecycle/release-ledger.d.ts +25 -0
  66. package/dist/lifecycle/release-ledger.d.ts.map +1 -1
  67. package/dist/lint/rules/comp/comp003-mutating-no-rollback.d.ts +2 -3
  68. package/dist/lint/rules/comp/comp003-mutating-no-rollback.d.ts.map +1 -1
  69. package/dist/op/activity-contract-registry.d.ts.map +1 -1
  70. package/dist/op/activity-registry.d.ts.map +1 -1
  71. package/dist/op/discover.d.ts.map +1 -1
  72. package/dist/op/gate-approval.d.ts.map +1 -1
  73. package/dist/op/generate-pipeline.d.ts.map +1 -1
  74. package/dist/op/runtimes/local.d.ts.map +1 -1
  75. package/dist/workspace/record-source.d.ts +22 -0
  76. package/dist/workspace/record-source.d.ts.map +1 -0
  77. package/dist/workspace/records-cli.d.ts +57 -0
  78. package/dist/workspace/records-cli.d.ts.map +1 -0
  79. package/dist/workspace/records.d.ts +118 -0
  80. package/dist/workspace/records.d.ts.map +1 -0
  81. package/package.json +3 -3
  82. package/src/audit/core.ts +13 -2
  83. package/src/audit/discover.ts +90 -12
  84. package/src/audit/terraform-state.test.ts +86 -1
  85. package/src/audit/terraform-state.ts +26 -0
  86. package/src/cli/command-group.ts +7 -0
  87. package/src/cli/commands/audit-walk-warnings.test.ts +156 -0
  88. package/src/cli/commands/audit.ts +93 -19
  89. package/src/cli/commands/check-lexicon-examples.ts +2 -1
  90. package/src/cli/commands/doctor.ts +3 -0
  91. package/src/cli/commands/import-agents.ts +11 -4
  92. package/src/cli/commands/init.ts +57 -25
  93. package/src/cli/commands/lint.test.ts +58 -1
  94. package/src/cli/commands/lint.ts +48 -18
  95. package/src/cli/commands/onboard.ts +15 -0
  96. package/src/cli/commands/update.ts +5 -1
  97. package/src/cli/conflict-check.test.ts +18 -1
  98. package/src/cli/conflict-check.ts +13 -0
  99. package/src/cli/discovery-skip.test.ts +168 -0
  100. package/src/cli/handlers/build.test.ts +11 -0
  101. package/src/cli/handlers/build.ts +1 -1
  102. package/src/cli/handlers/dev.ts +3 -0
  103. package/src/cli/handlers/graph.test.ts +20 -0
  104. package/src/cli/handlers/graph.ts +4 -2
  105. package/src/cli/handlers/lifecycle.ts +2 -1
  106. package/src/cli/handlers/misc.ts +16 -0
  107. package/src/cli/handlers/operator.ts +2 -1
  108. package/src/cli/handlers/promote.test.ts +261 -0
  109. package/src/cli/handlers/promote.ts +325 -0
  110. package/src/cli/handlers/run-generate.test.ts +160 -0
  111. package/src/cli/handlers/run-generate.ts +133 -0
  112. package/src/cli/handlers/run.ts +13 -1
  113. package/src/cli/main.test.ts +33 -1
  114. package/src/cli/main.ts +69 -16
  115. package/src/cli/mcp/op-tools.ts +2 -1
  116. package/src/cli/path-lexicon-messages.test.ts +152 -0
  117. package/src/cli/plugins.ts +44 -6
  118. package/src/cli/registry.ts +14 -1
  119. package/src/components/__fixtures__/alb-ecs-service.json +0 -8
  120. package/src/components/capability-plugin-loader.ts +3 -1
  121. package/src/components/cli-support.ts +3 -2
  122. package/src/components/discover.ts +26 -2
  123. package/src/components/driver.test.ts +12 -11
  124. package/src/components/driver.ts +2 -0
  125. package/src/components/fan-out-support.ts +2 -1
  126. package/src/components/pilots/README.md +1 -1
  127. package/src/components/pilots/alb-ecs.pilot.ts +7 -7
  128. package/src/components/presets/ecs-fargate.ts +10 -5
  129. package/src/components/promote.test.ts +371 -0
  130. package/src/components/promote.ts +537 -0
  131. package/src/components/unbound-gate-approval.test.ts +82 -0
  132. package/src/components/unbound-gate-approval.ts +51 -0
  133. package/src/config.test.ts +40 -0
  134. package/src/config.ts +128 -11
  135. package/src/discovery/convergence.test.ts +231 -0
  136. package/src/discovery/convergence.ts +399 -0
  137. package/src/discovery/files.test.ts +127 -1
  138. package/src/discovery/files.ts +97 -9
  139. package/src/discovery/fold-import.ts +83 -9
  140. package/src/discovery/sandbox/path-lexicon-parity.test.ts +162 -0
  141. package/src/graph-ir.ts +14 -0
  142. package/src/lexicon-module.test.ts +203 -0
  143. package/src/lexicon-module.ts +117 -0
  144. package/src/lexicon.ts +26 -0
  145. package/src/lifecycle/build-ledger-store.ts +11 -1
  146. package/src/lifecycle/legacy-digest.test.ts +114 -0
  147. package/src/lifecycle/legacy-digest.ts +62 -0
  148. package/src/lifecycle/release-ledger.ts +34 -0
  149. package/src/lint/rules/comp/comp003-mutating-no-rollback.ts +2 -3
  150. package/src/op/activity-contract-registry.ts +18 -4
  151. package/src/op/activity-registry.ts +26 -0
  152. package/src/op/discover-convergence.test.ts +70 -0
  153. package/src/op/discover.ts +4 -0
  154. package/src/op/gate-approval.ts +3 -1
  155. package/src/op/generate-pipeline.ts +2 -1
  156. package/src/op/runtimes/local.ts +2 -1
  157. package/src/workspace/record-source.ts +117 -0
  158. package/src/workspace/records-cli.ts +126 -0
  159. package/src/workspace/records-contract.test.ts +88 -0
  160. package/src/workspace/records.schema.json +120 -0
  161. package/src/workspace/records.test.ts +243 -0
  162. package/src/workspace/records.ts +383 -0
@@ -12,6 +12,7 @@ import {
12
12
  environmentEndpoint,
13
13
  matchesEnvironmentPattern,
14
14
  matchesDeclaredEnvironment,
15
+ resolveDiscoveryGlobs,
15
16
  } from "./config";
16
17
  import { writeFileSync, mkdirSync, rmSync } from "fs";
17
18
  import { join } from "path";
@@ -448,3 +449,42 @@ describe("resolveKnowledgeDir (#1864, design #1059)", () => {
448
449
  expect(resolveKnowledgeDir({ knowledge: {} }, "/proj")).toBe(join("/proj", "knowledge"));
449
450
  });
450
451
  });
452
+
453
+ describe("exclude / include discovery globs (#2519)", () => {
454
+ test("loads string arrays", async () => {
455
+ writeFileSync(join(TEST_DIR, "chant.config.json"), JSON.stringify({ exclude: ["ops/**"], include: ["ops/keep.ts"] }));
456
+ const { config } = await loadChantConfig(TEST_DIR);
457
+ expect(config.exclude).toEqual(["ops/**"]);
458
+ expect(config.include).toEqual(["ops/keep.ts"]);
459
+ });
460
+
461
+ test.each([
462
+ ["a non-string entry", { exclude: ["ops/**", 42] }, /exclude\.1: .*expected string/],
463
+ ["a bare string", { exclude: "ops/**" }, /exclude: .*expected array/],
464
+ ["an empty pattern", { include: [""] }, /include\.0: /],
465
+ ])("rejects %s loudly", async (_label, raw, message) => {
466
+ writeFileSync(join(TEST_DIR, "chant.config.json"), JSON.stringify(raw));
467
+ await expect(loadChantConfig(TEST_DIR)).rejects.toThrow(message);
468
+ await expect(resolveDiscoveryGlobs(TEST_DIR)).rejects.toThrow(message);
469
+ });
470
+
471
+ test("resolveDiscoveryGlobs is undefined without exclude, so include alone changes nothing", async () => {
472
+ expect(await resolveDiscoveryGlobs(TEST_DIR)).toBeUndefined();
473
+ writeFileSync(join(TEST_DIR, "chant.config.json"), JSON.stringify({ include: ["ops/**"] }));
474
+ expect(await resolveDiscoveryGlobs(TEST_DIR)).toBeUndefined();
475
+ });
476
+
477
+ test("resolveDiscoveryGlobs anchors the globs at the project config's directory, past a lint fragment", async () => {
478
+ const src = join(TEST_DIR, "src");
479
+ mkdirSync(src, { recursive: true });
480
+ writeFileSync(join(src, "chant.config.json"), JSON.stringify({ rules: { COR001: "off" } }));
481
+ writeFileSync(join(TEST_DIR, "chant.config.json"), JSON.stringify({ exclude: ["ops"] }));
482
+ expect(await resolveDiscoveryGlobs(src)).toEqual({ root: TEST_DIR, exclude: ["ops"], include: [] });
483
+ });
484
+
485
+ test("resolveDiscoveryGlobs leaves an unrelated config error to the command's own load", async () => {
486
+ writeFileSync(join(TEST_DIR, "chant.config.json"), JSON.stringify({ lexicons: [7], exclude: ["ops"] }));
487
+ await expect(loadChantConfig(TEST_DIR)).rejects.toThrow(/lexicons\.0/);
488
+ expect(await resolveDiscoveryGlobs(TEST_DIR)).toBeUndefined();
489
+ });
490
+ });
package/src/config.ts CHANGED
@@ -10,6 +10,7 @@ import type { BuildParamsConfig } from "./build-params";
10
10
  import type { BuildParamProvenance } from "./provenance";
11
11
  import { findProjectConfig } from "./project-root";
12
12
  import { evaluateProjectConfig } from "./config-sandbox";
13
+ import { lexiconNames, registerLexiconDeclarations, type LexiconDeclaration } from "./lexicon-module";
13
14
 
14
15
  /**
15
16
  * One project-declared environment (chant #1166). Historically always a bare
@@ -125,8 +126,16 @@ const EnvironmentEntrySchema = z.union([
125
126
  }),
126
127
  ]);
127
128
 
129
+ const LexiconEntrySchema = z.union([
130
+ z.string().min(1),
131
+ z.object({
132
+ name: z.string().min(1),
133
+ module: z.string().min(1),
134
+ }).strict(),
135
+ ]);
136
+
128
137
  export const ChantConfigSchema = z.object({
129
- lexicons: z.array(z.string().min(1)).optional(),
138
+ lexicons: z.array(LexiconEntrySchema).optional(),
130
139
  capabilities: z.array(z.string().min(1)).optional(),
131
140
  environments: z.array(EnvironmentEntrySchema).optional(),
132
141
  sourceDir: z.string().min(1).optional(),
@@ -184,6 +193,8 @@ export const ChantConfigSchema = z.object({
184
193
  knowledge: z.object({
185
194
  dir: z.string().min(1).optional(),
186
195
  }).optional(),
196
+ exclude: z.array(z.string().min(1)).optional(),
197
+ include: z.array(z.string().min(1)).optional(),
187
198
  }).passthrough();
188
199
 
189
200
  /**
@@ -192,8 +203,24 @@ export const ChantConfigSchema = z.object({
192
203
  * Loaded from `chant.config.ts` (preferred) or `chant.config.json`.
193
204
  */
194
205
  export interface ChantConfig {
195
- /** Lexicon package names to load (e.g. ["aws"]) */
196
- lexicons?: string[];
206
+ /**
207
+ * Lexicons to load. A bare name (`"aws"`) loads the package
208
+ * `@intentius/chant-lexicon-aws`. `{ name, module }` (#2520) loads a
209
+ * project-local lexicon from a module path, resolved against the directory
210
+ * holding this config; see `./lexicon-module.ts`.
211
+ *
212
+ * Once loaded, this holds names only: the loader replaces each
213
+ * `{ name, module }` entry with its name and records the path in
214
+ * {@link ChantConfig.lexiconModules}.
215
+ */
216
+ lexicons?: LexiconDeclaration[];
217
+
218
+ /**
219
+ * Set by the config loader, never written by hand: each path-declared
220
+ * lexicon's name mapped to its absolute module path. Absent when every
221
+ * `lexicons` entry is a bare name.
222
+ */
223
+ lexiconModules?: Record<string, string>;
197
224
 
198
225
  /**
199
226
  * Capability plugin package names to load in addition to the built-in
@@ -418,14 +445,43 @@ export interface ChantConfig {
418
445
  /** Bundle directory, relative to the project root. Defaults to `"knowledge"`. */
419
446
  dir?: string;
420
447
  };
448
+
449
+ /**
450
+ * Globs, relative to the directory holding this config, naming files that
451
+ * source discovery must skip (#2519): `build`, `lint`, `list`, `explain`
452
+ * and every other command that walks the project for declarations never
453
+ * import or lint a matching file. A pattern matches a file when it matches
454
+ * the file's path or the path of any directory above it, so `"ops"` and
455
+ * `"ops/**"` both skip everything under `ops/`. Matched with picomatch,
456
+ * dot files included. See `./discovery/files.ts`'s `compileDiscoveryFilter`.
457
+ */
458
+ exclude?: string[];
459
+
460
+ /**
461
+ * Globs that re-admit files {@link exclude} would skip. `include` always
462
+ * wins over `exclude`, and it never narrows discovery on its own: a file
463
+ * no `exclude` pattern matches is discovered whether or not it matches an
464
+ * `include`. It does not re-admit what discovery skips unconditionally
465
+ * (`node_modules`, test files, child projects, the generated and skip
466
+ * markers).
467
+ */
468
+ include?: string[];
421
469
  }
422
470
 
423
471
  /**
424
472
  * Resolved project configuration with metadata about how it was loaded.
425
473
  */
474
+ /**
475
+ * A configuration as the loader returns it. `lexicons` holds names only: each
476
+ * `{ name, module }` entry has been replaced by its name and its path moved
477
+ * to `lexiconModules` (chant #2520), so a reader of a loaded config never
478
+ * sees the object form.
479
+ */
480
+ export type LoadedChantConfig = Omit<ChantConfig, "lexicons"> & { lexicons?: string[] };
481
+
426
482
  export interface ResolvedConfig {
427
483
  /** The loaded configuration */
428
- config: ChantConfig;
484
+ config: LoadedChantConfig;
429
485
 
430
486
  /** Path to the config file that was loaded, or undefined if defaults */
431
487
  configPath?: string;
@@ -434,7 +490,7 @@ export interface ResolvedConfig {
434
490
  /**
435
491
  * Default configuration when no config file exists.
436
492
  */
437
- export const DEFAULT_CHANT_CONFIG: ChantConfig = {};
493
+ export const DEFAULT_CHANT_CONFIG: LoadedChantConfig = {};
438
494
 
439
495
  /**
440
496
  * Load project configuration from a directory.
@@ -499,14 +555,53 @@ export async function loadChantConfig(dir: string): Promise<ResolvedConfig> {
499
555
  * config declaring any project-level key still wins where it stands.
500
556
  */
501
557
  export async function loadChantConfigUpward(startDir: string): Promise<ResolvedConfig> {
558
+ const { dir, configPath } = findProjectConfigPastFragments(startDir);
559
+ if (configPath) warnIfFragmentShadowsProjectConfig(configPath);
560
+ return loadChantConfig(dir);
561
+ }
562
+
563
+ /** The walk {@link loadChantConfigUpward} makes, lint-only fragments skipped, without its warning. */
564
+ function findProjectConfigPastFragments(startDir: string): { dir: string; configPath?: string } {
502
565
  let { dir, configPath } = findProjectConfig(startDir);
503
566
  while (configPath && isLintOnlyFragment(configPath)) {
504
567
  const parent = dirname(dir);
505
568
  if (parent === dir) break;
506
569
  ({ dir, configPath } = findProjectConfig(parent));
507
570
  }
508
- if (configPath) warnIfFragmentShadowsProjectConfig(configPath);
509
- return loadChantConfig(dir);
571
+ return { dir, configPath };
572
+ }
573
+
574
+ /** A project's discovery globs (#2519) and the directory they are relative to. */
575
+ export interface DiscoveryGlobs {
576
+ /** The directory holding the config that declared the globs. */
577
+ root: string;
578
+ exclude: string[];
579
+ include: string[];
580
+ }
581
+
582
+ /**
583
+ * The `exclude`/`include` globs of the project `startDir` belongs to, found by
584
+ * the same upward walk as {@link loadChantConfigUpward}. `undefined` when the
585
+ * project declares no `exclude`, which leaves discovery exactly as it was
586
+ * before the keys existed: `include` only ever re-admits what `exclude` skips.
587
+ *
588
+ * A config that fails to load is left to the command's own config load to
589
+ * report, so discovery keeps working where it did before, unless the failure
590
+ * is in `exclude` or `include` themselves: ignoring those would discover the
591
+ * very files the project asked to skip.
592
+ */
593
+ export async function resolveDiscoveryGlobs(startDir: string): Promise<DiscoveryGlobs | undefined> {
594
+ const { dir, configPath } = findProjectConfigPastFragments(startDir);
595
+ if (!configPath) return undefined;
596
+ let config: ChantConfig;
597
+ try {
598
+ ({ config } = await loadChantConfig(dir));
599
+ } catch (err) {
600
+ if (err instanceof InvalidChantConfigError && (err.key === "exclude" || err.key === "include")) throw err;
601
+ return undefined;
602
+ }
603
+ if (!config.exclude || config.exclude.length === 0) return undefined;
604
+ return { root: dir, exclude: config.exclude, include: config.include ?? [] };
510
605
  }
511
606
 
512
607
  /**
@@ -531,7 +626,7 @@ const LINT_FRAGMENT_KEYS = new Set(["$schema", "extends", "rules", "overrides",
531
626
  */
532
627
  const isCommentKey = (k: string): boolean => k.startsWith("_");
533
628
 
534
- function isLintOnlyFragment(configPath: string): boolean {
629
+ export function isLintOnlyFragment(configPath: string): boolean {
535
630
  if (!configPath.endsWith("chant.config.json")) return false;
536
631
  try {
537
632
  const parsed = JSON.parse(readFileSync(configPath, "utf-8")) as unknown;
@@ -813,10 +908,20 @@ export function resolveKnowledgeDir(config: ChantConfig, projectPath: string): s
813
908
  return join(projectPath, config.knowledge?.dir ?? "knowledge");
814
909
  }
815
910
 
911
+ /**
912
+ * A config that failed schema validation. `key` is the top-level key of the
913
+ * first problem. `name` stays `"Error"`, so the printed error reads as before.
914
+ */
915
+ export class InvalidChantConfigError extends Error {
916
+ constructor(message: string, readonly key?: string) {
917
+ super(message);
918
+ }
919
+ }
920
+
816
921
  /**
817
922
  * Validate and normalize a raw config object into ChantConfig shape.
818
923
  */
819
- function normalizeConfig(raw: Record<string, unknown>, source?: string): ChantConfig {
924
+ function normalizeConfig(raw: Record<string, unknown>, source?: string): LoadedChantConfig {
820
925
  if (typeof raw !== "object" || raw === null) {
821
926
  return DEFAULT_CHANT_CONFIG;
822
927
  }
@@ -826,8 +931,20 @@ function normalizeConfig(raw: Record<string, unknown>, source?: string): ChantCo
826
931
  const issue = result.error.issues[0];
827
932
  const path = issue.path.length > 0 ? issue.path.join(".") : undefined;
828
933
  const loc = source ? ` in ${source}` : "";
829
- throw new Error(`Invalid chant config${loc}: ${path ? `${path}: ` : ""}${issue.message}`);
934
+ throw new InvalidChantConfigError(
935
+ `Invalid chant config${loc}: ${path ? `${path}: ` : ""}${issue.message}`,
936
+ issue.path.length > 0 ? String(issue.path[0]) : undefined,
937
+ );
938
+ }
939
+
940
+ // chant #2520 — a lexicon declared by module path. The path is recorded for
941
+ // the loaders and the entry becomes its name, so every reader of `lexicons`
942
+ // keeps seeing names. A config of plain names is returned untouched.
943
+ const lexicons = (raw as ChantConfig).lexicons;
944
+ const recorded = registerLexiconDeclarations(lexicons, source !== undefined ? dirname(source) : process.cwd());
945
+ if (Object.keys(recorded).length > 0) {
946
+ return { ...(raw as ChantConfig), lexicons: lexiconNames(lexicons), lexiconModules: recorded };
830
947
  }
831
948
 
832
- return raw as ChantConfig;
949
+ return raw as LoadedChantConfig;
833
950
  }
@@ -0,0 +1,231 @@
1
+ import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, realpathSync } from "node:fs";
3
+ import { execFileSync } from "node:child_process";
4
+ import { tmpdir } from "node:os";
5
+ import { dirname, join } from "node:path";
6
+ import { findInfraFiles } from "./files";
7
+ import { resetDiscoveryWarnings, warnDiscoveryChanges } from "./convergence";
8
+ import { discoverComponents } from "../components/discover";
9
+
10
+ /**
11
+ * chant#2527's warning release. Each category of file whose discovery status
12
+ * changes when the walkers converge gets a warning naming the file, the
13
+ * change and the glob that keeps today's behaviour, while the walker's
14
+ * returned list stays exactly what it is today. Lint's category lives in
15
+ * `../cli/commands/lint.test.ts`, the Op category in
16
+ * `../op/discover-convergence.test.ts`.
17
+ */
18
+
19
+ let dir: string;
20
+ let stderr: string[];
21
+
22
+ function write(rel: string, body = "export const x = 1;\n"): void {
23
+ mkdirSync(dirname(join(dir, rel)), { recursive: true });
24
+ writeFileSync(join(dir, rel), body);
25
+ }
26
+
27
+ /** A project config (it declares a project-level key, so it is no lint-only fragment). */
28
+ function project(rel = ".", extra: Record<string, unknown> = {}): void {
29
+ write(join(rel, "chant.config.json"), JSON.stringify({ lexicons: ["aws"], ...extra }));
30
+ }
31
+
32
+ const rel = (files: string[]): string[] => files.map((f) => f.slice(dir.length + 1)).sort();
33
+
34
+ beforeEach(() => {
35
+ dir = realpathSync(mkdtempSync(join(tmpdir(), "chant-2527-")));
36
+ resetDiscoveryWarnings();
37
+ stderr = [];
38
+ vi.spyOn(console, "error").mockImplementation((...args: unknown[]) => void stderr.push(args.join(" ")));
39
+ vi.spyOn(process, "cwd").mockReturnValue(dir);
40
+ });
41
+
42
+ afterEach(() => {
43
+ vi.restoreAllMocks();
44
+ rmSync(dir, { recursive: true, force: true });
45
+ });
46
+
47
+ describe("source discovery warns about files the converged walker reads differently (#2527)", () => {
48
+ test("a .ts file in a dot-directory", async () => {
49
+ project();
50
+ write("src/app.ts");
51
+ write(".cache/gen.ts");
52
+
53
+ expect(rel(await findInfraFiles(dir))).toEqual([".cache/gen.ts", "src/app.ts"]);
54
+ expect(stderr).toEqual([
55
+ "warning: Source discovery (build, graph, list, explain) under the current directory changes in the next release (chant#2527):\n" +
56
+ " .cache/gen.ts: .cache/ is a dot-directory. Every walker skips dot-directories from the next release. " +
57
+ 'To keep reading it after the change, add ".cache" to include in chant.config.json, which the next release honours.',
58
+ ]);
59
+ });
60
+
61
+ test("a git-ignored .ts file, ignored by a nested .gitignore", async () => {
62
+ execFileSync("git", ["init", "-q"], { cwd: dir });
63
+ project();
64
+ write("src/app.ts");
65
+ write("src/vendored.ts");
66
+ write("src/.gitignore", "vendored.ts\n");
67
+
68
+ expect(rel(await findInfraFiles(dir))).toEqual(["src/app.ts", "src/vendored.ts"]);
69
+ expect(stderr).toHaveLength(1);
70
+ expect(stderr[0]).toContain(
71
+ " src/vendored.ts is git-ignored. Every walker skips git-ignored files from the next release. " +
72
+ 'To keep reading it after the change, add "src/vendored.ts" to include in chant.config.json, which the next release honours.',
73
+ );
74
+ });
75
+
76
+ test("an ignored directory is named once, by its outermost ignored path", async () => {
77
+ execFileSync("git", ["init", "-q"], { cwd: dir });
78
+ project();
79
+ write("src/app.ts");
80
+ write(".gitignore", "vendor/\n");
81
+ for (const name of ["a", "b", "c", "d"]) write(`vendor/lib/${name}.ts`);
82
+
83
+ expect(rel(await findInfraFiles(dir))).toHaveLength(5);
84
+ expect(stderr[0]).toContain(" 4 files under vendor/: vendor/ is git-ignored.");
85
+ expect(stderr[0]).toContain('add "vendor" to include in chant.config.json, which the next release honours.');
86
+ });
87
+
88
+ test("a .ts file under dist", async () => {
89
+ project();
90
+ write("src/app.ts");
91
+ write("dist/out.ts");
92
+
93
+ expect(rel(await findInfraFiles(dir))).toEqual(["dist/out.ts", "src/app.ts"]);
94
+ expect(stderr[0]).toContain(
95
+ " dist/out.ts: dist/ is a dist directory. Every walker skips dist from the next release. " +
96
+ 'To keep reading it after the change, add "dist" to include in chant.config.json, which the next release honours.',
97
+ );
98
+ });
99
+
100
+ test("a nested child project whose chant.config.json declares a project", async () => {
101
+ project();
102
+ write("src/app.ts");
103
+ project("stacks/east");
104
+ write("stacks/east/east.ts");
105
+ // A lint-only fragment is not a boundary: no warning for this one.
106
+ write("stacks/west/chant.config.json", JSON.stringify({ rules: { COR004: "off" } }));
107
+ write("stacks/west/west.ts");
108
+
109
+ expect(rel(await findInfraFiles(dir))).toEqual(["src/app.ts", "stacks/east/east.ts", "stacks/west/west.ts"]);
110
+ expect(stderr).toHaveLength(1);
111
+ expect(stderr[0]).toContain(
112
+ " stacks/east/east.ts: stacks/east/ is a child project with its own chant.config. " +
113
+ "Discovery stops at child projects from the next release. " +
114
+ 'To keep reading it after the change, add "stacks/east" to include in chant.config.json, which the next release honours.',
115
+ );
116
+ expect(stderr[0]).not.toContain("west");
117
+ });
118
+
119
+ test("the first child project met is no longer read as the project's own source (the quirk)", async () => {
120
+ project();
121
+ write("app/chant.config.ts", "export default {};\n");
122
+ write("app/main.ts");
123
+
124
+ expect(rel(await findInfraFiles(dir))).toEqual(["app/chant.config.ts", "app/main.ts"]);
125
+ expect(stderr[0]).toContain(
126
+ " app/chant.config.ts, app/main.ts: app/ is a child project with its own chant.config, " +
127
+ "read today as this project's own source because the walk met it first. " +
128
+ "From the next release discovery stops there, as at any child project. " +
129
+ 'To keep reading them after the change, add "app" to include in chant.config.json, which the next release honours.',
130
+ );
131
+ });
132
+
133
+ test("the glob in the config is the fix: with it, nothing is printed and the list is the same", async () => {
134
+ project(".", { include: [".cache", "dist"] });
135
+ write("src/app.ts");
136
+ write(".cache/gen.ts");
137
+ write("dist/out.ts");
138
+
139
+ expect(rel(await findInfraFiles(dir))).toEqual([".cache/gen.ts", "dist/out.ts", "src/app.ts"]);
140
+ expect(stderr).toEqual([]);
141
+ });
142
+
143
+ test("a clean project prints nothing", async () => {
144
+ execFileSync("git", ["init", "-q"], { cwd: dir });
145
+ project();
146
+ write(".gitignore", "node_modules/\n.chant/types/\n");
147
+ write("src/app.ts");
148
+ write("src/lib/util.ts");
149
+ write("node_modules/dep/index.ts");
150
+ // chant init's generated type stubs: read today, never worth a warning.
151
+ write(".chant/types/core/index.d.ts");
152
+
153
+ expect(rel(await findInfraFiles(dir))).toEqual([".chant/types/core/index.d.ts", "src/app.ts", "src/lib/util.ts"]);
154
+ expect(stderr).toEqual([]);
155
+ });
156
+
157
+ test("a project-local lexicon named by module path (#2520) prints nothing", async () => {
158
+ execFileSync("git", ["init", "-q"], { cwd: dir });
159
+ write("chant.config.json", JSON.stringify({ lexicons: [{ name: "local", module: "./lexicon/index.ts" }] }));
160
+ write("lexicon/index.ts");
161
+ write("lexicon/rules/r1.ts");
162
+ write("src/app.ts");
163
+
164
+ expect(rel(await findInfraFiles(dir))).toEqual(["lexicon/index.ts", "lexicon/rules/r1.ts", "src/app.ts"]);
165
+ expect(stderr).toEqual([]);
166
+ });
167
+
168
+ test("a project-local lexicon's config still yields the glob when something else warns", async () => {
169
+ write("chant.config.json", JSON.stringify({ lexicons: [{ name: "local", module: "./lexicon/index.ts" }], include: [".cache"] }));
170
+ write("lexicon/index.ts");
171
+ write(".cache/gen.ts");
172
+ write("dist/out.ts");
173
+
174
+ expect(rel(await findInfraFiles(dir))).toEqual([".cache/gen.ts", "dist/out.ts", "lexicon/index.ts"]);
175
+ expect(stderr).toHaveLength(1);
176
+ expect(stderr[0]).not.toContain(".cache");
177
+ expect(stderr[0]).toContain('add "dist" to include in chant.config.json');
178
+ });
179
+
180
+ test("a warning prints once per process, however often the tree is walked", async () => {
181
+ project();
182
+ write(".cache/gen.ts");
183
+ await findInfraFiles(dir);
184
+ await findInfraFiles(dir);
185
+ expect(stderr).toHaveLength(1);
186
+ });
187
+ });
188
+
189
+ describe("component discovery outside a project reads every child project next release (#2527)", () => {
190
+ test("the second child project's component is named, with the exclude glob", async () => {
191
+ // No config and no .git/package.json at or above `dir`: outside a project.
192
+ write("one/chant.config.ts", "export default {};\n");
193
+ write("one/one.component.ts");
194
+ write("two/chant.config.ts", "export default {};\n");
195
+ write("two/two.component.ts");
196
+
197
+ const { sourceFiles } = await discoverComponents(dir);
198
+ // Today: whichever child the walk met first, and only that one.
199
+ expect(sourceFiles).toHaveLength(1);
200
+ const [read] = rel(sourceFiles);
201
+ const skipped = read.startsWith("one/") ? "two" : "one";
202
+
203
+ expect(stderr).toHaveLength(1);
204
+ expect(stderr[0]).toContain(
205
+ `warning: Component discovery under the current directory changes in the next release (chant#2527):\n` +
206
+ ` ${skipped}/${skipped}.component.ts: ${skipped}/ is a child project, skipped today ` +
207
+ "because the walk met another child project first. From the next release every child project is read, " +
208
+ "since the current directory is not inside a chant project. " +
209
+ `To keep skipping it, add "${skipped}" to exclude in chant.config.ts (a new file).`,
210
+ );
211
+ });
212
+ });
213
+
214
+ describe("the audit walk (#2527)", () => {
215
+ test("warns about a git-ignored candidate and leaves Terraform state to TF023", async () => {
216
+ execFileSync("git", ["init", "-q"], { cwd: dir });
217
+ write(".gitignore", ".env\n");
218
+ write(".env", "TOKEN=x\n");
219
+ write("k8s/deploy.yaml", "kind: Deployment\n");
220
+
221
+ const lines = await warnDiscoveryChanges({
222
+ walker: "audit",
223
+ root: dir,
224
+ files: [join(dir, ".env"), join(dir, "k8s/deploy.yaml"), join(dir, ".github/workflows/ci.yml")],
225
+ });
226
+ expect(lines).toEqual([
227
+ " .env is git-ignored. Every walker skips git-ignored files from the next release. " +
228
+ 'To keep reading it after the change, add ".env" to include in chant.config.ts (a new file), which the next release honours.',
229
+ ]);
230
+ });
231
+ });