@intentius/tsad-reference 1.4.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 (55) hide show
  1. package/CAVEATS.md +95 -0
  2. package/README.md +42 -0
  3. package/dist/adapter.d.ts +5 -0
  4. package/dist/adapter.d.ts.map +1 -0
  5. package/dist/adapter.js +50 -0
  6. package/dist/adapter.js.map +1 -0
  7. package/dist/fnbody.d.ts +11 -0
  8. package/dist/fnbody.d.ts.map +1 -0
  9. package/dist/fnbody.js +68 -0
  10. package/dist/fnbody.js.map +1 -0
  11. package/dist/fold.d.ts +86 -0
  12. package/dist/fold.d.ts.map +1 -0
  13. package/dist/fold.js +558 -0
  14. package/dist/fold.js.map +1 -0
  15. package/dist/foldable-helpers.d.ts +23 -0
  16. package/dist/foldable-helpers.d.ts.map +1 -0
  17. package/dist/foldable-helpers.js +25 -0
  18. package/dist/foldable-helpers.js.map +1 -0
  19. package/dist/host.d.ts +45 -0
  20. package/dist/host.d.ts.map +1 -0
  21. package/dist/host.js +5 -0
  22. package/dist/host.js.map +1 -0
  23. package/dist/index.d.ts +9 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +9 -0
  26. package/dist/index.js.map +1 -0
  27. package/dist/module.d.ts +20 -0
  28. package/dist/module.d.ts.map +1 -0
  29. package/dist/module.js +74 -0
  30. package/dist/module.js.map +1 -0
  31. package/dist/project.d.ts +22 -0
  32. package/dist/project.d.ts.map +1 -0
  33. package/dist/project.js +481 -0
  34. package/dist/project.js.map +1 -0
  35. package/dist/revive.d.ts +16 -0
  36. package/dist/revive.d.ts.map +1 -0
  37. package/dist/revive.js +109 -0
  38. package/dist/revive.js.map +1 -0
  39. package/dist/subset.d.ts +42 -0
  40. package/dist/subset.d.ts.map +1 -0
  41. package/dist/subset.js +214 -0
  42. package/dist/subset.js.map +1 -0
  43. package/package.json +37 -0
  44. package/src/adapter.ts +48 -0
  45. package/src/fnbody.ts +61 -0
  46. package/src/fold.ts +581 -0
  47. package/src/foldable-helpers.ts +38 -0
  48. package/src/host.ts +38 -0
  49. package/src/index.ts +8 -0
  50. package/src/module.ts +52 -0
  51. package/src/no-own-execution.test.ts +86 -0
  52. package/src/prebuild.test.ts +68 -0
  53. package/src/project.ts +495 -0
  54. package/src/revive.ts +120 -0
  55. package/src/subset.ts +224 -0
