@intentius/chant 0.70.0 → 0.71.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 (85) hide show
  1. package/dist/cli/handlers/fan-out.d.ts +45 -0
  2. package/dist/cli/handlers/fan-out.d.ts.map +1 -0
  3. package/dist/cli/handlers/lifecycle.d.ts +13 -0
  4. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  5. package/dist/cli/handlers/operator.d.ts.map +1 -1
  6. package/dist/cli/handlers/run.d.ts +35 -0
  7. package/dist/cli/handlers/run.d.ts.map +1 -1
  8. package/dist/cli/main.d.ts.map +1 -1
  9. package/dist/cli/registry.d.ts +23 -0
  10. package/dist/cli/registry.d.ts.map +1 -1
  11. package/dist/components/deploy-units.d.ts +12 -2
  12. package/dist/components/deploy-units.d.ts.map +1 -1
  13. package/dist/components/fan-out-output.d.ts +70 -0
  14. package/dist/components/fan-out-output.d.ts.map +1 -0
  15. package/dist/components/fan-out-run.d.ts +80 -0
  16. package/dist/components/fan-out-run.d.ts.map +1 -0
  17. package/dist/components/fan-out-support.d.ts +65 -0
  18. package/dist/components/fan-out-support.d.ts.map +1 -0
  19. package/dist/components/fan-out.d.ts +194 -0
  20. package/dist/components/fan-out.d.ts.map +1 -0
  21. package/dist/components/index.d.ts +4 -0
  22. package/dist/components/index.d.ts.map +1 -1
  23. package/dist/discovery/fold-import.d.ts +96 -1
  24. package/dist/discovery/fold-import.d.ts.map +1 -1
  25. package/dist/discovery/index.d.ts.map +1 -1
  26. package/dist/fold/subset.d.ts +22 -0
  27. package/dist/fold/subset.d.ts.map +1 -1
  28. package/dist/index.d.ts +2 -0
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/lifecycle/affected.d.ts +26 -0
  31. package/dist/lifecycle/affected.d.ts.map +1 -1
  32. package/dist/op/activities/activity-contracts.d.ts +16 -1
  33. package/dist/op/activities/activity-contracts.d.ts.map +1 -1
  34. package/dist/op/activities/index.d.ts +1 -1
  35. package/dist/op/activities/index.d.ts.map +1 -1
  36. package/dist/op/activities/shell.d.ts +31 -2
  37. package/dist/op/activities/shell.d.ts.map +1 -1
  38. package/dist/op/activity-contract.d.ts +1 -1
  39. package/dist/op/activity-contract.d.ts.map +1 -1
  40. package/dist/op/activity-profiles.d.ts +19 -0
  41. package/dist/op/activity-profiles.d.ts.map +1 -1
  42. package/dist/op/builders.d.ts +21 -3
  43. package/dist/op/builders.d.ts.map +1 -1
  44. package/dist/op/gate-name.d.ts +10 -0
  45. package/dist/op/gate-name.d.ts.map +1 -1
  46. package/dist/op/step-output-ref.d.ts +25 -8
  47. package/dist/op/step-output-ref.d.ts.map +1 -1
  48. package/package.json +1 -1
  49. package/src/cli/handlers/fan-out.test.ts +394 -0
  50. package/src/cli/handlers/fan-out.ts +336 -0
  51. package/src/cli/handlers/lifecycle.test.ts +74 -1
  52. package/src/cli/handlers/lifecycle.ts +29 -1
  53. package/src/cli/handlers/operator.test.ts +22 -0
  54. package/src/cli/handlers/operator.ts +11 -3
  55. package/src/cli/handlers/run.ts +7 -1
  56. package/src/cli/main.ts +25 -3
  57. package/src/cli/registry.ts +23 -0
  58. package/src/components/deploy-units.ts +14 -4
  59. package/src/components/fan-out-output.test.ts +216 -0
  60. package/src/components/fan-out-output.ts +162 -0
  61. package/src/components/fan-out-run.test.ts +194 -0
  62. package/src/components/fan-out-run.ts +221 -0
  63. package/src/components/fan-out-support.test.ts +125 -0
  64. package/src/components/fan-out-support.ts +95 -0
  65. package/src/components/fan-out.test.ts +284 -0
  66. package/src/components/fan-out.ts +421 -0
  67. package/src/components/index.ts +33 -0
  68. package/src/discovery/fold-import.ts +195 -5
  69. package/src/discovery/index.ts +13 -5
  70. package/src/fold/subset-public-export.test.ts +31 -0
  71. package/src/fold/subset.ts +23 -0
  72. package/src/index.ts +18 -0
  73. package/src/lifecycle/affected.test.ts +118 -0
  74. package/src/lifecycle/affected.ts +118 -14
  75. package/src/op/activities/activity-contracts.ts +17 -1
  76. package/src/op/activities/index.ts +1 -1
  77. package/src/op/activities/shell.test.ts +156 -0
  78. package/src/op/activities/shell.ts +84 -9
  79. package/src/op/activity-profiles.test.ts +16 -2
  80. package/src/op/activity-profiles.ts +18 -0
  81. package/src/op/builders.ts +22 -4
  82. package/src/op/gate-name.ts +11 -0
  83. package/src/op/op-ir.test.ts +4 -1
  84. package/src/op/op.test.ts +7 -2
  85. package/src/op/step-output-ref.ts +29 -8
