@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
@@ -19,14 +19,15 @@ import { runPostSynthChecks } from "../../lint/post-synth";
19
19
  import { rule } from "../../lint/declarative";
20
20
  import { watchDirectory, formatTimestamp, formatChangedFiles } from "../watch";
21
21
  import { formatError, formatInfo } from "../format";
22
- import { GENERATED_MARKER } from "../../discovery/files";
22
+ import { GENERATED_MARKER, hasSkipMarker, compileDiscoveryFilter } from "../../discovery/files";
23
+ import { dirProbe, warnDiscoveryChanges } from "../../discovery/convergence";
23
24
  import { buildParamValues, resolveBuildParams } from "../../build-params";
24
25
  import { setBuildParams } from "../../params";
25
26
  import { isNoLexiconDetected } from "../../detectLexicon";
26
27
 
27
28
  // Import config loader
28
29
  import { loadConfig, resolveRulesForFile, resolveConfiguredSeverity, findProjectRoot } from "../../lint/config";
29
- import { loadChantConfig, resolveKnowledgeDir } from "../../config";
30
+ import { loadChantConfig, resolveKnowledgeDir, resolveDiscoveryGlobs } from "../../config";
30
31
  import { findProjectConfig } from "../../project-root";
31
32
  import type { LintProjectConfig } from "../../lint/rule";
32
33
  import { loadOkfBundle, type OkfBundle } from "../../okf-read";
@@ -141,7 +142,7 @@ function lexiconResolutionDiagnostic(projectRoot: string, error: Error): LintDia
141
142
  */
142
143
  async function loadAllPluginRules(
143
144
  projectPath: string,
144
- ): Promise<{ rules: Map<string, LintRule>; intrinsics: IntrinsicDef[]; lexiconError?: Error }> {
145
+ ): Promise<{ rules: Map<string, LintRule>; intrinsics: IntrinsicDef[]; plugins: LexiconPlugin[]; lexiconError?: Error }> {
145
146
  const rules = new Map<string, LintRule>();
146
147
 
147
148
  // Load core COR/EVL rules directly
@@ -219,7 +220,7 @@ async function loadAllPluginRules(
219
220
  rules.set(r.id, r);
220
221
  }
221
222
 
222
- return { rules, intrinsics, ...(lexiconError ? { lexiconError } : {}) };
223
+ return { rules, intrinsics, plugins, ...(lexiconError ? { lexiconError } : {}) };
223
224
  }
224
225
 
225
226
  /**
@@ -276,12 +277,14 @@ export interface LintResult {
276
277
  * `git check-ignore` for exact gitignore semantics (nesting, negation) in one
277
278
  * batched call; a non-git tree (or absent git) filters nothing.
278
279
  */
