fallow-type-aware 0.0.0-bootstrap.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Bart Waardenburg
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,48 @@
1
+ # fallow-type-aware
2
+
3
+ Optional TypeScript-Go semantic refinement sidecar for Fallow. It accepts one
4
+ versioned JSON request on stdin and writes one JSON response to stdout.
5
+
6
+ The sidecar is deliberately narrower than a general type-aware linter. Protocol
7
+ v6 accepts a bounded set of tagged `symbol-use`, `symbol-trace`, `api-surface`,
8
+ `symbol-impact`, and `type-coupling` queries. Each selected TypeScript project
9
+ creates one Program. Symbol use, trace, and impact queries share one indexed
10
+ source traversal per Program.
11
+
12
+ Symbol identities include the canonical project-relative path, value or type
13
+ namespace, declaration kind, exported and local name, one-based line,
14
+ zero-based UTF-8 byte column, and optional owner. Results keep their semantic
15
+ assertion separate from `complete`, `partial`, or `unavailable` status. Evidence
16
+ and every operation-specific array are deterministic and bounded, with totals,
17
+ omissions, reason codes, actions, and truncation reported explicitly.
18
+
19
+ Unsafe project state never manufactures certainty. Structural diagnostics,
20
+ unknown identities, missing projects, unsupported syntax, dynamic behavior,
21
+ and capacity limits retain syntactic findings or produce an explicit advisory
22
+ gap. Required interfaces, abstract members, and overrides are returned as exact
23
+ contract evidence. Complete negative evidence carries an exact UTF-8 byte span
24
+ and SHA-256 declaration guard, but Fallow owns the final decision and fix
25
+ policy. The sidecar does not emit TypeScript compiler diagnostics as Fallow
26
+ findings and does not implement generic typed lint rules.
27
+
28
+ ## Run locally
29
+
30
+ ```sh
31
+ npm ci
32
+ npm test
33
+ ./fallow-type-aware.mjs < request.json
34
+ ```
35
+
36
+ The implementation pins `typescript@7.0.2` because `typescript/unstable/sync`
37
+ is an explicitly unstable API. That backend detail is contained behind
38
+ Fallow's stable, exact-version protocol. Package version, protocol version, and
39
+ TypeScript backend version are validated independently. See
40
+ [`docs/type-aware-analysis.md`](../../docs/type-aware-analysis.md)
41
+ for the Fallow integration contract, safety policy, and current limitations.
42
+
43
+ ## Release
44
+
45
+ Publication remains a separate release action. The package is designed to be
46
+ installed as an exact-version optional companion and launched through a
47
+ verified absolute path supplied by Fallow. It does not search the analyzed
48
+ project or arbitrary PATH entries for a backend.
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { run } from "./src/cli.mjs";
4
+
5
+ try {
6
+ await run({ input: process.stdin, output: process.stdout, args: process.argv.slice(2) });
7
+ } catch (error) {
8
+ const message = error instanceof Error ? error.message : String(error);
9
+ process.stderr.write(`fallow-type-aware: ${message}\n`);
10
+ process.exitCode = 2;
11
+ }
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "fallow-type-aware",
3
+ "version": "0.0.0-bootstrap.0",
4
+ "description": "Optional TypeScript-Go semantic refinement sidecar for Fallow",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/fallow-rs/fallow.git",
9
+ "directory": "tools/type-aware-sidecar"
10
+ },
11
+ "files": [
12
+ "fallow-type-aware.mjs",
13
+ "src"
14
+ ],
15
+ "type": "module",
16
+ "bin": {
17
+ "fallow-type-aware": "fallow-type-aware.mjs"
18
+ },
19
+ "scripts": {
20
+ "bench": "node bench/session.mjs",
21
+ "test": "node --test"
22
+ },
23
+ "dependencies": {
24
+ "typescript": "7.0.2"
25
+ },
26
+ "engines": {
27
+ "node": ">=20"
28
+ },
29
+ "publishConfig": {
30
+ "access": "public",
31
+ "provenance": true
32
+ },
33
+ "devDependencies": {
34
+ "@codspeed/tinybench-plugin": "5.7.1",
35
+ "tinybench": "6.1.2"
36
+ }
37
+ }
package/src/cli.mjs ADDED
@@ -0,0 +1,167 @@
1
+ import path from "node:path";
2
+ import { createInterface } from "node:readline";
3
+
4
+ import {
5
+ ANALYSIS_OPERATION,
6
+ SESSION_ENVELOPE_TYPES,
7
+ STATUS_OPERATION,
8
+ WIRE_PROTOCOL_VERSION,
9
+ } from "./generated-protocol.mjs";
10
+ import { analyzeSemanticQueries, createSemanticSession } from "./semantic.mjs";
11
+ import { createSemanticResponse, createStatusResponse, parseRequest } from "./protocol.mjs";
12
+
13
+ const MAX_REQUEST_BYTES = 8 * 1024 * 1024;
14
+ const [ANALYZE_ENVELOPE, SHUTDOWN_ENVELOPE] = SESSION_ENVELOPE_TYPES;
15
+ const STATUS_FIELDS = [
16
+ ["protocol_version", WIRE_PROTOCOL_VERSION],
17
+ ["operation", STATUS_OPERATION],
18
+ ];
19
+
20
+ export const readAll = async (input, maximumBytes = MAX_REQUEST_BYTES) => {
21
+ const chunks = [];
22
+ let byteLength = 0;
23
+ for await (const chunk of input) {
24
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
25
+ byteLength += buffer.byteLength;
26
+ if (byteLength > maximumBytes) {
27
+ throw new Error(`stdin exceeded the ${maximumBytes} byte request limit`);
28
+ }
29
+ chunks.push(buffer);
30
+ }
31
+ return Buffer.concat(chunks).toString("utf8");
32
+ };
33
+
34
+ const writeJson = (output, value) => {
35
+ output.write(`${JSON.stringify(value)}\n`);
36
+ };
37
+
38
+ const handleArguments = (args, output) => {
39
+ if (args.length === 1 && args[0] === "--status") {
40
+ writeJson(output, createStatusResponse());
41
+ return true;
42
+ }
43
+ if (args.length === 1 && args[0] === "--session") {
44
+ return false;
45
+ }
46
+ if (args.length > 0) {
47
+ throw new Error(`unknown argument: ${args[0]}`);
48
+ }
49
+ return false;
50
+ };
51
+
52
+ const sessionFileChanges = (value, root) => {
53
+ if (value === undefined) return undefined;
54
+ if (value?.invalidate_all === true && Object.keys(value).length === 1) {
55
+ return { invalidateAll: true };
56
+ }
57
+ const allowed = new Set(["changed", "created", "deleted"]);
58
+ if (
59
+ typeof value !== "object" ||
60
+ value === null ||
61
+ Array.isArray(value) ||
62
+ Object.keys(value).some((key) => !allowed.has(key))
63
+ ) {
64
+ throw new Error("session file_changes contains an unsupported field");
65
+ }
66
+ const resolveFiles = (files, field) => {
67
+ if (files === undefined) return undefined;
68
+ if (!Array.isArray(files)) throw new Error(`session file_changes.${field} must be an array`);
69
+ return files.map((file) => {
70
+ if (typeof file !== "string" || file.length === 0 || path.isAbsolute(file)) {
71
+ throw new Error(`session file_changes.${field} must contain project-relative paths`);
72
+ }
73
+ const resolved = path.resolve(root, file);
74
+ const relative = path.relative(root, resolved);
75
+ if (relative === ".." || relative.startsWith(`..${path.sep}`)) {
76
+ throw new Error(`session file_changes.${field} must stay within the session root`);
77
+ }
78
+ return resolved;
79
+ });
80
+ };
81
+ return {
82
+ ...(value.changed ? { changed: resolveFiles(value.changed, "changed") } : {}),
83
+ ...(value.created ? { created: resolveFiles(value.created, "created") } : {}),
84
+ ...(value.deleted ? { deleted: resolveFiles(value.deleted, "deleted") } : {}),
85
+ };
86
+ };
87
+
88
+ const runSession = async (input, output) => {
89
+ const lines = createInterface({ input, crlfDelay: Infinity });
90
+ let session;
91
+ let root;
92
+ try {
93
+ for await (const line of lines) {
94
+ if (Buffer.byteLength(line, "utf8") > MAX_REQUEST_BYTES) {
95
+ throw new Error(`session request exceeded the ${MAX_REQUEST_BYTES} byte limit`);
96
+ }
97
+ const envelope = parseJsonRequest(line);
98
+ if (envelope?.type === SHUTDOWN_ENVELOPE) return;
99
+ if (
100
+ envelope?.type !== ANALYZE_ENVELOPE ||
101
+ !Number.isSafeInteger(envelope.request_id) ||
102
+ envelope.request_id < 0 ||
103
+ !Number.isSafeInteger(envelope.revision) ||
104
+ envelope.revision < 1
105
+ ) {
106
+ throw new Error("invalid semantic session envelope");
107
+ }
108
+ const request = parseRequest(envelope.request);
109
+ if (!session) {
110
+ root = request.root;
111
+ session = createSemanticSession(root);
112
+ }
113
+ if (request.root !== root) throw new Error("semantic session root mismatch");
114
+ const startedAt = performance.now();
115
+ const result = session.analyze(request, {
116
+ revision: envelope.revision,
117
+ fileChanges: sessionFileChanges(envelope.file_changes, root),
118
+ });
119
+ writeJson(output, {
120
+ request_id: envelope.request_id,
121
+ revision: envelope.revision,
122
+ response: responseFor(request, result, performance.now() - startedAt),
123
+ });
124
+ }
125
+ } finally {
126
+ session?.close();
127
+ }
128
+ };
129
+
130
+ const parseJsonRequest = (source) => {
131
+ try {
132
+ return JSON.parse(source);
133
+ } catch {
134
+ throw new Error("stdin must contain one valid JSON request");
135
+ }
136
+ };
137
+
138
+ const isStatusRequest = (request) =>
139
+ Object.keys(request ?? {}).length === STATUS_FIELDS.length &&
140
+ STATUS_FIELDS.every(([name, value]) => request[name] === value);
141
+
142
+ const responseFor = (request, result, elapsedMs) => {
143
+ if (request.protocolVersion !== WIRE_PROTOCOL_VERSION) {
144
+ throw new Error(`unsupported protocol_version ${String(request.protocolVersion)}`);
145
+ }
146
+ return createSemanticResponse({ ...result, elapsedMs });
147
+ };
148
+
149
+ export const run = async ({ input, output, args = [] }) => {
150
+ if (args.length === 1 && args[0] === "--session") {
151
+ await runSession(input, output);
152
+ return;
153
+ }
154
+ if (handleArguments(args, output)) return;
155
+ const startedAt = performance.now();
156
+ const rawRequest = parseJsonRequest(await readAll(input));
157
+ if (isStatusRequest(rawRequest)) {
158
+ writeJson(output, createStatusResponse());
159
+ return;
160
+ }
161
+ const request = parseRequest(rawRequest);
162
+ if (rawRequest.operation !== ANALYSIS_OPERATION) {
163
+ throw new Error(`unsupported operation ${String(rawRequest.operation)}`);
164
+ }
165
+ const result = analyzeSemanticQueries(request);
166
+ writeJson(output, responseFor(request, result, performance.now() - startedAt));
167
+ };
@@ -0,0 +1,11 @@
1
+ import { realpathSync } from "node:fs";
2
+ import path from "node:path";
3
+
4
+ export const canonicalFileIdentity = (fileName) => {
5
+ const absolutePath = path.normalize(path.resolve(fileName));
6
+ try {
7
+ return path.normalize(realpathSync.native(absolutePath));
8
+ } catch {
9
+ return absolutePath;
10
+ }
11
+ };
@@ -0,0 +1,27 @@
1
+ // Generated from crates/api/type-aware-protocol.json. Do not edit.
2
+ export const TYPE_AWARE_PROTOCOL = Object.freeze({
3
+ schema_version: 1,
4
+ wire_protocol_version: 6,
5
+ semantic_schema_version: 2,
6
+ analysis_operation: "semantic-queries",
7
+ status_operation: "status",
8
+ query_operations: ["symbol-use", "symbol-trace", "api-surface", "symbol-impact", "type-coupling"],
9
+ session_envelope_types: ["analyze", "shutdown"],
10
+ backend: {
11
+ family: "typescript-go",
12
+ version: "7.0.2",
13
+ },
14
+ sidecar: {
15
+ package: "fallow-type-aware",
16
+ version_source: "workspace-package",
17
+ },
18
+ });
19
+ export const WIRE_PROTOCOL_VERSION = TYPE_AWARE_PROTOCOL.wire_protocol_version;
20
+ export const SEMANTIC_SCHEMA_VERSION = TYPE_AWARE_PROTOCOL.semantic_schema_version;
21
+ export const ANALYSIS_OPERATION = TYPE_AWARE_PROTOCOL.analysis_operation;
22
+ export const STATUS_OPERATION = TYPE_AWARE_PROTOCOL.status_operation;
23
+ export const QUERY_OPERATIONS = Object.freeze(TYPE_AWARE_PROTOCOL.query_operations);
24
+ export const SESSION_ENVELOPE_TYPES = Object.freeze(TYPE_AWARE_PROTOCOL.session_envelope_types);
25
+ export const BACKEND_FAMILY = TYPE_AWARE_PROTOCOL.backend.family;
26
+ export const BACKEND_VERSION = TYPE_AWARE_PROTOCOL.backend.version;
27
+ export const SIDECAR_PACKAGE = TYPE_AWARE_PROTOCOL.sidecar.package;
@@ -0,0 +1,114 @@
1
+ //! Deterministic graph algorithms shared by semantic capabilities.
2
+
3
+ const compareText = (left, right) => Buffer.compare(Buffer.from(left), Buffer.from(right));
4
+
5
+ const edgeAdjacency = (edges) => {
6
+ const adjacency = new Map();
7
+ for (const edge of edges) {
8
+ const source = edge.source.path;
9
+ const target = edge.target.path;
10
+ const targets = adjacency.get(source) ?? new Set();
11
+ targets.add(target);
12
+ adjacency.set(source, targets);
13
+ }
14
+ return adjacency;
15
+ };
16
+
17
+ const graphNodes = (adjacency) =>
18
+ [
19
+ ...new Set([
20
+ ...adjacency.keys(),
21
+ ...[...adjacency.values()].flatMap((targets) => [...targets]),
22
+ ]),
23
+ ].toSorted(compareText);
24
+
25
+ const finishingOrder = (adjacency) => {
26
+ const visited = new Set();
27
+ const finished = [];
28
+ for (const root of graphNodes(adjacency)) {
29
+ if (visited.has(root)) continue;
30
+ visited.add(root);
31
+ const stack = [
32
+ { node: root, targets: [...(adjacency.get(root) ?? [])].toSorted(compareText), i: 0 },
33
+ ];
34
+ while (stack.length > 0) {
35
+ const frame = stack.at(-1);
36
+ if (frame.i >= frame.targets.length) {
37
+ finished.push(frame.node);
38
+ stack.pop();
39
+ continue;
40
+ }
41
+ const target = frame.targets[frame.i];
42
+ frame.i += 1;
43
+ if (visited.has(target)) continue;
44
+ visited.add(target);
45
+ stack.push({
46
+ node: target,
47
+ targets: [...(adjacency.get(target) ?? [])].toSorted(compareText),
48
+ i: 0,
49
+ });
50
+ }
51
+ }
52
+ return finished;
53
+ };
54
+
55
+ const reverseAdjacency = (adjacency) => {
56
+ const reversed = new Map(graphNodes(adjacency).map((node) => [node, new Set()]));
57
+ for (const [source, targets] of adjacency) {
58
+ targets.forEach((target) => reversed.get(target).add(source));
59
+ }
60
+ return reversed;
61
+ };
62
+
63
+ const stronglyConnectedComponents = (adjacency) => {
64
+ const reversed = reverseAdjacency(adjacency);
65
+ const visited = new Set();
66
+ const components = [];
67
+ for (const root of finishingOrder(adjacency).toReversed()) {
68
+ if (visited.has(root)) continue;
69
+ const component = [];
70
+ const pending = [root];
71
+ visited.add(root);
72
+ while (pending.length > 0) {
73
+ const node = pending.pop();
74
+ component.push(node);
75
+ for (const target of [...(reversed.get(node) ?? [])].toSorted(compareText).toReversed()) {
76
+ if (visited.has(target)) continue;
77
+ visited.add(target);
78
+ pending.push(target);
79
+ }
80
+ }
81
+ components.push(component.toSorted(compareText));
82
+ }
83
+ return components;
84
+ };
85
+
86
+ const componentCycle = (adjacency, component) => {
87
+ const allowed = new Set(component);
88
+ const start = component[0];
89
+ const queue = [...(adjacency.get(start) ?? [])]
90
+ .filter((target) => allowed.has(target))
91
+ .toSorted(compareText)
92
+ .map((target) => [start, target]);
93
+ const visited = new Set(queue.map((route) => route.at(-1)));
94
+ while (queue.length > 0) {
95
+ const current = queue.shift();
96
+ const node = current.at(-1);
97
+ for (const target of [...(adjacency.get(node) ?? [])].toSorted(compareText)) {
98
+ if (target === start) return [...current, start];
99
+ if (!allowed.has(target) || visited.has(target)) continue;
100
+ visited.add(target);
101
+ queue.push([...current, target]);
102
+ }
103
+ }
104
+ return [];
105
+ };
106
+
107
+ export const findCycles = (edges) => {
108
+ const adjacency = edgeAdjacency(edges);
109
+ return stronglyConnectedComponents(adjacency)
110
+ .filter((component) => component.length > 1)
111
+ .map((component) => componentCycle(adjacency, component))
112
+ .filter((cycle) => cycle.length > 0)
113
+ .toSorted((left, right) => compareText(left.join("\0"), right.join("\0")));
114
+ };
@@ -0,0 +1,194 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import path from "node:path";
4
+
5
+ import { canonicalFileIdentity } from "./file-identity.mjs";
6
+ import { relativePath } from "./semantic-identity.mjs";
7
+
8
+ const INFERRED_PROJECT = "<inferred>";
9
+ const compareText = (left, right) => Buffer.compare(Buffer.from(left), Buffer.from(right));
10
+ const slash = (value) => value.split(path.sep).join("/");
11
+
12
+ const blockingDiagnosticCount = (project) =>
13
+ project.program.getConfigFileParsingDiagnostics().length +
14
+ project.program.getProgramDiagnostics().length +
15
+ project.program.getSyntacticDiagnostics().length +
16
+ project.program.getBindDiagnostics().length;
17
+
18
+ const configPath = (root, project) => {
19
+ const normalized = slash(project.configFileName);
20
+ if (normalized.endsWith("/dev/null/inferred")) return INFERRED_PROJECT;
21
+ return relativePath(root, project.configFileName) || path.basename(project.configFileName);
22
+ };
23
+
24
+ const normalizedConfigValue = (root, value) => {
25
+ if (Array.isArray(value)) return value.map((item) => normalizedConfigValue(root, item));
26
+ if (value && typeof value === "object") {
27
+ return Object.fromEntries(
28
+ Object.entries(value)
29
+ .filter(([, item]) => item !== undefined)
30
+ .toSorted(([left], [right]) => compareText(left, right))
31
+ .map(([key, item]) => [key, normalizedConfigValue(root, item)]),
32
+ );
33
+ }
34
+ if (typeof value !== "string" || !path.isAbsolute(value)) return value;
35
+ const relative = path.relative(root, value);
36
+ return relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)
37
+ ? slash(value)
38
+ : slash(relative || ".");
39
+ };
40
+
41
+ const stripJsonComments = (text) => {
42
+ let result = "";
43
+ let inString = false;
44
+ let escaped = false;
45
+ for (let index = 0; index < text.length; index += 1) {
46
+ const current = text[index];
47
+ const next = text[index + 1];
48
+ if (inString) {
49
+ result += current;
50
+ if (escaped) escaped = false;
51
+ else if (current === "\\") escaped = true;
52
+ else if (current === '"') inString = false;
53
+ continue;
54
+ }
55
+ if (current === '"') {
56
+ inString = true;
57
+ result += current;
58
+ continue;
59
+ }
60
+ if (current === "/" && next === "/") {
61
+ result += " ";
62
+ index += 2;
63
+ while (index < text.length && text[index] !== "\n") {
64
+ result += " ";
65
+ index += 1;
66
+ }
67
+ if (index < text.length) result += "\n";
68
+ continue;
69
+ }
70
+ if (current === "/" && next === "*") {
71
+ result += " ";
72
+ index += 2;
73
+ while (index < text.length && !(text[index] === "*" && text[index + 1] === "/")) {
74
+ result += text[index] === "\n" ? "\n" : " ";
75
+ index += 1;
76
+ }
77
+ if (index < text.length) {
78
+ result += " ";
79
+ index += 1;
80
+ }
81
+ continue;
82
+ }
83
+ result += current;
84
+ }
85
+ return result;
86
+ };
87
+
88
+ const stripTrailingCommas = (text) => {
89
+ let result = "";
90
+ let inString = false;
91
+ let escaped = false;
92
+ for (let index = 0; index < text.length; index += 1) {
93
+ const current = text[index];
94
+ if (inString) {
95
+ result += current;
96
+ if (escaped) escaped = false;
97
+ else if (current === "\\") escaped = true;
98
+ else if (current === '"') inString = false;
99
+ continue;
100
+ }
101
+ if (current === '"') {
102
+ inString = true;
103
+ result += current;
104
+ continue;
105
+ }
106
+ if (current === ",") {
107
+ let lookahead = index + 1;
108
+ while (/\s/u.test(text[lookahead] ?? "")) lookahead += 1;
109
+ if (text[lookahead] === "}" || text[lookahead] === "]") continue;
110
+ }
111
+ result += current;
112
+ }
113
+ return result;
114
+ };
115
+
116
+ const readConfigDocument = (fileName) => {
117
+ try {
118
+ const text = readFileSync(fileName, "utf8");
119
+ return JSON.parse(stripTrailingCommas(stripJsonComments(text)));
120
+ } catch {
121
+ return undefined;
122
+ }
123
+ };
124
+
125
+ const resolveConfigDocument = (configFileName, specifier) => {
126
+ if (typeof specifier !== "string") return undefined;
127
+ if (!path.isAbsolute(specifier) && !specifier.startsWith(".")) return undefined;
128
+ const target = path.resolve(path.dirname(configFileName), specifier);
129
+ const candidates = path.extname(target)
130
+ ? [target]
131
+ : [path.join(target, "tsconfig.json"), `${target}.json`, target];
132
+ return candidates.find((candidate) => existsSync(candidate) && readConfigDocument(candidate));
133
+ };
134
+
135
+ const configDocumentClosure = (root, configFileName, seen = new Set()) => {
136
+ const document = readConfigDocument(configFileName);
137
+ if (!document) return null;
138
+ const canonical = canonicalFileIdentity(configFileName);
139
+ if (seen.has(canonical)) {
140
+ return { config: relativePath(root, canonical), cycle: true };
141
+ }
142
+ const nextSeen = new Set(seen).add(canonical);
143
+ const extensions = Array.isArray(document.extends) ? document.extends : [document.extends];
144
+ const referenced = Array.isArray(document.references) ? document.references : [];
145
+ const closure = (specifiers) =>
146
+ specifiers
147
+ .map((specifier) => resolveConfigDocument(configFileName, specifier))
148
+ .filter(Boolean)
149
+ .map((resolved) => configDocumentClosure(root, resolved, nextSeen))
150
+ .filter(Boolean)
151
+ .toSorted((left, right) => compareText(left.config, right.config));
152
+ return {
153
+ config: relativePath(root, canonical),
154
+ document: normalizedConfigValue(root, document),
155
+ extends: closure(extensions),
156
+ references: closure(referenced.map((reference) => reference?.path)),
157
+ };
158
+ };
159
+
160
+ const effectiveProjectConfigHash = (root, project) => {
161
+ const effective = {
162
+ config: configPath(root, project),
163
+ compiler_options: normalizedConfigValue(root, project.compilerOptions),
164
+ root_files: project.rootFiles
165
+ .map((fileName) => normalizedConfigValue(root, fileName))
166
+ .toSorted(compareText),
167
+ config_document: configDocumentClosure(root, project.configFileName),
168
+ };
169
+ return `sha256:${createHash("sha256").update(JSON.stringify(effective)).digest("hex")}`;
170
+ };
171
+
172
+ export const projectState = (root, project, source) => {
173
+ const diagnosticCount = blockingDiagnosticCount(project);
174
+ return {
175
+ project,
176
+ config: configPath(root, project),
177
+ effective_config_hash: effectiveProjectConfigHash(root, project),
178
+ source,
179
+ status: diagnosticCount === 0 ? "complete" : "unavailable",
180
+ reason_code: diagnosticCount === 0 ? null : "blocking-diagnostics",
181
+ blocking_diagnostic_count: diagnosticCount,
182
+ source_file_count: project.program.getSourceFileNames().length,
183
+ program_reused: false,
184
+ candidate_count: 0,
185
+ confirmed_used_count: 0,
186
+ contract_preserved_count: 0,
187
+ no_static_references_count: 0,
188
+ fix_eligible_count: 0,
189
+ unresolved_count: 0,
190
+ abstained_count: 0,
191
+ };
192
+ };
193
+
194
+ export const projectResult = ({ project: _project, ...state }) => state;