@intentius/chant 0.69.0 → 0.69.1

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.
@@ -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":"AAEA;;;;;;GAMG;AACH,wBAAsB,YAAY,CAChC,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAQlC"}
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant",
3
- "version": "0.69.0",
3
+ "version": "0.69.1",
4
4
  "description": "Declarative infrastructure-as-code toolkit — TypeScript on Node.js",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
@@ -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
+ });
@@ -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
- throw new DiscoveryError(path, message, "import");
64
+ const discoveryError = new DiscoveryError(path, message, "import");
65
+ evaluationFailures.set(path, discoveryError);
66
+ throw discoveryError;
19
67
  }
20
68
  }