open-context-engine 0.1.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 (38) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +177 -0
  3. package/assets/brand/logo-lockup-dark.svg +14 -0
  4. package/assets/brand/logo-lockup.svg +14 -0
  5. package/assets/brand/logo.svg +9 -0
  6. package/bin/opencontextengine.mjs +64 -0
  7. package/docs/QUICKSTART.md +192 -0
  8. package/docs/RERANKER_API.md +32 -0
  9. package/package.json +81 -0
  10. package/requirements.txt +2 -0
  11. package/scripts/mcp-opencontextengine.mjs +37 -0
  12. package/scripts/retrieval-server.py +135 -0
  13. package/src/client.mjs +37 -0
  14. package/src/config.mjs +49 -0
  15. package/src/environment.mjs +10 -0
  16. package/src/eval/remote-models.mjs +70 -0
  17. package/src/mcp.mjs +58 -0
  18. package/src/retrieval/batched.py +220 -0
  19. package/src/retrieval/cascade.py +142 -0
  20. package/src/retrieval/engine.py +195 -0
  21. package/src/retrieval/entities.py +187 -0
  22. package/src/retrieval/languages/__init__.py +129 -0
  23. package/src/retrieval/languages/files.py +90 -0
  24. package/src/retrieval/languages/go.py +154 -0
  25. package/src/retrieval/languages/go_ast.go +204 -0
  26. package/src/retrieval/languages/go_types.go +169 -0
  27. package/src/retrieval/languages/python.py +113 -0
  28. package/src/retrieval/languages/schema.py +81 -0
  29. package/src/retrieval/languages/text.py +39 -0
  30. package/src/retrieval/languages/typescript.mjs +233 -0
  31. package/src/retrieval/languages/typescript.py +23 -0
  32. package/src/retrieval/live.py +273 -0
  33. package/src/retrieval/reranker.py +83 -0
  34. package/src/retrieval/routed.py +35 -0
  35. package/src/runtime.mjs +60 -0
  36. package/src/service.mjs +77 -0
  37. package/src/setup.mjs +66 -0
  38. package/src/workspaces.mjs +59 -0