279
- function filterGitIgnored(files: string[], cwd: string): string[] {
280
+ function filterGitIgnored(files: string[], cwd: string, ignoredOut?: Set<string>): string[] {
280
281
  if (files.length === 0) return files;
281
282
  try {
282
283
  const out = execFileSync("git", ["check-ignore", "--stdin"], {
283
284
  cwd,
284
- input: files.join("\n"),
285
+ // The scan root rides along as one extra probe for #2527's warning
286
+ // release: whether it is itself ignored. It is never a file in `files`.
287
+ input: [...files, dirProbe(cwd)].join("\n"),
285
288
  encoding: "utf-8",
286
289
  // stdin piped (input), stdout captured, stderr silenced so a non-git tree's
287
290
  // "fatal: not a git repository" never leaks to the lint output.
@@ -290,6 +293,7 @@ function filterGitIgnored(files: string[], cwd: string): string[] {
290
293
  // catch handles it; exit 128 (not a repo) lands there too and filters none.
291
294
  });
292
295
  const ignored = new Set(out.split(/\r?\n/).filter(Boolean));
296
+ for (const p of ignored) ignoredOut?.add(p);
293
297
  return ignored.size === 0 ? files : files.filter((f) => !ignored.has(f));
294
298
  } catch {
295
299
  // No git, not a repo, or nothing ignored (exit 1) — keep every file.
@@ -297,10 +301,19 @@ function filterGitIgnored(files: string[], cwd: string): string[] {
297
301
  }
298
302
  }
299
303
 
304
+ /** What {@link getTypeScriptFiles} saw before its git-ignore filter, for #2527's warning. */
305
+ interface TypeScriptScan {
306
+ raw: string[];
307
+ ignored: Set<string>;
308
+ }
309
+
300
310
  /**
301
- * Get all TypeScript files recursively, skipping git-ignored paths.
311
+ * Get all TypeScript files recursively, skipping git-ignored paths and the
312
+ * files `skip` (the project's `exclude` globs, #2519) names. `scanOut`, when
313
+ * given, receives the files found before the ignore filter and the paths git
314
+ * reported ignored.
302
315
  */
303
- function getTypeScriptFiles(dir: string): string[] {
316
+ function getTypeScriptFiles(dir: string, skip?: (file: string) => boolean, scanOut?: TypeScriptScan): string[] {
304
317
  const files: string[] = [];
305
318
 
306
319
  function scan(currentDir: string): void {
@@ -315,9 +328,12 @@ function getTypeScriptFiles(dir: string): string[] {
315
328
  scan(fullPath);
316
329
  }
317
330
  } else if (entry.endsWith(".ts") && !entry.endsWith(".test.ts") && !entry.endsWith(".spec.ts")) {
331
+ if (skip?.(fullPath)) continue;
318
332
  // Skip chant-generated files (worker/workflow/activities bootstrap): they
319
333
  // hold no authored source and use runtime patterns the EVL* rules forbid.
320
- if (readFileSync(fullPath, "utf-8").slice(0, 256).includes(GENERATED_MARKER)) {
334
+ // A hand-written file opts out with the skip marker (#2519).
335
+ const head = readFileSync(fullPath, "utf-8").slice(0, 1024);
336
+ if (head.slice(0, 256).includes(GENERATED_MARKER) || hasSkipMarker(head)) {
321
337
  continue;
322
338
  }
323
339
  files.push(fullPath);
@@ -326,7 +342,8 @@ function getTypeScriptFiles(dir: string): string[] {
326
342
  }
327
343
 
328
344
  scan(dir);
329
- return filterGitIgnored(files, dir);
345
+ scanOut?.raw.push(...files);
346
+ return filterGitIgnored(files, dir, scanOut?.ignored);
330
347
  }
331
348
 
332
349
  /**
@@ -522,8 +539,10 @@ async function runComponentCheckDiagnostics(
522
539
  *
523
540
  * The project's configured lexicon NAMES are read, though (chant #2101), to
524
541
  * resolve the activity contracts each one declares at
525
- * `@intentius/chant-lexicon-<name>/op/activity-contracts` — one dynamic
526
- * import per lexicon, no plugin load. Without them a step calling a
542
+ * `@intentius/chant-lexicon-<name>/op/activity-contracts`. The plugins
543
+ * `lintCommand` already loaded are passed in (chant #2520), so a plugin's
544
+ * `activityContracts()` member counts here as it does in `chant build`, and
545
+ * a lexicon declared by module path is covered. Without them a step calling a
527
546
  * lexicon's activity is flagged for the absence of a contract that lexicon
528
547
  * does declare, which is the whole point of OPS013 firing outside a build.
529
548
  *
@@ -538,6 +557,7 @@ async function runOpCheckDiagnostics(
538
557
  infraPath: string,
539
558
  files: string[],
540
559
  buildParams?: BuildParamProvenance[],
560
+ plugins: readonly LexiconPlugin[] = [],
541
561
  ): Promise<{ diagnostics: LintDiagnostic[]; suppressed: Array<LintDiagnostic & { reason?: string }> }> {
542
562
  const config = loadConfig(findProjectRoot(infraPath));
543
563
  const opFiles = files.filter((f) => f.endsWith(".op.ts"));
@@ -573,8 +593,13 @@ async function runOpCheckDiagnostics(
573
593
 
574
594
  if (entities.size === 0) return { diagnostics: [], suppressed: [] };
575
595
 
576
- const lexiconNames = await resolveProjectLexicons(infraPath).catch(() => [] as string[]);
577
- const activityContracts = await loadActivityContracts(lexiconNames);
596
+ // chant #2520 — hand over the loaded plugin objects when there are any, as
597
+ // `chant build` does, so a plugin's `activityContracts()` member is read
598
+ // here too. Names alone are the fallback when no plugin loaded.
599
+ const activityContracts =
600
+ plugins.length > 0
601
+ ? await loadActivityContracts(plugins)
602
+ : await loadActivityContracts(await resolveProjectLexicons(infraPath).catch(() => [] as string[]));
578
603
 
579
604
  const raw = runPostSynthChecks(
580
605
  coreOpChecks(),
@@ -698,8 +723,13 @@ export async function lintCommand(options: LintOptions): Promise<LintResult> {
698
723
  allRules = new Map([...allRules, ...pluginRules]);
699
724
  }
700
725
 
701
- // Get all TypeScript files (scan scoped to the lint arg, git-ignored trees dropped)
702
- const files = getTypeScriptFiles(infraPath);
726
+ // Get all TypeScript files (scan scoped to the lint arg, git-ignored trees
727
+ // and the project's `exclude` globs dropped)
728
+ const scanned: TypeScriptScan = { raw: [], ignored: new Set() };
729
+ const files = getTypeScriptFiles(infraPath, compileDiscoveryFilter(await resolveDiscoveryGlobs(infraPath)), scanned);
730
+ // #2527's warning release: which of these the converged walker reads
731
+ // differently. `files` is today's list, unchanged.
732
+ await warnDiscoveryChanges({ walker: "lint", root: infraPath, files, lintIgnored: scanned });
703
733
 
704
734
  // Run lint — use per-file rules when overrides are present
705
735
  let diagnostics: LintDiagnostic[];
@@ -736,7 +766,7 @@ export async function lintCommand(options: LintOptions): Promise<LintResult> {
736
766
  // Run the OPS* Op-model post-synth checks (#2122) over every `*.op.ts`
737
767
  // file under the lint target — see runOpCheckDiagnostics's doc for why
738
768
  // this needs no lexicon or build to fire.
739
- const opResult = await runOpCheckDiagnostics(infraPath, files, buildParams);
769
+ const opResult = await runOpCheckDiagnostics(infraPath, files, buildParams, loaded.plugins);
740
770
  diagnostics.push(...opResult.diagnostics);
741
771
  suppressed.push(...opResult.suppressed);
742
772
 
@@ -790,7 +820,7 @@ export async function lintCommand(options: LintOptions): Promise<LintResult> {
790
820
 
791
821
  // OPS* checks have no `.fix` either; re-run for the same consistency
792
822
  // reason as the COMP* re-run just above.
793
- const postOpResult = await runOpCheckDiagnostics(infraPath, files, buildParams);
823
+ const postOpResult = await runOpCheckDiagnostics(infraPath, files, buildParams, loaded.plugins);
794
824
  diagnostics.push(...postOpResult.diagnostics);
795
825
  suppressed.push(...postOpResult.suppressed);
796
826
  }
@@ -2,6 +2,7 @@ import { readFileSync, writeFileSync, existsSync } from "fs";
2
2
  import { join, dirname } from "path";
3
3
  import { fileURLToPath } from "url";
4
4
  import { formatSuccess, formatError, formatWarning } from "../format";
5
+ import { lexiconModulePath, lexiconSourceLabel } from "../../lexicon-module";
5
6
 
6
7
  export interface OnboardOptions {
7
8
  name: string;
@@ -331,6 +332,20 @@ export function patchDockerfile(filePath: string, name: string): PatchResult {
331
332
  * Execute the onboard command — patches monorepo infrastructure for a new lexicon.
332
333
  */
333
334
  export function onboardCommand(options: OnboardOptions): OnboardResult {
335
+ // chant#2578 — onboard wires a lexicon package into the chant monorepo. A
336
+ // lexicon declared by path has no package, so there is nothing to wire and
337
+ // no install step to print; say where it loads from instead.
338
+ if (lexiconModulePath(options.name) !== undefined) {
339
+ return {
340
+ success: false,
341
+ patched: [],
342
+ skipped: [],
343
+ error:
344
+ `lexicon "${options.name}" is declared by path in chant.config.ts and loads from ${lexiconSourceLabel(options.name)}. ` +
345
+ "onboard adds a lexicon package to the chant monorepo, and a lexicon loaded by path has no package to add.",
346
+ };
347
+ }
348
+
334
349
  const root = options.root ?? findRepoRoot();
335
350
  const patched: string[] = [];
336
351
  const skipped: string[] = [];
@@ -1,3 +1,4 @@
1
+ import { lexiconModulePath, lexiconNames } from "../../lexicon-module";
1
2
  import { existsSync, mkdirSync, writeFileSync, cpSync, readdirSync, statSync, readFileSync } from "fs";
2
3
  import { join, resolve } from "path";
3
4
  import { createRequire } from "module";
@@ -105,7 +106,7 @@ export async function updateCommand(options: UpdateOptions): Promise<UpdateResul
105
106
 
106
107
  // Load config to get lexicons
107
108
  const { config } = await loadChantConfig(projectDir);
108
- const lexicons = config.lexicons ?? [];
109
+ const lexicons = lexiconNames(config.lexicons ?? []);
109
110
 
110
111
  if (lexicons.length === 0) {
111
112
  return {
@@ -137,6 +138,9 @@ export async function updateCommand(options: UpdateOptions): Promise<UpdateResul
137
138
 
138
139
  // Sync each lexicon
139
140
  for (const lexicon of lexicons) {
141
+ // chant #2520 — a lexicon declared by module path is project source, with
142
+ // no package to copy types from.
143
+ if (lexiconModulePath(lexicon) !== undefined) continue;
140
144
  const pkgName = `@intentius/chant-lexicon-${lexicon}`;
141
145
  const pkgPath = resolvePackagePath(pkgName, projectDir);
142
146
 
@@ -1,5 +1,5 @@
1
1
  import { describe, test, expect } from "vitest";
2
- import { checkConflicts } from "./conflict-check";
2
+ import { checkConflicts, describeConflict } from "./conflict-check";
3
3
  import type { LexiconPlugin, CommandGroup } from "../lexicon";
4
4
 
5
5
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -228,6 +228,23 @@ describe("checkConflicts", () => {
228
228
  expect(report.conflicts).toEqual([{ type: "command-group-name", key: "build", plugins: ["rogue"] }]);
229
229
  });
230
230
 
231
+ // #2529 — `workspace` is reserved ahead of core's own command, and the
232
+ // error says it is reserved rather than naming a second lexicon.
233
+ test("a command group named workspace is a hard conflict with a clear message", () => {
234
+ const plugins = [makePlugin("rogue", { commandGroup: { name: "workspace", description: "d", commands: [] } })];
235
+ const report = checkConflicts(plugins);
236
+ expect(report.conflicts).toEqual([{ type: "command-group-name", key: "workspace", plugins: ["rogue"] }]);
237
+ expect(describeConflict(report.conflicts[0])).toMatch(
238
+ /"workspace" is a reserved chant command name, so no lexicon can register a command group with it/,
239
+ );
240
+ });
241
+
242
+ test("a conflict between two lexicons is described without the reserved-name note", () => {
243
+ expect(describeConflict({ type: "command-group-name", key: "kube", plugins: ["k8s", "fly"] })).toBe(
244
+ ' command-group-name "kube" from: k8s, fly',
245
+ );
246
+ });
247
+
231
248
  test("no conflict for a single lexicon's own, non-reserved command-group name", () => {
232
249
  const plugins = [
233
250
  makePlugin("k8s", { commandGroup: { name: "kube", description: "d", commands: [] } }),
@@ -108,3 +108,16 @@ export function checkConflicts(plugins: LexiconPlugin[]): ConflictReport {
108
108
 
109
109
  return { conflicts, warnings };
110
110
  }
111
+
112
+ /**
113
+ * One line of the load-time error for a conflict. A command-group name that
114
+ * core reserves says so and says what to do, since the lexicon author reading
115
+ * it may not know the word is core's (#2529).
116
+ */
117
+ export function describeConflict(c: ConflictEntry): string {
118
+ const line = ` ${c.type} "${c.key}" from: ${c.plugins.join(", ")}`;
119
+ if (c.type === "command-group-name" && RESERVED_COMMAND_NAMES.has(c.key)) {
120
+ return `${line} — "${c.key}" is a reserved chant command name, so no lexicon can register a command group with it. Rename the group.`;
121
+ }
122
+ return line;
123
+ }
@@ -0,0 +1,168 @@
1
+ import { describe, test, expect, beforeEach, afterEach } from "vitest";
2
+ import { spawnSync } from "node:child_process";
3
+ import { mkdir, writeFile, rm, symlink, realpath } from "node:fs/promises";
4
+ import { existsSync } from "node:fs";
5
+ import { join, dirname, resolve } from "node:path";
6
+ import { tmpdir } from "node:os";
7
+ import { fileURLToPath } from "node:url";
8
+ import { SKIP_MARKER } from "../discovery/files";
9
+
10
+ /**
11
+ * chant #2519 — a project keeps a runnable `.ts` script with top-level side
12
+ * effects next to its declarations, and whole-project commands neither import
13
+ * nor lint it once the project says so, with an `exclude` glob or the skip
14
+ * marker.
15
+ *
16
+ * The script is the one from the issue: it reads `process.argv` as an Op name
17
+ * and exits the process with code 2. Imported by discovery, it reads chant's
18
+ * own argv and ends the command, which is what the first test shows. It also
19
+ * writes a file, so "imported" is visible apart from the exit code, and it
20
+ * indexes `process.env` dynamically, which EVL003 flags, so "linted" is
21
+ * visible in lint output.
22
+ *
23
+ * End-to-end through the real CLI (`main.ts`, spawned as `bin/chant` spawns
24
+ * it): an in-process `build()` would be killed by the script's `process.exit`,
25
+ * and the point is that every command's own path honours the setting.
26
+ */
27
+
28
+ const thisDir = dirname(fileURLToPath(import.meta.url));
29
+ const mainTsPath = resolve(thisDir, "main.ts");
30
+ const repoRoot = resolve(thisDir, "../../../..");
31
+ /** One CLI run can take a minute on a loaded CI runner, and a test makes up to three. */
32
+ const SPAWN_TIMEOUT = 150_000;
33
+ const TIMEOUT = 3 * SPAWN_TIMEOUT + 30_000;
34
+
35
+ describe("CLI end-to-end — discovery skips a side-effecting script (#2519)", () => {
36
+ let testDir: string;
37
+ let ranPath: string;
38
+
39
+ /** Write the project config; `extra` is spliced into the default export. */
40
+ async function writeConfig(extra: string): Promise<void> {
41
+ await writeFile(join(testDir, "chant.config.ts"), `export default { lexicons: ["k8s"]${extra} };\n`);
42
+ }
43
+
44
+ /** The runner from the issue, optionally headed by `header`. */
45
+ async function writeRunner(header = ""): Promise<void> {
46
+ await writeFile(
47
+ join(testDir, "ops", "run.ts"),
48
+ [
49
+ header,
50
+ 'import { writeFileSync } from "node:fs";',
51
+ "const op = process.argv[2];",
52
+ "const target = process.env[`OP_${op}`];",
53
+ `writeFileSync(${JSON.stringify(ranPath)}, String(op));`,
54
+ 'console.error(`no op named "${op}"`, target ?? "");',
55
+ "process.exit(2);",
56
+ "",
57
+ ].join("\n"),
58
+ );
59
+ }
60
+
61
+ beforeEach(async () => {
62
+ testDir = await realpath(
63
+ await (async () => {
64
+ const dir = join(tmpdir(), `chant-discovery-skip-test-${Date.now()}-${Math.random()}`);
65
+ await mkdir(join(dir, "src"), { recursive: true });
66
+ await mkdir(join(dir, "ops"), { recursive: true });
67
+ return dir;
68
+ })(),
69
+ );
70
+ ranPath = join(testDir, "runner-ran.txt");
71
+
72
+ await mkdir(join(testDir, "node_modules", "@intentius"), { recursive: true });
73
+ await symlink(
74
+ join(repoRoot, "lexicons", "k8s"),
75
+ join(testDir, "node_modules", "@intentius", "chant-lexicon-k8s"),
76
+ "dir",
77
+ );
78
+ await writeFile(
79
+ join(testDir, "src", "infra.ts"),
80
+ [
81
+ 'import { Namespace } from "@intentius/chant-lexicon-k8s";',
82
+ 'export const ns = new Namespace({ metadata: { name: "discovery-skip" } });',
83
+ "",
84
+ ].join("\n"),
85
+ );
86
+ });
87
+
88
+ afterEach(async () => {
89
+ await rm(testDir, { recursive: true, force: true });
90
+ });
91
+
92
+ function runCli(args: string[]): { status: number | null; stdout: string; stderr: string } {
93
+ const result = spawnSync("npx", ["tsx", mainTsPath, ...args], {
94
+ cwd: repoRoot,
95
+ encoding: "utf-8",
96
+ timeout: SPAWN_TIMEOUT,
97
+ });
98
+ return { status: result.status, stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
99
+ }
100
+
101
+ test("without exclude or the marker, the script runs under `chant build` and ends it", async () => {
102
+ await writeConfig("");
103
+ await writeRunner();
104
+
105
+ const { status, stderr } = runCli(["build", testDir]);
106
+
107
+ expect(status).toBe(2);
108
+ expect(stderr).toContain('no op named "build"');
109
+ expect(existsSync(ranPath)).toBe(true);
110
+ }, TIMEOUT);
111
+
112
+ test("an `exclude` glob keeps build and lint out of the script", async () => {
113
+ await writeConfig(', exclude: ["ops/**"]');
114
+ await writeRunner();
115
+
116
+ const build = runCli(["build", testDir]);
117
+ expect(build.stderr).not.toContain("no op named");
118
+ expect(build.status).toBe(0);
119
+ expect(build.stdout).toContain("name: discovery-skip");
120
+
121
+ const lint = runCli(["lint", testDir, "--format", "json"]);
122
+ expect(lint.status).toBe(0);
123
+ const diagnostics = JSON.parse(lint.stdout) as Array<{ file: string }>;
124
+ expect(diagnostics.some((d) => d.file.endsWith("infra.ts"))).toBe(true);
125
+ expect(diagnostics.filter((d) => d.file.includes("ops"))).toEqual([]);
126
+
127
+ expect(existsSync(ranPath), "the script was imported").toBe(false);
128
+ }, TIMEOUT);
129
+
130
+ test("an `exclude` glob keeps list and explain out of the script", async () => {
131
+ await writeConfig(', exclude: ["ops/**"]');
132
+ await writeRunner();
133
+
134
+ for (const command of ["list", "explain"]) {
135
+ const { status, stdout, stderr } = runCli([command, testDir]);
136
+ expect(stderr, command).not.toContain("no op named");
137
+ expect(status, command).toBe(0);
138
+ expect(stdout, command).toContain("ns");
139
+ }
140
+
141
+ expect(existsSync(ranPath), "the script was imported").toBe(false);
142
+ }, TIMEOUT);
143
+
144
+ test("the skip marker keeps build and lint out of the script with no config change", async () => {
145
+ await writeConfig("");
146
+ await writeRunner(`// ${SKIP_MARKER}: a runner, not a declaration`);
147
+
148
+ const build = runCli(["build", testDir]);
149
+ expect(build.stderr).not.toContain("no op named");
150
+ expect(build.status).toBe(0);
151
+
152
+ const lint = runCli(["lint", testDir, "--format", "json"]);
153
+ expect(lint.status).toBe(0);
154
+ const diagnostics = JSON.parse(lint.stdout) as Array<{ file: string }>;
155
+ expect(diagnostics.filter((d) => d.file.includes("ops"))).toEqual([]);
156
+
157
+ expect(existsSync(ranPath), "the script was imported").toBe(false);
158
+ }, TIMEOUT);
159
+
160
+ test("the control: without exclude, `chant lint` lints the script", async () => {
161
+ await writeConfig("");
162
+ await writeRunner();
163
+
164
+ const lint = runCli(["lint", testDir, "--format", "json"]);
165
+ const diagnostics = JSON.parse(lint.stdout) as Array<{ file: string; ruleId: string }>;
166
+ expect(diagnostics.some((d) => d.file.endsWith(join("ops", "run.ts")) && d.ruleId === "EVL003")).toBe(true);
167
+ }, TIMEOUT);
168
+ });
@@ -70,6 +70,17 @@ describe("runBuild --components --generate (chant #1108 build-time parameters)",
70
70
  vi.restoreAllMocks();
71
71
  });
72
72
 
73
+ test("--promote-to is forwarded to the generator as promoteTo (#2575)", async () => {
74
+ generateComponentsPipelineMock.mockResolvedValue({ success: true, yaml: "stages: []", stages: [], jobs: [] });
75
+ vi.spyOn(console, "log").mockImplementation(() => {});
76
+
77
+ const exit = await runBuild({ args: makeArgs({ env: "staging", promoteTo: "prod" }), plugins: [], serializers: [] });
78
+
79
+ expect(exit).toBe(0);
80
+ expect(generateComponentsPipelineMock).toHaveBeenCalledWith(".", "gitlab", { env: "staging", promoteTo: "prod" }, undefined, []);
81
+ vi.restoreAllMocks();
82
+ });
83
+
73
84
  test("chant.config.ts's declared buildParams resolve, log, and are forwarded to generateComponentsPipeline", async () => {
74
85
  loadChantConfigUpwardMock.mockResolvedValue({
75
86
  config: { buildParams: { tier: { type: "string", default: "light" } } },
@@ -51,7 +51,7 @@ async function runGenerateComponents(ctx: CommandContext): Promise<number> {
51
51
  const result = await generateComponentsPipeline(
52
52
  args.path,
53
53
  lexicon,
54
- { env: args.env },
54
+ { env: args.env, ...(args.promoteTo ? { promoteTo: args.promoteTo } : {}) },
55
55
  args.sandbox,
56
56
  paramsResolution.provenance,
57
57
  );
@@ -33,6 +33,9 @@ export async function runDevOnboard(ctx: CommandContext): Promise<number> {
33
33
  }
34
34
 
35
35
  const { onboardCommand, printOnboardResult } = await import("../commands/onboard");
36
+ // chant#2578 — so a lexicon the project declares by path is recognised.
37
+ const { recordProjectLexicons } = await import("../plugins");
38
+ await recordProjectLexicons(process.cwd());
36
39
  const result = onboardCommand({ name, verbose: ctx.args.verbose });
37
40
  await printOnboardResult(result, name);
38
41
  return result.success ? 0 : 1;
@@ -2,6 +2,7 @@ import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
2
2
  import type { ParsedArgs } from "../registry";
3
3
  import { DECLARABLE_MARKER, type Declarable } from "../../declarable";
4
4
  import { AttrRef } from "../../attrref";
5
+ import { GRAPH_IR_VERSION } from "../../graph-ir";
5
6
 
6
7
  /**
7
8
  * The aws emulator capability, as the real plugin declares it. `--live`
@@ -226,6 +227,25 @@ describe("runGraph", () => {
226
227
  expect(ir.edges).toContainEqual({ from: "subnet", to: "vpc", kind: "ref", viaAttr: "network" });
227
228
  });
228
229
 
230
+ // #2529 — the IR carries its version, as the first key, so a reader can
231
+ // decide how to read the rest before it reads anything else.
232
+ test("--format ir leads with the IR version", async () => {
233
+ lintClean(); discovered();
234
+ const exit = await runGraph({ args: makeArgs({ format: "ir" }), plugins: [], serializers: [] });
235
+ expect(exit).toBe(0);
236
+ const ir = JSON.parse(stdoutBuf.join("\n"));
237
+ expect(ir.version).toBe(GRAPH_IR_VERSION);
238
+ expect(GRAPH_IR_VERSION).toBe(1);
239
+ expect(Object.keys(ir)[0]).toBe("version");
240
+ });
241
+
242
+ test("the other view formats carry no version field", async () => {
243
+ lintClean(); discovered();
244
+ const exit = await runGraph({ args: makeArgs({ format: "mermaid" }), plugins: [], serializers: [] });
245
+ expect(exit).toBe(0);
246
+ expect(stdoutBuf.join("\n")).not.toContain("version");
247
+ });
248
+
229
249
  // #1675 — discovery scans sourceDir, but ops live beside it (project root,
230
250
  // `ops/`) and are found by discoverOps from the git root. The IR joins them.
231
251
  describe("ops outside sourceDir reach the IR (#1675)", () => {
@@ -2,7 +2,7 @@ import { dirname, resolve } from "node:path";
2
2
  import { discoverOps } from "../../op/discover";
3
3
  import { discover } from "../../discovery/index";
4
4
  import { partitionByLexicon, computeStackGraph, build, mergeBuildRootEntities } from "../../build";
5
- import { buildGraphIr, buildLiveGraphIr, collectUnobserved, overlayGraphs, sourceOverlayGraphs, type GraphIR, type IRPipeline, type LiveObservation } from "../../graph-ir";
5
+ import { buildGraphIr, buildLiveGraphIr, collectUnobserved, overlayGraphs, sourceOverlayGraphs, GRAPH_IR_VERSION, type GraphIR, type IRPipeline, type LiveObservation } from "../../graph-ir";
6
6
  import { behaviourOverlay } from "../../behaviour-overlay";
7
7
  import type { SerializerResult } from "../../serializer";
8
8
  import { applyBehaviourOverlay, behaviourRequestFromIr } from "../../behaviour-graph";
@@ -886,7 +886,9 @@ async function emitIr(
886
886
  }
887
887
  case "ir":
888
888
  default:
889
- console.log(JSON.stringify(ir, null, 2));
889
+ // The version leads the document (#2529), so a reader sees it before
890
+ // anything it might not understand.
891
+ console.log(JSON.stringify({ version: GRAPH_IR_VERSION, ...ir }, null, 2));
890
892
  return 0;
891
893
  }
892
894
  }
@@ -1,3 +1,4 @@
1
+ import { lexiconNames } from "../../lexicon-module";
1
2
  import { resolve } from "node:path";
2
3
  import { commandBuildParams } from "../build-params-cli";
3
4
  import { build } from "../../build";
@@ -559,7 +560,7 @@ interface BetweenDiffArgs {
559
560
  async function runLifecycleDiffBetween(args: BetweenDiffArgs): Promise<number> {
560
561
  await fetchLifecycle();
561
562
  const { config } = await loadChantConfig(resolve("."));
562
- const lexicons = args.lexiconFilter ? [args.lexiconFilter] : config.lexicons ?? [];
563
+ const lexicons = args.lexiconFilter ? [args.lexiconFilter] : lexiconNames(config.lexicons ?? []);
563
564
  if (lexicons.length === 0) {
564
565
  console.error(formatError({ message: "No lexicons to diff — pass a lexicon or declare `lexicons` in chant.config." }));
565
566
  return 1;
@@ -114,6 +114,21 @@ export async function runAudit(ctx: CommandContext): Promise<number> {
114
114
  return result.exitCode;
115
115
  }
116
116
 
117
+ // `--max-files` (#2528) only means something to the local walk. A URL audit
118
+ // fetches through the host's tree API with its own caps, so the flag is
119
+ // refused there rather than silently ignored.
120
+ const maxFiles = args.maxFiles;
121
+ if (maxFiles !== undefined) {
122
+ if (!Number.isInteger(maxFiles) || maxFiles < 1) {
123
+ console.error(formatError({ message: `--max-files must be a positive integer (got "${maxFiles}")` }));
124
+ return 1;
125
+ }
126
+ if (/^https?:\/\//.test(args.path)) {
127
+ console.error(formatError({ message: "--max-files applies to a local path; a repository URL is fetched with its own limits." }));
128
+ return 1;
129
+ }
130
+ }
131
+
117
132
  // HTML report customization: --template <file> (full override) + --theme <file> (JSON knobs).
118
133
  let template: string | undefined;
119
134
  let theme: ReportTheme | undefined;
@@ -146,6 +161,7 @@ export async function runAudit(ctx: CommandContext): Promise<number> {
146
161
  template,
147
162
  theme,
148
163
  toolVersion: CHANT_VERSION,
164
+ maxFiles,
149
165
  });
150
166
  printAuditResult(result);
151
167
  return result.exitCode;
@@ -6,6 +6,7 @@
6
6
  * `run.ts`'s `runOpLocal` shape (SIGINT → AbortController, `loadActivities`/
7
7
  * `loadProfiles` from the project's configured lexicons).
8
8
  */
9
+ import { lexiconNames } from "../../lexicon-module";
9
10
  import { loadChantConfig } from "../../config";
10
11
  import { build } from "../../build";
11
12
  import { isResourceDeclarable } from "../../declarable";
@@ -52,7 +53,7 @@ import type { CommandContext } from "../registry";
52
53
  async function loadOperatorActivities() {
53
54
  let lexicons: string[] = [];
54
55
  try {
55
- lexicons = (await loadChantConfig(process.cwd())).config.lexicons ?? [];
56
+ lexicons = lexiconNames((await loadChantConfig(process.cwd())).config.lexicons ?? []);
56
57
  } catch {
57
58
  // No/invalid chant.config — fall back to base activities only.
58
59
  }