@intentius/chant 0.69.0 → 0.70.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/lifecycle.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/discovery/import.d.ts +15 -1
- package/dist/discovery/import.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/handlers/lifecycle.test.ts +67 -2
- package/src/cli/handlers/lifecycle.ts +18 -5
- package/src/cli/main.ts +5 -0
- package/src/discovery/import.test.ts +110 -2
- package/src/discovery/import.ts +50 -2
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"lifecycle.d.ts","sourceRoot":"","sources":["../../../src/cli/handlers/lifecycle.ts"],"names":[],"mappings":"AA6CA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAKlD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAC9C,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAEhD;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,UAAU,EAAE,MAAM,EAAE,WAAW,GAAG,MAAM,CAE9E;AAmCD;;GAEG;AACH,wBAAsB,oBAAoB,CAAC,GAAG,EAAE,cAAc,GAAG,OAAO,CAAC,MAAM,CAAC,CAyH/E;AAED;;GAEG;AACH,wBAAsB,gBAAgB,CAAC,GAAG,EAAE,cAAc,GAAG,OAAO,CAAC,MAAM,CAAC,CA4C3E;AAED;;;;;;GAMG;AACH,wBAAsB,oBAAoB,CAAC,GAAG,EAAE,cAAc,GAAG,OAAO,CAAC,MAAM,CAAC,CA8B/E;AAED;;GAEG;AACH,wBAAsB,gBAAgB,CAAC,GAAG,EAAE,cAAc,GAAG,OAAO,CAAC,MAAM,CAAC,CAmL3E;AAmrBD;;;;;;;;;GASG;AACH,wBAAsB,gBAAgB,CAAC,GAAG,EAAE,cAAc,GAAG,OAAO,CAAC,MAAM,CAAC,
|
|
1
|
+
{"version":3,"file":"lifecycle.d.ts","sourceRoot":"","sources":["../../../src/cli/handlers/lifecycle.ts"],"names":[],"mappings":"AA6CA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAKlD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAC9C,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAEhD;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,UAAU,EAAE,MAAM,EAAE,WAAW,GAAG,MAAM,CAE9E;AAmCD;;GAEG;AACH,wBAAsB,oBAAoB,CAAC,GAAG,EAAE,cAAc,GAAG,OAAO,CAAC,MAAM,CAAC,CAyH/E;AAED;;GAEG;AACH,wBAAsB,gBAAgB,CAAC,GAAG,EAAE,cAAc,GAAG,OAAO,CAAC,MAAM,CAAC,CA4C3E;AAED;;;;;;GAMG;AACH,wBAAsB,oBAAoB,CAAC,GAAG,EAAE,cAAc,GAAG,OAAO,CAAC,MAAM,CAAC,CA8B/E;AAED;;GAEG;AACH,wBAAsB,gBAAgB,CAAC,GAAG,EAAE,cAAc,GAAG,OAAO,CAAC,MAAM,CAAC,CAmL3E;AAmrBD;;;;;;;;;GASG;AACH,wBAAsB,gBAAgB,CAAC,GAAG,EAAE,cAAc,GAAG,OAAO,CAAC,MAAM,CAAC,CAqR3E;AAED;;;;;;;;;;;GAWG;AACH,wBAAsB,oBAAoB,CAAC,GAAG,EAAE,cAAc,GAAG,OAAO,CAAC,MAAM,CAAC,CA2L/E;AAsBD;;GAEG;AACH,wBAAsB,eAAe,CAAC,GAAG,EAAE,cAAc,GAAG,OAAO,CAAC,MAAM,CAAC,CAiB1E;AAgBD;;;;;;;;GAQG;AACH,wBAAsB,kBAAkB,CAAC,GAAG,EAAE,cAAc,GAAG,OAAO,CAAC,MAAM,CAAC,CAmE7E;AAiCD;;GAEG;AACH,wBAAsB,mBAAmB,CAAC,GAAG,EAAE,cAAc,GAAG,OAAO,CAAC,MAAM,CAAC,CAM9E;AAkBD;;;;;;GAMG;AACH,wBAAsB,oBAAoB,CAAC,GAAG,EAAE,cAAc,GAAG,OAAO,CAAC,MAAM,CAAC,CA+C/E"}
|
package/dist/cli/main.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"main.d.ts","sourceRoot":"","sources":["../../src/cli/main.ts"],"names":[],"mappings":";AAMA,OAAO,EAAkB,KAAK,UAAU,EAAE,KAAK,UAAU,EAAE,MAAM,YAAY,CAAC;AAwF9E;;GAEG;AACH,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,UAAU,CA6VpD;
|
|
1
|
+
{"version":3,"file":"main.d.ts","sourceRoot":"","sources":["../../src/cli/main.ts"],"names":[],"mappings":";AAMA,OAAO,EAAkB,KAAK,UAAU,EAAE,KAAK,UAAU,EAAE,MAAM,YAAY,CAAC;AAwF9E;;GAEG;AACH,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,UAAU,CA6VpD;AAqbD;;;;;;;;;GASG;AACH,eAAO,MAAM,eAAe,EAAE,UAAU,EAkGvC,CAAC;AAgIF;;;;;;;;GAQG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,MAAM,CAAC,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC,CAuB5E"}
|
|
@@ -1,9 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Forget every recorded evaluation failure.
|
|
3
|
+
*
|
|
4
|
+
* For a harness that deliberately starts a fresh module graph and wants the
|
|
5
|
+
* failures forgotten with it. Nothing in chant calls this: a real build
|
|
6
|
+
* imports once, and a second import inside one process is supposed to see what
|
|
7
|
+
* the first one saw.
|
|
8
|
+
*
|
|
9
|
+
* It does not make a module re-evaluate. Node's own registry still holds the
|
|
10
|
+
* failed record and still re-throws; this only clears chant's memory of it. A
|
|
11
|
+
* caller wanting genuine re-evaluation needs a fresh process.
|
|
12
|
+
*/
|
|
13
|
+
export declare function resetImportFailures(): void;
|
|
1
14
|
/**
|
|
2
15
|
* Dynamically import a module and return its exports.
|
|
3
16
|
*
|
|
4
17
|
* @param path - The file path to import
|
|
5
18
|
* @returns The module exports
|
|
6
|
-
* @throws {DiscoveryError} with type "import" if the import fails
|
|
19
|
+
* @throws {DiscoveryError} with type "import" if the import fails, including
|
|
20
|
+
* on every later import of a module whose evaluation has already failed
|
|
7
21
|
*/
|
|
8
22
|
export declare function importModule(path: string): Promise<Record<string, unknown>>;
|
|
9
23
|
//# sourceMappingURL=import.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"import.d.ts","sourceRoot":"","sources":["../../src/discovery/import.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"import.d.ts","sourceRoot":"","sources":["../../src/discovery/import.ts"],"names":[],"mappings":"AA4BA;;;;;;;;;;;GAWG;AACH,wBAAgB,mBAAmB,IAAI,IAAI,CAE1C;AAED;;;;;;;GAOG;AACH,wBAAsB,YAAY,CAChC,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAalC"}
|
package/package.json
CHANGED
|
@@ -916,6 +916,71 @@ describe("runLifecyclePlan", () => {
|
|
|
916
916
|
// #2162 — a held property is not an action, so the plan carries it beside
|
|
917
917
|
// `entries` rather than inside one, attributed to its lexicon the same way
|
|
918
918
|
// an entry is.
|
|
919
|
+
test("without --deep the pass does not run, and the entries are unchanged (#2405)", async () => {
|
|
920
|
+
// The property that makes `--deep` safe to default off: a held property is
|
|
921
|
+
// never a proposal, so the change set is identical either way and the only
|
|
922
|
+
// thing the flag buys is the HELD section. If that stops being true, this
|
|
923
|
+
// is where it shows.
|
|
924
|
+
const makeBuild = () => {
|
|
925
|
+
const b = makeBuildResult({ k8s: ["web"] });
|
|
926
|
+
b.entities.set("web", {
|
|
927
|
+
lexicon: "k8s",
|
|
928
|
+
entityType: "K8s::Apps::Deployment",
|
|
929
|
+
props: { spec: { replicas: heldElsewhere<number>({ by: "hpa", reason: "the autoscaler owns replicas after the first apply" }) } },
|
|
930
|
+
} as never);
|
|
931
|
+
return b;
|
|
932
|
+
};
|
|
933
|
+
let deepCalls = 0;
|
|
934
|
+
const makePlugins = (): LexiconPlugin[] => {
|
|
935
|
+
const inner = staticDeepObservation({
|
|
936
|
+
web: { type: "K8s::Apps::Deployment", properties: { spec: { replicas: 5 } } },
|
|
937
|
+
});
|
|
938
|
+
return [
|
|
939
|
+
createMockPlugin({
|
|
940
|
+
name: "k8s",
|
|
941
|
+
describeResources: staticObservation({ web: meta({ type: "K8s::Apps::Deployment" }) }),
|
|
942
|
+
// Counting wrapper: the helper's return type is the plugin member's,
|
|
943
|
+
// which is optional, so it is narrowed once here rather than at the
|
|
944
|
+
// call.
|
|
945
|
+
observeResourcesDeep: ((opts: never) => {
|
|
946
|
+
deepCalls += 1;
|
|
947
|
+
return inner!(opts);
|
|
948
|
+
}) as never,
|
|
949
|
+
}),
|
|
950
|
+
];
|
|
951
|
+
};
|
|
952
|
+
|
|
953
|
+
const run = async (deep: boolean) => {
|
|
954
|
+
stdoutBuf.length = 0;
|
|
955
|
+
buildMock.mockResolvedValue(makeBuild());
|
|
956
|
+
const plugins = makePlugins();
|
|
957
|
+
const exit = await runLifecyclePlan({
|
|
958
|
+
args: makeArgs({ path: "plan", extraPositional: "prod", json: true, ...(deep ? { deep: true } : {}) }),
|
|
959
|
+
plugins,
|
|
960
|
+
serializers: plugins.map((p) => p.serializer),
|
|
961
|
+
} as never);
|
|
962
|
+
expect(exit).toBe(0);
|
|
963
|
+
return JSON.parse(stdoutBuf.join("\n"));
|
|
964
|
+
};
|
|
965
|
+
|
|
966
|
+
deepCalls = 0;
|
|
967
|
+
const shallow = await run(false);
|
|
968
|
+
const callsWithoutDeep = deepCalls;
|
|
969
|
+
|
|
970
|
+
deepCalls = 0;
|
|
971
|
+
const deep = await run(true);
|
|
972
|
+
|
|
973
|
+
// The cost: the pass is not run at all without the flag.
|
|
974
|
+
expect(callsWithoutDeep).toBe(0);
|
|
975
|
+
expect(deepCalls).toBeGreaterThan(0);
|
|
976
|
+
|
|
977
|
+
// The correctness: the same proposals either way, and the held section
|
|
978
|
+
// only when it was asked for.
|
|
979
|
+
expect(shallow.entries).toEqual(deep.entries);
|
|
980
|
+
expect(shallow.held).toBeUndefined();
|
|
981
|
+
expect(deep.held).toBeDefined();
|
|
982
|
+
});
|
|
983
|
+
|
|
919
984
|
test("--json carries the held set beside entries, attributed to its lexicon", async () => {
|
|
920
985
|
const build = makeBuildResult({ k8s: ["web"] });
|
|
921
986
|
build.entities.set("web", {
|
|
@@ -934,7 +999,7 @@ describe("runLifecyclePlan", () => {
|
|
|
934
999
|
}),
|
|
935
1000
|
];
|
|
936
1001
|
const exit = await runLifecyclePlan({
|
|
937
|
-
args: makeArgs({ path: "plan", extraPositional: "prod", json: true }),
|
|
1002
|
+
args: makeArgs({ path: "plan", extraPositional: "prod", json: true, deep: true }),
|
|
938
1003
|
plugins,
|
|
939
1004
|
serializers: plugins.map((p) => p.serializer),
|
|
940
1005
|
});
|
|
@@ -971,7 +1036,7 @@ describe("runLifecyclePlan", () => {
|
|
|
971
1036
|
}),
|
|
972
1037
|
];
|
|
973
1038
|
const exit = await runLifecyclePlan({
|
|
974
|
-
args: makeArgs({ path: "plan", extraPositional: "prod" }),
|
|
1039
|
+
args: makeArgs({ path: "plan", extraPositional: "prod", deep: true }),
|
|
975
1040
|
plugins,
|
|
976
1041
|
serializers: plugins.map((p) => p.serializer),
|
|
977
1042
|
});
|
|
@@ -1319,11 +1319,24 @@ export async function runLifecyclePlan(ctx: CommandContext): Promise<number> {
|
|
|
1319
1319
|
|
|
1320
1320
|
Object.assign(allObservedResources, observed.resources);
|
|
1321
1321
|
|
|
1322
|
-
// Held properties (#2162)
|
|
1323
|
-
//
|
|
1324
|
-
//
|
|
1325
|
-
// carried on the plan beside `entries
|
|
1326
|
-
|
|
1322
|
+
// Held properties (#2162), behind `--deep` since #2405.
|
|
1323
|
+
//
|
|
1324
|
+
// A held property is never a proposal: it is computed against this same
|
|
1325
|
+
// observation and carried on the plan beside `entries`, never folded
|
|
1326
|
+
// into one. So the entries a plan proposes are identical whether this
|
|
1327
|
+
// ran or not, and the only thing `--deep` buys is the HELD section.
|
|
1328
|
+
//
|
|
1329
|
+
// It used to run whenever the lexicon could, which made the read's cost
|
|
1330
|
+
// a function of how many resources an estate holds rather than how it is
|
|
1331
|
+
// organised. Measured on 528 resources across 8 stacks: 24 stack-shaped
|
|
1332
|
+
// calls, and 496 from this pass. At ten thousand resources that is
|
|
1333
|
+
// roughly twenty thousand calls against eighty, for a section the caller
|
|
1334
|
+
// did not ask for.
|
|
1335
|
+
//
|
|
1336
|
+
// `--deep` is the flag `lifecycle snapshot` already uses for the same
|
|
1337
|
+
// choice, and `lifecycle diff` still runs the pass unconditionally
|
|
1338
|
+
// because property drift is the question it exists to answer.
|
|
1339
|
+
if (args.deep && plugin.observeResourcesDeep) {
|
|
1327
1340
|
const deep = await deepDiffForLexicon(plugin, {
|
|
1328
1341
|
environment,
|
|
1329
1342
|
buildOutput,
|
package/src/cli/main.ts
CHANGED
|
@@ -593,6 +593,11 @@ Lifecycle (alias: lc):
|
|
|
593
593
|
property-level drift; --update-baseline records
|
|
594
594
|
what it reports as accepted so it stops alerting)
|
|
595
595
|
lifecycle plan <env> Typed change set (create/update/delete/adopt) vs live
|
|
596
|
+
--deep: also report properties declared
|
|
597
|
+
heldElsewhere(). Never changes what the plan
|
|
598
|
+
proposes; costs a provider call pair per readable
|
|
599
|
+
resource, so the read's cost becomes a function of
|
|
600
|
+
estate size rather than stack count (#2405)
|
|
596
601
|
lifecycle affected Stacks a change affects (--base <ref> [--include-dependents])
|
|
597
602
|
--json: emit the ChangeSet as JSON
|
|
598
603
|
lifecycle whoami <env> Who chant would act as in each configured lexicon,
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { describe, test, expect } from "vitest";
|
|
2
|
-
import { importModule } from "./import";
|
|
2
|
+
import { importModule, resetImportFailures } from "./import";
|
|
3
|
+
import { discover } from "./index";
|
|
3
4
|
import { DiscoveryError } from "../errors";
|
|
4
5
|
import { withTestDir, expectToThrow } from "@intentius/chant-test-utils";
|
|
5
6
|
import { writeFile } from "node:fs/promises";
|
|
6
|
-
import { join } from "node:path";
|
|
7
|
+
import { join, resolve } from "node:path";
|
|
7
8
|
|
|
8
9
|
describe("importModule", () => {
|
|
9
10
|
|
|
@@ -198,3 +199,110 @@ describe("importModule", () => {
|
|
|
198
199
|
});
|
|
199
200
|
});
|
|
200
201
|
});
|
|
202
|
+
|
|
203
|
+
describe("a module whose evaluation threw stays thrown (#2368)", () => {
|
|
204
|
+
/**
|
|
205
|
+
* The spec records an evaluation failure on the module record and re-throws
|
|
206
|
+
* it on every later import, forever. Node does that; vitest's module runner
|
|
207
|
+
* does not, and hands the second importer a namespace holding whatever was
|
|
208
|
+
* initialized before the throw.
|
|
209
|
+
*
|
|
210
|
+
* These assert the contract directly. They do not reproduce the runner bug —
|
|
211
|
+
* a module written to a temp directory is outside vitest's transform root and
|
|
212
|
+
* is imported by Node, which already records the failure. The reproduction is
|
|
213
|
+
* the corpus-backed test at the bottom of this file, which is the one that
|
|
214
|
+
* fails without the failure map.
|
|
215
|
+
*/
|
|
216
|
+
test("a second import throws the same error, not a half-initialized namespace", async () => {
|
|
217
|
+
await withTestDir(async (testDir) => {
|
|
218
|
+
const filePath = join(testDir, "throws.ts");
|
|
219
|
+
await writeFile(filePath, `const o = undefined as unknown as { k: string };\nexport const value = o.k;\n`);
|
|
220
|
+
|
|
221
|
+
const first = await expectToThrow(() => importModule(filePath), DiscoveryError);
|
|
222
|
+
const second = await expectToThrow(() => importModule(filePath), DiscoveryError);
|
|
223
|
+
|
|
224
|
+
expect(second.message).toBe(first.message);
|
|
225
|
+
expect(second.file).toBe(filePath);
|
|
226
|
+
// The same error object, because it is the recorded one being replayed
|
|
227
|
+
// rather than a second failure that happened to look alike.
|
|
228
|
+
expect(second).toBe(first);
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("a third and fourth import keep throwing", async () => {
|
|
233
|
+
await withTestDir(async (testDir) => {
|
|
234
|
+
const filePath = join(testDir, "throws-again.ts");
|
|
235
|
+
await writeFile(filePath, `const o = undefined as unknown as { k: string };\nexport const value = o.k;\n`);
|
|
236
|
+
|
|
237
|
+
await expectToThrow(() => importModule(filePath), DiscoveryError);
|
|
238
|
+
await expectToThrow(() => importModule(filePath), DiscoveryError);
|
|
239
|
+
await expectToThrow(() => importModule(filePath), DiscoveryError);
|
|
240
|
+
await expectToThrow(() => importModule(filePath), DiscoveryError);
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("a module that imports cleanly is unaffected and is not cached as failed", async () => {
|
|
245
|
+
await withTestDir(async (testDir) => {
|
|
246
|
+
const filePath = join(testDir, "fine.ts");
|
|
247
|
+
await writeFile(filePath, `export const value = "ok";\n`);
|
|
248
|
+
|
|
249
|
+
const a = await importModule(filePath);
|
|
250
|
+
const b = await importModule(filePath);
|
|
251
|
+
expect(a.value).toBe("ok");
|
|
252
|
+
expect(b.value).toBe("ok");
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("resetImportFailures forgets the record, and does not make the module re-evaluate", async () => {
|
|
257
|
+
await withTestDir(async (testDir) => {
|
|
258
|
+
const filePath = join(testDir, "throws-reset.ts");
|
|
259
|
+
await writeFile(filePath, `const o = undefined as unknown as { k: string };\nexport const value = o.k;\n`);
|
|
260
|
+
|
|
261
|
+
const first = await expectToThrow(() => importModule(filePath), DiscoveryError);
|
|
262
|
+
resetImportFailures();
|
|
263
|
+
|
|
264
|
+
// Under Node the registry still holds the failed record and throws
|
|
265
|
+
// again; under vitest the module is served from its cache. Either way
|
|
266
|
+
// the escape hatch only clears chant's memory, which is what its doc
|
|
267
|
+
// says — so this asserts the clearing, not a re-evaluation.
|
|
268
|
+
let replayedSameObject = false;
|
|
269
|
+
try {
|
|
270
|
+
await importModule(filePath);
|
|
271
|
+
} catch (err) {
|
|
272
|
+
replayedSameObject = err === first;
|
|
273
|
+
}
|
|
274
|
+
expect(replayedSameObject).toBe(false);
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
describe("discover() reports the same errors on a second pass (#2368)", () => {
|
|
280
|
+
/**
|
|
281
|
+
* The reproduction from the issue, over the corpus entry that exposed it.
|
|
282
|
+
*
|
|
283
|
+
* It has to be a file inside the project: vitest transforms and caches those,
|
|
284
|
+
* and it is that cache which forgets an evaluation failure. A module written
|
|
285
|
+
* to a temp directory outside the runner's root is imported by Node natively
|
|
286
|
+
* and already behaves correctly, so a fixture built with `withTestDir` would
|
|
287
|
+
* pass with or without the fix and prove nothing.
|
|
288
|
+
*
|
|
289
|
+
* `examples/fold-adversarial/src/nullish-property-read.ts` folds to a refusal
|
|
290
|
+
* and then throws a TypeError when the run path imports it (#2328). Before
|
|
291
|
+
* the failure map: `discover #1 errors=1`, `discover #2 errors=0`.
|
|
292
|
+
*/
|
|
293
|
+
const ADVERSARIAL = resolve(import.meta.dirname, "../../../../examples/fold-adversarial/src");
|
|
294
|
+
|
|
295
|
+
test("the corpus entry with a throwing file reports it on every pass", async () => {
|
|
296
|
+
const first = await discover(ADVERSARIAL);
|
|
297
|
+
const second = await discover(ADVERSARIAL);
|
|
298
|
+
const third = await discover(ADVERSARIAL);
|
|
299
|
+
|
|
300
|
+
expect(first.errors.length).toBeGreaterThan(0);
|
|
301
|
+
expect(second.errors.length).toBe(first.errors.length);
|
|
302
|
+
expect(third.errors.length).toBe(first.errors.length);
|
|
303
|
+
|
|
304
|
+
const named = (r: { errors: unknown[] }): boolean =>
|
|
305
|
+
(r.errors[0] as { file?: string }).file?.endsWith("nullish-property-read.ts") === true;
|
|
306
|
+
expect(named(first) && named(second) && named(third)).toBe(true);
|
|
307
|
+
});
|
|
308
|
+
});
|
package/src/discovery/import.ts
CHANGED
|
@@ -1,20 +1,68 @@
|
|
|
1
1
|
import { DiscoveryError } from "../errors";
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* Modules whose evaluation threw, and the error each threw with (#2368).
|
|
5
|
+
*
|
|
6
|
+
* The ES module spec says an evaluation failure is recorded on the module
|
|
7
|
+
* record and re-thrown on every later import of the same specifier, forever,
|
|
8
|
+
* in that realm. Node implements it — including after the file is edited, so a
|
|
9
|
+
* module that threw keeps throwing until the process restarts.
|
|
10
|
+
*
|
|
11
|
+
* Vitest's module runner does not. It caches the module as *evaluated* and
|
|
12
|
+
* hands later importers a namespace carrying whichever bindings were
|
|
13
|
+
* initialized before the throw, so the second import resolves with
|
|
14
|
+
* half-initialized exports and no error at all.
|
|
15
|
+
*
|
|
16
|
+
* That divergence is invisible to `chant build`, which imports a directory
|
|
17
|
+
* once per process, and highly visible to anything that builds the same
|
|
18
|
+
* directory twice and compares: whichever build ran first carried the error
|
|
19
|
+
* and every build after it reported none, so error parity came out of the
|
|
20
|
+
* ordering rather than out of the tool.
|
|
21
|
+
*
|
|
22
|
+
* This map is what makes the answer the same either way. Under Node it never
|
|
23
|
+
* fires — `import()` throws again before the lookup matters — so it costs
|
|
24
|
+
* nothing and changes nothing there. Under a runner that forgets, it supplies
|
|
25
|
+
* the memory the spec requires.
|
|
26
|
+
*/
|
|
27
|
+
const evaluationFailures = new Map<string, DiscoveryError>();
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Forget every recorded evaluation failure.
|
|
31
|
+
*
|
|
32
|
+
* For a harness that deliberately starts a fresh module graph and wants the
|
|
33
|
+
* failures forgotten with it. Nothing in chant calls this: a real build
|
|
34
|
+
* imports once, and a second import inside one process is supposed to see what
|
|
35
|
+
* the first one saw.
|
|
36
|
+
*
|
|
37
|
+
* It does not make a module re-evaluate. Node's own registry still holds the
|
|
38
|
+
* failed record and still re-throws; this only clears chant's memory of it. A
|
|
39
|
+
* caller wanting genuine re-evaluation needs a fresh process.
|
|
40
|
+
*/
|
|
41
|
+
export function resetImportFailures(): void {
|
|
42
|
+
evaluationFailures.clear();
|
|
43
|
+
}
|
|
44
|
+
|
|
3
45
|
/**
|
|
4
46
|
* Dynamically import a module and return its exports.
|
|
5
47
|
*
|
|
6
48
|
* @param path - The file path to import
|
|
7
49
|
* @returns The module exports
|
|
8
|
-
* @throws {DiscoveryError} with type "import" if the import fails
|
|
50
|
+
* @throws {DiscoveryError} with type "import" if the import fails, including
|
|
51
|
+
* on every later import of a module whose evaluation has already failed
|
|
9
52
|
*/
|
|
10
53
|
export async function importModule(
|
|
11
54
|
path: string
|
|
12
55
|
): Promise<Record<string, unknown>> {
|
|
56
|
+
const failed = evaluationFailures.get(path);
|
|
57
|
+
if (failed) throw failed;
|
|
58
|
+
|
|
13
59
|
try {
|
|
14
60
|
return await import(path);
|
|
15
61
|
} catch (error) {
|
|
16
62
|
const message =
|
|
17
63
|
error instanceof Error ? error.message : "Unknown import error";
|
|
18
|
-
|
|
64
|
+
const discoveryError = new DiscoveryError(path, message, "import");
|
|
65
|
+
evaluationFailures.set(path, discoveryError);
|
|
66
|
+
throw discoveryError;
|
|
19
67
|
}
|
|
20
68
|
}
|