package/src/project.ts ADDED
@@ -0,0 +1,495 @@
1
+ /**
2
+ * J2, the per-file verdict, and J3, the identity-taint fixpoint.
3
+ *
4
+ * Written from `spec/judgments.md` (#21, #22), not derived from any
5
+ * implementation. A project here is a map of path to source; there is no
6
+ * filesystem and no real module system, which is enough for the judgments
7
+ * because both are defined over a finite set of files and the edges between
8
+ * them.
9
+ *
10
+ * Revival (`F-Val-Fate`, #61) runs through the host the caller supplies: a
11
+ * `{__resource}` envelope becomes a real instance of the class the folding
12
+ * file imported, and with `EMPTY_HOST` it stays an envelope, which is an
13
+ * object either way as far as F-Capture's identity test is concerned. What
14
+ * this file does not do is in `CAVEATS.md`.
15
+ */
16
+ import { posix } from "node:path";
17
+ import * as ts from "typescript";
18
+ import { EMPTY_HOST, type Host } from "./host.js";
19
+ import { foldExpr, collectConsts, collectLocalFunctions, FoldRejection, FoldableFunction, isFoldableFunction, type Scope } from "./fold.js";
20
+ import { registerHelpers, registerHostSpecifiers, isHostOwnedSpecifier } from "./foldable-helpers.js";
21
+ import { revive } from "./revive.js";
22
+ import type { FnDecl } from "./fnbody.js";
23
+
24
+ export type Verdict =
25
+ | { kind: "fold"; exports: Map<string, unknown>; captures: Set<string> }
26
+ | { kind: "run"; rule: string; reason: string };
27
+
28
+ const parse = (path: string, source: string) => ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true);
29
+ const isProjectSpecifier = (s: string) => s.startsWith(".") || s.startsWith("/");
30
+
31
+ /**
32
+ * Resolve a relative specifier against the project's own key set: the
33
+ * specifier joined to the importer's directory and normalised, then the three
34
+ * obvious candidates. `..` segments are resolved; the corpus found a version
35
+ * of this that left `../../config` unjoined, which dropped the import edge and
36
+ * with it the forward taint, so a file chant ran folded here (#96).
37
+ */
38
+ function resolveKey(from: string, spec: string, files: ReadonlyMap<string, string>): string | undefined {
39
+ const base = from.includes("/") ? from.slice(0, from.lastIndexOf("/") + 1) : "";
40
+ const joined = posix.normalize(base + spec).replace(/^\.\//, "");
41
+ for (const cand of [joined, `${joined}.ts`, `${joined}/index.ts`]) if (files.has(cand)) return cand;
42
+ return undefined;
43
+ }
44
+
45
+ // ── F-Scan: the statement gate of grammar.md §1 ─────────────────────────────
46
+ type Declarator =
47
+ | { kind: "resource" | "single"; name: string; expr: ts.Expression }
48
+ | { kind: "destructure"; expr: ts.Expression; elements: { key: string; as: string }[] }
49
+ | { kind: "named-export"; elements: { local: string; as: string }[] }
50
+ | { kind: "re-export"; specifier: string; elements: { imported: string; as: string }[] }
51
+ | { kind: "function"; name: string; fn: FnDecl }
52
+ | { kind: "default"; expr: ts.Expression };
53
+
54
+ interface Scan { declarators: Declarator[]; disqualified?: string }
55
+
56
+ function scanExports(sf: ts.SourceFile, admitDefault: boolean): Scan {
57
+ const out: Declarator[] = [];
58
+ for (const st of sf.statements) {
59
+ if (ts.isExportAssignment(st)) {
60
+ // S-ExportDefault (spec 1.2, #94): in data-host, `export default ⟨Expr⟩`
61
+ // is the declarator named `default`; in full it stays S-Disqualify until
62
+ // chant admits it. `export = …` disqualifies in both.
63
+ if (st.isExportEquals || !admitDefault) return { declarators: out, disqualified: "`export default` is not foldable" };
64
+ out.push({ kind: "default", expr: st.expression });
65
+ continue;
66
+ }
67
+ if (ts.isExportDeclaration(st)) {
68
+ if (st.isTypeOnly) continue;
69
+ if (!st.exportClause || !ts.isNamedExports(st.exportClause)) {
70
+ return { declarators: out, disqualified: "`export * from` is not foldable" };
71
+ }
72
+ const elements = st.exportClause.elements
73
+ .filter((e) => !e.isTypeOnly)
74
+ .map((e) => ({ imported: (e.propertyName ?? e.name).text, local: (e.propertyName ?? e.name).text, as: e.name.text }));
75
+ if (st.moduleSpecifier && ts.isStringLiteral(st.moduleSpecifier)) {
76
+ out.push({ kind: "re-export", specifier: st.moduleSpecifier.text, elements });
77
+ } else {
78
+ out.push({ kind: "named-export", elements: elements.map(({ local, as }) => ({ local, as })) });
79
+ }
80
+ continue;
81
+ }
82
+ const exported = (st as { modifiers?: ts.NodeArray<ts.ModifierLike> }).modifiers?.some(
83
+ (m) => m.kind === ts.SyntaxKind.ExportKeyword,
84
+ );
85
+ if (ts.isFunctionDeclaration(st) && exported) {
86
+ if (st.modifiers?.some((m) => m.kind === ts.SyntaxKind.DefaultKeyword) || !st.name) {
87
+ return { declarators: out, disqualified: "`export default function` is not foldable" };
88
+ }
89
+ if (st.body) out.push({ kind: "function", name: st.name.text, fn: st });
90
+ continue;
91
+ }
92
+ if (ts.isClassDeclaration(st) && exported) return { declarators: out, disqualified: "an exported class is not foldable" };
93
+ if (!ts.isVariableStatement(st) || !exported) continue;
94
+ if ((st.declarationList.flags & ts.NodeFlags.Const) === 0) {
95
+ return { declarators: out, disqualified: "an exported `let`/`var` is not foldable" };
96
+ }
97
+ for (const d of st.declarationList.declarations) {
98
+ if (!d.initializer) return { declarators: out, disqualified: "an exported uninitialized declaration is not foldable" };
99
+ if (ts.isIdentifier(d.name)) {
100
+ out.push({ kind: ts.isNewExpression(d.initializer) ? "resource" : "single", name: d.name.text, expr: d.initializer });
101
+ continue;
102
+ }
103
+ if (ts.isObjectBindingPattern(d.name)) {
104
+ const elements: { key: string; as: string }[] = [];
105
+ for (const el of d.name.elements) {
106
+ if (el.dotDotDotToken || el.initializer || !ts.isIdentifier(el.name)) {
107
+ return { declarators: out, disqualified: "an exported destructured declaration with a rest, default, or nested element is not foldable" };
108
+ }
109
+ elements.push({ key: ((el.propertyName ?? el.name) as ts.Identifier).text, as: el.name.text });
110
+ }
111
+ out.push({ kind: "destructure", expr: d.initializer, elements });
112
+ continue;
113
+ }
114
+ return { declarators: out, disqualified: "an exported array-destructured declaration is not foldable" };
115
+ }
116
+ }
117
+ return { declarators: out };
118
+ }
119
+
120
+ // ── J2 ──────────────────────────────────────────────────────────────────────
121
+ interface Session {
122
+ readonly files: ReadonlyMap<string, string>;
123
+ readonly host: Host;
124
+ /** F-Memo: at most one verdict per file per build. */
125
+ readonly memo: Map<string, Verdict>;
126
+ /** F-Cycle: the resolution stack. */
127
+ readonly stack: string[];
128
+ /** Local functions this build created, so F-CallLeak's flag is observable. */
129
+ readonly locals: Map<string, FoldableFunction[]>;
130
+ /**
131
+ * Every non-primitive in some folded `X(g)`, to the `g` that produced it.
132
+ * F-Capture is stated over `X(f)`, not over what `f` imported, so the edge
133
+ * can only be decided once the namespace exists. F-Memo is what makes the
134
+ * index meaningful: one object per entity, so the first owner is the owner.
135
+ */
136
+ readonly owner: Map<object, string>;
137
+ }
138
+
139
+ /**
140
+ * Index every non-primitive `f` produced, so a later file's capture of one is
141
+ * attributable. "Non-primitive" is F-Identity's reference test, the broad one
142
+ * F-Import uses: a plain object counts, and the walk does not ask whether
143
+ * anything inside it is live.
144
+ */
145
+ function indexOwned(value: unknown, file: string, session: Session, seen = new Set<unknown>()): void {
146
+ if (value === null || typeof value !== "object") return;
147
+ if (seen.has(value)) return;
148
+ seen.add(value);
149
+ if (!session.owner.has(value)) session.owner.set(value, file);
150
+ // Recurse through plain structure only: an entity's interior belongs to the
151
+ // entity, and reaching into it would attribute its fields to the wrong file.
152
+ const proto = Object.getPrototypeOf(value);
153
+ if (proto !== Object.prototype && proto !== Array.prototype && proto !== null) return;
154
+ for (const inner of Object.values(value)) indexOwned(inner, file, session, seen);
155
+ }
156
+
157
+ /**
158
+ * F-Capture, decided over the produced namespace: which other files' objects
159
+ * are in `X(f)`.
160
+ *
161
+ * The walk descends through an entity's own properties as well as through
162
+ * plain structure, which is where `indexOwned` above stops. F-Capture asks
163
+ * whether some value in `X(f)` came from `X(g)`, and an object handed to a
164
+ * constructor is still in `X(f)` after the constructor kept it. Stopping at
165
+ * the entity lost the edge entirely for the ordinary case, a shared plain
166
+ * object passed as a resource's `labels`, and the loss was invisible until
167
+ * the corpus ran against a real host (#25): with no host the entity never
168
+ * becomes an instance, so the walk stayed inside plain objects and found the
169
+ * object anyway.
170
+ */
171
+ function capturesIn(value: unknown, self: string, session: Session, out: Set<string>, seen = new Set<unknown>()): void {
172
+ if (value === null || typeof value !== "object") return;
173
+ if (seen.has(value)) return;
174
+ seen.add(value);
175
+ const from = session.owner.get(value);
176
+ if (from !== undefined && from !== self) out.add(from);
177
+ for (const inner of ownData(value)) capturesIn(inner, self, session, out, seen);
178
+ }
179
+
180
+ /**
181
+ * An object's own data properties, enumerable or not, accessors skipped.
182
+ *
183
+ * A host's entity class is free to keep what it was handed wherever it likes,
184
+ * and chant's keeps it on a non-enumerable `props`. `Object.values` cannot see
185
+ * that, so a capture walk built on it reports no capture for the ordinary case
186
+ * of a shared object passed to a constructor. Accessors are skipped rather
187
+ * than invoked: reading one can throw (chant's `AttrRef.toJSON` does, for a
188
+ * reference whose logical name is not yet assigned), and an accessor computes
189
+ * a value rather than holding one.
190
+ */
191
+ function ownData(value: object): unknown[] {
192
+ // An object held weakly is still held: an attribute reference keeps its
193
+ // entity behind a `WeakRef`, and the file holding the reference holds the
194
+ // entity for F-Capture's purposes, the same as if it held it directly.
195
+ if (value instanceof WeakRef) {
196
+ const target = value.deref();
197
+ return target === undefined ? [] : [target];
198
+ }
199
+ const out: unknown[] = [];
200
+ for (const key of Reflect.ownKeys(value)) {
201
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
202
+ if (descriptor && "value" in descriptor) out.push(descriptor.value);
203
+ }
204
+ return out;
205
+ }
206
+
207
+ /** Every value binding a non-type-only import clause introduces. */
208
+ function bindingNames(clause: ts.ImportClause): string[] {
209
+ const out: string[] = [];
210
+ if (clause.name) out.push(clause.name.text);
211
+ const nb = clause.namedBindings;
212
+ if (nb && ts.isNamespaceImport(nb)) out.push(nb.name.text);
213
+ if (nb && ts.isNamedImports(nb)) for (const el of nb.elements) if (!el.isTypeOnly) out.push(el.name.text);
214
+ return out;
215
+ }
216
+
217
+ function verdictOf(path: string, session: Session): Verdict {
218
+ const memo = session.memo.get(path);
219
+ if (memo) return memo;
220
+ if (session.stack.includes(path)) {
221
+ // F-Cycle: a located error naming the cycle, not an infinite regress.
222
+ return { kind: "run", rule: "F-Cycle", reason: `import cycle: ${[...session.stack, path].join(" -> ")}` };
223
+ }
224
+ session.stack.push(path);
225
+ const v = foldFile(path, session);
226
+ session.stack.pop();
227
+ session.memo.set(path, v);
228
+ return v;
229
+ }
230
+
231
+ function foldFile(path: string, session: Session): Verdict {
232
+ const source = session.files.get(path);
233
+ if (source === undefined) return { kind: "run", rule: "F-NotProject", reason: `${path} is not project source` };
234
+ const sf = parse(path, source);
235
+
236
+ // F-Scan
237
+ const scan = scanExports(sf, session.host.profile === "data-host");
238
+ if (scan.disqualified) return { kind: "run", rule: "F-Scan", reason: scan.disqualified };
239
+ // F-NoExports
240
+ if (scan.declarators.length === 0) return { kind: "run", rule: "F-NoExports", reason: "no foldable resource exports" };
241
+
242
+ // F-Bind
243
+ const consts = collectConsts(sf);
244
+ const externals = new Map<string, unknown>();
245
+ const captures = new Set<string>();
246
+ const mine: FoldableFunction[] = [];
247
+ session.locals.set(path, mine);
248
+ /** F-Import: why a binding was left unresolved, "for diagnostics only". */
249
+ const unresolved = new Map<string, { rule: string; reason: string }>();
250
+
251
+ // F-Import
252
+ for (const st of sf.statements) {
253
+ if (!ts.isImportDeclaration(st) || !ts.isStringLiteral(st.moduleSpecifier)) continue;
254
+ const spec = st.moduleSpecifier.text;
255
+ const clause = st.importClause;
256
+ if (!clause || clause.isTypeOnly) continue;
257
+ if (!isProjectSpecifier(spec)) {
258
+ // F-Import, bare specifier: a package is never a member of F. A host-owned
259
+ // one still binds its REAL exports (F-Host-Trust arm 1), which is what lets
260
+ // revival construct anything at all (F-Val-Fate).
261
+ const supplied = session.host.values.get(spec);
262
+ if (supplied && isHostOwnedSpecifier(spec) && clause.namedBindings && ts.isNamedImports(clause.namedBindings)) {
263
+ for (const el of clause.namedBindings.elements) {
264
+ if (el.isTypeOnly) continue;
265
+ const imported = (el.propertyName ?? el.name).text;
266
+ if (supplied.has(imported)) externals.set(el.name.text, supplied.get(imported));
267
+ }
268
+ }
269
+ continue;
270
+ }
271
+ const target = resolveKey(path, spec, session.files);
272
+ if (!target) continue;
273
+ const tv = verdictOf(target, session);
274
+ if (tv.kind !== "fold") {
275
+ // F-Import: the binding is not resolved, and the cause is kept for F-Reason.
276
+ for (const n of bindingNames(clause)) unresolved.set(n, { rule: tv.rule, reason: tv.reason });
277
+ continue;
278
+ }
279
+ if (clause.name && tv.exports.has("default")) {
280
+ // `import n from "./g"` binds the target's default export (S-ExportDefault, spec 1.2).
281
+ const value = tv.exports.get("default");
282
+ externals.set(clause.name.text, value);
283
+ if (value !== null && typeof value === "object" && !isFoldableFunction(value)) captures.add(target);
284
+ }
285
+ if (clause.namedBindings && ts.isNamespaceImport(clause.namedBindings)) {
286
+ // F-Namespace: a synthetic plain object of the target's entries.
287
+ const ns = Object.fromEntries(tv.exports);
288
+ externals.set(clause.namedBindings.name.text, ns);
289
+ continue;
290
+ }
291
+ if (clause.namedBindings && ts.isNamedImports(clause.namedBindings)) {
292
+ for (const el of clause.namedBindings.elements) {
293
+ if (el.isTypeOnly) continue;
294
+ const imported = (el.propertyName ?? el.name).text;
295
+ if (!tv.exports.has(imported)) continue;
296
+ const value = tv.exports.get(imported);
297
+ externals.set(el.name.text, value);
298
+ // F-Import: an imported value with identity is a capture at the
299
+ // import, by F-Identity's reference test, whether or not it reaches
300
+ // X(f). A project-local function is a callable, not a value, and
301
+ // F-CallLeak decides its edge at the call instead. The corpus found
302
+ // the walk over X(f) below is not enough on its own: a file that
303
+ // reads only primitives out of an imported object holds no object in
304
+ // its namespace, and chant taints it anyway, as the text says (#96).
305
+ if (value !== null && typeof value === "object" && !isFoldableFunction(value)) captures.add(target);
306
+ }
307
+ }
308
+ }
309
+
310
+ // F-Declarator, into X
311
+ const scope: Scope = { consts, externals, depth: 0, captures };
312
+ const evalHost = { intrinsics: session.host.intrinsics };
313
+ const exports = new Map<string, unknown>();
314
+ /** F-Val-Fate: what the declarator produced, revived through this file's own imports. */
315
+ const live = (v: unknown, node: ts.Node, what: string) => {
316
+ const { line, character } = sf.getLineAndCharacterOfPosition(node.getStart());
317
+ return revive(v, externals, { line: line + 1, column: character + 1, what });
318
+ };
319
+
320
+ // F-Bind, S-LocalFunction: the file's own functions, exported or not, bound
321
+ // before anything can call them. The exported ones are also declarators
322
+ // below, and reuse the same marker so F-CallLeak's flag is one object.
323
+ for (const fn of collectLocalFunctions(sf, path, consts, externals)) {
324
+ mine.push(fn);
325
+ externals.set(fn.name, fn);
326
+ }
327
+
328
+ // F-Prebuild: every same-file `const n = new T(…)`, exported or not, built
329
+ // once in source order before any declarator reads it, and bound in
330
+ // `externals` where F-Eval-Ident step 1 looks. A construction that fails is
331
+ // skipped rather than failing the file here: the name stays unbound, a
332
+ // reference to it rejects under step 1, and an exported one reproduces the
333
+ // failure below with its own located reason. `collectConsts` yields source
334
+ // order, so a later construction sees an earlier one (#68).
335
+ const prebuilt = new Map<ts.Expression, unknown>();
336
+ for (const [name, init] of consts) {
337
+ if (!ts.isNewExpression(init)) continue;
338
+ try {
339
+ const instance = live(foldExpr(init, scope, evalHost), init, name);
340
+ prebuilt.set(init, instance);
341
+ externals.set(name, instance);
342
+ } catch (e) {
343
+ if (!(e instanceof FoldRejection)) throw e;
344
+ }
345
+ }
346
+
347
+ for (const d of scan.declarators) {
348
+ try {
349
+ if (d.kind === "resource") {
350
+ // F-Count: the instance F-Prebuild built for this initializer is the
351
+ // one exported; a second construction would be a second entity.
352
+ exports.set(d.name, prebuilt.has(d.expr) ? prebuilt.get(d.expr) : live(foldExpr(d.expr, scope, evalHost), d.expr, d.name));
353
+ } else if (d.kind === "single") {
354
+ exports.set(d.name, live(foldExpr(d.expr, scope, evalHost), d.expr, d.name));
355
+ } else if (d.kind === "destructure") {
356
+ const base = live(foldExpr(d.expr, scope, evalHost), d.expr, "a destructured declaration");
357
+ if (base === null || typeof base !== "object") {
358
+ return { kind: "run", rule: "F-Declarator", reason: "destructured source is not an object" };
359
+ }
360
+ for (const el of d.elements) exports.set(el.as, (base as Record<string, unknown>)[el.key]);
361
+ } else if (d.kind === "named-export") {
362
+ for (const el of d.elements) {
363
+ const init = consts.get(el.local);
364
+ // Through F-Eval-Ident, never by re-folding the initializer: a name
365
+ // bound to a same-file `new` reads F-Prebuild's instance, and a
366
+ // failed one reproduces step 1's rejection rather than building a
367
+ // second entity here.
368
+ const v =
369
+ init && ts.isNewExpression(init)
370
+ ? prebuilt.has(init)
371
+ ? prebuilt.get(init)
372
+ : live(foldExpr(init, scope, evalHost), init, el.as)
373
+ : init
374
+ ? live(foldExpr(init, scope, evalHost), init, el.as)
375
+ : externals.get(el.local);
376
+ if (v === undefined && !consts.has(el.local) && !externals.has(el.local)) {
377
+ return { kind: "run", rule: "F-Reference", reason: `unresolved identifier: ${el.local}` };
378
+ }
379
+ exports.set(el.as, v);
380
+ }
381
+ } else if (d.kind === "re-export") {
382
+ const target = resolveKey(path, d.specifier, session.files);
383
+ if (!target) return { kind: "run", rule: "F-Import", reason: `cannot resolve re-export from ${d.specifier}` };
384
+ const tv = verdictOf(target, session);
385
+ if (tv.kind !== "fold") return { kind: "run", rule: "F-Import", reason: `re-export source ${target} falls back to run` };
386
+ // A re-export is a capture, and the owner index below records it as one.
387
+ for (const el of d.elements) exports.set(el.as, tv.exports.get(el.imported));
388
+ } else if (d.kind === "function") {
389
+ exports.set(d.name, externals.get(d.name));
390
+ } else if (d.kind === "default") {
391
+ exports.set("default", live(foldExpr(d.expr, scope, evalHost), d.expr, "default"));
392
+ }
393
+ } catch (e) {
394
+ // F-Total: one failed declarator is a failure of the whole file. F-Reason.
395
+ if (e instanceof FoldRejection) {
396
+ // F-Reason: an unresolved identifier that came from an unresolved import
397
+ // reports the import's own cause, not just the bare name.
398
+ const name = /unresolved identifier: (\w+)$/.exec(e.message)?.[1];
399
+ const cause = name ? unresolved.get(name) : undefined;
400
+ if (cause) {
401
+ const rule = cause.rule === "F-Cycle" ? "F-Cycle" : "F-Import";
402
+ return { kind: "run", rule, reason: `${e.message} (its module falls back to run: ${cause.reason})` };
403
+ }
404
+ return { kind: "run", rule: e.rule, reason: e.message };
405
+ }
406
+ throw e;
407
+ }
408
+ }
409
+ // F-Capture over X(f). F-CallLeak has already put its own edges in `captures`.
410
+ for (const v of exports.values()) capturesIn(v, path, session, captures);
411
+ for (const v of exports.values()) indexOwned(v, path, session);
412
+ return { kind: "fold", exports, captures };
413
+ }
414
+
415
+ // ── J3 ──────────────────────────────────────────────────────────────────────
416
+ export interface ProjectResult {
417
+ /** Final verdicts, after the fixpoint. */
418
+ readonly verdicts: Map<string, Verdict>;
419
+ /** Tentative J2 verdicts, before J3 disposed of them. */
420
+ readonly tentative: Map<string, Verdict>;
421
+ /** Why a file that reduced on its own was nonetheless tainted. */
422
+ readonly taintReason: Map<string, string>;
423
+ /** The file whose taint reached it, the other end of the F-Succ edge that fired. */
424
+ readonly taintSource: Map<string, string>;
425
+ }
426
+
427
+ export function foldProject(files: ReadonlyMap<string, string>, host: Host = EMPTY_HOST): ProjectResult {
428
+ registerHelpers(host.helpers);
429
+ registerHostSpecifiers(host.ownedSpecifierPrefixes);
430
+ const session: Session = { files, host, memo: new Map(), stack: [], locals: new Map(), owner: new Map() };
431
+
432
+ const tentative = new Map<string, Verdict>();
433
+ for (const path of files.keys()) tentative.set(path, verdictOf(path, session));
434
+
435
+ // Edges. Forward: f imports g. Backward: c captured from f.
436
+ const imports = new Map<string, Set<string>>();
437
+ for (const [path, source] of files) {
438
+ const set = new Set<string>();
439
+ for (const st of parse(path, source).statements) {
440
+ const spec =
441
+ (ts.isImportDeclaration(st) || ts.isExportDeclaration(st)) && st.moduleSpecifier && ts.isStringLiteral(st.moduleSpecifier)
442
+ ? st.moduleSpecifier.text
443
+ : undefined;
444
+ if (!spec || !isProjectSpecifier(spec)) continue;
445
+ const target = resolveKey(path, spec, files);
446
+ if (target) set.add(target);
447
+ }
448
+ imports.set(path, set);
449
+ }
450
+
451
+ // F-Succ. Both directions from one tainted file.
452
+ const succ = new Map<string, Set<string>>();
453
+ const add = (from: string, to: string) => {
454
+ if (!succ.has(from)) succ.set(from, new Set());
455
+ succ.get(from)!.add(to);
456
+ };
457
+ for (const [f, gs] of imports) for (const g of gs) add(f, g); // forward: f taints what it imports
458
+ for (const [c, v] of tentative) {
459
+ if (v.kind !== "fold") continue;
460
+ for (const source of v.captures) add(source, c); // backward: the captured source taints the capturer
461
+ }
462
+ // F-Seed, F-Taint, F-Fix: least fixpoint by worklist over a finite set.
463
+ const tainted = new Set<string>([...tentative].filter(([, v]) => v.kind !== "fold").map(([p]) => p));
464
+ const reason = new Map<string, string>();
465
+ const source = new Map<string, string>();
466
+ const queue = [...tainted];
467
+ while (queue.length > 0) {
468
+ const current = queue.shift()!;
469
+ for (const next of succ.get(current) ?? []) {
470
+ if (tainted.has(next)) continue;
471
+ tainted.add(next);
472
+ source.set(next, current);
473
+ // F-Reason for a taint: name the edge that fired. The forward edge is the
474
+ // one a reader can see in the file's own imports; the backward edge is the
475
+ // one nothing in the file predicts, so it has to be spelled out.
476
+ reason.set(
477
+ next,
478
+ imports.get(current)?.has(next)
479
+ ? `would fold in isolation, but ${current}, which imports it, falls back to run`
480
+ : `would fold in isolation, but it captured objects from ${current}, which falls back to run`,
481
+ );
482
+ queue.push(next);
483
+ }
484
+ }
485
+
486
+ // F-Verdict
487
+ const verdicts = new Map<string, Verdict>();
488
+ for (const [path, v] of tentative) {
489
+ verdicts.set(
490
+ path,
491
+ tainted.has(path) && v.kind === "fold" ? { kind: "run", rule: "F-Taint", reason: reason.get(path) ?? "tainted" } : v,
492
+ );
493
+ }
494
+ return { verdicts, tentative, taintReason: reason, taintSource: source };
495
+ }
package/src/revive.ts ADDED
@@ -0,0 +1,120 @@
1
+ /**
2
+ * F-Val-Fate: revival. A folded tree holds envelopes, values that *denote*
3
+ * something not yet constructed; revival walks the tree and replaces each one
4
+ * by resolving its name through the folding file's own imports and invoking
5
+ * the real constructor or function the host supplies (#61).
6
+ *
7
+ * Written from `spec/values.md`. Five of the six envelope kinds are here.
8
+ * `__compositeStep` is not: its fate is "resolve the composite (J2 F-Call),
9
+ * then read `.step` off the real result", and this implementation has no
10
+ * composite factory form, so it rejects rather than guessing. See CAVEATS.md.
11
+ *
12
+ * Revival is J2's step, not J1's. `foldExpr` yields envelopes and that is the
13
+ * answer the expression fixtures compare; `F-Declarator` revives what the
14
+ * declarator produced.
15
+ */
16
+ import { FoldRejection, isEnvelope, isLiveObject } from "./fold.js";
17
+
18
+ /** Where a revival happened, for the located rejection F-Reason wants. */
19
+ export interface RevivalSite {
20
+ readonly line: number;
21
+ readonly column: number;
22
+ /** The declarator or call this tree came from, for the message. */
23
+ readonly what: string;
24
+ }
25
+
26
+ const DOTTED = /^[A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)*$/;
27
+
28
+ type Env = Record<string, unknown>;
29
+
30
+ /**
31
+ * @param bindings the folding file's resolved names, `externals`. A host-owned
32
+ * import puts the real value here (F-Import, F-Host-Trust arm 1), which is
33
+ * what makes revival possible at all.
34
+ * @param inHostArgs true inside an intrinsic's or helper's arguments, where
35
+ * F-Val-Position rejects an `{__attrRef}` instead of passing it through.
36
+ */
37
+ export function revive(
38
+ value: unknown,
39
+ bindings: ReadonlyMap<string, unknown>,
40
+ site: RevivalSite,
41
+ inHostArgs = false,
42
+ ): unknown {
43
+ if (value === null || typeof value !== "object") return value;
44
+ // F-Val-Live: a live object passes through unchanged, never rebuilt.
45
+ if (isLiveObject(value)) return value;
46
+ // A structure with no envelope anywhere inside is already final, and is
47
+ // returned AS IS rather than rebuilt. F-Memo requires every reference to
48
+ // X(g) to be the same object; a rebuilt copy would make F-Capture record an
49
+ // edge to a copy and the judgment vacuous.
50
+ if (Array.isArray(value)) {
51
+ const next = value.map((v) => revive(v, bindings, site, inHostArgs));
52
+ return next.every((v, i) => v === value[i]) ? value : next;
53
+ }
54
+ if (!isEnvelope(value)) {
55
+ const out: Env = {};
56
+ let changed = false;
57
+ for (const [k, v] of Object.entries(value)) {
58
+ out[k] = revive(v, bindings, site, inHostArgs);
59
+ if (out[k] !== v) changed = true;
60
+ }
61
+ return changed ? out : value;
62
+ }
63
+ return reviveEnvelope(value as Env, bindings, site, inHostArgs);
64
+ }
65
+
66
+ function fail(site: RevivalSite, message: string): never {
67
+ throw new FoldRejection("F-Val-Fate", site.line, site.column, `${site.what}: ${message}`);
68
+ }
69
+
70
+ function resolve(name: string, bindings: ReadonlyMap<string, unknown>, site: RevivalSite): unknown {
71
+ if (!bindings.has(name)) fail(site, `revival cannot resolve ${name}; the folding file does not import it from the host`);
72
+ return bindings.get(name);
73
+ }
74
+
75
+ function callable(name: string, bindings: ReadonlyMap<string, unknown>, site: RevivalSite): (...a: unknown[]) => unknown {
76
+ const v = resolve(name, bindings, site);
77
+ if (typeof v !== "function") fail(site, `${name} resolves to ${typeof v}, which revival cannot invoke`);
78
+ return v as (...a: unknown[]) => unknown;
79
+ }
80
+
81
+ function reviveEnvelope(e: Env, bindings: ReadonlyMap<string, unknown>, site: RevivalSite, inHostArgs: boolean): unknown {
82
+ // `__attrRef` is the one envelope that survives, except inside host arguments.
83
+ if ("__attrRef" in e) {
84
+ if (inHostArgs) {
85
+ const { entity, attribute } = e.__attrRef as { entity: string; attribute: string };
86
+ fail(site, `an attribute reference (${entity}.${attribute}) inside a host call's arguments is rejected, not revived (F-Val-Position)`);
87
+ }
88
+ return e;
89
+ }
90
+ const args = (a: unknown) => (a as unknown[]).map((x) => revive(x, bindings, site, true));
91
+
92
+ if ("__resource" in e) {
93
+ const name = e.__resource as string;
94
+ const C = callable(name, bindings, site) as unknown as new (...a: unknown[]) => unknown;
95
+ // F-Val-Arity: spread `args` when present, otherwise props and optional attributes.
96
+ if (Array.isArray(e.args)) return new C(...(e.args as unknown[]).map((x) => revive(x, bindings, site, false)));
97
+ const props = revive(e.props, bindings, site, false);
98
+ return "attributes" in e ? new C(props, revive(e.attributes, bindings, site, false)) : new C(props);
99
+ }
100
+ if ("__intrinsic" in e) {
101
+ const fn = callable(e.__intrinsic as string, bindings, site);
102
+ // The tag form keeps its cooked strings; the call form is a plain call.
103
+ if (Array.isArray(e.strings)) return fn(e.strings as unknown as string[], ...args(e.values));
104
+ return fn(...args(e.args));
105
+ }
106
+ if ("__helper" in e) return callable(e.__helper as string, bindings, site)(...args(e.args));
107
+ if ("__symbol" in e) {
108
+ const text = e.__symbol as string;
109
+ if (!DOTTED.test(text)) fail(site, `a symbolic reference revives only through a simple dotted chain, and "${text}" is not one`);
110
+ const [root, ...rest] = text.split(".");
111
+ let current = resolve(root, bindings, site);
112
+ for (const step of rest) {
113
+ if (current === null || current === undefined) fail(site, `revival of "${text}" read "${step}" on ${String(current)}`);
114
+ current = (current as Env)[step];
115
+ }
116
+ return current;
117
+ }
118
+ // __compositeStep
119
+ fail(site, "a composite step envelope needs a composite factory form, which this implementation does not have");
120
+ }