fallow-type-aware 3.34.1 → 3.36.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fallow-type-aware",
3
- "version": "3.34.1",
3
+ "version": "3.36.0",
4
4
  "description": "Optional TypeScript-Go semantic refinement sidecar for Fallow",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -23,6 +23,14 @@
23
23
  "dependencies": {
24
24
  "typescript": "7.0.2"
25
25
  },
26
+ "peerDependencies": {
27
+ "tsrs": "0.18.0-ts7.1.0-dev.20260929"
28
+ },
29
+ "peerDependenciesMeta": {
30
+ "tsrs": {
31
+ "optional": true
32
+ }
33
+ },
26
34
  "engines": {
27
35
  "node": ">=20"
28
36
  },
@@ -33,6 +41,7 @@
33
41
  "devDependencies": {
34
42
  "@codspeed/tinybench-plugin": "5.7.1",
35
43
  "tinybench": "6.1.2",
44
+ "tsrs": "0.18.0-ts7.1.0-dev.20260929",
36
45
  "zod": "4.4.3"
37
46
  }
38
47
  }
@@ -1,7 +1,8 @@
1
1
  import { createRequire } from "node:module";
2
2
  import path from "node:path";
3
3
 
4
- import { BACKEND_VERSION } from "./generated-protocol.mjs";
4
+ import { selectBackend } from "./backend-selection.mjs";
5
+ import { BACKEND_VERSION, BACKEND_FAMILY } from "./generated-protocol.mjs";
5
6
 
6
7
  const REQUIRED_MAJOR = Number.parseInt(BACKEND_VERSION, 10);
7
8
  const INSTALL_HINT =
@@ -9,7 +10,7 @@ const INSTALL_HINT =
9
10
 
10
11
  /**
11
12
  * Verify that module resolution hands the sidecar a typescript install that
12
- * provides the `typescript/unstable/sync` backend entry point.
13
+ * provides the `typescript` sync API backend entry point.
13
14
  *
14
15
  * The semantic modules import that entry point statically, so an incompatible
15
16
  * resolution (for example a typescript 6 hoisted into an ancestor
@@ -18,11 +19,23 @@ const INSTALL_HINT =
18
19
  * and names the resolved version, its location, and the fix. Exact
19
20
  * backend-version parity stays enforced in `protocol.mjs`.
20
21
  *
22
+ * A backend that `FALLOW_TYPE_AWARE_BACKEND` selects instead of the default
23
+ * must resolve at its exact pinned version.
24
+ *
21
25
  * @param {string | URL} resolveFrom Module URL or file path to resolve from;
22
26
  * defaults to this module so the check mirrors the semantic imports.
27
+ * @param {NodeJS.ProcessEnv} env Environment that selects the backend.
23
28
  */
