@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
@@ -1,6 +1,10 @@
1
1
  import { readdir, stat, open } from "node:fs/promises";
2
2
  import { existsSync } from "node:fs";
3
- import { join, relative } from "node:path";
3
+ import { join, relative, sep, isAbsolute } from "node:path";
4
+ // @ts-ignore — picomatch has no types declaration
5
+ import picomatch from "picomatch";
6
+ import { resolveDiscoveryGlobs, type DiscoveryGlobs } from "../config";
7
+ import { warnDiscoveryChanges } from "./convergence";
4
8
 
5
9
  /**
6
10
  * Marker chant writes at the top of files it generates (a hosting lexicon's
@@ -12,13 +16,38 @@ import { join, relative } from "node:path";
12
16
  */
13
17
  export const GENERATED_MARKER = "Generated by chant — do not edit directly";
14
18
 
15
- /** True when a file's header carries the {@link GENERATED_MARKER}. Reads only the head. */
16
- async function isGeneratedFile(file: string): Promise<boolean> {
19
+ /**
20
+ * Comment a hand-written file carries to keep discovery out of it (#2519): a
21
+ * runnable script with top-level side effects, say, that lives next to the
22
+ * project's declarations. It must be a comment of its own (`// chant-discovery-skip`
23
+ * or inside a block comment) within the first {@link SKIP_MARKER_HEAD_BYTES}
24
+ * bytes of the file. See {@link hasSkipMarker}.
25
+ */
26
+ export const SKIP_MARKER = "chant-discovery-skip";
27
+
28
+ /** How much of a file's head {@link hasSkipMarker} reads: room for a shebang and a license header. */
29
+ const SKIP_MARKER_HEAD_BYTES = 1024;
30
+
31
+ /** {@link SKIP_MARKER} opening a `//`, `/*` or ` * ` comment line, as a whole word. */
32
+ const SKIP_MARKER_PATTERN = /^[ \t]*(?:\/\/|\/\*+|\*)[ \t]*chant-discovery-skip(?![\w-])/m;
33
+
34
+ /** True when `head`, the start of a file, carries the {@link SKIP_MARKER} comment. */
35
+ export function hasSkipMarker(head: string): boolean {
36
+ return SKIP_MARKER_PATTERN.test(head.slice(0, SKIP_MARKER_HEAD_BYTES));
37
+ }
38
+
39
+ /**
40
+ * Whether a file's head carries the {@link GENERATED_MARKER} or the
41
+ * {@link SKIP_MARKER}. Reads only the head. The generated marker is looked for
42
+ * in the first 256 bytes, as it always was.
43
+ */
44
+ export async function hasDiscoveryMarker(file: string): Promise<boolean> {
17
45
  let fh;
18
46
  try {
19
47
  fh = await open(file, "r");
20
- const { buffer, bytesRead } = await fh.read({ buffer: Buffer.alloc(256), position: 0 });
21
- return buffer.toString("utf8", 0, bytesRead).includes(GENERATED_MARKER);
48
+ const { buffer, bytesRead } = await fh.read({ buffer: Buffer.alloc(SKIP_MARKER_HEAD_BYTES), position: 0 });
49
+ if (buffer.toString("utf8", 0, Math.min(bytesRead, 256)).includes(GENERATED_MARKER)) return true;
50
+ return hasSkipMarker(buffer.toString("utf8", 0, bytesRead));
22
51
  } catch {
23
52
  return false;
24
53
  } finally {
@@ -26,14 +55,55 @@ async function isGeneratedFile(file: string): Promise<boolean> {
26
55
  }
27
56
  }
28
57
 
58
+ /**
59
+ * Compile a project's `exclude`/`include` globs (#2519) into a predicate that
60
+ * is true for a file discovery must skip, or `undefined` when there is nothing
61
+ * to skip.
62
+ *
63
+ * Each pattern is matched, with picomatch and dot files included, against the
64
+ * file's path relative to `globs.root` (forward slashes) and against the path
65
+ * of every directory above it, so a pattern naming a directory covers
66
+ * everything under it. A file is skipped when some `exclude` pattern matches
67
+ * and no `include` pattern does: `include` always wins. Files outside
68
+ * `globs.root` are never skipped.
69
+ */
70
+ export function compileDiscoveryFilter(globs: DiscoveryGlobs | undefined): ((file: string) => boolean) | undefined {
71
+ if (!globs || globs.exclude.length === 0) return undefined;
72
+ const excluded = picomatch(globs.exclude, { dot: true });
73
+ const included = globs.include.length > 0 ? picomatch(globs.include, { dot: true }) : () => false;
74
+ return (file: string): boolean => {
75
+ const rel = relative(globs.root, file);
76
+ if (rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) return false;
77
+ const parts = rel.split(sep);
78
+ const candidates = parts.map((_, i) => parts.slice(0, i + 1).join("/"));
79
+ return candidates.some((c) => excluded(c)) && !candidates.some((c) => included(c));
80
+ };
81
+ }
82
+
83
+ /** Options for {@link findInfraFiles}. */
84
+ export interface FindInfraFilesOptions {
85
+ /**
86
+ * The project's discovery globs. Omitted, they are read from the project
87
+ * config `path` belongs to ({@link resolveDiscoveryGlobs}); `null` applies none.
88
+ */
89
+ globs?: DiscoveryGlobs | null;
90
+ }
91
+
29
92
  /**
30
93
  * Recursively find all TypeScript infrastructure files in a directory
31
94
  * @param path - The directory path to search
32
- * @returns Array of file paths to .ts files (excluding test files and chant-generated files)
95
+ * @returns Array of file paths to .ts files (excluding test files, chant-generated
96
+ * files, files carrying the skip marker, and files the project's `exclude` globs name)
33
97
  */
34
- export async function findInfraFiles(path: string): Promise<string[]> {
98
+ export async function findInfraFiles(path: string, options?: FindInfraFilesOptions): Promise<string[]> {
35
99
  const files: string[] = [];
100
+ const skip = compileDiscoveryFilter(
101
+ options?.globs === null ? undefined : (options?.globs ?? (await resolveDiscoveryGlobs(path))),
102
+ );
36
103
  let sourceRoot: string | null = null;
104
+ // Child projects skipped because the source root was already set: the
105
+ // next release reads them when `path` is outside a project (#2527).
106
+ const skippedChildren: string[] = [];
37
107
 
38
108
  async function scanDirectory(dir: string): Promise<void> {
39
109
  let entries;
@@ -63,17 +133,20 @@ export async function findInfraFiles(path: string): Promise<string[]> {
63
133
  if (sourceRoot === null) {
64
134
  sourceRoot = fullPath;
65
135
  } else {
136
+ skippedChildren.push(fullPath);
66
137
  continue;
67
138
  }
68
139
  }
69
140
  await scanDirectory(fullPath);
70
141
  } else if (entry.isFile()) {
71
- // Include only .ts files, exclude test files and chant-generated files
142
+ // Include only .ts files, exclude test files, files the project's
143
+ // globs exclude, and chant-generated or skip-marked files
72
144
  if (
73
145
  entry.name.endsWith(".ts") &&
74
146
  !entry.name.endsWith(".test.ts") &&
75
147
  !entry.name.endsWith(".spec.ts") &&
76
- !(await isGeneratedFile(fullPath))
148
+ !skip?.(fullPath) &&
149
+ !(await hasDiscoveryMarker(fullPath))
77
150
  ) {
78
151
  files.push(fullPath);
79
152
  }
@@ -82,5 +155,20 @@ export async function findInfraFiles(path: string): Promise<string[]> {
82
155
  }
83
156
 
84
157
  await scanDirectory(path);
158
+ // The warning release for #2527: say which files the converged walker will
159
+ // read differently. The list returned is today's, unchanged.
160
+ await warnDiscoveryChanges({
161
+ walker: "source",
162
+ root: path,
163
+ files,
164
+ sourceRoot,
165
+ skippedChildren,
166
+ fileOk: async (name, full) =>
167
+ name.endsWith(".ts") &&
168
+ !name.endsWith(".test.ts") &&
169
+ !name.endsWith(".spec.ts") &&
170
+ !skip?.(full) &&
171
+ !(await hasDiscoveryMarker(full)),
172
+ });
85
173
  return files;
86
174
  }
@@ -27,6 +27,7 @@ import {
27
27
  type FoldedCompositeStepCall,
28
28
  } from "../fold/fold";
29
29
  import { isChantOwnedSpecifier, isFoldableHelperName } from "../fold/foldable-helpers";
30
+ import { lexiconModulePath } from "../lexicon-module";
30
31
  import {
31
32
  briefNodeText,
32
33
  callExpressionMessage,
@@ -247,6 +248,21 @@ export interface FoldSession {
247
248
  * established one.
248
249
  */
249
250
  readonly lexiconPackages: ReadonlySet<string>;
251
+ /**
252
+ * chant#2577 — the absolute module paths of this build's lexicons that
253
+ * `chant.config.ts` declares by path (#2520), each resolved to the file an
254
+ * import of it reaches. Such a lexicon has no package specifier, so a
255
+ * project imports it by a relative or absolute path. A binding whose path
256
+ * resolves to one of these is treated exactly like a binding of a
257
+ * {@link lexiconPackages} entry: its data exports resolve as values, and
258
+ * under `--sandbox` it is on {@link isTrustedExecutableBinding}'s allowlist,
259
+ * because `loadPlugins` has already imported and run it in this process.
260
+ *
261
+ * Only the declared module itself is in the set. A package has a root that
262
+ * bounds its subpaths, and a module path has none, so other files beside it
263
+ * stay project files.
264
+ */
265
+ readonly lexiconModules: ReadonlySet<string>;
250
266
  /**
251
267
  * chant #1093 — this build asked for the #1045 sandbox
252
268
  * (`DiscoveryOptions.sandbox`, `chant build --sandbox`), so fold must not
@@ -327,6 +343,15 @@ export function createFoldSession(
327
343
  /** chant#2455 — `ι = executing`. Mutually exclusive with `sandbox`. */
328
344
  executing = false,
329
345
  ): FoldSession {
346
+ // chant#2577 — a lexicon declared by path is that module and no package, so
347
+ // its name contributes the module path and not `@intentius/chant-lexicon-<name>`.
348
+ const packages = new Set(lexiconPackages);
349
+ const modules = new Set<string>();
350
+ for (const name of lexicons) {
351
+ const modulePath = lexiconModulePath(name);
352
+ if (modulePath === undefined) packages.add(lexiconPackageName(name));
353
+ else modules.add(resolveModulePath(modulePath, modulePath));
354
+ }
330
355
  return {
331
356
  intrinsics,
332
357
  cache: new Map(),
@@ -334,7 +359,8 @@ export function createFoldSession(
334
359
  importCache: new Map(),
335
360
  resolvePathCache: new Map(),
336
361
  buildParams,
337
- lexiconPackages: new Set([...lexicons.map(lexiconPackageName), ...lexiconPackages]),
362
+ lexiconPackages: packages,
363
+ lexiconModules: modules,
338
364
  sandbox,
339
365
  executing,
340
366
  factoryModules: new Map(),
@@ -1104,6 +1130,29 @@ function activeLexiconPackage(specifier: string, lexiconPackages: ReadonlySet<st
1104
1130
  return lexiconPackages.has(specifier) ? specifier : undefined;
1105
1131
  }
1106
1132
 
1133
+ /**
1134
+ * chant#2577 — the path-declared counterpart of {@link activeLexiconPackage}:
1135
+ * the resolved module path when a relative or absolute `specifier` reaches
1136
+ * one of this build's {@link FoldSession.lexiconModules}, `undefined`
1137
+ * otherwise. A bare specifier is never resolved here, and a build with no
1138
+ * path-declared lexicon resolves nothing.
1139
+ */
1140
+ function activeLexiconModule(
1141
+ specifier: string,
1142
+ fromFile: string,
1143
+ lexiconModules: ReadonlySet<string>,
1144
+ resolvePathCache: Map<string, string>,
1145
+ ): string | undefined {
1146
+ if (lexiconModules.size === 0 || !isProjectFileSpecifier(specifier)) return undefined;
1147
+ let modulePath: string;
1148
+ try {
1149
+ modulePath = resolveModulePathMemoized(specifier, fromFile, resolvePathCache);
1150
+ } catch {
1151
+ return undefined;
1152
+ }
1153
+ return lexiconModules.has(modulePath) ? modulePath : undefined;
1154
+ }
1155
+
1107
1156
  /**
1108
1157
  * chant #1995 — the package-ROOT portion of a bare subpath specifier
1109
1158
  * (`@intentius/chant-lexicon-azure/generated/index` ->
@@ -1168,13 +1217,17 @@ async function resolveActiveLexiconExport(
1168
1217
  fromFile: string,
1169
1218
  session: FoldSession,
1170
1219
  ): Promise<{ value: unknown } | undefined> {
1171
- if (!activeLexiconPackage(binding.specifier, session.lexiconPackages)) return undefined;
1172
-
1173
- let modulePath: string;
1174
- try {
1175
- modulePath = resolveModulePathMemoized(binding.specifier, fromFile, session.resolvePathCache);
1176
- } catch {
1177
- return undefined;
1220
+ let modulePath: string | undefined;
1221
+ if (activeLexiconPackage(binding.specifier, session.lexiconPackages)) {
1222
+ try {
1223
+ modulePath = resolveModulePathMemoized(binding.specifier, fromFile, session.resolvePathCache);
1224
+ } catch {
1225
+ return undefined;
1226
+ }
1227
+ } else {
1228
+ // chant#2577 — a lexicon declared by path, imported by its path.
1229
+ modulePath = activeLexiconModule(binding.specifier, fromFile, session.lexiconModules, session.resolvePathCache);
1230
+ if (modulePath === undefined) return undefined;
1178
1231
  }
1179
1232
 
1180
1233
  let mod: Record<string, unknown>;
@@ -1278,6 +1331,8 @@ interface ResolveCtx {
1278
1331
  * site that imports and executes a module.
1279
1332
  */
1280
1333
  lexiconPackages: ReadonlySet<string>;
1334
+ /** chant#2577 — see {@link FoldSession.lexiconModules}. */
1335
+ lexiconModules: ReadonlySet<string>;
1281
1336
  /** chant #1093 — see {@link FoldSession.sandbox}. */
1282
1337
  sandbox: boolean;
1283
1338
  /** chant#2455 — see {@link FoldSession.executing}. */
@@ -2053,7 +2108,10 @@ function findCompositeDefinition(
2053
2108
  // chant build the set holds only `@intentius/chant-lexicon-*`, and no
2054
2109
  // lexicon exports a `Composite`, so nothing about a chant project moves.
2055
2110
  const chantOwned = isChantOwnedHelperBinding(compositeBinding, { ...ctx, file: scope.file });
2056
- const hostOwned = activeLexiconPackage(compositeBinding.specifier, ctx.lexiconPackages) !== undefined;
2111
+ const hostOwned =
2112
+ activeLexiconPackage(compositeBinding.specifier, ctx.lexiconPackages) !== undefined ||
2113
+ activeLexiconModule(compositeBinding.specifier, scope.file, ctx.lexiconModules, ctx.resolvePathCache) !==
2114
+ undefined;
2057
2115
  if (!chantOwned && !hostOwned) return undefined;
2058
2116
 
2059
2117
  const [fnArg, nameArg] = init.arguments;
@@ -2425,6 +2483,7 @@ async function interpretCompositeFactory(
2425
2483
  importCache: ctx.importCache,
2426
2484
  resolvePathCache: ctx.resolvePathCache,
2427
2485
  lexiconPackages: ctx.lexiconPackages,
2486
+ lexiconModules: ctx.lexiconModules,
2428
2487
  sandbox: ctx.sandbox,
2429
2488
  executing: ctx.executing,
2430
2489
  session: ctx.session,
@@ -3048,6 +3107,9 @@ function isTrustedExecutableBinding(binding: ImportBinding, ctx: ResolveCtx): bo
3048
3107
  } catch {
3049
3108
  return false;
3050
3109
  }
3110
+ // chant#2577 — arm 1 for a lexicon declared by path: the module
3111
+ // `loadPlugins` already imported, reached by the path the project imports it by.
3112
+ if (ctx.lexiconModules.has(targetPath)) return true;
3051
3113
  const root = chantCoreRoot();
3052
3114
  return targetPath === root || targetPath.startsWith(root + sep);
3053
3115
  }
@@ -3607,6 +3669,14 @@ async function buildExternals(
3607
3669
  } catch {
3608
3670
  continue;
3609
3671
  }
3672
+ // chant#2577 — a path-declared lexicon is a lexicon, not a project file:
3673
+ // resolve its data export the way the bare-specifier branch above does,
3674
+ // with no `liveSources` edge for the same reason.
3675
+ if (session.lexiconModules.has(targetPath)) {
3676
+ const lexiconExport = await resolveActiveLexiconExport(binding, file, session);
3677
+ if (lexiconExport) externals.set(localName, lexiconExport.value);
3678
+ continue;
3679
+ }
3610
3680
  const result = await foldFileMemoized(targetPath, session);
3611
3681
  if (!result.ok) {
3612
3682
  failures.set(localName, locatedMessage(binding.specifierNode, result.reason));
@@ -3632,6 +3702,9 @@ async function buildExternals(
3632
3702
  } catch {
3633
3703
  continue;
3634
3704
  }
3705
+ // chant#2577 — a namespace import of a lexicon is left alone, as the
3706
+ // bare-specifier check above leaves a package's alone.
3707
+ if (session.lexiconModules.has(targetPath)) continue;
3635
3708
  const result = await foldFileMemoized(targetPath, session);
3636
3709
  if (!result.ok) {
3637
3710
  failures.set(localName, locatedMessage(binding.specifierNode, result.reason));
@@ -3738,6 +3811,7 @@ async function tryFoldFileCore(file: string, session: FoldSession): Promise<Fold
3738
3811
  importCache: session.importCache,
3739
3812
  resolvePathCache: session.resolvePathCache,
3740
3813
  lexiconPackages: session.lexiconPackages,
3814
+ lexiconModules: session.lexiconModules,
3741
3815
  sandbox: session.sandbox,
3742
3816
  executing: session.executing,
3743
3817
  session,
@@ -0,0 +1,162 @@
1
+ /**
2
+ * chant#2577 — a lexicon declared by path in `chant.config.ts` (#2520) gets the
3
+ * same fold treatment as the same lexicon installed as a package, with and
4
+ * without `--sandbox`.
5
+ *
6
+ * One lexicon source is written twice: once as the package
7
+ * `@intentius/chant-lexicon-demo` under the fixture's own `node_modules`, and
8
+ * once as `./lexicon/index.ts` named by path. The two projects differ only in
9
+ * how `chant.config.ts` names the lexicon and in the specifier `main.ts`
10
+ * imports it by. Each project's config is loaded, which is what records a
11
+ * path-declared lexicon, and each is built with the lexicon list a CLI build
12
+ * passes (the loaded plugins' names). The outputs and fold verdicts are compared.
13
+ */
14
+ import { describe, test, expect, beforeAll, afterAll, afterEach } from "vitest";
15
+ import { mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises";
16
+ import { join, resolve } from "node:path";
17
+ import { tmpdir } from "node:os";
18
+ import { build } from "../../build";
19
+ import { loadChantConfig } from "../../config";
20
+ import { resetLexiconModules } from "../../lexicon-module";
21
+ import type { LexiconPlugin } from "../../lexicon";
22
+
23
+ const runtimePath = resolve(import.meta.dirname, "../../runtime");
24
+ const compositePath = resolve(import.meta.dirname, "../../composite");
25
+
26
+ const LEXICON_SOURCE = `
27
+ import { createResource } from ${JSON.stringify(runtimePath)};
28
+ import { Composite } from ${JSON.stringify(compositePath)};
29
+
30
+ export const Bucket = createResource("Demo::Bucket", "demo", { arn: "Arn" });
31
+ export const Role = createResource("Demo::Role", "demo", {});
32
+ export const Regions = { primary: "us-east-1" };
33
+
34
+ export const WebApp = Composite((props) => {
35
+ const bucket = new Bucket({ bucketName: props.name });
36
+ const role = new Role({ resource: bucket.arn });
37
+ return { bucket, role };
38
+ }, "WebApp");
39
+
40
+ function plain(value) {
41
+ if (value && typeof value === "object" && typeof value.getLogicalName === "function") {
42
+ return { ref: value.getLogicalName(), attr: value.attribute };
43
+ }
44
+ if (Array.isArray(value)) return value.map(plain);
45
+ if (value && typeof value === "object") {
46
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, plain(v)]));
47
+ }
48
+ return value;
49
+ }
50
+
51
+ export const demoPlugin = {
52
+ name: "demo",
53
+ serializer: {
54
+ name: "demo",
55
+ rulePrefix: "DEMO",
56
+ serialize: (entities) =>
57
+ JSON.stringify(
58
+ Object.fromEntries(
59
+ [...entities.keys()].sort().map((name) => {
60
+ const entity = entities.get(name);
61
+ return [name, { type: entity.entityType, props: plain(entity.props) }];
62
+ }),
63
+ ),
64
+ ),
65
+ },
66
+ generate: async () => {},
67
+ validate: async () => {},
68
+ coverage: async () => {},
69
+ package: async () => {},
70
+ };
71
+ `;
72
+
73
+ function mainSource(specifier: string): string {
74
+ return `
75
+ import { Bucket, Regions, WebApp } from ${JSON.stringify(specifier)};
76
+
77
+ export const logs = new Bucket({ bucketName: Regions.primary });
78
+ export const web = WebApp({ name: "data" });
79
+ `;
80
+ }
81
+
82
+ let root: string;
83
+
84
+ beforeAll(async () => {
85
+ root = await realpath(await mkdtemp(join(tmpdir(), "chant-2577-")));
86
+
87
+ const pkg = join(root, "pkg");
88
+ const pkgDir = join(pkg, "node_modules", "@intentius", "chant-lexicon-demo");
89
+ await mkdir(pkgDir, { recursive: true });
90
+ await writeFile(
91
+ join(pkgDir, "package.json"),
92
+ JSON.stringify({ name: "@intentius/chant-lexicon-demo", type: "module", main: "index.ts" }),
93
+ );
94
+ await writeFile(join(pkgDir, "index.ts"), LEXICON_SOURCE);
95
+ await mkdir(join(pkg, "src"));
96
+ await writeFile(join(pkg, "src", "main.ts"), mainSource("@intentius/chant-lexicon-demo"));
97
+ await writeFile(join(pkg, "chant.config.ts"), `export default { lexicons: ["demo"] };\n`);
98
+
99
+ const path = join(root, "path");
100
+ await mkdir(join(path, "lexicon"), { recursive: true });
101
+ await writeFile(join(path, "lexicon", "index.ts"), LEXICON_SOURCE);
102
+ await mkdir(join(path, "src"));
103
+ await writeFile(join(path, "src", "main.ts"), mainSource("../lexicon"));
104
+ await writeFile(
105
+ join(path, "chant.config.ts"),
106
+ `export default { lexicons: [{ name: "demo", module: "./lexicon/index.ts" }] };\n`,
107
+ );
108
+ });
109
+
110
+ afterAll(async () => {
111
+ await rm(root, { recursive: true, force: true });
112
+ });
113
+
114
+ afterEach(() => {
115
+ resetLexiconModules();
116
+ });
117
+
118
+ async function buildProject(project: "pkg" | "path", modes: { fold: boolean; sandbox: boolean }) {
119
+ const dir = join(root, project);
120
+ const lexiconFile =
121
+ project === "pkg"
122
+ ? join(dir, "node_modules", "@intentius", "chant-lexicon-demo", "index.ts")
123
+ : join(dir, "lexicon", "index.ts");
124
+ await loadChantConfig(dir);
125
+ const plugin = (await import(lexiconFile)).demoPlugin as LexiconPlugin;
126
+ const result = await build(join(dir, "src"), [plugin.serializer], undefined, {
127
+ ...modes,
128
+ lexicons: [plugin.name],
129
+ });
130
+ return {
131
+ errors: result.errors,
132
+ output: result.outputs.get("demo") as string,
133
+ decisions: result.foldDecisions.map((d) => ({ file: d.file.slice(dir.length), mode: d.mode, reason: d.reason })),
134
+ };
135
+ }
136
+
137
+ describe("a lexicon declared by path folds like the same lexicon installed as a package (chant#2577)", () => {
138
+ for (const modes of [
139
+ { fold: true, sandbox: true },
140
+ { fold: true, sandbox: false },
141
+ ]) {
142
+ test(
143
+ `--fold${modes.sandbox ? " --sandbox" : ""}: same output, same fold verdicts`,
144
+ async () => {
145
+ const asPackage = await buildProject("pkg", modes);
146
+ const byPath = await buildProject("path", modes);
147
+
148
+ expect(asPackage.errors).toEqual([]);
149
+ expect(byPath.errors).toEqual([]);
150
+ expect(byPath.output).toBe(asPackage.output);
151
+ expect(JSON.parse(byPath.output)).toEqual({
152
+ logs: { type: "Demo::Bucket", props: { bucketName: "us-east-1" } },
153
+ webBucket: { type: "Demo::Bucket", props: { bucketName: "data" } },
154
+ webRole: { type: "Demo::Role", props: { resource: { ref: "webBucket", attr: "Arn" } } },
155
+ });
156
+ expect(byPath.decisions).toEqual(asPackage.decisions);
157
+ expect(byPath.decisions).toEqual([{ file: "/src/main.ts", mode: "fold", reason: undefined }]);
158
+ },
159
+ 60_000,
160
+ );
161
+ }
162
+ });
package/src/graph-ir.ts CHANGED
@@ -290,8 +290,22 @@ export interface IRPipeline {
290
290
  edges: IRPipelineEdge[];
291
291
  }
292
292
 
293
+ /**
294
+ * The version `chant graph --format ir` writes on its output (#2529, #2524 D8).
295
+ * The format changes only additively: a new field or edge kind never bumps
296
+ * it, and a consumer ignores the fields and edge kinds it does not know. The
297
+ * number moves only for a change a consumer could not ignore.
298
+ */
299
+ export const GRAPH_IR_VERSION = 1;
300
+
293
301
  /** The full graph IR for a project at the default (declarable) detail level. */
294
302
  export interface GraphIR {
303
+ /**
304
+ * {@link GRAPH_IR_VERSION}, stamped by `chant graph --format ir` when it
305
+ * writes the IR (#2529). Absent on an IR built in memory, and on output
306
+ * from a chant older than the field, which a reader treats as version 1.
307
+ */
308
+ version?: number;
295
309
  nodes: IRNode[];
296
310
  edges: IREdge[];
297
311
  groups: IRGroups;