@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.
- package/dist/cli/handlers/fan-out.d.ts +45 -0
- package/dist/cli/handlers/fan-out.d.ts.map +1 -0
- package/dist/cli/handlers/lifecycle.d.ts +13 -0
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/handlers/operator.d.ts.map +1 -1
- package/dist/cli/handlers/run.d.ts +35 -0
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +23 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/deploy-units.d.ts +12 -2
- package/dist/components/deploy-units.d.ts.map +1 -1
- package/dist/components/fan-out-output.d.ts +70 -0
- package/dist/components/fan-out-output.d.ts.map +1 -0
- package/dist/components/fan-out-run.d.ts +80 -0
- package/dist/components/fan-out-run.d.ts.map +1 -0
- package/dist/components/fan-out-support.d.ts +65 -0
- package/dist/components/fan-out-support.d.ts.map +1 -0
- package/dist/components/fan-out.d.ts +194 -0
- package/dist/components/fan-out.d.ts.map +1 -0
- package/dist/components/index.d.ts +4 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/discovery/fold-import.d.ts +96 -1
- package/dist/discovery/fold-import.d.ts.map +1 -1
- package/dist/discovery/index.d.ts.map +1 -1
- package/dist/fold/subset.d.ts +22 -0
- package/dist/fold/subset.d.ts.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/lifecycle/affected.d.ts +26 -0
- package/dist/lifecycle/affected.d.ts.map +1 -1
- package/dist/op/activities/activity-contracts.d.ts +16 -1
- package/dist/op/activities/activity-contracts.d.ts.map +1 -1
- package/dist/op/activities/index.d.ts +1 -1
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/op/activities/shell.d.ts +31 -2
- package/dist/op/activities/shell.d.ts.map +1 -1
- package/dist/op/activity-contract.d.ts +1 -1
- package/dist/op/activity-contract.d.ts.map +1 -1
- package/dist/op/activity-profiles.d.ts +19 -0
- package/dist/op/activity-profiles.d.ts.map +1 -1
- package/dist/op/builders.d.ts +21 -3
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/gate-name.d.ts +10 -0
- package/dist/op/gate-name.d.ts.map +1 -1
- package/dist/op/step-output-ref.d.ts +25 -8
- package/dist/op/step-output-ref.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/handlers/fan-out.test.ts +394 -0
- package/src/cli/handlers/fan-out.ts +336 -0
- package/src/cli/handlers/lifecycle.test.ts +74 -1
- package/src/cli/handlers/lifecycle.ts +29 -1
- package/src/cli/handlers/operator.test.ts +22 -0
- package/src/cli/handlers/operator.ts +11 -3
- package/src/cli/handlers/run.ts +7 -1
- package/src/cli/main.ts +25 -3
- package/src/cli/registry.ts +23 -0
- package/src/components/deploy-units.ts +14 -4
- package/src/components/fan-out-output.test.ts +216 -0
- package/src/components/fan-out-output.ts +162 -0
- package/src/components/fan-out-run.test.ts +194 -0
- package/src/components/fan-out-run.ts +221 -0
- package/src/components/fan-out-support.test.ts +125 -0
- package/src/components/fan-out-support.ts +95 -0
- package/src/components/fan-out.test.ts +284 -0
- package/src/components/fan-out.ts +421 -0
- package/src/components/index.ts +33 -0
- package/src/discovery/fold-import.ts +195 -5
- package/src/discovery/index.ts +13 -5
- package/src/fold/subset-public-export.test.ts +31 -0
- package/src/fold/subset.ts +23 -0
- package/src/index.ts +18 -0
- package/src/lifecycle/affected.test.ts +118 -0
- package/src/lifecycle/affected.ts +118 -14
- package/src/op/activities/activity-contracts.ts +17 -1
- package/src/op/activities/index.ts +1 -1
- package/src/op/activities/shell.test.ts +156 -0
- package/src/op/activities/shell.ts +84 -9
- package/src/op/activity-profiles.test.ts +16 -2
- package/src/op/activity-profiles.ts +18 -0
- package/src/op/builders.ts +22 -4
- package/src/op/gate-name.ts +11 -0
- package/src/op/op-ir.test.ts +4 -1
- package/src/op/op.test.ts +7 -2
- 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<
|
|
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
|
}
|
package/src/discovery/index.ts
CHANGED
|
@@ -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,
|
|
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
|
|
274
|
-
? await
|
|
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
|
-
:
|
|
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
|
+
});
|
package/src/fold/subset.ts
CHANGED
|
@@ -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 () => {
|