@@ -0,0 +1,113 @@
1
+ """Python AST adapter; preserves v1 spans and edges for the frozen baseline.
2
+
3
+ Name-resolved calls/inheritance remain heuristic (shadowing/dynamic dispatch are
4
+ not fully resolved); confidence is a provenance category, not a probability.
5
+ """
6
+ import ast
7
+ from collections import defaultdict
8
+
9
+ def extract(sources, max_lines=65, options=None):
10
+ if options:
11
+ raise ValueError("Python adapter does not accept language options")
12
+ units, trees, aliases, class_bases = [], {}, {}, {}
13
+ for source in sources:
14
+ path, text = source.path, source.text
15
+ lines = text.splitlines()
16
+ module = path.removesuffix('.py').replace('/', '.')
17
+ if module.endswith('.__init__'):
18
+ module = module[:-9]
19
+ tree = ast.parse(text, filename=path)
20
+ trees[module] = tree
21
+ imports = {}
22
+ for n in tree.body:
23
+ if isinstance(n, ast.Import):
24
+ for item in n.names:
25
+ imports[item.asname or item.name.split('.')[0]] = item.name if item.asname else item.name.split('.')[0]
26
+ elif isinstance(n, ast.ImportFrom):
27
+ prefix = n.module or ''
28
+ if n.level:
29
+ package = module if path.endswith('/__init__.py') else module.rsplit('.', 1)[0]
30
+ parts = package.split('.')
31
+ prefix = '.'.join(parts[:len(parts) - n.level + 1] + ([prefix] if prefix else []))
32
+ for item in n.names:
33
+ imports[item.asname or item.name] = prefix + '.' + item.name
34
+ aliases[module] = imports
35
+
36
+ def add(start, end, name, kind, node=None, owner=None):
37
+ if start > end:
38
+ return
39
+ # Prefer statement boundaries; oversized statements are split by line.
40
+ boundaries = sorted({n.lineno for n in ast.walk(node) if isinstance(n, ast.stmt)
41
+ and start < n.lineno <= end}) if node else []
42
+ while start <= end:
43
+ stop = min(end, start + max_lines - 1)
44
+ choices = [b - 1 for b in boundaries if start + max_lines // 2 <= b <= stop + 1]
45
+ if stop < end and choices:
46
+ stop = max(choices)
47
+ snippet = '\n'.join(lines[start - 1:stop])
48
+ if snippet.strip():
49
+ units.append({'id': len(units), 'path': path, 'module': module, 'name': name,
50
+ 'symbol': module + ('.' + name if name else ''), 'kind': kind, 'owner': owner,
51
+ 'start': start, 'end': stop, 'text': snippet,
52
+ 'calls': [ast.unparse(n.func) for n in ast.walk(node)
53
+ if isinstance(n, ast.Call) and start <= n.lineno <= stop] if node else [],
54
+ 'edges': []})
55
+ start = stop + 1
56
+
57
+ def definitions(body, prefix='', low=1, high=None, owner=None):
58
+ cursor = low
59
+ for n in body:
60
+ if not isinstance(n, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)):
61
+ continue
62
+ start = min([n.lineno] + [d.lineno for d in n.decorator_list])
63
+ add(cursor, start - 1, prefix, 'class-body' if prefix else 'module', owner=owner)
64
+ name = '.'.join(x for x in [prefix, n.name] if x)
65
+ if isinstance(n, ast.ClassDef):
66
+ class_bases[module + '.' + name] = [ast.unparse(b) for b in n.bases]
67
+ # Keep class header, attributes and docstring as their own spans.
68
+ definitions(n.body, name, start, n.end_lineno, module + '.' + name)
69
+ else:
70
+ add(start, n.end_lineno, name, 'function', n, owner)
71
+ cursor = n.end_lineno + 1
72
+ add(cursor, high if high is not None else len(lines), prefix,
73
+ 'class-body' if prefix else 'module', owner=owner)
74
+
75
+ definitions(tree.body)
76
+
77
+ symbols = defaultdict(list)
78
+ for u in units:
79
+ symbols[u['symbol']].append(u['id'])
80
+ for u in units:
81
+ targets, relations = [], []
82
+
83
+ def relate(ids, kind, confidence, resolution):
84
+ targets.extend(ids)
85
+ relations.extend({'target': target, 'kind': kind, 'confidence': confidence,
86
+ 'resolution': resolution} for target in ids if target != u['id'])
87
+ if u['owner']:
88
+ relate(symbols.get(u['owner'], []), 'member_of', 1.0, 'syntax')
89
+ for call in u['calls']:
90
+ pieces = call.split('.')
91
+ first = pieces[0]
92
+ target = None
93
+ if first in ('self', 'cls') and u['owner']:
94
+ target = u['owner'] + '.' + '.'.join(pieces[1:])
95
+ elif first in aliases[u['module']]:
96
+ target = aliases[u['module']][first] + ('.' + '.'.join(pieces[1:]) if len(pieces) > 1 else '')
97
+ elif len(pieces) == 1:
98
+ target = u['module'] + '.' + call
99
+ if target:
100
+ relate(symbols.get(target, []), 'calls', .7, 'static-name')
101
+ for base in class_bases.get(u['owner'] or u['symbol'], []):
102
+ pieces = base.split('.')
103
+ target = aliases[u['module']].get(pieces[0], u['module'] + '.' + pieces[0])
104
+ if len(pieces) > 1:
105
+ target += '.' + '.'.join(pieces[1:])
106
+ relate(symbols.get(target, []), 'inherits', .7, 'static-name')
107
+ # A split function's parts form one symbol, preserving continuation links.
108
+ relate(symbols[u['symbol']], 'same_symbol', 1.0, 'syntax')
109
+ u['edges'] = sorted(set(targets) - {u['id']})
110
+ u.update(language='python', scope=u['owner'] or u['module'],
111
+ relations=sorted({(r['target'], r['kind'], r['confidence'], r['resolution']): r
112
+ for r in relations}.values(), key=lambda r: (r['target'], r['kind'])))
113
+ return units
@@ -0,0 +1,81 @@
1
+ """Language-neutral source units. Relations describe static evidence, not runtime proof."""
2
+ from dataclasses import dataclass
3
+ import math
4
+ from typing import TypedDict
5
+
6
+ SCHEMA_VERSION = 'source-units-v2'
7
+ RELATION_KINDS = frozenset({'calls', 'member_of', 'inherits', 'implements',
8
+ 'same_symbol', 'imports', 'references_type'})
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class SourceFile:
13
+ path: str
14
+ text: str
15
+ sha256: str
16
+
17
+
18
+ def physical_lines(text):
19
+ """Match editor line numbers: CRLF/CR/LF, not Unicode paragraph separators."""
20
+ lines = text.replace('\r\n', '\n').replace('\r', '\n').split('\n')
21
+ return lines[:-1] if lines and not lines[-1] else lines
22
+
23
+
24
+ class Relation(TypedDict):
25
+ target: int
26
+ kind: str
27
+ confidence: float
28
+ resolution: str
29
+
30
+
31
+ class CodeUnit(TypedDict):
32
+ id: int
33
+ language: str
34
+ path: str
35
+ module: str
36
+ name: str
37
+ symbol: str
38
+ kind: str
39
+ scope: str
40
+ owner: str | None
41
+ start: int
42
+ end: int
43
+ text: str
44
+ relations: list[Relation]
45
+ # Compatibility projection used by the unchanged retrieval algorithms.
46
+ edges: list[int]
47
+
48
+
49
+ def validate_units(units, sources):
50
+ """Require lossless source coordinates, non-overlapping spans and valid graph IDs."""
51
+ text_paths = {unit['path'] for unit in units if unit['language'] in {'text', 'javascript', 'typescript', 'go'}}
52
+ lines = {source.path: (physical_lines(source.text) if source.path in text_paths
53
+ else source.text.splitlines()) for source in sources}
54
+ covered = {path: set() for path in lines}
55
+ for expected, unit in enumerate(units):
56
+ if unit['id'] != expected or unit['path'] not in lines:
57
+ raise ValueError('Invalid unit ID or source path')
58
+ source = lines[unit['path']]
59
+ start, end = unit['start'], unit['end']
60
+ if not 1 <= start <= end <= len(source):
61
+ raise ValueError('Invalid source span')
62
+ if unit['text'] != '\n'.join(source[start-1:end]):
63
+ raise ValueError('Source text does not match its span')
64
+ span = set(range(start, end+1))
65
+ if covered[unit['path']] & span:
66
+ raise ValueError('Overlapping source spans')
67
+ covered[unit['path']].update(span)
68
+ if not all(isinstance(unit[key], str) and unit[key] for key in ['language', 'module', 'symbol', 'kind', 'scope']):
69
+ raise ValueError('Missing unit identity')
70
+ for relation in unit['relations']:
71
+ target, confidence = relation['target'], relation['confidence']
72
+ if (type(target) is not int or not 0 <= target < len(units) or target == expected
73
+ or relation['kind'] not in RELATION_KINDS or not math.isfinite(confidence)
74
+ or not 0 <= confidence <= 1 or not relation['resolution']):
75
+ raise ValueError('Invalid static relation')
76
+ if unit['edges'] != sorted({relation['target'] for relation in unit['relations']}):
77
+ raise ValueError('Edges must be the exact projection of typed relations')
78
+ for path, source in lines.items():
79
+ missing = [i for i, line in enumerate(source, 1) if line.strip() and i not in covered[path]]
80
+ if missing:
81
+ raise ValueError(f'Uncovered source lines: {path}: {missing[:5]}')
@@ -0,0 +1,39 @@
1
+ """Lossless line chunks for text without a structural adapter; no inferred edges."""
2
+ from pathlib import PurePosixPath
3
+ from .schema import physical_lines
4
+
5
+ VERSION = 'line-chunks-v1'
6
+ MAX_CHARS = 1800
7
+
8
+
9
+ def extract(sources, max_lines=65, options=None):
10
+ if options:
11
+ raise ValueError('Text adapter does not accept language options')
12
+ units = []
13
+ for source in sources:
14
+ lines = physical_lines(source.text)
15
+ start = 0
16
+ while start < len(lines):
17
+ end, size = start, 0
18
+ while end < len(lines) and end-start < max_lines:
19
+ cost = len(lines[end])+1
20
+ # Keep an oversized line intact so its source coordinates remain true.
21
+ if end > start and size+cost > MAX_CHARS:
22
+ break
23
+ size += cost
24
+ end += 1
25
+ if end < len(lines):
26
+ boundaries = [i+1 for i in range(start+(end-start)//2, end)
27
+ if not lines[i].strip()]
28
+ if boundaries:
29
+ end = boundaries[-1]
30
+ snippet = '\n'.join(lines[start:end])
31
+ if snippet.strip():
32
+ units.append({'id': len(units), 'language': 'text', 'path': source.path,
33
+ 'module': source.path, 'name': PurePosixPath(source.path).name,
34
+ 'symbol': f'text:{source.path}:L{start+1}', 'kind': 'text',
35
+ 'scope': source.path, 'owner': None,
36
+ 'start': start+1, 'end': end, 'text': snippet,
37
+ 'relations': [], 'edges': []})
38
+ start = end
39
+ return units
@@ -0,0 +1,233 @@
1
+ /** Parse and bind only the frozen source set. Never emit, execute, or load repo plugins. */
2
+ import ts from 'typescript';
3
+ import { readFileSync } from 'node:fs';
4
+ import path from 'node:path';
5
+
6
+ const COMPILER_VERSION = '5.9.3';
7
+ if (ts.version !== COMPILER_VERSION) throw new Error(`Expected TypeScript ${COMPILER_VERSION}; run npm ci`);
8
+ const ROOT = '/reponerve-snapshot';
9
+ const absolute = name => path.posix.resolve(ROOT, name);
10
+ const slashLines = text => text.replace(/\r\n?/g, '\n').split('\n');
11
+
12
+ export function extract(files, maxLines = 65, settings = {}) {
13
+ if (!settings || typeof settings !== 'object' || Array.isArray(settings)
14
+ || Object.keys(settings).some(key => !['baseUrl', 'paths'].includes(key))) {
15
+ throw new Error('TypeScript options support only explicit baseUrl and paths');
16
+ }
17
+ if (settings.baseUrl !== undefined && typeof settings.baseUrl !== 'string') throw new Error('baseUrl must be text');
18
+ if (settings.paths !== undefined && (!settings.paths || typeof settings.paths !== 'object' || Array.isArray(settings.paths)
19
+ || Object.values(settings.paths).some(v => !Array.isArray(v) || !v.length || v.some(s => typeof s !== 'string')))) {
20
+ throw new Error('paths must map aliases to nonempty arrays of paths');
21
+ }
22
+ const baseUrl = absolute(settings.baseUrl ?? '.');
23
+ if (baseUrl !== ROOT && !baseUrl.startsWith(ROOT + '/')) throw new Error('baseUrl escapes snapshot');
24
+ const texts = new Map(files.map(file => [absolute(file.path), file.text]));
25
+ const directories = new Set([ROOT]);
26
+ for (const name of texts.keys()) {
27
+ for (let directory = path.posix.dirname(name); directory.startsWith(ROOT); directory = path.posix.dirname(directory)) {
28
+ directories.add(directory);
29
+ }
30
+ }
31
+ const options = { target: ts.ScriptTarget.ESNext, module: ts.ModuleKind.ESNext,
32
+ moduleResolution: ts.ModuleResolutionKind.Bundler, jsx: ts.JsxEmit.Preserve,
33
+ noEmit: true, noLib: true, skipLibCheck: true, allowImportingTsExtensions: true,
34
+ ...(files.some(f => /\.(?:jsx?|mjs|cjs)$/.test(f.path)) ? { allowJs: true, checkJs: true } : {}),
35
+ experimentalDecorators: true, baseUrl, paths: settings.paths };
36
+ const host = {
37
+ getSourceFile: (name, version) => texts.has(name) ? ts.createSourceFile(name, texts.get(name), version, true) : undefined,
38
+ getDefaultLibFileName: () => '', writeFile: () => { throw new Error('Emit is disabled'); },
39
+ getCurrentDirectory: () => ROOT, getDirectories: () => [],
40
+ fileExists: name => texts.has(name), readFile: name => texts.get(name),
41
+ directoryExists: name => directories.has(name), realpath: name => name,
42
+ getCanonicalFileName: name => name, useCaseSensitiveFileNames: () => true, getNewLine: () => '\n',
43
+ };
44
+ const program = ts.createProgram([...texts.keys()], options, host);
45
+ const errors = program.getSyntacticDiagnostics();
46
+ if (errors.length) {
47
+ throw new Error(errors.slice(0, 5).map(d => {
48
+ const line = d.file.getLineAndCharacterOfPosition(d.start ?? 0).line + 1;
49
+ return `${path.posix.relative(ROOT, d.file.fileName)}:${line}: ${ts.flattenDiagnosticMessageText(d.messageText, ' ')}`;
50
+ }).join('\n'));
51
+ }
52
+ const checker = program.getTypeChecker();
53
+ const units = [], records = new Map(), nodeEntries = new Map();
54
+ const executable = node => Boolean(node && ((ts.isFunctionLike(node) && node.body) || ts.isClassLike(node)));
55
+ const named = node => node.name?.getText() ?? (ts.isConstructorDeclaration(node) ? 'constructor' : 'default');
56
+ const describe = node => {
57
+ if (ts.isClassDeclaration(node)) return ['class-body', named(node)];
58
+ if (ts.isInterfaceDeclaration(node)) return ['interface', named(node)];
59
+ if (ts.isTypeAliasDeclaration(node)) return ['type', named(node)];
60
+ if (ts.isEnumDeclaration(node)) return ['enum', named(node)];
61
+ if (ts.isModuleDeclaration(node)) return ['namespace', named(node)];
62
+ if (ts.isFunctionDeclaration(node)) return ['function', named(node)];
63
+ if (ts.isMethodDeclaration(node) || ts.isMethodSignature(node) || ts.isConstructorDeclaration(node)
64
+ || ts.isGetAccessorDeclaration(node) || ts.isSetAccessorDeclaration(node)) return ['method', named(node)];
65
+ if (ts.isPropertyDeclaration(node) || ts.isPropertySignature(node)) return ['property', named(node)];
66
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer
67
+ && (ts.isArrowFunction(node.initializer) || ts.isFunctionExpression(node.initializer)
68
+ || ts.isClassExpression(node.initializer))) {
69
+ return [ts.isClassExpression(node.initializer) ? 'class-body' : 'function', named(node)];
70
+ }
71
+ return null;
72
+ };
73
+
74
+ for (const file of files) {
75
+ const sf = program.getSourceFile(absolute(file.path));
76
+ const lines = slashLines(file.text);
77
+ // Python splitlines() omits the final empty line; use the same span convention.
78
+ if (lines.at(-1) === '') lines.pop();
79
+ const module = file.path.replace(/\.(?:tsx?|mts|cts|jsx?|mjs|cjs)$/, '');
80
+ // Source citations use physical CR/LF lines, including when JavaScript
81
+ // treats a Unicode separator as a lexical line terminator. Offsets are UTF-16.
82
+ const starts = [0, ...[...file.text.matchAll(/\r\n|\r|\n/g)].map(m => m.index + m[0].length)];
83
+ const lineOf = position => {
84
+ let low = 0, high = starts.length;
85
+ while (low + 1 < high) {
86
+ const middle = (low + high) >>> 1;
87
+ if (starts[middle] <= position) low = middle; else high = middle;
88
+ }
89
+ return low + 1;
90
+ };
91
+ const root = { node: sf, name: '', symbol: module + '::', kind: 'module', start: 1,
92
+ end: lines.length, parent: null, depth: 0, ids: [] };
93
+ const entries = [root], boundaries = new Set();
94
+ nodeEntries.set(sf, root);
95
+ function visit(node, parent) {
96
+ let current = parent;
97
+ const description = describe(node);
98
+ if (description) {
99
+ const [kind, shortName] = description;
100
+ const name = parent.name ? `${parent.name}.${shortName}` : shortName;
101
+ current = { node, name, symbol: module + '::' + name, kind, parent, depth: parent.depth + 1,
102
+ start: lineOf(node.getStart(sf, true)), end: lineOf(Math.max(node.getStart(sf), node.end - 1)), ids: [] };
103
+ entries.push(current); nodeEntries.set(node, current);
104
+ if (ts.isVariableDeclaration(node) && node.initializer) nodeEntries.set(node.initializer, current);
105
+ }
106
+ if (ts.isStatement(node)) boundaries.add(lineOf(node.getStart(sf)));
107
+ ts.forEachChild(node, child => visit(child, current));
108
+ }
109
+ ts.forEachChild(sf, child => visit(child, root));
110
+ const owners = Array(lines.length).fill(root);
111
+ for (const entry of entries.slice(1).sort((a, b) => a.depth - b.depth || a.start - b.start)) {
112
+ // A line-addressed evidence unit cannot separate members from a one-line
113
+ // container. Keep the enclosing declaration's identity for that line.
114
+ if (entry.parent !== root && entry.start === entry.parent.start && entry.end === entry.parent.end) continue;
115
+ for (let i = entry.start - 1; i < entry.end; i++) owners[i] = entry;
116
+ }
117
+ const lineUnits = Array(lines.length).fill(null);
118
+ let start = 1;
119
+ while (start <= lines.length) {
120
+ const owner = owners[start - 1];
121
+ let end = start;
122
+ while (end < lines.length && owners[end] === owner) end++;
123
+ while (start <= end) {
124
+ let stop = Math.min(end, start + maxLines - 1);
125
+ if (stop < end) {
126
+ const candidates = [...boundaries].filter(b => start + Math.floor(maxLines / 2) <= b && b <= stop + 1 && b > start);
127
+ if (candidates.length) stop = Math.max(...candidates) - 1;
128
+ }
129
+ const text = lines.slice(start - 1, stop).join('\n');
130
+ if (text.trim()) {
131
+ const id = units.length;
132
+ const unit = { id, language: /\.(?:jsx?|mjs|cjs)$/.test(file.path) ? 'javascript' : 'typescript', path: file.path, module, name: owner.name,
133
+ symbol: owner.symbol, kind: owner.kind, scope: owner.parent?.symbol ?? root.symbol,
134
+ owner: owner.parent && owner.parent !== root ? owner.parent.symbol : null,
135
+ start, end: stop, text, calls: [], relations: [], edges: [], unresolved: [] };
136
+ units.push(unit); owner.ids.push(id);
137
+ for (let i = start - 1; i < stop; i++) lineUnits[i] = id;
138
+ }
139
+ start = stop + 1;
140
+ }
141
+ }
142
+ records.set(sf.fileName, { sf, entries, root, lineUnits, lineOf });
143
+ }
144
+ const unitAt = node => {
145
+ const record = records.get(node.getSourceFile().fileName);
146
+ return record?.lineUnits[record.lineOf(node.getStart()) - 1];
147
+ };
148
+ const entryFor = node => {
149
+ for (let cursor = node; cursor; cursor = cursor.parent) if (nodeEntries.has(cursor)) return nodeEntries.get(cursor);
150
+ return undefined;
151
+ };
152
+ const idsFor = node => {
153
+ if (!records.has(node.getSourceFile().fileName)) return [];
154
+ const entry = entryFor(node);
155
+ // Multiple declarations can share a line. Link to that actual source unit
156
+ // instead of inventing overlapping spans or dropping the declaration.
157
+ return entry?.ids.length ? entry.ids : [unitAt(node)].filter(id => id !== null && id !== undefined);
158
+ };
159
+ const symbolAt = node => {
160
+ let symbol = checker.getSymbolAtLocation(node);
161
+ if (symbol?.flags & ts.SymbolFlags.Alias) symbol = checker.getAliasedSymbol(symbol);
162
+ return symbol;
163
+ };
164
+ const declarations = node => symbolAt(node)?.declarations ?? [];
165
+ const add = (from, targets, kind, confidence, resolution) => {
166
+ if (from === null || from === undefined) return;
167
+ for (const target of targets) if (target !== from) units[from].relations.push({ target, kind, confidence, resolution });
168
+ };
169
+ const unresolved = (node, kind, reason) => {
170
+ const id = unitAt(node);
171
+ if (id !== null && id !== undefined) units[id].unresolved.push({ kind, text: node.getText().slice(0, 200),
172
+ line: records.get(node.getSourceFile().fileName).lineOf(node.getStart()), reason });
173
+ };
174
+ for (const record of records.values()) {
175
+ for (const entry of record.entries) {
176
+ for (const id of entry.ids) {
177
+ add(id, entry.ids, 'same_symbol', 1, 'syntax');
178
+ if (entry.parent && entry.parent !== record.root) add(id, idsFor(entry.parent.node), 'member_of', 1, 'syntax');
179
+ }
180
+ }
181
+ function link(node) {
182
+ if (ts.isCallExpression(node) || ts.isNewExpression(node)) {
183
+ const expression = node.expression;
184
+ const id = unitAt(expression);
185
+ if (id !== null && id !== undefined) units[id].calls.push(expression.getText());
186
+ const type = checker.getTypeAtLocation(expression);
187
+ let targets = [];
188
+ if (!(type.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown))) {
189
+ const symbolNode = ts.isPropertyAccessExpression(expression) ? expression.name : expression;
190
+ targets = declarations(symbolNode).filter(executable);
191
+ const signature = checker.getResolvedSignature(node)?.declaration;
192
+ if (!targets.length && executable(signature)) targets = [signature];
193
+ }
194
+ const ids = targets.flatMap(idsFor);
195
+ if (ids.length) add(id, ids, 'calls', .9, 'compiler-symbol');
196
+ else unresolved(expression, 'calls', 'dynamic, external, or no implementation in snapshot');
197
+ }
198
+ if ((ts.isClassLike(node) || ts.isInterfaceDeclaration(node)) && node.heritageClauses) {
199
+ for (const clause of node.heritageClauses) for (const type of clause.types) {
200
+ const targets = declarations(type.expression).flatMap(idsFor);
201
+ const kind = clause.token === ts.SyntaxKind.ExtendsKeyword ? 'inherits' : 'implements';
202
+ for (const id of idsFor(node)) add(id, targets, kind, .9, 'compiler-symbol');
203
+ if (!targets.length) unresolved(type, kind, 'no declaration in snapshot');
204
+ }
205
+ }
206
+ if (ts.isImportClause(node) || ts.isImportSpecifier(node) || ts.isNamespaceImport(node)) {
207
+ if (node.name) {
208
+ const targets = declarations(node.name).flatMap(idsFor);
209
+ add(unitAt(node), targets, 'imports', 1, 'compiler-symbol');
210
+ if (!targets.length) unresolved(node, 'imports', 'external or unresolved module');
211
+ }
212
+ }
213
+ if (ts.isTypeReferenceNode(node)) add(unitAt(node), declarations(node.typeName).flatMap(idsFor), 'references_type', .9, 'compiler-symbol');
214
+ ts.forEachChild(node, link);
215
+ }
216
+ link(record.sf);
217
+ }
218
+ for (const unit of units) {
219
+ unit.relations = [...new Map(unit.relations.map(r => [r.target + ':' + r.kind, r])).values()]
220
+ .sort((a, b) => a.target - b.target || a.kind.localeCompare(b.kind, 'en'));
221
+ unit.edges = [...new Set(unit.relations.map(r => r.target))].sort((a, b) => a - b);
222
+ }
223
+ return { compilerVersion: ts.version, units };
224
+ }
225
+
226
+ if (process.argv[1] && new URL(import.meta.url).pathname === path.resolve(process.argv[1])) {
227
+ try {
228
+ const input = JSON.parse(readFileSync(0, 'utf8'));
229
+ process.stdout.write(JSON.stringify(extract(input.files, input.maxLines, input.options)));
230
+ } catch (error) {
231
+ console.error(error.message); process.exitCode = 1;
232
+ }
233
+ }
@@ -0,0 +1,23 @@
1
+ """Bridge to the pinned TypeScript compiler; reads source, never executes it."""
2
+ import json
3
+ from pathlib import Path
4
+ import subprocess
5
+
6
+ COMPILER_VERSION = '5.9.3'
7
+
8
+
9
+ def extract(sources, max_lines=65, options=None):
10
+ payload = {'files': [{'path': source.path, 'text': source.text} for source in sources],
11
+ 'maxLines': max_lines, 'options': options or {}}
12
+ try:
13
+ result = subprocess.run(['node', str(Path(__file__).with_suffix('.mjs'))],
14
+ input=json.dumps(payload), text=True, capture_output=True,
15
+ timeout=120, check=False)
16
+ except FileNotFoundError as error:
17
+ raise RuntimeError('TypeScript indexing requires Node.js and npm ci') from error
18
+ if result.returncode:
19
+ raise ValueError('TypeScript adapter failed: ' + result.stderr.strip()[:2000])
20
+ output = json.loads(result.stdout)
21
+ if output['compilerVersion'] != COMPILER_VERSION:
22
+ raise ValueError('Unexpected TypeScript compiler version; run npm ci')
23
+ return output['units']