@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
@@ -6,14 +6,15 @@
6
6
  */
7
7
 
8
8
  import { existsSync, readFileSync, statSync, writeFileSync } from "fs";
9
- import { join } from "path";
9
+ import { join, resolve } from "path";
10
10
  import { auditFiles, type AuditInput, type AuditFinding, type ChecksProvider, type SuppressionStats } from "../../audit/core";
11
- import { AUDIT_LEXICONS, classifyFiles, collectCandidates, loadAuditPlugins, unclaimedFiles, type DetectPlugin, type RepoFile, type UnclaimedFile } from "../../audit/discover";
11
+ import { AUDIT_LEXICONS, classifyFiles, loadAuditPlugins, unclaimedFiles, walkCandidates, type CandidateWalk, type DetectPlugin, type RepoFile, type UnclaimedFile } from "../../audit/discover";
12
12
  import { RULE_CATALOG, resolveAuditCatalog, type RuleMeta } from "../../audit/catalog";
13
13
  import { scanForSecrets, parseSecretsConfig, type SecretsScanOptions } from "../../audit/secrets";
14
14
  import { auditWranglerConfigs } from "../../audit/wrangler";
15
15
  import { auditNginxConfigs } from "../../audit/nginx";
16
- import { auditTerraformState } from "../../audit/terraform-state";
16
+ import { auditTerraformState, isTerraformStatePath } from "../../audit/terraform-state";
17
+ import { warnDiscoveryChanges } from "../../discovery/convergence";
17
18
  import { renderMarkdown } from "../../audit/report";
18
19
  import { renderHtml, type ReportTheme } from "../../audit/report-html";
19
20
  import { buildReportJson, REPORT_SCHEMA_VERSION, type AuditSnapshot } from "../../audit/report-model";