@@ -1247,6 +1247,21 @@ interface ResolveCtx {
1247
1247
  * behavior doesn't depend on it.
1248
1248
  */
1249
1249
  crossFileFailures: Map<string, string>;
1250
+ /**
1251
+ * chant #2422/#2423 — for a same-file `const x = new T(...)` whose pre-build
1252
+ * ({@link preresolveResourceConsts}) failed, WHY, located.
1253
+ *
1254
+ * The pre-build swallows a failed construction by design, so the name stays
1255
+ * absent from {@link externals} and the first reference to it rejects with
1256
+ * "same-file resource `x` used as a value", at the reference. That rejection
1257
+ * is right, and it points at the consequence: the located cause is on the
1258
+ * `new` line, which the reader never sees. `F-Reason` asks a reason to carry
1259
+ * the innermost located cause, so {@link describeFoldFailure} appends this.
1260
+ *
1261
+ * Present only on the one context the pre-build runs in. Purely cosmetic;
1262
+ * nothing about whether a file folds depends on it.
1263
+ */
1264
+ prebuildFailures?: Map<string, string>;
1250
1265
  /**
1251
1266
  * chant #1020 hang fix — session-wide {@link importModule} memo (see
1252
1267
  * {@link FoldSession.importCache}'s doc). Every constructor/composite-
@@ -3235,9 +3250,16 @@ async function preresolveResourceConsts(ctx: ResolveCtx): Promise<Map<ts.Express
3235
3250
  stampParamDependencies(instance, initializer, ctx);
3236
3251
  built.set(initializer, instance);
3237
3252
  ctx.externals.set(name, instance);
3238
- } catch {
3253
+ } catch (err) {
3239
3254
  // Not constructible here (an unresolvable constructor import, a prop
3240
- // outside the fold subset, a --sandbox refusal). Leave the name alone.
3255
+ // outside the fold subset, a --sandbox refusal). Leave the name alone:
3256
+ // the failure is deliberately not fatal, and the first reference to the
3257
+ // name rejects on its own terms.
3258
+ //
3259
+ // chant#2423 — but keep the located reason. Without it the file's only
3260
+ // reported cause is the reference site, which is where the consequence
3261
+ // is, not where the problem is.
3262
+ ctx.prebuildFailures?.set(name, describeFoldFailure(err, ctx));
3241
3263
  }
3242
3264
  }
3243
3265
  return built;
@@ -3292,6 +3314,9 @@ async function resolveResourceEntity(
3292
3314
 
3293
3315
  const UNRESOLVED_IDENTIFIER_RE = /unresolved identifier: (\S+)$/;
3294
3316
 
3317
+ /** The rejection a reference to a const whose pre-build failed produces (chant#2423). */
3318
+ const SAME_FILE_RESOURCE_RE = /same-file resource `([^`]+)` used as a value is not foldable/;
3319
+
3295
3320
  /**
3296
3321
  * Enrich an otherwise-generic "unresolved identifier: X" failure when X is a
3297
3322
  * name whose OWN cross-file resolution was attempted and failed for a known
@@ -3306,6 +3331,13 @@ function describeFoldFailure(err: unknown, ctx: ResolveCtx): string {
3306
3331
  const reason = ctx.crossFileFailures.get(match[1]);
3307
3332
  if (reason) return `${err.message} (${reason})`;
3308
3333
  }
3334
+ // chant#2423 — the reference rejected because the pre-build never produced
3335
+ // the instance. Say what stopped the pre-build, at the line it stopped on.
3336
+ const sameFile = SAME_FILE_RESOURCE_RE.exec(err.message);
3337
+ if (sameFile) {
3338
+ const cause = ctx.prebuildFailures?.get(sameFile[1]);
3339
+ if (cause) return `${err.message} (${cause})`;
3340
+ }
3309
3341
  return err.message;
3310
3342
  }
3311
3343
 
@@ -3635,6 +3667,10 @@ async function tryFoldFileCore(file: string, session: FoldSession): Promise<Fold
3635
3667
  sandbox: session.sandbox,
3636
3668
  session,
3637
3669
  interpretDepth: 0,
3670
+ // chant#2423 — filled by the pre-build below, read by
3671
+ // `describeFoldFailure` when a reference rejects for a const it could
3672
+ // not build.
3673
+ prebuildFailures: new Map<string, string>(),
3638
3674
  };
3639
3675
 
3640
3676
  // chant #1169 — every same-file `const x = new Type(...)`, built once, in
@@ -3949,7 +3985,40 @@ export async function planFoldTaint(
3949
3985
  files: readonly string[],
3950
3986
  wouldFold: ReadonlyMap<string, boolean>,
3951
3987
  liveSources?: ReadonlyMap<string, ReadonlySet<string>>,
3952
- ): Promise<Set<string>> {
3988
+ ): Promise<ReadonlySet<string>> {
3989
+ return (await planFoldTaintWithEdges(files, wouldFold, liveSources)).tainted;
3990
+ }
3991
+
3992
+ /** Which of the two rules put a file on the run path. */
3993
+ export type TaintEdgeKind =
3994
+ /** A file this one imports, directly or transitively, falls back to run (#1023). */
3995
+ | "importer"
3996
+ /** This file captured the objects of a file that falls back to run (#1044). */
3997
+ | "capture";
3998
+
3999
+ export interface TaintPlan {
4000
+ /** Every file that must run: the seed, plus everything reachable from it. */
4001
+ readonly tainted: ReadonlySet<string>;
4002
+ /**
4003
+ * For a file the walk reached (never for a seed file), the file whose taint
4004
+ * reached it and by which rule. chant#2406: the fallback reason has to name
4005
+ * the actual edge — a reverse-tainted file is not imported by anything that
4006
+ * runs, and saying it is sends a reader looking for an importer that does
4007
+ * not exist.
4008
+ */
4009
+ readonly reachedBy: ReadonlyMap<string, { from: string; kind: TaintEdgeKind }>;
4010
+ }
4011
+
4012
+ /**
4013
+ * {@link planFoldTaint}'s walk, keeping the edge that fired for each file it
4014
+ * reaches. Split out for two consumers that need to say *why*: the fallback
4015
+ * reason in `discover()`, and {@link foldProject}'s verdicts.
4016
+ */
4017
+ export async function planFoldTaintWithEdges(
4018
+ files: readonly string[],
4019
+ wouldFold: ReadonlyMap<string, boolean>,
4020
+ liveSources?: ReadonlyMap<string, ReadonlySet<string>>,
4021
+ ): Promise<TaintPlan> {
3953
4022
  const fileSet = new Set(files);
3954
4023
 
3955
4024
  // file -> set of OTHER discovered files it relatively imports OR re-exports
@@ -3958,7 +4027,9 @@ export async function planFoldTaint(
3958
4027
 
3959
4028
  // chant #1044 — reverse edges: consumed-file -> the folded files that
3960
4029
  // captured its objects. Same taint set, same fixpoint walk; see this
3961
- // function's doc for the crash this closes.
4030
+ // function's doc for the crash this closes. Kept in their own map as well,
4031
+ // so the walk below can tell which rule an edge belongs to.
4032
+ const reverse = new Map<string, Set<string>>();
3962
4033
  for (const [consumer, sources] of liveSources ?? []) {
3963
4034
  if (!fileSet.has(consumer)) continue;
3964
4035
  for (const source of sources) {
@@ -3969,20 +4040,139 @@ export async function planFoldTaint(
3969
4040
  edges.set(source, back);
3970
4041
  }
3971
4042
  back.add(consumer);
4043
+ let mine = reverse.get(source);
4044
+ if (!mine) {
4045
+ mine = new Set<string>();
4046
+ reverse.set(source, mine);
4047
+ }
4048
+ mine.add(consumer);
3972
4049
  }
3973
4050
  }
3974
4051
 
3975
4052
  const tainted = new Set<string>(files.filter((f) => wouldFold.get(f) !== true));
4053
+ const reachedBy = new Map<string, { from: string; kind: TaintEdgeKind }>();
3976
4054
  const queue = [...tainted];
3977
4055
  while (queue.length > 0) {
3978
4056
  const current = queue.shift()!;
3979
4057
  for (const target of edges.get(current) ?? []) {
3980
4058
  if (!tainted.has(target)) {
3981
4059
  tainted.add(target);
4060
+ // A forward edge means `current` imports `target`; the reverse map is
4061
+ // consulted second so a file reachable both ways is reported as the
4062
+ // importer case, the one a reader can see in its own source.
4063
+ const forward = !reverse.get(current)?.has(target);
4064
+ reachedBy.set(target, { from: current, kind: forward ? "importer" : "capture" });
3982
4065
  queue.push(target);
3983
4066
  }
3984
4067
  }
3985
4068
  }
3986
4069
 
3987
- return tainted;
4070
+ return { tainted, reachedBy };
4071
+ }
4072
+
4073
+ /**
4074
+ * The rest of the session a whole-build fold needs, mirroring the same fields
4075
+ * on `DiscoveryOptions` (chant#2422).
4076
+ *
4077
+ * Without them {@link foldProject} answered a strictly harsher question than a
4078
+ * real `chant build --fold` does. `lexiconPackages` was always empty, and per
4079
+ * its own contract an empty set "disables lexicon-package resolution entirely
4080
+ * rather than falling back to something more permissive", so no file that reads
4081
+ * a lexicon data export as a value could fold through this entry. `buildParams`
4082
+ * was always unset, so no file reading `params` could either. Both fold under a
4083
+ * real build, and 21 corpus files flipped from `run` to `fold` at
4084
+ * `chant-v0.70.1` once the list was supplied.
4085
+ *
4086
+ * `../build.ts` already threads all three into `../discovery/index.ts`; this is
4087
+ * the same three reaching the same `createFoldSession` by the other route.
4088
+ */
4089
+ export interface FoldProjectOptions {
4090
+ /**
4091
+ * Lexicon NAMES active for this build (`["aws", "k8s"]`), as
4092
+ * `resolveProjectLexicons()` returns them. A caller building through core has
4093
+ * to supply these itself, the same way `examples/differential-corpus.ts`
4094
+ * reproduces the CLI's `options.plugins.map((p) => p.name)` step, or the fold
4095
+ * is measured without the bare-specifier allowlist a real build gives it.
4096
+ */
4097
+ readonly lexicons?: readonly string[];
4098
+ /** Resolved build-time parameter values, so a file reading `params.<name>` folds. */
4099
+ readonly buildParams?: Readonly<Record<string, BuildParamValue>>;
4100
+ /** chant #1093: this build asked for the sandbox, so fold may not reach outside the trusted allowlist. */
4101
+ readonly sandbox?: boolean;
4102
+ }
4103
+
4104
+ /** One file's place in a whole-build fold, as {@link foldProject} reports it. */
4105
+ export interface FoldProjectVerdict {
4106
+ /** What the build does with this file. */
4107
+ readonly verdict: "fold" | "run";
4108
+ /** What the file's own fold attempt concluded, before the taint walk. */
4109
+ readonly tentative: "fold" | "run";
4110
+ /** Present when `tentative` is "run": the located reason its own fold gave. */
4111
+ readonly reason?: string;
4112
+ /** Present when the file would have folded and taint overruled it. */
4113
+ readonly taintedBy?: { from: string; kind: TaintEdgeKind };
4114
+ /** The file's complete export namespace, present only when `verdict` is "fold". */
4115
+ readonly exports?: ReadonlyMap<string, unknown>;
4116
+ }
4117
+
4118
+ /**
4119
+ * Fold a whole set of project files and report every file's verdict
4120
+ * (chant#2408).
4121
+ *
4122
+ * `discover()` does this as part of building, and its per-file decisions are
4123
+ * not addressable from outside. Anything that wants to *check* the
4124
+ * cross-file rules needs the whole picture: the forward rule (#1023) needs an
4125
+ * importer, the reverse rule (#1044) needs a capturing sibling, and the
4126
+ * fixpoint needs the whole set, so none of the three is observable one file at
4127
+ * a time. The conformance suite of the specification is the first such caller
4128
+ * (INTENTIUS/typescript-as-data#62).
4129
+ *
4130
+ * Same session, same memo, same taint walk as a real build — this is not a
4131
+ * second implementation of the rules, it is the existing one with its
4132
+ * intermediate results kept instead of consumed.
4133
+ *
4134
+ * `options` carries the rest of what a build's session holds (chant#2422).
4135
+ * Omitting it answers a harsher question than a real build asks: with no
4136
+ * lexicon list nothing reading a lexicon data export folds, and with no build
4137
+ * parameters nothing reading `params` does. See {@link FoldProjectOptions}.
4138
+ */
4139
+ export async function foldProject(
4140
+ files: readonly string[],
4141
+ intrinsics: readonly IntrinsicDef[] = [],
4142
+ options: FoldProjectOptions = {},
4143
+ ): Promise<Map<string, FoldProjectVerdict>> {
4144
+ const session = createFoldSession(
4145
+ intrinsics,
4146
+ options.buildParams,
4147
+ options.lexicons ?? [],
4148
+ options.sandbox ?? false,
4149
+ );
4150
+ const attempts = new Map<string, FoldFileResult>();
4151
+ for (const file of files) attempts.set(file, await tryFoldFile(file, intrinsics, session));
4152
+
4153
+ const plan = await planFoldTaintWithEdges(
4154
+ files,
4155
+ new Map(files.map((file) => [file, attempts.get(file)?.ok === true])),
4156
+ new Map(files.flatMap((file) => {
4157
+ const attempt = attempts.get(file);
4158
+ return attempt?.ok === true ? [[file, attempt.liveSources] as const] : [];
4159
+ })),
4160
+ );
4161
+
4162
+ const out = new Map<string, FoldProjectVerdict>();
4163
+ for (const file of files) {
4164
+ const attempt = attempts.get(file)!;
4165
+ const tentative = attempt.ok ? "fold" : "run";
4166
+ if (attempt.ok && !plan.tainted.has(file)) {
4167
+ out.set(file, { verdict: "fold", tentative, exports: attempt.exportedValues });
4168
+ continue;
4169
+ }
4170
+ out.set(file, {
4171
+ verdict: "run",
4172
+ tentative,
4173
+ reason: attempt.ok ? undefined : attempt.reason,
4174
+ taintedBy: attempt.ok ? plan.reachedBy.get(file) : undefined,
4175
+ });
4176
+ }
4177
+ return out;
3988
4178
  }
@@ -1,3 +1,4 @@
1
+ import { relative } from "node:path";
1
2
  import type { Declarable } from "../declarable";
2
3
  import type { DiscoveryError } from "../errors";
3
4
  import type { IntrinsicDef } from "../lexicon";
@@ -6,7 +7,7 @@ import { importModule } from "./import";
6
7
  import { collectEntities } from "./collect";
7
8
  import { resolveAttrRefs } from "./resolve";
8
9
  import { buildDependencyGraph } from "./graph";
9
- import { tryFoldFile, planFoldTaint, createFoldSession } from "./fold-import";
10
+ import { tryFoldFile, planFoldTaintWithEdges, createFoldSession } from "./fold-import";
10
11
  import { getProvenance } from "../provenance";
11
12
  import type { BuildParamProvenance } from "../provenance";
12
13
  import { buildParamValues } from "../build-params";
@@ -270,8 +271,8 @@ export async function discover(path: string, options?: DiscoveryOptions): Promis
270
271
  foldAttempts.set(file, await tryFoldFile(file, options.intrinsics, foldSession));
271
272
  }
272
273
  }
273
- const taintedFiles = options?.fold
274
- ? await planFoldTaint(
274
+ const taintPlan = options?.fold
275
+ ? await planFoldTaintWithEdges(
275
276
  files,
276
277
  new Map(files.map((file) => [file, foldAttempts.get(file)?.ok === true])),
277
278
  // chant #1044 — which files' OBJECTS each successful fold captured,
@@ -284,7 +285,8 @@ export async function discover(path: string, options?: DiscoveryOptions): Promis
284
285
  }),
285
286
  ),
286
287
  )
287
- : new Set<string>();
288
+ : { tainted: new Set<string>(), reachedBy: new Map() };
289
+ const taintedFiles = taintPlan.tainted;
288
290
 
289
291
  for (const file of files) {
290
292
  if (options?.fold) {
@@ -307,9 +309,15 @@ export async function discover(path: string, options?: DiscoveryOptions): Promis
307
309
  foldDecisions.push({ file, mode: "fold", resourceCount: folded.entities.length });
308
310
  continue;
309
311
  }
312
+ // chant#2406 — name the edge that actually fired. A reverse-tainted file
313
+ // is not imported by anything that falls back; saying so sends a reader
314
+ // looking for an importer that does not exist.
315
+ const edge = taintPlan.reachedBy.get(file);
310
316
  const reason = !folded.ok
311
317
  ? folded.reason
312
- : `would fold in isolation, but a file that imports it (directly or transitively) falls back to run — folding independently would create a duplicate, non-identical instance`;
318
+ : edge?.kind === "capture"
319
+ ? `would fold in isolation, but it captured objects from ${relative(process.cwd(), edge.from)}, which falls back to run — folding independently would hold an instance the build never collects`
320
+ : `would fold in isolation, but a file that imports it (directly or transitively) falls back to run — folding independently would create a duplicate, non-identical instance`;
313
321
  foldDecisions.push({ file, mode: "run", reason, reverseTainted: folded.ok });
314
322
  }
315
323
 
@@ -25,3 +25,34 @@ describe("findSubsetViolation is exported from the package entry", () => {
25
25
  expect(chant.findSubsetViolation(initializerOf("export const x = cfg[key];"))?.ruleId).toBe("EVL003");
26
26
  });
27
27
  });
28
+
29
+ /**
30
+ * chant#2424 — the specification's conformance adapter reads `SPEC_VERSION`
31
+ * off exactly this namespace:
32
+ *
33
+ * ```ts
34
+ * specVersion: (chant as { SPEC_VERSION?: string }).SPEC_VERSION ?? "undeclared",
35
+ * ```
36
+ *
37
+ * so a suite that finds nothing there reports chant as `undeclared` rather
38
+ * than as implementing anything. The barrel is the thing that can silently
39
+ * drop it, which is what this pins.
40
+ */
41
+ describe("SPEC_VERSION is declared on the package entry (chant#2424)", () => {
42
+ test("the public namespace carries it", () => {
43
+ expect(typeof chant.SPEC_VERSION).toBe("string");
44
+ expect(chant.SPEC_VERSION).not.toBe("");
45
+ });
46
+
47
+ test("it is a specification version, not a chant release", () => {
48
+ // `spec/VERSION` carries a two-part version that moves separately from
49
+ // chant's own releases (INTENTIUS/typescript-as-data#18), so a value that
50
+ // looks like a package version is the mistake worth catching.
51
+ expect(chant.SPEC_VERSION).toMatch(/^\d+\.\d+$/);
52
+ });
53
+
54
+ test("and it is the same string the subset module defines", async () => {
55
+ const { SPEC_VERSION } = await import("./subset");
56
+ expect(chant.SPEC_VERSION).toBe(SPEC_VERSION);
57
+ });
58
+ });
@@ -147,7 +147,30 @@ import { intrinsicCallFolds, intrinsicCallFoldsEagerly, type IntrinsicDef } from
147
147
  * folder would actually accept) — never the reverse. Making EVL
148
148
  * flow-sensitive would mean re-implementing an evaluator inside a lint
149
149
  * rule; out of scope here. See #1024.
150
+ *
151
+ * ## Which version of the specification this is
152
+ *
153
+ * A specification version names a set of rules and moves on its own schedule,
154
+ * separately from chant's releases (INTENTIUS/typescript-as-data#18). The
155
+ * version chant implements is {@link SPEC_VERSION}, and it is exported from
156
+ * `@intentius/chant`'s public entry so the specification's conformance suite
157
+ * can read it. A suite that finds no declaration reports the implementation as
158
+ * `undeclared` rather than assuming it is current, which is the right default
159
+ * and a useless answer to get from an implementation that does know.
160
+ *
161
+ * Raising it is part of adopting a new version of the rules, alongside the
162
+ * spec-first change process above: land the rule there, implement it here
163
+ * citing the identifier, then move this constant.
164
+ */
165
+
166
+ /**
167
+ * The version of the TypeScript-as-Data specification chant implements
168
+ * (chant#2424).
169
+ *
170
+ * `spec/VERSION` in the specification repository carries the same string, and
171
+ * the conformance adapter reads this one to fill its `specVersion` field.
150
172
  */
173
+ export const SPEC_VERSION = "1.0";
151
174
 
152
175
  /** The two EVL rule ids a shape violation can be attributed to. */
153
176
  export type SubsetRuleId = "EVL001" | "EVL003";
package/src/index.ts CHANGED
@@ -44,6 +44,24 @@ export * from "./fold/fold";
44
44
  // half of the fold subset a conformance adapter needs that `fold()` alone
45
45
  // does not expose. INTENTIUS/typescript-as-data#11.
46
46
  export { findSubsetViolation, checkObjectMember, type SubsetViolation, type SubsetRuleId } from "./fold/subset";
47
+ // The version of the TypeScript-as-Data specification chant implements
48
+ // (INTENTIUS/typescript-as-data#18, chant#2424). The specification's
49
+ // conformance adapter reads this off the public entry; without it a suite
50
+ // reports chant as `undeclared` rather than as implementing anything.
51
+ export { SPEC_VERSION } from "./fold/subset";
52
+ // The whole-build fold. `fold()` and `foldModule()` answer one expression and
53
+ // one file; neither cross-file rule is observable at that granularity — the
54
+ // forward rule needs an importer, the reverse rule needs a capturing sibling,
55
+ // and the fixpoint needs the whole set. chant#2408,
56
+ // INTENTIUS/typescript-as-data#62.
57
+ export {
58
+ foldProject,
59
+ planFoldTaintWithEdges,
60
+ type FoldProjectOptions,
61
+ type FoldProjectVerdict,
62
+ type TaintPlan,
63
+ type TaintEdgeKind,
64
+ } from "./discovery/fold-import";
47
65
  export * from "./lint/parser";
48
66
  export * from "./lint/rule";
49
67
  export * from "./lint/rules";
@@ -138,6 +138,124 @@ describe("affectedStacks — baseDir (caller-supplied)", () => {
138
138
  });
139
139
  });
140
140
 
141
+ // A second lexicon, so a stack that serializes through two of them can be shown
142
+ // folding into one artifact.
143
+ const fakeSerializer2: Serializer = {
144
+ name: "fake2",
145
+ rulePrefix: "FAKE2",
146
+ serialize: (entities) =>
147
+ JSON.stringify([...entities.keys()].sort().map((k) => ({ k, t: entities.get(k)!.entityType }))),
148
+ };
149
+
150
+ function otherWidget(type: string): string {
151
+ return `export const bar = { lexicon: "fake2", entityType: "${type}", [Symbol.for("chant.declarable")]: true };\n`;
152
+ }
153
+
154
+ function deployTimeParam(): string {
155
+ return `export const p = { lexicon: "fake", entityType: "Param", parameterType: "String", [Symbol.for("chant.declarable")]: true };\n`;
156
+ }
157
+
158
+ describe("affectedStacks — per-stack mode (#2420)", () => {
159
+ let root: string;
160
+ beforeEach(() => {
161
+ root = mkdtempSync(join(tmpdir(), "chant-affected-stacks-"));
162
+ });
163
+ afterEach(() => rmSync(root, { recursive: true, force: true }));
164
+
165
+ // A project root holding one source directory per stack, as ChantConfig.stacks
166
+ // describes it: { api: <infra.ts contents>, worker: ... }.
167
+ const project = (name: string, sources: Record<string, string>): string => {
168
+ const dir = join(root, name);
169
+ for (const [stack, content] of Object.entries(sources)) {
170
+ mkdirSync(join(dir, stack), { recursive: true });
171
+ writeFileSync(join(dir, stack, "infra.ts"), content);
172
+ }
173
+ return dir;
174
+ };
175
+
176
+ const stacks = [
177
+ { name: "api-stack", src: "api" },
178
+ { name: "worker-stack", src: "worker" },
179
+ ];
180
+
181
+ test("a stacks[] entry whose src is not there is refused by name at head", async () => {
182
+ const base = project("base", { api: widget("Widget"), worker: widget("Queue") });
183
+ const head = project("head", { api: widget("Gadget") });
184
+ await expect(
185
+ affectedStacks({ projectPath: head, baseDir: base, serializers: [fakeSerializer], stacks }),
186
+ ).rejects.toThrow(/stack "worker-stack" declares src "worker", which does not exist/);
187
+ });
188
+
189
+ test("a stack added since base is changed, rather than refused for having no base source", async () => {
190
+ const base = project("base", { api: widget("Widget") });
191
+ const head = project("head", { api: widget("Widget"), worker: widget("Queue") });
192
+ const r = await affectedStacks({ projectPath: head, baseDir: base, serializers: [fakeSerializer], stacks });
193
+ expect(r.changed).toEqual(["worker-stack"]);
194
+ });
195
+
196
+ test("names the changed stack, not its lexicon — and leaves the untouched stack out", async () => {
197
+ const base = project("base", { api: widget("Widget"), worker: widget("Queue") });
198
+ const head = project("head", { api: widget("Gadget"), worker: widget("Queue") });
199
+ const r = await affectedStacks({ projectPath: head, baseDir: base, serializers: [fakeSerializer], stacks });
200
+ expect(r.changed).toEqual(["api-stack"]);
201
+ expect(r.changed).not.toContain("fake"); // the lexicon name is not an answer
202
+ });
203
+
204
+ test("a no-output-change refactor in the changed stack's own directory is NOT affected", async () => {
205
+ const base = project("base", { api: widget("Widget"), worker: widget("Queue") });
206
+ const head = project("head", { api: "// a harmless refactor\n" + widget("Widget"), worker: widget("Queue") });
207
+ const r = await affectedStacks({ projectPath: head, baseDir: base, serializers: [fakeSerializer], stacks });
208
+ expect(r.changed).toEqual([]);
209
+ });
210
+
211
+ test("a stack spanning two lexicons folds into one artifact keyed by the stack", async () => {
212
+ const both = (type: string) => widget("Widget") + otherWidget(type);
213
+ const base = project("base", { api: both("Topic"), worker: widget("Queue") });
214
+ // Only the second lexicon's partition moves; the stack is still what changed.
215
+ const head = project("head", { api: both("Bus"), worker: widget("Queue") });
216
+ const r = await affectedStacks({
217
+ projectPath: head,
218
+ baseDir: base,
219
+ serializers: [fakeSerializer, fakeSerializer2],
220
+ stacks,
221
+ });
222
+ expect(r.changed).toEqual(["api-stack"]);
223
+ });
224
+
225
+ test("a deploy-time Parameter is reported as indeterminate under the stack name", async () => {
226
+ const base = project("base", { api: deployTimeParam(), worker: widget("Queue") });
227
+ const head = project("head", { api: deployTimeParam(), worker: widget("Queue") });
228
+ const r = await affectedStacks({ projectPath: head, baseDir: base, serializers: [fakeSerializer], stacks });
229
+ expect(r.indeterminate).toEqual(["api-stack"]);
230
+ });
231
+
232
+ test("dependents stay empty — the stack-to-stack relation is not in the build", async () => {
233
+ const base = project("base", { api: widget("Widget"), worker: widget("Queue") });
234
+ const head = project("head", { api: widget("Gadget"), worker: widget("Queue") });
235
+ const r = await affectedStacks({
236
+ projectPath: head,
237
+ baseDir: base,
238
+ serializers: [fakeSerializer],
239
+ stacks,
240
+ includeDependents: true,
241
+ });
242
+ expect(r.changed).toEqual(["api-stack"]);
243
+ expect(r.dependents).toEqual([]);
244
+ });
245
+
246
+ test("an empty stacks list keeps the single-root, lexicon-keyed answer", async () => {
247
+ const base = project("base", { api: widget("Widget") });
248
+ const head = project("head", { api: widget("Gadget") });
249
+ const r = await affectedStacks({
250
+ projectPath: join(head, "api"),
251
+ baseDir: join(base, "api"),
252
+ serializers: [fakeSerializer],
253
+ stacks: [],
254
+ });
255
+ expect(r.changed).toEqual(["fake"]);
256
+ });
257
+ });
258
+
141
259
  describe("affectedStacks — baseRef (git worktree)", () => {
142
260
  let repo: string;
143
261
  beforeEach(async () => {