arcscope 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +9 -0
- package/dist/adoption/counter.js +23 -0
- package/dist/engine/discover.js +53 -0
- package/dist/engine/extract.js +71 -0
- package/dist/engine/glob.js +35 -0
- package/dist/engine/grammar-registry.js +89 -0
- package/dist/engine/import-graph.js +0 -0
- package/dist/engine/imports.js +89 -0
- package/dist/engine/index-store.js +196 -0
- package/dist/engine/module-resolver.js +134 -0
- package/dist/engine/ref-scan.js +88 -0
- package/dist/engine/types.js +2 -0
- package/dist/grammars/arcscope-extra-javascript.scm +9 -0
- package/dist/grammars/arcscope-extra-typescript.scm +23 -0
- package/dist/grammars/javascript-tags.scm +99 -0
- package/dist/grammars/tree-sitter-javascript.wasm +0 -0
- package/dist/grammars/tree-sitter-tsx.wasm +0 -0
- package/dist/grammars/tree-sitter-typescript.wasm +0 -0
- package/dist/grammars/typescript-tags.scm +23 -0
- package/dist/grammars/web-tree-sitter.wasm +0 -0
- package/dist/index.js +31 -0
- package/dist/init/init.js +66 -0
- package/dist/knowledge/drift.js +68 -0
- package/dist/knowledge/locator.js +24 -0
- package/dist/knowledge/resolver.js +45 -0
- package/dist/knowledge/types.js +2 -0
- package/dist/knowledge/vocab-loader.js +69 -0
- package/dist/log.js +9 -0
- package/dist/server/serve.js +179 -0
- package/dist/tools/arch-list.js +33 -0
- package/dist/tools/arch-query.js +81 -0
- package/dist/tools/dep-graph.js +0 -0
- package/dist/tools/find-def.js +46 -0
- package/dist/tools/find-refs.js +88 -0
- package/package.json +55 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { posix, join } from 'node:path';
|
|
3
|
+
const EXTS = ['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx'];
|
|
4
|
+
// Resolves a module specifier (as written in an import) to the repo-relative file
|
|
5
|
+
// it points at, or null for external/unresolvable specifiers. Handles relative
|
|
6
|
+
// paths and tsconfig `paths` aliases — the one sanctioned config read (spec §4
|
|
7
|
+
// lists tsconfig paths as a secondary signal; no nx.json/project.json/BUILD).
|
|
8
|
+
// File existence is delegated so resolution is tied to arcscope's index, not raw FS.
|
|
9
|
+
export class ModuleResolver {
|
|
10
|
+
root;
|
|
11
|
+
fileExists;
|
|
12
|
+
aliases = null;
|
|
13
|
+
constructor(root, fileExists) {
|
|
14
|
+
this.root = root;
|
|
15
|
+
this.fileExists = fileExists;
|
|
16
|
+
}
|
|
17
|
+
resolve(importerRel, specifier) {
|
|
18
|
+
if (specifier.startsWith('.')) {
|
|
19
|
+
const base = posix.dirname(importerRel);
|
|
20
|
+
return this.tryCandidate(posix.normalize(posix.join(base, specifier)));
|
|
21
|
+
}
|
|
22
|
+
for (const alias of this.loadAliases()) {
|
|
23
|
+
const substituted = this.matchAlias(alias, specifier);
|
|
24
|
+
if (substituted === null)
|
|
25
|
+
continue;
|
|
26
|
+
for (const target of substituted) {
|
|
27
|
+
const hit = this.tryCandidate(posix.normalize(target));
|
|
28
|
+
if (hit)
|
|
29
|
+
return hit;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return null; // bare specifier (@angular/core, rxjs, …) — external
|
|
33
|
+
}
|
|
34
|
+
matchAlias(alias, specifier) {
|
|
35
|
+
if (!alias.wildcard) {
|
|
36
|
+
return specifier === alias.prefix ? alias.targets : null;
|
|
37
|
+
}
|
|
38
|
+
if (!specifier.startsWith(alias.prefix))
|
|
39
|
+
return null;
|
|
40
|
+
const rest = specifier.slice(alias.prefix.length);
|
|
41
|
+
return alias.targets.map((t) => t.replace('*', rest));
|
|
42
|
+
}
|
|
43
|
+
tryCandidate(p) {
|
|
44
|
+
if (this.fileExists(p))
|
|
45
|
+
return p; // tsconfig targets often already carry .ts
|
|
46
|
+
// NodeNext writes a './x.js' specifier for a './x.ts' source — strip the JS
|
|
47
|
+
// extension so the candidate loop can find the real TS/JS file.
|
|
48
|
+
const base = p.replace(/\.[cm]?jsx?$/, '');
|
|
49
|
+
for (const ext of EXTS)
|
|
50
|
+
if (this.fileExists(base + ext))
|
|
51
|
+
return base + ext;
|
|
52
|
+
for (const ext of EXTS)
|
|
53
|
+
if (this.fileExists(`${base}/index${ext}`))
|
|
54
|
+
return `${base}/index${ext}`;
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
loadAliases() {
|
|
58
|
+
if (this.aliases)
|
|
59
|
+
return this.aliases;
|
|
60
|
+
this.aliases = [];
|
|
61
|
+
for (const name of ['tsconfig.base.json', 'tsconfig.json']) {
|
|
62
|
+
const file = join(this.root, name);
|
|
63
|
+
if (!existsSync(file))
|
|
64
|
+
continue;
|
|
65
|
+
let config;
|
|
66
|
+
try {
|
|
67
|
+
config = JSON.parse(stripJsonc(readFileSync(file, 'utf8')));
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const paths = config.compilerOptions?.paths;
|
|
73
|
+
if (!paths)
|
|
74
|
+
continue;
|
|
75
|
+
const baseUrl = config.compilerOptions?.baseUrl ?? '.';
|
|
76
|
+
for (const [key, targets] of Object.entries(paths)) {
|
|
77
|
+
const wildcard = key.endsWith('*');
|
|
78
|
+
this.aliases.push({
|
|
79
|
+
prefix: wildcard ? key.slice(0, -1) : key,
|
|
80
|
+
wildcard,
|
|
81
|
+
targets: targets.map((t) => posix.normalize(posix.join(baseUrl, t))),
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
break; // first tsconfig with paths wins
|
|
85
|
+
}
|
|
86
|
+
return this.aliases;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// Tolerant JSON-with-comments parse prep: strip // and /* */ comments (respecting
|
|
90
|
+
// string literals) and trailing commas, so real-world tsconfigs parse.
|
|
91
|
+
function stripJsonc(s) {
|
|
92
|
+
let out = '';
|
|
93
|
+
let i = 0;
|
|
94
|
+
let inStr = false;
|
|
95
|
+
let strCh = '';
|
|
96
|
+
while (i < s.length) {
|
|
97
|
+
const c = s[i];
|
|
98
|
+
const n = s[i + 1];
|
|
99
|
+
if (inStr) {
|
|
100
|
+
out += c;
|
|
101
|
+
if (c === '\\') {
|
|
102
|
+
out += n ?? '';
|
|
103
|
+
i += 2;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (c === strCh)
|
|
107
|
+
inStr = false;
|
|
108
|
+
i++;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (c === '"' || c === "'") {
|
|
112
|
+
inStr = true;
|
|
113
|
+
strCh = c;
|
|
114
|
+
out += c;
|
|
115
|
+
i++;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (c === '/' && n === '/') {
|
|
119
|
+
while (i < s.length && s[i] !== '\n')
|
|
120
|
+
i++;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (c === '/' && n === '*') {
|
|
124
|
+
i += 2;
|
|
125
|
+
while (i < s.length && !(s[i] === '*' && s[i + 1] === '/'))
|
|
126
|
+
i++;
|
|
127
|
+
i += 2;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
out += c;
|
|
131
|
+
i++;
|
|
132
|
+
}
|
|
133
|
+
return out.replace(/,(\s*[}\]])/g, '$1');
|
|
134
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { Query } from 'web-tree-sitter';
|
|
2
|
+
import { extname } from 'node:path';
|
|
3
|
+
// Compiled once per grammar — every value/type identifier node. `type_identifier`
|
|
4
|
+
// is TS-only; the JavaScript grammar doesn't have that node, so its query must omit
|
|
5
|
+
// it (else Query compilation throws "Bad node name 'type_identifier'").
|
|
6
|
+
const refsQueryCache = new Map();
|
|
7
|
+
const REFS_QUERY_SRC = {
|
|
8
|
+
typescript: '[(identifier) (type_identifier)] @id',
|
|
9
|
+
tsx: '[(identifier) (type_identifier)] @id',
|
|
10
|
+
javascript: '(identifier) @id',
|
|
11
|
+
};
|
|
12
|
+
// Parents where the matching identifier is the *name being declared*, not a
|
|
13
|
+
// reference — excluded so a definition doesn't report itself.
|
|
14
|
+
const DECL_NAME_PARENTS = new Set([
|
|
15
|
+
'class_declaration',
|
|
16
|
+
'abstract_class_declaration',
|
|
17
|
+
'function_declaration',
|
|
18
|
+
'generator_function_declaration',
|
|
19
|
+
'function_signature',
|
|
20
|
+
'interface_declaration',
|
|
21
|
+
'type_alias_declaration',
|
|
22
|
+
'enum_declaration',
|
|
23
|
+
'method_definition',
|
|
24
|
+
'variable_declarator',
|
|
25
|
+
'internal_module',
|
|
26
|
+
'module',
|
|
27
|
+
]);
|
|
28
|
+
// Re-parse one file and find every reference to `localName` (value or type
|
|
29
|
+
// position), classified by refKind. Used by find_refs only on the files the
|
|
30
|
+
// import graph says actually import the symbol — so this never runs repo-wide.
|
|
31
|
+
export async function findOccurrences(registry, rel, source, localName) {
|
|
32
|
+
const grammar = await registry.getForExt(extname(rel));
|
|
33
|
+
if (!grammar)
|
|
34
|
+
return [];
|
|
35
|
+
const parser = await registry.ensureInit();
|
|
36
|
+
parser.setLanguage(grammar.language);
|
|
37
|
+
const tree = parser.parse(source);
|
|
38
|
+
if (!tree)
|
|
39
|
+
return [];
|
|
40
|
+
try {
|
|
41
|
+
let query = refsQueryCache.get(grammar.id);
|
|
42
|
+
if (!query) {
|
|
43
|
+
query = new Query(grammar.language, REFS_QUERY_SRC[grammar.id] ?? '(identifier) @id');
|
|
44
|
+
refsQueryCache.set(grammar.id, query);
|
|
45
|
+
}
|
|
46
|
+
const lines = source.split('\n');
|
|
47
|
+
const out = [];
|
|
48
|
+
for (const cap of query.captures(tree.rootNode)) {
|
|
49
|
+
const node = cap.node;
|
|
50
|
+
if (node.text !== localName || isDeclarationName(node))
|
|
51
|
+
continue;
|
|
52
|
+
out.push({
|
|
53
|
+
file: rel,
|
|
54
|
+
line: node.startPosition.row + 1,
|
|
55
|
+
column: node.startPosition.column + 1,
|
|
56
|
+
snippet: (lines[node.startPosition.row] ?? '').trim().slice(0, 120),
|
|
57
|
+
refKind: classify(node),
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
finally {
|
|
63
|
+
tree.delete();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
// web-tree-sitter returns fresh Node wrappers per access, so compare by position.
|
|
67
|
+
function isDeclarationName(node) {
|
|
68
|
+
const p = node.parent;
|
|
69
|
+
return !!p && DECL_NAME_PARENTS.has(p.type) && p.childForFieldName('name')?.startIndex === node.startIndex;
|
|
70
|
+
}
|
|
71
|
+
function classify(node) {
|
|
72
|
+
if (node.type === 'type_identifier')
|
|
73
|
+
return 'type';
|
|
74
|
+
const p = node.parent;
|
|
75
|
+
if (!p)
|
|
76
|
+
return 'identifier';
|
|
77
|
+
if (p.type === 'call_expression' && p.childForFieldName('function')?.startIndex === node.startIndex)
|
|
78
|
+
return 'call';
|
|
79
|
+
if (p.type === 'new_expression' && p.childForFieldName('constructor')?.startIndex === node.startIndex)
|
|
80
|
+
return 'new';
|
|
81
|
+
if (p.type === 'member_expression' && p.childForFieldName('object')?.startIndex === node.startIndex)
|
|
82
|
+
return 'access';
|
|
83
|
+
if (p.type === 'import_specifier' || p.type === 'namespace_import' || p.type === 'import_clause')
|
|
84
|
+
return 'import';
|
|
85
|
+
if (p.type === 'extends_clause' || p.type === 'class_heritage' || p.type === 'implements_clause')
|
|
86
|
+
return 'extends';
|
|
87
|
+
return 'identifier';
|
|
88
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
; arcscope-authored additions, appended to the upstream javascript tags.scm.
|
|
2
|
+
; Captures exported non-function const/let bindings (upstream only tags
|
|
3
|
+
; function-valued declarators). Scoped to export_statement so file-local consts
|
|
4
|
+
; don't pollute the index. Verified to compile against the javascript grammar.
|
|
5
|
+
|
|
6
|
+
(export_statement
|
|
7
|
+
(lexical_declaration
|
|
8
|
+
(variable_declarator
|
|
9
|
+
name: (identifier) @name))) @definition.constant
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
; arcscope-authored additions, appended to the upstream javascript + typescript
|
|
2
|
+
; tags.scm. The stock tree-sitter tags omit these TS definition forms, which an
|
|
3
|
+
; agent realistically navigates to; without them find_def loses to grep on TS.
|
|
4
|
+
; Verified to compile against the typescript AND tsx grammars (web-tree-sitter 0.26.9),
|
|
5
|
+
; and to capture the forms below on real code (see the engine extraction tests).
|
|
6
|
+
; Capture convention matches upstream: @definition.<kind> on the node, @name on its identifier.
|
|
7
|
+
|
|
8
|
+
(type_alias_declaration
|
|
9
|
+
name: (type_identifier) @name) @definition.type
|
|
10
|
+
|
|
11
|
+
(enum_declaration
|
|
12
|
+
name: (identifier) @name) @definition.enum
|
|
13
|
+
|
|
14
|
+
; `namespace X {}` parses as internal_module (upstream only tags `module "x" {}`).
|
|
15
|
+
(internal_module
|
|
16
|
+
name: (identifier) @name) @definition.module
|
|
17
|
+
|
|
18
|
+
; Exported const/let bindings of any value (upstream only tags function-valued ones).
|
|
19
|
+
; Scoped to export_statement so file-local consts don't pollute the index.
|
|
20
|
+
(export_statement
|
|
21
|
+
(lexical_declaration
|
|
22
|
+
(variable_declarator
|
|
23
|
+
name: (identifier) @name))) @definition.constant
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
(
|
|
2
|
+
(comment)* @doc
|
|
3
|
+
.
|
|
4
|
+
(method_definition
|
|
5
|
+
name: (property_identifier) @name) @definition.method
|
|
6
|
+
(#not-eq? @name "constructor")
|
|
7
|
+
(#strip! @doc "^[\\s\\*/]+|^[\\s\\*/]$")
|
|
8
|
+
(#select-adjacent! @doc @definition.method)
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
(
|
|
12
|
+
(comment)* @doc
|
|
13
|
+
.
|
|
14
|
+
[
|
|
15
|
+
(class
|
|
16
|
+
name: (_) @name)
|
|
17
|
+
(class_declaration
|
|
18
|
+
name: (_) @name)
|
|
19
|
+
] @definition.class
|
|
20
|
+
(#strip! @doc "^[\\s\\*/]+|^[\\s\\*/]$")
|
|
21
|
+
(#select-adjacent! @doc @definition.class)
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
(
|
|
25
|
+
(comment)* @doc
|
|
26
|
+
.
|
|
27
|
+
[
|
|
28
|
+
(function_expression
|
|
29
|
+
name: (identifier) @name)
|
|
30
|
+
(function_declaration
|
|
31
|
+
name: (identifier) @name)
|
|
32
|
+
(generator_function
|
|
33
|
+
name: (identifier) @name)
|
|
34
|
+
(generator_function_declaration
|
|
35
|
+
name: (identifier) @name)
|
|
36
|
+
] @definition.function
|
|
37
|
+
(#strip! @doc "^[\\s\\*/]+|^[\\s\\*/]$")
|
|
38
|
+
(#select-adjacent! @doc @definition.function)
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
(
|
|
42
|
+
(comment)* @doc
|
|
43
|
+
.
|
|
44
|
+
(lexical_declaration
|
|
45
|
+
(variable_declarator
|
|
46
|
+
name: (identifier) @name
|
|
47
|
+
value: [(arrow_function) (function_expression)]) @definition.function)
|
|
48
|
+
(#strip! @doc "^[\\s\\*/]+|^[\\s\\*/]$")
|
|
49
|
+
(#select-adjacent! @doc @definition.function)
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
(
|
|
53
|
+
(comment)* @doc
|
|
54
|
+
.
|
|
55
|
+
(variable_declaration
|
|
56
|
+
(variable_declarator
|
|
57
|
+
name: (identifier) @name
|
|
58
|
+
value: [(arrow_function) (function_expression)]) @definition.function)
|
|
59
|
+
(#strip! @doc "^[\\s\\*/]+|^[\\s\\*/]$")
|
|
60
|
+
(#select-adjacent! @doc @definition.function)
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
(assignment_expression
|
|
64
|
+
left: [
|
|
65
|
+
(identifier) @name
|
|
66
|
+
(member_expression
|
|
67
|
+
property: (property_identifier) @name)
|
|
68
|
+
]
|
|
69
|
+
right: [(arrow_function) (function_expression)]
|
|
70
|
+
) @definition.function
|
|
71
|
+
|
|
72
|
+
(pair
|
|
73
|
+
key: (property_identifier) @name
|
|
74
|
+
value: [(arrow_function) (function_expression)]) @definition.function
|
|
75
|
+
|
|
76
|
+
(
|
|
77
|
+
(call_expression
|
|
78
|
+
function: (identifier) @name) @reference.call
|
|
79
|
+
(#not-match? @name "^(require)$")
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
(call_expression
|
|
83
|
+
function: (member_expression
|
|
84
|
+
property: (property_identifier) @name)
|
|
85
|
+
arguments: (_) @reference.call)
|
|
86
|
+
|
|
87
|
+
(new_expression
|
|
88
|
+
constructor: (_) @name) @reference.class
|
|
89
|
+
|
|
90
|
+
(export_statement value: (assignment_expression left: (identifier) @name right: ([
|
|
91
|
+
(number)
|
|
92
|
+
(string)
|
|
93
|
+
(identifier)
|
|
94
|
+
(undefined)
|
|
95
|
+
(null)
|
|
96
|
+
(new_expression)
|
|
97
|
+
(binary_expression)
|
|
98
|
+
(call_expression)
|
|
99
|
+
]))) @definition.constant
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
(function_signature
|
|
2
|
+
name: (identifier) @name) @definition.function
|
|
3
|
+
|
|
4
|
+
(method_signature
|
|
5
|
+
name: (property_identifier) @name) @definition.method
|
|
6
|
+
|
|
7
|
+
(abstract_method_signature
|
|
8
|
+
name: (property_identifier) @name) @definition.method
|
|
9
|
+
|
|
10
|
+
(abstract_class_declaration
|
|
11
|
+
name: (type_identifier) @name) @definition.class
|
|
12
|
+
|
|
13
|
+
(module
|
|
14
|
+
name: (identifier) @name) @definition.module
|
|
15
|
+
|
|
16
|
+
(interface_declaration
|
|
17
|
+
name: (type_identifier) @name) @definition.interface
|
|
18
|
+
|
|
19
|
+
(type_annotation
|
|
20
|
+
(type_identifier) @name) @reference.type
|
|
21
|
+
|
|
22
|
+
(new_expression
|
|
23
|
+
constructor: (identifier) @name) @reference.class
|
|
Binary file
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { logError } from './log.js';
|
|
3
|
+
// Single bin, branches on argv. Subcommands are imported lazily so `init` doesn't
|
|
4
|
+
// pay to load the MCP SDK and `serve` starts with a clean module graph.
|
|
5
|
+
const command = process.argv[2];
|
|
6
|
+
const root = process.cwd();
|
|
7
|
+
try {
|
|
8
|
+
switch (command) {
|
|
9
|
+
case 'init': {
|
|
10
|
+
const { init } = await import('./init/init.js');
|
|
11
|
+
await init(root);
|
|
12
|
+
break;
|
|
13
|
+
}
|
|
14
|
+
case 'serve': {
|
|
15
|
+
const { serve } = await import('./server/serve.js');
|
|
16
|
+
await serve(root);
|
|
17
|
+
break;
|
|
18
|
+
}
|
|
19
|
+
default: {
|
|
20
|
+
process.stderr.write('arcscope — fully-local, architecture-aware code-navigation MCP server\n\n' +
|
|
21
|
+
'Usage:\n' +
|
|
22
|
+
' arcscope init Index this repo, write .mcp.json, and update .gitignore\n' +
|
|
23
|
+
' arcscope serve Run the MCP server over stdio (spawned by your MCP client)\n');
|
|
24
|
+
process.exit(command ? 1 : 0);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
catch (err) {
|
|
29
|
+
logError(err instanceof Error ? (err.stack ?? err.message) : err);
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { join, relative } from 'node:path';
|
|
4
|
+
import { GrammarRegistry } from '../engine/grammar-registry.js';
|
|
5
|
+
import { IndexStore } from '../engine/index-store.js';
|
|
6
|
+
// `arcscope init`: index the repo once (the acceptance measurement), register the
|
|
7
|
+
// server for the MCP client offline, and ensure local cache files are gitignored.
|
|
8
|
+
// init is a human-facing CLI, so writing to stdout here is fine — the stdout
|
|
9
|
+
// hygiene rule applies to `serve`'s JSON-RPC stream, not this command.
|
|
10
|
+
export async function init(root) {
|
|
11
|
+
const out = (s) => process.stdout.write(s + '\n');
|
|
12
|
+
// 1. Index pass — measure cold-index time + symbol count on the real tree.
|
|
13
|
+
const store = new IndexStore(root, new GrammarRegistry());
|
|
14
|
+
const stats = await store.sync();
|
|
15
|
+
out(`arcscope: indexed ${stats.fileCount} files, ${stats.symbolCount} symbols in ${stats.elapsedMs}ms`);
|
|
16
|
+
// 2. Registration — point the client at the locally-resolved bin so server
|
|
17
|
+
// spawn is offline and deterministic (never `npx arcscope serve`). Derive the
|
|
18
|
+
// bin path from this module (dist/init/init.js -> dist/index.js) rather than
|
|
19
|
+
// process.argv[1], so it's correct even when invoked via a symlink/wrapper.
|
|
20
|
+
const binPath = fileURLToPath(new URL('../index.js', import.meta.url));
|
|
21
|
+
const mcpPath = join(root, '.mcp.json');
|
|
22
|
+
writeMcpJson(mcpPath, binPath);
|
|
23
|
+
out(`arcscope: wrote ${relative(root, mcpPath) || '.mcp.json'} (node -> ${binPath} serve)`);
|
|
24
|
+
// 3. Keep regenerable local state out of git.
|
|
25
|
+
if (ensureGitignore(root))
|
|
26
|
+
out('arcscope: updated .gitignore (.arcscope/* ignored, vocab.yaml committed)');
|
|
27
|
+
out('arcscope: ready — reconnect your MCP client to load the server.');
|
|
28
|
+
}
|
|
29
|
+
export function writeMcpJson(mcpPath, binPath) {
|
|
30
|
+
let config = {};
|
|
31
|
+
if (existsSync(mcpPath)) {
|
|
32
|
+
try {
|
|
33
|
+
const parsed = JSON.parse(readFileSync(mcpPath, 'utf8'));
|
|
34
|
+
if (parsed && typeof parsed === 'object')
|
|
35
|
+
config = parsed;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// unreadable/invalid -> start fresh rather than fail init
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (!config.mcpServers || typeof config.mcpServers !== 'object')
|
|
42
|
+
config.mcpServers = {};
|
|
43
|
+
config.mcpServers['arcscope'] = { command: 'node', args: [binPath, 'serve'] };
|
|
44
|
+
writeFileSync(mcpPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
|
|
45
|
+
}
|
|
46
|
+
// Ignore the regenerable local cache (index/usage/anchors) but COMMIT the
|
|
47
|
+
// vocabulary — `.arcscope/vocab.yaml` is the repo's declared knowledge and must
|
|
48
|
+
// travel with it. Note: re-including a file requires ignoring `.arcscope/*` (not
|
|
49
|
+
// the whole `.arcscope/` directory), so an old wholesale ignore is upgraded.
|
|
50
|
+
export function ensureGitignore(root) {
|
|
51
|
+
const p = join(root, '.gitignore');
|
|
52
|
+
const lines = (existsSync(p) ? readFileSync(p, 'utf8') : '').split('\n');
|
|
53
|
+
if (lines.some((l) => l.trim() === '.arcscope/*'))
|
|
54
|
+
return false; // already in the right shape
|
|
55
|
+
const kept = lines
|
|
56
|
+
.filter((l) => {
|
|
57
|
+
const t = l.trim();
|
|
58
|
+
return t !== '.arcscope/' && t !== '.arcscope' && !/^#\s*arcscope local index/i.test(t);
|
|
59
|
+
})
|
|
60
|
+
.join('\n')
|
|
61
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
62
|
+
.replace(/\s+$/, '');
|
|
63
|
+
const block = '# arcscope: ignore the local cache (index/usage/anchors); commit the vocabulary\n.arcscope/*\n!.arcscope/vocab.yaml\n';
|
|
64
|
+
writeFileSync(p, kept ? `${kept}\n\n${block}` : block, 'utf8');
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
function sha1(s) {
|
|
5
|
+
return createHash('sha1').update(s).digest('hex').slice(0, 16);
|
|
6
|
+
}
|
|
7
|
+
// Symbol anchors hash the def's kind+signature (so a header change drifts but a
|
|
8
|
+
// pure line move does not); path anchors hash the file's content.
|
|
9
|
+
export function computeAnchors(root, resolved) {
|
|
10
|
+
const byKey = new Map();
|
|
11
|
+
const fileCache = new Map();
|
|
12
|
+
for (const r of resolved) {
|
|
13
|
+
let anchor;
|
|
14
|
+
if (r.via === 'symbol') {
|
|
15
|
+
anchor = { key: `${r.file}#${r.symbol}`, hash: sha1(`${r.kind} ${r.signature ?? ''}`) };
|
|
16
|
+
}
|
|
17
|
+
else {
|
|
18
|
+
let content = fileCache.get(r.file);
|
|
19
|
+
if (content === undefined) {
|
|
20
|
+
try {
|
|
21
|
+
content = readFileSync(join(root, r.file), 'utf8');
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
content = '';
|
|
25
|
+
}
|
|
26
|
+
fileCache.set(r.file, content);
|
|
27
|
+
}
|
|
28
|
+
anchor = { key: r.file, hash: sha1(content) };
|
|
29
|
+
}
|
|
30
|
+
byKey.set(anchor.key, anchor); // dedupe overlapping locators
|
|
31
|
+
}
|
|
32
|
+
return [...byKey.values()];
|
|
33
|
+
}
|
|
34
|
+
export function compareDrift(current, baseline) {
|
|
35
|
+
const cur = new Map(current.map((a) => [a.key, a.hash]));
|
|
36
|
+
const base = new Map(baseline.map((a) => [a.key, a.hash]));
|
|
37
|
+
const added = [...cur.keys()].filter((k) => !base.has(k));
|
|
38
|
+
const removed = [...base.keys()].filter((k) => !cur.has(k));
|
|
39
|
+
const changed = [...cur.keys()].filter((k) => base.has(k) && base.get(k) !== cur.get(k));
|
|
40
|
+
return { status: added.length || removed.length || changed.length ? 'drifted' : 'fresh', added, removed, changed };
|
|
41
|
+
}
|
|
42
|
+
function anchorsPath(root) {
|
|
43
|
+
return join(root, '.arcscope', 'anchors.json');
|
|
44
|
+
}
|
|
45
|
+
// Local, gitignored baseline (per the chosen drift model).
|
|
46
|
+
export function loadAnchorStore(root) {
|
|
47
|
+
const p = anchorsPath(root);
|
|
48
|
+
if (!existsSync(p))
|
|
49
|
+
return { concepts: {} };
|
|
50
|
+
try {
|
|
51
|
+
const parsed = JSON.parse(readFileSync(p, 'utf8'));
|
|
52
|
+
if (parsed && typeof parsed === 'object' && 'concepts' in parsed)
|
|
53
|
+
return parsed;
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// corrupt -> start fresh
|
|
57
|
+
}
|
|
58
|
+
return { concepts: {} };
|
|
59
|
+
}
|
|
60
|
+
export function baselineFor(store, conceptId) {
|
|
61
|
+
return store.concepts[conceptId]?.anchors;
|
|
62
|
+
}
|
|
63
|
+
export function captureBaseline(root, conceptId, anchors, nowIso) {
|
|
64
|
+
const store = loadAnchorStore(root);
|
|
65
|
+
store.concepts[conceptId] = { anchors, capturedAt: nowIso };
|
|
66
|
+
mkdirSync(join(root, '.arcscope'), { recursive: true });
|
|
67
|
+
writeFileSync(anchorsPath(root), JSON.stringify(store, null, 2) + '\n', 'utf8');
|
|
68
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
const KIND_ALIASES = { const: 'constant' };
|
|
2
|
+
// Parse a symbol locator query: "<kind> <namePattern> [= <valueConstraint>]".
|
|
3
|
+
// Examples: "interface I*Repository", "class GraphReducer",
|
|
4
|
+
// "const *_REPOSITORY = InjectionToken", "method normalizeElement".
|
|
5
|
+
// Pure string parsing — no regex injection, no shell-out (invariant 4).
|
|
6
|
+
export function parseSymbolQuery(query) {
|
|
7
|
+
let head = query.trim();
|
|
8
|
+
let valueConstraint;
|
|
9
|
+
const eq = head.indexOf('=');
|
|
10
|
+
if (eq >= 0) {
|
|
11
|
+
valueConstraint = head.slice(eq + 1).trim() || undefined;
|
|
12
|
+
head = head.slice(0, eq).trim();
|
|
13
|
+
}
|
|
14
|
+
const sp = head.indexOf(' ');
|
|
15
|
+
if (sp < 0) {
|
|
16
|
+
throw new Error(`invalid symbol locator query: "${query}" (expected "<kind> <namePattern> [= <value>]")`);
|
|
17
|
+
}
|
|
18
|
+
const rawKind = head.slice(0, sp).trim();
|
|
19
|
+
const namePattern = head.slice(sp + 1).trim();
|
|
20
|
+
if (rawKind.length === 0 || namePattern.length === 0) {
|
|
21
|
+
throw new Error(`invalid symbol locator query: "${query}" (missing kind or name pattern)`);
|
|
22
|
+
}
|
|
23
|
+
return { kind: KIND_ALIASES[rawKind] ?? rawKind, namePattern, valueConstraint };
|
|
24
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { matchGlob } from '../engine/glob.js';
|
|
2
|
+
import { parseSymbolQuery } from './locator.js';
|
|
3
|
+
// Resolve a concept LIVE against the current index. Symbol locators filter the def
|
|
4
|
+
// index (reuse P0); path locators filter the file set. Staged concepts resolve
|
|
5
|
+
// each stage in order and tag results with the stage title. Everything routes
|
|
6
|
+
// through the engine — no shell-out.
|
|
7
|
+
export function resolveConcept(store, concept) {
|
|
8
|
+
if (concept.stages) {
|
|
9
|
+
const out = [];
|
|
10
|
+
for (const stage of concept.stages) {
|
|
11
|
+
for (const loc of resolveLocator(store, stage))
|
|
12
|
+
out.push({ ...loc, stage: stage.title });
|
|
13
|
+
}
|
|
14
|
+
return out;
|
|
15
|
+
}
|
|
16
|
+
return (concept.locators ?? []).flatMap((loc) => resolveLocator(store, loc));
|
|
17
|
+
}
|
|
18
|
+
function resolveLocator(store, loc) {
|
|
19
|
+
if (loc.kind === 'symbol') {
|
|
20
|
+
const { kind, namePattern, valueConstraint } = parseSymbolQuery(loc.query);
|
|
21
|
+
return store
|
|
22
|
+
.allDefs()
|
|
23
|
+
.filter((d) => d.kind === kind &&
|
|
24
|
+
matchGlob(d.symbol, namePattern) &&
|
|
25
|
+
(loc.in === undefined || matchGlob(d.file, loc.in)) &&
|
|
26
|
+
(valueConstraint === undefined || d.signature.includes(valueConstraint)))
|
|
27
|
+
.sort(byFileLine)
|
|
28
|
+
.map((d) => ({
|
|
29
|
+
file: d.file,
|
|
30
|
+
line: d.line,
|
|
31
|
+
kind: d.kind,
|
|
32
|
+
symbol: d.symbol,
|
|
33
|
+
signature: d.signature,
|
|
34
|
+
via: 'symbol',
|
|
35
|
+
precisionTier: 'tree-sitter',
|
|
36
|
+
}));
|
|
37
|
+
}
|
|
38
|
+
return [...store.relFileSet()]
|
|
39
|
+
.filter((f) => matchGlob(f, loc.glob) && (loc.in === undefined || matchGlob(f, loc.in)))
|
|
40
|
+
.sort()
|
|
41
|
+
.map((f) => ({ file: f, kind: 'file', via: 'path', precisionTier: 'tree-sitter' }));
|
|
42
|
+
}
|
|
43
|
+
function byFileLine(a, b) {
|
|
44
|
+
return a.file === b.file ? a.line - b.line : a.file < b.file ? -1 : 1;
|
|
45
|
+
}
|