@@ -21,6 +22,8 @@ import { fetchRepoFiles, resolveActionSha, resolveImageDigest, resolveRepoCommit
21
22
  import { extractUnpinnedActions, extractUnpinnedImages } from "../../audit/proof";
22
23
  import type { ProveOptions } from "../../audit/proof";
23
24
  import type { Severity } from "../../lint/rule";
25
+ import { formatWarning } from "../format";
26
+ import { lexiconModulePath, lexiconPackagesToInstall, lexiconSourceLabel } from "../../lexicon-module";
24
27
 
25
28
  export type AuditFormat = "stylish" | "json" | "sarif" | "markdown" | "html";
26
29
  export type AuditTier = "merge-worthy" | "all";
@@ -68,6 +71,23 @@ export interface AuditCommandOptions {
68
71
  * present (URL targets have no local config file to read).
69
72
  */
70
73
  secretsScan?: SecretsScanOptions;
74
+ /**
75
+ * Files the local walk takes before it stops, counting every file
76
+ * (`--max-files`, #2528). Defaults to 1000. Local paths only.
77
+ */
78
+ maxFiles?: number;
79
+ }
80
+
81
+ /**
82
+ * A diagnostic about the run rather than the repository. Printed on stderr by
83
+ * `printAuditResult`, never part of `output`, so the report on stdout is the
84
+ * same with or without one.
85
+ */
86
+ export interface AuditWarning {
87
+ /** The path the warning is about, when there is one. */
88
+ file?: string;
89
+ message: string;
90
+ hint?: string;
71
91
  }
72
92
 
73
93
  export interface AuditCommandResult {
@@ -96,14 +116,27 @@ export interface AuditCommandResult {
96
116
  * summary line.
97
117
  */
98
118
  suppressedCount?: number;
119
+ /** Run diagnostics for stderr (#2528). */
120
+ warnings?: AuditWarning[];
99
121
  }
100
122
 
101
123
  /** Exit code when the audit had no lexicons to look with. Distinct from 1 (findings / failure). */
102
124
  export const NO_LEXICONS_EXIT_CODE = 2;
103
125
 
104
- /** The npm package that provides an audit lexicon's detection and checks. */
105
- function lexiconPackage(name: string): string {
106
- return `@intentius/chant-lexicon-${name}`;
126
+ /**
127
+ * What to tell the user about lexicons that did not load: an `npm i` line for
128
+ * the package-backed ones, and the declared path for any lexicon
129
+ * `chant.config.ts` names by path (#2520), which has no package to install
130
+ * (chant#2578).
131
+ */
132
+ function missingLexiconRemedy(names: readonly string[]): string {
133
+ const parts: string[] = [];
134
+ const pkgs = lexiconPackagesToInstall(names);
135
+ if (pkgs.length > 0) parts.push(`npm i ${pkgs.join(" ")}`);
136
+ for (const name of names) {
137
+ if (lexiconModulePath(name) !== undefined) parts.push(`${name} is declared by path: ${lexiconSourceLabel(name)}`);
138
+ }
139
+ return parts.join("; ");
107
140
  }
108
141
 
109
142
  /** Missing audit lexicons the unclaimed files pointed at, in first-seen order. */
@@ -117,7 +150,8 @@ function wantedLexicons(unclaimed: UnclaimedFile[]): string[] {
117
150
  * them on the same resolution path.
118
151
  */
119
152
  export function installLine(lexicons: string[], target: string): string {
120
- const pkgs = ["@intentius/chant", ...lexicons.map(lexiconPackage)];
153
+ // chant#2578 — a lexicon declared by path has no package for npx to fetch.
154
+ const pkgs = ["@intentius/chant", ...lexiconPackagesToInstall(lexicons)];
121
155
  return `npx ${pkgs.map((p) => `-p ${p}`).join(" ")} chant audit ${target}`;
122
156
  }
123
157
 
@@ -127,7 +161,7 @@ function missingLexiconHint(unclaimed: UnclaimedFile[]): string | undefined {
127
161
  if (wanted.length === 0) return undefined;
128
162
  const n = unclaimed.length;
129
163
  return `${n} file${n === 1 ? " looks" : "s look"} like ${wanted.join("/")} but ${wanted.length === 1 ? "that lexicon is" : "those lexicons are"} not installed, so ${n === 1 ? "it was" : "they were"} skipped` +
130
- ` (npm i ${wanted.map(lexiconPackage).join(" ")}).`;
164
+ ` (${missingLexiconRemedy(wanted)}).`;
131
165
  }
132
166
 
133
167
  /** Human-readable diagnostic for the zero-lexicon case. */
@@ -372,6 +406,34 @@ async function buildSnapshot(options: AuditCommandOptions, files: string[], isUr
372
406
  };
373
407
  }
374
408
 
409
+ /**
410
+ * Warnings for the two level-0 changes #2528 makes in the next release, one
411
+ * release ahead of it (#2525). Neither changes today's report: a truncated walk
412
+ * is still reported as if it were whole, and TF023 still reads only the root
413
+ * `.gitignore`.
414
+ */
415
+ export function walkWarnings(walk: CandidateWalk, target: string): AuditWarning[] {
416
+ const warnings: AuditWarning[] = [];
417
+ if (walk.truncated) {
418
+ warnings.push({
419
+ message:
420
+ `the scan of ${target} stopped at ${walk.maxFiles} files, so part of the tree was not audited. ` +
421
+ "The limit counts every file the walk reaches, not only the ones it audits.",
422
+ hint:
423
+ "Raise it with --max-files <n>. From the next release, text and JSON output will also report a truncated scan.",
424
+ });
425
+ }
426
+ for (const change of walk.nestedGitignore) {
427
+ warnings.push({
428
+ file: change.path,
429
+ message: `TF023 reports this path because chant audit reads only the root .gitignore, but ${change.gitignore} ignores it.`,
430
+ hint:
431
+ "From the next release chant audit reads every .gitignore between a file and the scan root, and TF023 will no longer report this path.",
432
+ });
433
+ }
434
+ return warnings;
435
+ }
436
+
375
437
  /** Run the audit and produce a rendered result. */
376
438
  export async function auditCommand(options: AuditCommandOptions): Promise<AuditCommandResult> {
377
439
  const format = options.format ?? "stylish";
@@ -387,6 +449,7 @@ export async function auditCommand(options: AuditCommandOptions): Promise<AuditC
387
449
  // render the same way (#1623).
388
450
  const plugins = options.plugins ?? (await loadAuditPlugins());
389
451
  let candidates: RepoFile[];
452
+ let warnings: AuditWarning[] = [];
390
453
  if (isUrl) {
391
454
  try {
392
455
  // Fetch the whole repo's candidate files (all lexicons, not just CI) and
@@ -406,8 +469,21 @@ export async function auditCommand(options: AuditCommandOptions): Promise<AuditC
406
469
  // One walk, plugin-delegated detection. CI (path), Dockerfiles (name), and
407
470
  // Helm charts (bundle) are special-cased by the classifier since content
408
471
  // shape alone can't disambiguate them.
409
- candidates = collectCandidates(options.path);
472
+ const walk = walkCandidates(options.path, { maxFiles: options.maxFiles });
473
+ candidates = walk.files;
474
+ warnings = walkWarnings(walk, options.path);
475
+ // #2527's warning release: the converged walk skips git-ignored files and,
476
+ // inside a project, child projects. Terraform state paths are TF023's own
477
+ // question and stay out of it. `candidates` is unchanged.
478
+ await warnDiscoveryChanges({
479
+ walker: "audit",
480
+ root: options.path,
481
+ files: candidates.filter((f) => !isTerraformStatePath(f.path)).map((f) => resolve(options.path, f.path)),
482
+ });
410
483
  }
484
+ // Only a non-empty list rides on the result, so a run with nothing to warn
485
+ // about returns exactly the object it did before #2528.
486
+ const withWarnings = warnings.length > 0 ? { warnings } : {};
411
487
  // A local target hands its directory to the classifier, so an input whose
412
488
  // path names a directory carries the directory itself (#2217): terraform's
413
489
  // parse-to-graph follows a root module's local `module` calls from there. A
@@ -439,14 +515,14 @@ export async function auditCommand(options: AuditCommandOptions): Promise<AuditC
439
515
 
440
516
  if (plugins.length === 0) {
441
517
  const output = format === "json" ? renderNoLexiconsJson(options.path, unclaimed) : renderNoLexicons(options.path, unclaimed);
442
- return { success: true, status: "no-lexicons", output, findings: [...secretsFindings, ...wranglerFindings, ...nginxFindings, ...terraformStateFindings], scanned: [], unclaimed, exitCode: NO_LEXICONS_EXIT_CODE, stream: format === "json" ? "stdout" : "stderr" };
518
+ return { success: true, status: "no-lexicons", output, findings: [...secretsFindings, ...wranglerFindings, ...nginxFindings, ...terraformStateFindings], scanned: [], unclaimed, exitCode: NO_LEXICONS_EXIT_CODE, stream: format === "json" ? "stdout" : "stderr", ...withWarnings };
443
519
  }
444
520
 
445
521
  const missingLexiconNote = missingLexiconHint(unclaimed);
446
522
 
447
523
  if (inputs.length === 0 && secretsFindings.length === 0 && wranglerFindings.length === 0 && nginxFindings.length === 0 && terraformStateFindings.length === 0) {
448
524
  const output = `No auditable files found under ${options.path}.${missingLexiconNote ? ` ${missingLexiconNote}` : ""}`;
449
- return { success: true, status: "ok", output, findings: [], scanned: [], unclaimed, exitCode: 0 };
525
+ return { success: true, status: "ok", output, findings: [], scanned: [], unclaimed, exitCode: 0, ...withWarnings };
450
526
  }
451
527
 
452
528
  let findings: AuditFinding[] = [];
@@ -545,22 +621,20 @@ export async function auditCommand(options: AuditCommandOptions): Promise<AuditC
545
621
  } catch (err) {
546
622
  return { success: false, output, findings, scanned, exitCode: 1, error: `Failed to write ${options.output}: ${err instanceof Error ? err.message : String(err)}` };
547
623
  }
548
- return { success: true, status: "ok", output, findings, scanned, unclaimed, exitCode, wroteTo: options.output, suppressedCount: suppressionStats.count };
624
+ return { success: true, status: "ok", output, findings, scanned, unclaimed, exitCode, wroteTo: options.output, suppressedCount: suppressionStats.count, ...withWarnings };
549
625
  }
550
626
 
551
- return { success: true, status: "ok", output, findings, scanned, unclaimed, exitCode, suppressedCount: suppressionStats.count };
627
+ return { success: true, status: "ok", output, findings, scanned, unclaimed, exitCode, suppressedCount: suppressionStats.count, ...withWarnings };
552
628
  }
553
629
 
554
- /** Print an audit result to stdout. */
630
+ /** Print an audit result to stdout, and its warnings to stderr. */
555
631
  export function printAuditResult(result: AuditCommandResult): void {
556
632
  if (!result.success) {
557
633
  console.error(result.error ?? "Audit failed");
558
634
  return;
559
635
  }
560
- if (result.wroteTo) {
561
- console.error(`Wrote report to ${result.wroteTo}`);
562
- return;
563
- }
564
- if (result.stream === "stderr") console.error(result.output);
636
+ if (result.wroteTo) console.error(`Wrote report to ${result.wroteTo}`);
637
+ else if (result.stream === "stderr") console.error(result.output);
565
638
  else console.log(result.output);
639
+ for (const w of result.warnings ?? []) console.error(formatWarning(w));
566
640
  }
@@ -33,6 +33,7 @@
33
33
  * validates against its own schemas.
34
34
  */
35
35
 
36
+ import { lexiconNames as declaredLexiconNames } from "../../lexicon-module";
36
37
  import { existsSync, readdirSync } from "fs";
37
38
  import { join } from "path";
38
39
  import { build, type BuildResult } from "../../build";
@@ -141,7 +142,7 @@ export async function checkExamplesBuild(lexiconDir: string): Promise<ExampleBui
141
142
  const { config: exampleConfig } = await loadChantConfig(join(examplesDir, entry.name));
142
143
  const lexiconNames =
143
144
  exampleConfig.lexicons && exampleConfig.lexicons.length > 0
144
- ? exampleConfig.lexicons
145
+ ? declaredLexiconNames(exampleConfig.lexicons)
145
146
  : await detectLexicons(files);
146
147
  if (lexiconNames.length === 0) {
147
148
  results.push({
@@ -95,6 +95,9 @@ export async function doctorCommand(path: string): Promise<DoctorReport> {
95
95
  const lexicons = config?.lexicons as string[] | undefined;
96
96
  if (lexicons && Array.isArray(lexicons)) {
97
97
  for (const lex of lexicons) {
98
+ // chant #2520 — a lexicon declared by module path is project source,
99
+ // not a package `chant update` copies types from.
100
+ if (typeof lex !== "string") continue;
98
101
  const lexDir = join(projectPath, ".chant", "types", `lexicon-${lex}`);
99
102
  if (!existsSync(lexDir)) {
100
103
  checks.push({ name: `lexicon-${lex}-types`, status: "fail", message: `.chant/types/lexicon-${lex}/ not found — run chant update` });
@@ -23,7 +23,8 @@ import { scanAgentConfigs } from "../../agents/discover";
23
23
  import type { AgentRuntime, AgentScope } from "../../agents/types";
24
24
  import type { AgentImportOutcome } from "../../agents/importer";
25
25
  import type { LexiconPlugin } from "../../lexicon";
26
- import { loadPlugin } from "../plugins";
26
+ import { loadPlugin, recordProjectLexicons } from "../plugins";
27
+ import { lexiconModulePath, lexiconSourceLabel } from "../../lexicon-module";
27
28
 
28
29
  /** The lexicon used when `--lexicon` isn't given: the one that models agent workloads. */
29
30
  export const DEFAULT_AGENT_LEXICON = "fountain";
@@ -91,18 +92,24 @@ export async function importAgentsCommand(opts: ImportAgentsOptions = {}): Promi
91
92
  };
92
93
  }
93
94
 
95
+ // chant#2578 — a lexicon the project's chant.config declares by path loads
96
+ // from that module, so read the declarations before loading the plugin.
97
+ if (!opts.pluginLoader) await recordProjectLexicons(process.cwd());
98
+
94
99
  let plugin: LexiconPlugin;
95
100
  try {
96
101
  plugin = await (opts.pluginLoader ?? loadPlugin)(lexiconName);
97
102
  } catch (err) {
103
+ const remedy =
104
+ lexiconModulePath(lexiconName) !== undefined
105
+ ? `The ${lexiconName} lexicon is declared by path in chant.config.ts: ${lexiconSourceLabel(lexiconName)}`
106
+ : `Install it with: npm i @intentius/chant-lexicon-${lexiconName}`;
98
107
  return {
99
108
  success: false,
100
109
  generatedFiles: [],
101
110
  summary: { discovered: scan.sites.length, mapped: 0 },
102
111
  warnings,
103
- error:
104
- `Could not load the ${lexiconName} lexicon: ${err instanceof Error ? err.message : String(err)}\n` +
105
- `Install it with: npm i @intentius/chant-lexicon-${lexiconName}`,
112
+ error: `Could not load the ${lexiconName} lexicon: ${err instanceof Error ? err.message : String(err)}\n${remedy}`,
106
113
  };
107
114
  }
108
115
 
@@ -3,7 +3,8 @@ import { join, resolve, dirname } from "path";
3
3
  import { fileURLToPath } from "url";
4
4
  import { createInterface } from "readline";
5
5
  import { formatSuccess, formatWarning } from "../format";
6
- import { loadPlugin } from "../plugins";
6
+ import { loadPlugin, recordProjectLexicons } from "../plugins";
7
+ import { lexiconModulePath, lexiconSourceLabel } from "../../lexicon-module";
7
8
  import { MCP_CONFIG_FILENAME, detectPackageManager, generateMcpConfig, mcpConfigPath } from "../mcp-config";
8
9
 
9
10
  /** Read the current chant package version from our own package.json. */
@@ -53,16 +54,27 @@ export interface InitResult {
53
54
  warnings: string[];
54
55
  /** Error message if failed */
55
56
  error?: string;
57
+ /**
58
+ * chant#2578 — where the lexicon loads from when the directory's existing
59
+ * chant.config declares it by path. There is no package to depend on or
60
+ * install for it.
61
+ */
62
+ lexiconModule?: string;
56
63
  }
57
64
 
58
65
  /**
59
66
  * Generate package.json content
60
67
  */
61
- function generatePackageJson(lexicon: string, extraScripts?: Record<string, string>): string {
68
+ function generatePackageJson(
69
+ lexicon: string,
70
+ extraScripts?: Record<string, string>,
71
+ lexiconIsPackage = true,
72
+ ): string {
62
73
  const ver = getChantVersion();
63
74
  const dependencies: Record<string, string> = {
64
75
  "@intentius/chant": `^${ver}`,
65
- [`@intentius/chant-lexicon-${lexicon}`]: `^${ver}`,
76
+ // chant#2578 — a lexicon declared by path is not a package to depend on.
77
+ ...(lexiconIsPackage ? { [`@intentius/chant-lexicon-${lexicon}`]: `^${ver}` } : {}),
66
78
  };
67
79
 
68
80
  const pkg = {
@@ -252,6 +264,17 @@ export async function initCommand(options: InitOptions): Promise<InitResult> {
252
264
  mkdirSync(targetDir, { recursive: true });
253
265
  }
254
266
 
267
+ // chant#2578 — `--force` into an existing project keeps its chant.config.
268
+ // When that config declares the lexicon by path, it loads from the module
269
+ // and there is no package to depend on or install.
270
+ let lexiconModule: string | undefined;
271
+ if (existsSync(join(targetDir, "chant.config.ts")) || existsSync(join(targetDir, "chant.config.json"))) {
272
+ await recordProjectLexicons(targetDir);
273
+ if (lexiconModulePath(options.lexicon) !== undefined) {
274
+ lexiconModule = lexiconSourceLabel(options.lexicon, targetDir);
275
+ }
276
+ }
277
+
255
278
  // Load plugin early to get template set (used for scripts + source files)
256
279
  let templateSet: import("../../lexicon").InitTemplateSet | undefined;
257
280
  try {
@@ -272,7 +295,7 @@ export async function initCommand(options: InitOptions): Promise<InitResult> {
272
295
  // Generate package.json
273
296
  writeIfNotExists(
274
297
  join(targetDir, "package.json"),
275
- generatePackageJson(options.lexicon, templateSet?.scripts),
298
+ generatePackageJson(options.lexicon, templateSet?.scripts, lexiconModule === undefined),
276
299
  "package.json",
277
300
  createdFiles,
278
301
  warnings,
@@ -361,29 +384,32 @@ export async function initCommand(options: InitOptions): Promise<InitResult> {
361
384
  warnings,
362
385
  );
363
386
 
364
- // Scaffold .chant/types/lexicon-{lexicon}/ stub
365
- const lexiconTypesDir = join(targetDir, ".chant", "types", `lexicon-${options.lexicon}`);
366
- mkdirSync(lexiconTypesDir, { recursive: true });
387
+ // Scaffold .chant/types/lexicon-{lexicon}/ stub. A lexicon declared by path
388
+ // is imported by its path, so it has no package name to stub (chant#2578).
389
+ if (lexiconModule === undefined) {
390
+ const lexiconTypesDir = join(targetDir, ".chant", "types", `lexicon-${options.lexicon}`);
391
+ mkdirSync(lexiconTypesDir, { recursive: true });
367
392
 
368
- writeIfNotExists(
369
- join(lexiconTypesDir, "package.json"),
370
- JSON.stringify(
371
- { name: `@intentius/chant-lexicon-${options.lexicon}`, version: "0.0.0", types: "./index.d.ts" },
372
- null,
373
- 2,
374
- ),
375
- `.chant/types/lexicon-${options.lexicon}/package.json`,
376
- createdFiles,
377
- warnings,
378
- );
393
+ writeIfNotExists(
394
+ join(lexiconTypesDir, "package.json"),
395
+ JSON.stringify(
396
+ { name: `@intentius/chant-lexicon-${options.lexicon}`, version: "0.0.0", types: "./index.d.ts" },
397
+ null,
398
+ 2,
399
+ ),
400
+ `.chant/types/lexicon-${options.lexicon}/package.json`,
401
+ createdFiles,
402
+ warnings,
403
+ );
379
404
 
380
- writeIfNotExists(
381
- join(lexiconTypesDir, "index.d.ts"),
382
- `// Lexicon type stubs — run "chant update" to sync full types\nexport {};\n`,
383
- `.chant/types/lexicon-${options.lexicon}/index.d.ts`,
384
- createdFiles,
385
- warnings,
386
- );
405
+ writeIfNotExists(
406
+ join(lexiconTypesDir, "index.d.ts"),
407
+ `// Lexicon type stubs — run "chant update" to sync full types\nexport {};\n`,
408
+ `.chant/types/lexicon-${options.lexicon}/index.d.ts`,
409
+ createdFiles,
410
+ warnings,
411
+ );
412
+ }
387
413
 
388
414
  // Generate the project's MCP config. It lands in the directory init was
389
415
  // pointed at, like everything else init produces, and at the path
@@ -425,6 +451,7 @@ export async function initCommand(options: InitOptions): Promise<InitResult> {
425
451
  success: true,
426
452
  createdFiles,
427
453
  warnings,
454
+ ...(lexiconModule !== undefined ? { lexiconModule } : {}),
428
455
  };
429
456
  }
430
457
 
@@ -453,6 +480,11 @@ export async function printInitResult(
453
480
 
454
481
  console.log("");
455
482
 
483
+ if (result.lexiconModule !== undefined) {
484
+ console.log(`The lexicon loads from ${result.lexiconModule}, as chant.config.ts declares. It has no package to install.`);
485
+ console.log("");
486
+ }
487
+
456
488
  const pm = detectPackageManager(options?.cwd);
457
489
 
458
490
  // Interactive install prompt
@@ -1,4 +1,4 @@
1
- import { describe, test, expect, beforeEach, afterEach } from "vitest";
1
+ import { describe, test, expect, beforeEach, afterEach, vi } from "vitest";
2
2
  import { lintCommand, isLintRule, loadPluginRules, LEXICON_RESOLUTION_RULE_ID, type LintOptions } from "./lint";
3
3
  import { loadPlugins, resolveProjectLexicons } from "../plugins";
4
4
  import { mkdir, rm, writeFile } from "node:fs/promises";
@@ -6,6 +6,7 @@ import { join, resolve } from "node:path";
6
6
  import { tmpdir } from "node:os";
7
7
  import { execFileSync } from "node:child_process";
8
8
  import { params, setBuildParams } from "../../params";
9
+ import { resetDiscoveryWarnings } from "../../discovery/convergence";
9
10
 
10
11
  describe("lintCommand", () => {
11
12
  let testDir: string;
@@ -641,6 +642,62 @@ describe("lintCommand — git-ignored files are not linted", () => {
641
642
  });
642
643
  });
643
644
 
645
+ /**
646
+ * chant#2527's warning release for lint: a child project is linted today and
647
+ * stops the converged walk next release; a scan root that is itself ignored
648
+ * lints nothing today and is linted next release. Both still lint exactly
649
+ * what they did, with a warning naming the files and the glob.
650
+ */
651
+ describe("lintCommand — files the converged walker reads differently (#2527)", () => {
652
+ let testDir: string;
653
+ let stderr: string[];
654
+ const EVL003 = `const t = { a: 1 };\nexport function g(k: string) { return t[k]; }\n`;
655
+
656
+ beforeEach(async () => {
657
+ testDir = join(tmpdir(), `chant-lint-2527-${Date.now()}-${Math.random()}`);
658
+ await mkdir(testDir, { recursive: true });
659
+ process.env.NO_COLOR = "1";
660
+ resetDiscoveryWarnings();
661
+ stderr = [];
662
+ vi.spyOn(console, "error").mockImplementation((...args: unknown[]) => void stderr.push(args.join(" ")));
663
+ });
664
+
665
+ afterEach(async () => {
666
+ vi.restoreAllMocks();
667
+ await rm(testDir, { recursive: true, force: true });
668
+ delete process.env.NO_COLOR;
669
+ });
670
+
671
+ test("a nested child project is still linted, and named with its include glob", async () => {
672
+ await writeFile(join(testDir, "chant.config.json"), JSON.stringify({ knowledge: { dir: "knowledge" } }));
673
+ await writeFile(join(testDir, "index.ts"), `export const x = 1;\n`);
674
+ await mkdir(join(testDir, "stacks", "east"), { recursive: true });
675
+ await writeFile(join(testDir, "stacks", "east", "chant.config.ts"), `export default {};\n`);
676
+ await writeFile(join(testDir, "stacks", "east", "east.ts"), EVL003);
677
+
678
+ const result = await lintCommand({ path: testDir, format: "stylish" });
679
+ expect(result.diagnostics.some((d) => d.file.includes("east.ts") && d.ruleId === "EVL003")).toBe(true);
680
+ const warning = stderr.find((l) => l.startsWith("warning: chant lint under"));
681
+ expect(warning).toContain(" stacks/east/chant.config.ts, stacks/east/east.ts: stacks/east/ is a child project");
682
+ expect(warning).toContain('To keep reading them after the change, add "stacks/east" to include in');
683
+ });
684
+
685
+ test("an ignored scan root lints nothing today, and says it will be linted", async () => {
686
+ execFileSync("git", ["init", "-q"], { cwd: testDir });
687
+ await writeFile(join(testDir, ".gitignore"), "/carveout/\n");
688
+ await mkdir(join(testDir, "carveout", "src"), { recursive: true });
689
+ await writeFile(join(testDir, "carveout", "src", "main.ts"), EVL003);
690
+
691
+ const result = await lintCommand({ path: join(testDir, "carveout", "src"), format: "stylish" });
692
+ expect(result.diagnostics.filter((d) => d.file.includes("main.ts"))).toHaveLength(0);
693
+ const warning = stderr.find((l) => l.startsWith("warning: chant lint under"));
694
+ expect(warning).toContain(" main.ts: ");
695
+ expect(warning).toContain("is itself git-ignored, so today nothing under it is linted. ");
696
+ expect(warning).toContain("so the files under it are linted.");
697
+ expect(warning).toContain('To keep skipping it, add "**" to exclude in');
698
+ });
699
+ });
700
+
644
701
  /**
645
702
  * chant #2222 (epic #2215 sub-issue 7) — `chant lint` used to print "No
646
703
  * problems found" and exit 0 on a project whose declared lexicon could not be