24
- export const assertTypescriptBackendResolvable = (resolveFrom = import.meta.url) => {
29
+ export const assertTypescriptBackendResolvable = (
30
+ resolveFrom = import.meta.url,
31
+ env = process.env,
32
+ ) => {
33
+ const backend = selectBackend(env);
25
34
  const sidecarRequire = createRequire(resolveFrom);
35
+ if (backend.family !== BACKEND_FAMILY) {
36
+ assertPinnedBackendResolvable(sidecarRequire, backend);
37
+ return;
38
+ }
26
39
  let manifestPath;
27
40
  try {
28
41
  manifestPath = sidecarRequire.resolve("typescript/package.json");
@@ -36,7 +49,23 @@ export const assertTypescriptBackendResolvable = (resolveFrom = import.meta.url)
36
49
  }
37
50
  throw new Error(
38
51
  `resolved typescript ${version} from ${path.dirname(manifestPath)}, which lacks the ` +
39
- `typescript/unstable/sync backend; fallow-type-aware needs typescript ${BACKEND_VERSION}; ` +
52
+ `typescript sync API backend; fallow-type-aware needs typescript ${BACKEND_VERSION}; ` +
40
53
  INSTALL_HINT,
41
54
  );
42
55
  };
56
+
57
+ const assertPinnedBackendResolvable = (sidecarRequire, backend) => {
58
+ const install = `install ${backend.package}@${backend.version} where fallow-type-aware can resolve it`;
59
+ let manifestPath;
60
+ try {
61
+ manifestPath = sidecarRequire.resolve(`${backend.package}/package.json`);
62
+ } catch {
63
+ throw new Error(`the ${backend.family} backend is not installed; ${install}`);
64
+ }
65
+ const { version } = sidecarRequire(manifestPath);
66
+ if (version === backend.version) return;
67
+ throw new Error(
68
+ `resolved ${backend.package} ${version} from ${path.dirname(manifestPath)}; ` +
69
+ `the ${backend.family} backend needs exactly ${backend.version}; ${install}`,
70
+ );
71
+ };
@@ -0,0 +1,18 @@
1
+ import { BACKENDS, BACKEND_FAMILY } from "./generated-protocol.mjs";
2
+
3
+ const BACKEND_ENV = "FALLOW_TYPE_AWARE_BACKEND";
4
+
5
+ /**
6
+ * Resolve the backend that `FALLOW_TYPE_AWARE_BACKEND` selects. An unset or
7
+ * empty value selects the default backend.
8
+ *
9
+ * @param {NodeJS.ProcessEnv} env
10
+ * @returns {{ family: string, package: string, version: string }}
11
+ */
12
+ export const selectBackend = (env = process.env) => {
13
+ const requested = env[BACKEND_ENV]?.trim() || BACKEND_FAMILY;
14
+ const backend = BACKENDS.find((candidate) => candidate.family === requested);
15
+ if (backend) return backend;
16
+ const known = BACKENDS.map((candidate) => candidate.family).join(", ");
17
+ throw new Error(`unknown ${BACKEND_ENV} value \`${requested}\`; expected one of: ${known}`);
18
+ };
@@ -0,0 +1,169 @@
1
+ import { existsSync } from "node:fs";
2
+ import path from "node:path";
3
+
4
+ import { selectBackend } from "./backend-selection.mjs";
5
+
6
+ export const BACKEND = selectBackend();
7
+
8
+ const { version: resolvedVersion } = await import(BACKEND.package);
9
+ // TypeScript 7.1 serves the API at `typescript/sync`. TypeScript 7.0 and its
10
+ // 7.1 dev builds serve it at `typescript/unstable/sync`.
11
+ const importApi = async (subpath) => {
12
+ try {
13
+ return await import(`${BACKEND.package}/${subpath}`);
14
+ } catch (error) {
15
+ if (error?.code !== "ERR_PACKAGE_PATH_NOT_EXPORTED") throw error;
16
+ return import(`${BACKEND.package}/unstable/${subpath}`);
17
+ }
18
+ };
19
+ export const sync = await importApi("sync");
20
+ export const ast = await importApi("ast/is");
21
+ export const { SyntaxKind } = await importApi("ast");
22
+ const { serverFS } = await importApi("fs");
23
+
24
+ if (resolvedVersion !== BACKEND.version) {
25
+ throw new Error(
26
+ `${BACKEND.package} backend version ${resolvedVersion} does not match protocol ${BACKEND.version}`,
27
+ );
28
+ }
29
+
30
+ /**
31
+ * The value a `readFile` callback returns to read the file from disk.
32
+ * TypeScript 7.0 reads from disk on `undefined`. TypeScript 7.1 treats
33
+ * `undefined` as a missing file and needs the `serverFS.useOS` sentinel.
34
+ */
35
+ export const READ_FROM_DISK = serverFS?.useOS;
36
+
37
+ const FS_CALLBACK_DEFAULTS = serverFS
38
+ ? {
39
+ readFile: serverFS.useOS,
40
+ fileExists: serverFS.useOS,
41
+ directoryExists: serverFS.useOS,
42
+ getAccessibleEntries: serverFS.useOS,
43
+ realpath: serverFS.useOS,
44
+ stat: serverFS.useOS,
45
+ writeFile: serverFS.useOS,
46
+ removeFile: serverFS.useOS,
47
+ }
48
+ : {};
49
+
50
+ /**
51
+ * TypeScript 7.0 finds a project by its config file name. TypeScript 7.1 keys
52
+ * `getProject` by project id and finds a config file through
53
+ * `getConfiguredProject`.
54
+ */
55
+ const getProject = (snapshot, name) =>
56
+ snapshot.getProject(name) ?? snapshot.getConfiguredProject(name);
57
+
58
+ /**
59
+ * Give a TypeScript 7.1 API the TypeScript 7.0 `updateSnapshot(changes)`
60
+ * contract: each call applies `changes` to the newest snapshot and returns
61
+ * current programs.
62
+ *
63
+ * - 7.0 `fileChanges` become 7.1 `fileNotifications`, and `ensurePrograms`
64
+ * rebuilds the programs that those notifications make dirty.
65
+ * - 7.1 derives a snapshot from a live base snapshot, so a snapshot that the
66
+ * caller disposes stays alive while it is the newest one. The caller's
67
+ * handle still acts as disposed, as it does on 7.0.
68
+ * - 7.1 rejects the whole update when an `openFiles` entry does not exist.
69
+ * 7.0 ignores that file, so the wrapper drops missing files first.
70
+ */
71
+ const withLinearSnapshots = (api) => {
72
+ const entries = new Set();
73
+ let head;
74
+ const disposeWhenUnused = (entry) => {
75
+ if (entry === head || entry.open > 0) return;
76
+ entries.delete(entry);
77
+ if (!entry.snapshot.isDisposed()) entry.snapshot.dispose();
78
+ };
79
+ const handle = (entry) => {
80
+ entry.open += 1;
81
+ let released = false;
82
+ const dispose = () => {
83
+ if (released) return;
84
+ released = true;
85
+ entry.open -= 1;
86
+ disposeWhenUnused(entry);
87
+ };
88
+ return new Proxy(entry.snapshot, {
89
+ get(target, property) {
90
+ if (property === "dispose" || property === Symbol.dispose) return dispose;
91
+ if (property === "isDisposed") return () => released || target.isDisposed();
92
+ if (released) throw new Error("Snapshot is disposed");
93
+ if (property === "getProject") return getProject.bind(undefined, target);
94
+ const value = Reflect.get(target, property, target);
95
+ return typeof value === "function" ? value.bind(target) : value;
96
+ },
97
+ });
98
+ };
99
+ const updateSnapshot = ({ fileChanges, openFiles, ...rest } = {}) => {
100
+ const changes = {
101
+ ...rest,
102
+ ...(openFiles ? { openFiles: openFiles.filter((file) => existsSync(file)) } : {}),
103
+ ...(fileChanges ? { fileNotifications: fileChanges } : {}),
104
+ ensurePrograms: true,
105
+ };
106
+ const previous = head;
107
+ head = {
108
+ snapshot: previous ? previous.snapshot.update(changes) : api.createSnapshot(changes),
109
+ open: 0,
110
+ };
111
+ entries.add(head);
112
+ if (previous) disposeWhenUnused(previous);
113
+ return handle(head);
114
+ };
115
+ const close = () => {
116
+ for (const entry of entries) {
117
+ if (!entry.snapshot.isDisposed()) entry.snapshot.dispose();
118
+ }
119
+ entries.clear();
120
+ head = undefined;
121
+ api.close();
122
+ };
123
+ return new Proxy(api, {
124
+ get(target, property) {
125
+ if (property === "updateSnapshot") return updateSnapshot;
126
+ if (property === "close") return close;
127
+ const value = Reflect.get(target, property, target);
128
+ return typeof value === "function" ? value.bind(target) : value;
129
+ },
130
+ });
131
+ };
132
+
133
+ /**
134
+ * Create a backend API with the TypeScript 7.0 snapshot contract.
135
+ *
136
+ * @param {{ cwd?: string, fs?: Record<string, unknown> }} options
137
+ */
138
+ export const createApi = (options = {}) => {
139
+ const fs = options.fs ? { ...FS_CALLBACK_DEFAULTS, ...options.fs } : undefined;
140
+ const api = new sync.API({ ...options, ...(fs ? { fs } : {}) });
141
+ return typeof api.createSnapshot === "function" ? withLinearSnapshots(api) : api;
142
+ };
143
+
144
+ const INFERRED_PROJECT_ID = "/dev/null/inferred";
145
+
146
+ /**
147
+ * Whether a project is the inferred project. Both versions give it the id
148
+ * `/dev/null/inferred`. Only 7.0 also uses that id as its config file name.
149
+ */
150
+ export const isInferredProject = (project) =>
151
+ project.id.split(path.sep).join("/") === INFERRED_PROJECT_ID;
152
+
153
+ /** Config file name with the 7.0 value `/dev/null/inferred` for the inferred project. */
154
+ export const projectConfigFileName = (project) =>
155
+ isInferredProject(project) ? INFERRED_PROJECT_ID : project.configFileName;
156
+
157
+ /**
158
+ * Name node of a heritage clause type. TypeScript 7.0 wraps it in an
159
+ * ExpressionWithTypeArguments (`expression`). TypeScript 7.1 uses a
160
+ * TypeReference (`typeName`) in `implements` and interface `extends` clauses.
161
+ */
162
+ export const heritageTypeName = (heritageType) => heritageType.expression ?? heritageType.typeName;
163
+
164
+ /**
165
+ * Compiler options of a project. TypeScript 7.1 beta moves them from
166
+ * `compilerOptions` to `parsedCommandLine.options`.
167
+ */
168
+ export const projectCompilerOptions = (project) =>
169
+ project.compilerOptions ?? project.parsedCommandLine?.options ?? {};
@@ -0,0 +1,45 @@
1
+ import { ast } from "./backend.mjs";
2
+ import { patternOfKey, patternSourceType } from "./shorthand-references.mjs";
3
+ import { matchingSymbolEntries, recordSymbolUncertainty } from "./symbol-targets.mjs";
4
+
5
+ const { isComputedPropertyName, isElementAccessExpression, isStringLiteralLikeNode } = ast;
6
+
7
+ const isComputedAccess = (node) =>
8
+ isElementAccessExpression(node) &&
9
+ node.argumentExpression !== undefined &&
10
+ !isStringLiteralLikeNode(node.argumentExpression);
11
+
12
+ const isComputedKey = (node) =>
13
+ isComputedPropertyName(node) && !isStringLiteralLikeNode(node.expression);
14
+
15
+ // `object[key]` and `const { [key]: value } = object` both read a member of
16
+ // `object` that the checker cannot name. A null type is an unknown receiver.
17
+ const computedReceiverType = (project, node) => {
18
+ if (isComputedAccess(node)) return project.checker.getTypeAtLocation(node.expression);
19
+ if (!isComputedKey(node)) return undefined;
20
+ const pattern = patternOfKey(node.expression);
21
+ if (!pattern) return undefined;
22
+ return patternSourceType(project, pattern) ?? null;
23
+ };
24
+
25
+ /**
26
+ * Records a `dynamic-member-access` uncertainty for the class members that a
27
+ * computed member access or a computed pattern key can read.
28
+ */
29
+ export const recordComputedMemberAccess = (state, project, entries, node) => {
30
+ if (entries.size === 0 || state.projectsWithUnknownDynamicAccess.has(project)) return;
31
+ const receiverType = computedReceiverType(project, node);
32
+ if (receiverType === undefined) return;
33
+ const receiverProperties = receiverType ? project.checker.getPropertiesOfType(receiverType) : [];
34
+ if (receiverProperties.length === 0) {
35
+ entries.forEach((entry) => recordSymbolUncertainty(state, entry, "dynamic-member-access"));
36
+ state.projectsWithUnknownDynamicAccess.add(project);
37
+ return;
38
+ }
39
+ const matched = new Set(
40
+ receiverProperties
41
+ .filter((property) => state.candidateNames.has(property.name))
42
+ .flatMap((property) => [...matchingSymbolEntries(project, property, "value", state)]),
43
+ );
44
+ matched.forEach((entry) => recordSymbolUncertainty(state, entry, "dynamic-member-access"));
45
+ };
@@ -11,7 +11,15 @@ export const TYPE_AWARE_PROTOCOL = Object.freeze({
11
11
  backend: {
12
12
  family: "typescript-go",
13
13
  version: "7.0.2",
14
+ package: "typescript",
14
15
  },
16
+ experimental_backends: [
17
+ {
18
+ family: "tsrs",
19
+ package: "tsrs",
20
+ version: "0.18.0-ts7.1.0-dev.20260929",
21
+ },
22
+ ],
15
23
  sidecar: {
16
24
  package: "fallow-type-aware",
17
25
  version_source: "workspace-package",
@@ -26,4 +34,8 @@ export const QUERY_OPERATIONS = Object.freeze(TYPE_AWARE_PROTOCOL.query_operatio
26
34
  export const SESSION_ENVELOPE_TYPES = Object.freeze(TYPE_AWARE_PROTOCOL.session_envelope_types);
27
35
  export const BACKEND_FAMILY = TYPE_AWARE_PROTOCOL.backend.family;
28
36
  export const BACKEND_VERSION = TYPE_AWARE_PROTOCOL.backend.version;
37
+ export const BACKENDS = Object.freeze([
38
+ TYPE_AWARE_PROTOCOL.backend,
39
+ ...TYPE_AWARE_PROTOCOL.experimental_backends,
40
+ ]);
29
41
  export const SIDECAR_PACKAGE = TYPE_AWARE_PROTOCOL.sidecar.package;
@@ -10,10 +10,18 @@ import { createHash } from "node:crypto";
10
10
  import { readFileSync } from "node:fs";
11
11
  import path from "node:path";
12
12
 
13
- import { API, DiagnosticCategory } from "typescript/unstable/sync";
13
+ import {
14
+ READ_FROM_DISK,
15
+ createApi as createBackendApi,
16
+ isInferredProject,
17
+ projectCompilerOptions,
18
+ sync,
19
+ } from "./backend.mjs";
14
20
 
15
21
  import { canonicalFileIdentity } from "./file-identity.mjs";
16
22
 
23
+ const { DiagnosticCategory } = sync;
24
+
17
25
  const JAVASCRIPT_EXTENSIONS = new Set([".js", ".jsx", ".mjs", ".cjs"]);
18
26
  const LEADING_COMMENTS = /^(?:\s|\/\/[^\n]*|\/\*[\s\S]*?\*\/)*/u;
19
27
  const NO_CHECK_PRAGMA = /@ts-nocheck\b/u;
@@ -21,15 +29,12 @@ const MAX_CODES = 8;
21
29
 
22
30
  const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
23
31
 
24
- const isInferredProject = (project) =>
25
- project.configFileName.split(path.sep).join("/").endsWith("/dev/null/inferred");
26
-
27
32
  const hasNoCheckPragma = (text) => NO_CHECK_PRAGMA.test(LEADING_COMMENTS.exec(text)?.[0] ?? "");
28
33
 
29
34
  const isChecked = (project, absolutePath, text) => {
30
35
  if (hasNoCheckPragma(text)) return false;
31
36
  if (!JAVASCRIPT_EXTENSIONS.has(path.extname(absolutePath).toLowerCase())) return true;
32
- return project.compilerOptions?.checkJs === true;
37
+ return projectCompilerOptions(project).checkJs === true;
33
38
  };
34
39
 
35
40
  const errorKey = (diagnostic) => `${diagnostic.code}\u0000${diagnostic.text}`;
@@ -99,7 +104,8 @@ const createOverlayApi = (root, createApi) => {
99
104
  cwd: root,
100
105
  fs: {
101
106
  readFile: (fileName) =>
102
- overlay.size === 0 ? undefined : overlay.get(canonicalFileIdentity(fileName)),
107
+ (overlay.size === 0 ? undefined : overlay.get(canonicalFileIdentity(fileName))) ??
108
+ READ_FROM_DISK,
103
109
  },
104
110
  });
105
111
  return { api, overlay };
@@ -163,10 +169,7 @@ const checkFile = (context, file) => {
163
169
  };
164
170
 
165
171
  /** Type-checks each mutant of a parsed `mutant-typecheck` request. */
166
- export const checkMutants = (
167
- { root, files },
168
- { createApi = (options) => new API(options) } = {},
169
- ) => {
172
+ export const checkMutants = ({ root, files }, { createApi = createBackendApi } = {}) => {
170
173
  if (files.length === 0) return [];
171
174
  const context = createOverlayApi(root, createApi);
172
175
  try {