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.
@@ -0,0 +1,69 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import yaml from 'js-yaml';
3
+ // Load and validate .arcscope/vocab.yaml. The file is hand-authored and committed
4
+ // (the repo's declared knowledge), so we fail loud on a malformed shape rather than
5
+ // silently dropping concepts. Returns an empty vocabulary if the file is absent.
6
+ export function loadVocabulary(vocabPath) {
7
+ if (!existsSync(vocabPath))
8
+ return { concepts: [] };
9
+ let raw;
10
+ try {
11
+ raw = yaml.load(readFileSync(vocabPath, 'utf8'));
12
+ }
13
+ catch (err) {
14
+ throw new Error(`vocab.yaml is not valid YAML: ${err instanceof Error ? err.message : String(err)}`);
15
+ }
16
+ const conceptsObj = isRecord(raw) && isRecord(raw['concepts']) ? raw['concepts'] : {};
17
+ const concepts = [];
18
+ for (const [id, value] of Object.entries(conceptsObj)) {
19
+ concepts.push(parseConcept(id, value));
20
+ }
21
+ return { concepts };
22
+ }
23
+ function parseConcept(id, value) {
24
+ if (!isRecord(value))
25
+ throw new Error(`concept "${id}" must be a mapping`);
26
+ const locators = value['locators'];
27
+ const stages = value['stages'];
28
+ const concept = {
29
+ id,
30
+ title: typeof value['title'] === 'string' ? value['title'] : id,
31
+ description: typeof value['description'] === 'string' ? value['description'] : undefined,
32
+ note: typeof value['note'] === 'string' ? value['note'] : undefined,
33
+ };
34
+ if (Array.isArray(stages)) {
35
+ concept.stages = stages.map((s, i) => parseStage(id, i, s));
36
+ }
37
+ else if (Array.isArray(locators)) {
38
+ concept.locators = locators.map((l, i) => parseLocator(`${id}.locators[${i}]`, l));
39
+ }
40
+ else {
41
+ throw new Error(`concept "${id}" must have a "locators" or "stages" list`);
42
+ }
43
+ return concept;
44
+ }
45
+ function parseStage(conceptId, i, value) {
46
+ if (!isRecord(value) || typeof value['title'] !== 'string') {
47
+ throw new Error(`concept "${conceptId}" stage ${i} must be a mapping with a "title"`);
48
+ }
49
+ return { ...parseLocator(`${conceptId}.stages[${i}]`, value), title: value['title'] };
50
+ }
51
+ function parseLocator(where, value) {
52
+ if (!isRecord(value))
53
+ throw new Error(`${where} must be a mapping`);
54
+ const inGlob = typeof value['in'] === 'string' ? value['in'] : undefined;
55
+ if (value['kind'] === 'symbol') {
56
+ if (typeof value['query'] !== 'string')
57
+ throw new Error(`${where} (symbol) needs a "query" string`);
58
+ return { kind: 'symbol', query: value['query'], in: inGlob };
59
+ }
60
+ if (value['kind'] === 'path') {
61
+ if (typeof value['glob'] !== 'string')
62
+ throw new Error(`${where} (path) needs a "glob" string`);
63
+ return { kind: 'path', glob: value['glob'], in: inGlob };
64
+ }
65
+ throw new Error(`${where} has unknown kind ${JSON.stringify(value['kind'])} (v1 supports: symbol, path)`);
66
+ }
67
+ function isRecord(v) {
68
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
69
+ }
package/dist/log.js ADDED
@@ -0,0 +1,9 @@
1
+ // stderr-ONLY logging. This is the single chokepoint that enforces stdio hygiene:
2
+ // arcscope speaks JSON-RPC on stdout, and a stray write to stdout corrupts that
3
+ // stream and silently hangs the server. Never use console.log anywhere; log here.
4
+ export function log(...args) {
5
+ console.error('[arcscope]', ...args);
6
+ }
7
+ export function logError(...args) {
8
+ console.error('[arcscope:error]', ...args);
9
+ }
@@ -0,0 +1,179 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
+ import { readFileSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { GrammarRegistry } from '../engine/grammar-registry.js';
6
+ import { IndexStore } from '../engine/index-store.js';
7
+ import { InvocationCounter } from '../adoption/counter.js';
8
+ import { runFindDef, findDefInputShape } from '../tools/find-def.js';
9
+ import { runFindRefs, findRefsInputShape } from '../tools/find-refs.js';
10
+ import { runDepGraph, depGraphInputShape } from '../tools/dep-graph.js';
11
+ import { runArchList } from '../tools/arch-list.js';
12
+ import { runArchQuery, archQueryInputShape } from '../tools/arch-query.js';
13
+ import { log, logError } from '../log.js';
14
+ // Read from package.json (shipped in the tarball) so it never drifts from the
15
+ // published version.
16
+ const VERSION = (() => {
17
+ try {
18
+ const pkg = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'));
19
+ return pkg.version ?? '0.0.0';
20
+ }
21
+ catch {
22
+ return '0.0.0';
23
+ }
24
+ })();
25
+ // Server-level instructions are surfaced by the client (Claude Code) at session
26
+ // start and are the highest-leverage adoption lever: they tell the agent WHEN to
27
+ // reach for arcscope instead of grep. Kept concise (clients truncate ~2KB).
28
+ const INSTRUCTIONS = [
29
+ 'arcscope navigates this codebase by parsing it with tree-sitter — fully local, no network.',
30
+ 'Prefer it over grep/Glob for structural questions about where code lives.',
31
+ '',
32
+ 'Use find_def to locate where a named symbol is defined (function, class, method, interface,',
33
+ 'type, enum, or exported constant). It returns the exact definition site and signature, and',
34
+ 'skips the comments, strings, and unrelated same-named matches that text search turns up.',
35
+ "When you need \"where is X defined?\", call find_def with the symbol name instead of searching text.",
36
+ '',
37
+ 'Use find_refs to find who references a symbol. It resolves tsconfig aliases and barrel',
38
+ 're-exports, so it finds callers grep misses and excludes same-named symbols in unrelated files —',
39
+ 'much more precise than grepping a name. Prefer it for "what uses X?" / "who calls X?".',
40
+ '',
41
+ 'Use dep_graph to see structure: the most depended-on files (hubs) with no focus, or a file\'s',
42
+ 'neighborhood (what it imports / what imports it) with a focus. Prefer it over reading imports by hand.',
43
+ '',
44
+ "Use arch_list to learn this repo's named architecture concepts (its committed vocabulary), and arch_query",
45
+ 'to resolve one live to its current code locations. These answer concept-level questions ("the repository',
46
+ 'tokens", "the action pipeline") that grep and docs can\'t — recomputed every call, so they never go stale.',
47
+ '',
48
+ "Every result is labeled with a precision tier so you can calibrate trust (currently",
49
+ "'tree-sitter': structural and high-signal, but not compiler-exact).",
50
+ ].join('\n');
51
+ const ARCH_LIST_DESCRIPTION = [
52
+ "List this repo's declared architecture concepts — named, repo-committed ideas (e.g. \"the repository tokens\",",
53
+ '"the editor state pipeline", "the plugin registry") bound to live code locators. Use at the START of working',
54
+ 'in an unfamiliar codebase to learn its vocabulary, or when the user names an architectural concept. Each is',
55
+ 'answered live against current code (never stale prose).',
56
+ ].join(' ');
57
+ const ARCH_QUERY_DESCRIPTION = [
58
+ 'Resolve one named architecture concept to its live code locations. Use when you need to understand or change a',
59
+ 'declared concept (from arch_list) — it recomputes the locator against the current tree and returns the exact',
60
+ 'files/symbols (a staged concept comes back as an ordered pipeline). More reliable than reading prose docs,',
61
+ 'which silently rot; this is recomputed every call and flags drift.',
62
+ ].join(' ');
63
+ const DEP_GRAPH_DESCRIPTION = [
64
+ 'Show the module/file dependency graph from real import edges (resolving aliases + barrels).',
65
+ 'Use to understand structure: with no focus it returns the most depended-on files (hubs) and a',
66
+ 'module summary; with a focus file it returns that file\'s neighborhood — what it imports and what',
67
+ 'imports it; with cycles:true it finds circular dependencies (files that import each other).',
68
+ 'Optionally a directory prefix focus, or a neighborhood depth (1-2). Token-bounded.',
69
+ ].join(' ');
70
+ const FIND_REFS_DESCRIPTION = [
71
+ 'Find where a symbol is referenced (its callers/consumers), following tsconfig path aliases and',
72
+ 'barrel re-exports. Use when you need who uses a function, class, method, interface, type, or',
73
+ 'constant — e.g. "what calls ActionRouterService?". More precise than text search: it resolves',
74
+ 'which files actually import the symbol, so it excludes same-named symbols in unrelated files and',
75
+ 'includes references reached through barrels. Each reference carries its kind (call/new/type/...)',
76
+ 'and the definition it resolves to.',
77
+ ].join(' ');
78
+ const FIND_DEF_DESCRIPTION = [
79
+ 'Find where a symbol is defined and show its signature.',
80
+ 'Use when you need the definition site of a named function, class, method, interface, type,',
81
+ "enum, or exported constant — e.g. \"where is GraphReducer defined?\".",
82
+ 'More precise than text search: it parses the code, so it skips comments, strings, and unrelated',
83
+ 'same-named matches. Returns each definition as file:line, kind, and header signature, with a',
84
+ 'precision tier. If no exact match exists, it suggests symbols with similar names — so you can',
85
+ "call it even when you're unsure of the precise name. Optionally scope to part of the repo with a path glob.",
86
+ ].join(' ');
87
+ export async function serve(root) {
88
+ const registry = new GrammarRegistry();
89
+ const store = new IndexStore(root, registry);
90
+ const counter = new InvocationCounter(join(root, '.arcscope', 'usage.jsonl'));
91
+ const stats = await store.sync();
92
+ log(`indexed ${stats.fileCount} files, ${stats.symbolCount} symbols in ${stats.elapsedMs}ms (root: ${root})`);
93
+ const server = new McpServer({ name: 'arcscope', version: VERSION }, { instructions: INSTRUCTIONS });
94
+ server.registerTool('find_def', {
95
+ title: 'Find symbol definition',
96
+ description: FIND_DEF_DESCRIPTION,
97
+ inputSchema: findDefInputShape,
98
+ }, async (args) => {
99
+ void counter.record('find_def', args);
100
+ try {
101
+ const { text } = await runFindDef(store, args);
102
+ return { content: [{ type: 'text', text }] };
103
+ }
104
+ catch (err) {
105
+ logError('find_def failed:', err);
106
+ return {
107
+ content: [{ type: 'text', text: `find_def error: ${err instanceof Error ? err.message : String(err)}` }],
108
+ isError: true,
109
+ };
110
+ }
111
+ });
112
+ server.registerTool('find_refs', {
113
+ title: 'Find symbol references',
114
+ description: FIND_REFS_DESCRIPTION,
115
+ inputSchema: findRefsInputShape,
116
+ }, async (args) => {
117
+ void counter.record('find_refs', args);
118
+ try {
119
+ const { text } = await runFindRefs(store, registry, root, args);
120
+ return { content: [{ type: 'text', text }] };
121
+ }
122
+ catch (err) {
123
+ logError('find_refs failed:', err);
124
+ return {
125
+ content: [{ type: 'text', text: `find_refs error: ${err instanceof Error ? err.message : String(err)}` }],
126
+ isError: true,
127
+ };
128
+ }
129
+ });
130
+ server.registerTool('dep_graph', {
131
+ title: 'Module dependency graph',
132
+ description: DEP_GRAPH_DESCRIPTION,
133
+ inputSchema: depGraphInputShape,
134
+ }, async (args) => {
135
+ void counter.record('dep_graph', args);
136
+ try {
137
+ const { text } = await runDepGraph(store, root, args);
138
+ return { content: [{ type: 'text', text }] };
139
+ }
140
+ catch (err) {
141
+ logError('dep_graph failed:', err);
142
+ return {
143
+ content: [{ type: 'text', text: `dep_graph error: ${err instanceof Error ? err.message : String(err)}` }],
144
+ isError: true,
145
+ };
146
+ }
147
+ });
148
+ server.registerTool('arch_list', { title: 'List architecture concepts', description: ARCH_LIST_DESCRIPTION }, async () => {
149
+ void counter.record('arch_list', {});
150
+ try {
151
+ const { text } = await runArchList(store, root);
152
+ return { content: [{ type: 'text', text }] };
153
+ }
154
+ catch (err) {
155
+ logError('arch_list failed:', err);
156
+ return {
157
+ content: [{ type: 'text', text: `arch_list error: ${err instanceof Error ? err.message : String(err)}` }],
158
+ isError: true,
159
+ };
160
+ }
161
+ });
162
+ server.registerTool('arch_query', { title: 'Resolve an architecture concept', description: ARCH_QUERY_DESCRIPTION, inputSchema: archQueryInputShape }, async (args) => {
163
+ void counter.record('arch_query', args);
164
+ try {
165
+ const { text } = await runArchQuery(store, root, args);
166
+ return { content: [{ type: 'text', text }] };
167
+ }
168
+ catch (err) {
169
+ logError('arch_query failed:', err);
170
+ return {
171
+ content: [{ type: 'text', text: `arch_query error: ${err instanceof Error ? err.message : String(err)}` }],
172
+ isError: true,
173
+ };
174
+ }
175
+ });
176
+ const transport = new StdioServerTransport();
177
+ await server.connect(transport);
178
+ log('serving on stdio');
179
+ }
@@ -0,0 +1,33 @@
1
+ import { join } from 'node:path';
2
+ import { loadVocabulary } from '../knowledge/vocab-loader.js';
3
+ import { resolveConcept } from '../knowledge/resolver.js';
4
+ import { computeAnchors, compareDrift, loadAnchorStore, baselineFor } from '../knowledge/drift.js';
5
+ // List the repo's declared architecture concepts (progressive disclosure: names +
6
+ // counts + freshness first). Resolved LIVE; read-only — never captures a baseline
7
+ // (that's arch_query's job), so unqueried concepts show as "unverified".
8
+ export async function runArchList(store, root) {
9
+ await store.sync();
10
+ const vocab = loadVocabulary(join(root, '.arcscope', 'vocab.yaml'));
11
+ if (vocab.concepts.length === 0) {
12
+ return {
13
+ conceptCount: 0,
14
+ text: 'No architecture vocabulary found. Add named concepts to .arcscope/vocab.yaml (committed) to make this repo self-describing.',
15
+ };
16
+ }
17
+ const anchorStore = loadAnchorStore(root);
18
+ const lines = vocab.concepts.map((c) => {
19
+ const resolved = resolveConcept(store, c);
20
+ const baseline = baselineFor(anchorStore, c.id);
21
+ const freshness = baseline ? compareDrift(computeAnchors(root, resolved), baseline).status : 'unverified';
22
+ return ` ${c.id}${c.stages ? ' (staged)' : ''} — ${c.title} · ${resolved.length} location${resolved.length === 1 ? '' : 's'} [${freshness}]`;
23
+ });
24
+ return {
25
+ conceptCount: vocab.concepts.length,
26
+ text: [
27
+ `${vocab.concepts.length} architecture concept${vocab.concepts.length === 1 ? '' : 's'} (answered live against current code):`,
28
+ ...lines,
29
+ '',
30
+ 'Use arch_query <id> to resolve one concept to its live locations (and capture/check its drift baseline).',
31
+ ].join('\n'),
32
+ };
33
+ }
@@ -0,0 +1,81 @@
1
+ import { join } from 'node:path';
2
+ import { z } from 'zod';
3
+ import { loadVocabulary } from '../knowledge/vocab-loader.js';
4
+ import { resolveConcept } from '../knowledge/resolver.js';
5
+ import { computeAnchors, compareDrift, loadAnchorStore, baselineFor, captureBaseline } from '../knowledge/drift.js';
6
+ export const archQueryInputShape = {
7
+ concept: z.string().min(1).describe("Concept id from arch_list, e.g. 'repository-tokens' or 'editor-state-flow'."),
8
+ reaccept: z
9
+ .boolean()
10
+ .optional()
11
+ .describe('Re-snapshot the drift baseline to the current resolution (use after a legitimate change that the concept should now treat as correct).'),
12
+ };
13
+ // Resolve one named concept LIVE against the current tree — never cached prose.
14
+ // (Drift detection is layered on in Slice 2.)
15
+ export async function runArchQuery(store, root, args) {
16
+ await store.sync();
17
+ const vocab = loadVocabulary(join(root, '.arcscope', 'vocab.yaml'));
18
+ const concept = vocab.concepts.find((c) => c.id === args.concept);
19
+ if (!concept) {
20
+ const known = vocab.concepts.length
21
+ ? ` Known concepts: ${vocab.concepts.map((c) => c.id).join(', ')}.`
22
+ : ' (.arcscope/vocab.yaml is missing or empty.)';
23
+ return { resolved: [], freshness: 'unknown', text: `No concept "${args.concept}" in the vocabulary.${known}` };
24
+ }
25
+ const resolved = resolveConcept(store, concept);
26
+ // Drift: capture a baseline on first query (or --reaccept), else compare.
27
+ const current = computeAnchors(root, resolved);
28
+ const baseline = baselineFor(loadAnchorStore(root), concept.id);
29
+ let freshness;
30
+ let drift;
31
+ if (!baseline || args.reaccept) {
32
+ captureBaseline(root, concept.id, current, new Date().toISOString());
33
+ freshness = baseline ? 'fresh (baseline re-captured)' : 'fresh (baseline captured)';
34
+ }
35
+ else {
36
+ drift = compareDrift(current, baseline);
37
+ freshness = drift.status === 'drifted' ? 'DRIFTED' : 'fresh';
38
+ }
39
+ return { resolved, freshness, drift, text: formatConcept(concept, resolved, freshness, drift) };
40
+ }
41
+ export function formatConcept(concept, resolved, freshness, drift) {
42
+ const tag = freshness ? `, ${freshness}` : ', answered live';
43
+ const head = `Concept \`${concept.id}\` — ${concept.title} (${resolved.length} location${resolved.length === 1 ? '' : 's'}${tag}):`;
44
+ const body = [];
45
+ if (concept.description)
46
+ body.push(` ${concept.description}`);
47
+ if (concept.stages) {
48
+ for (const stage of concept.stages) {
49
+ body.push(` ▸ ${stage.title}`);
50
+ const locs = resolved.filter((r) => r.stage === stage.title);
51
+ if (locs.length === 0)
52
+ body.push(' (no match — possible drift)');
53
+ for (const r of locs)
54
+ body.push(` ${formatLoc(r)}`);
55
+ }
56
+ }
57
+ else {
58
+ if (resolved.length === 0)
59
+ body.push(' (nothing resolved — the locators may need updating, or the concept drifted)');
60
+ for (const r of resolved)
61
+ body.push(` ${formatLoc(r)}`);
62
+ }
63
+ if (concept.note)
64
+ body.push(` note: ${concept.note}`);
65
+ if (drift && drift.status === 'drifted') {
66
+ body.push('', ` ⚠ DRIFT vs baseline: ${drift.added.length} added, ${drift.removed.length} removed, ${drift.changed.length} changed.`);
67
+ for (const k of drift.added.slice(0, 5))
68
+ body.push(` + ${k}`);
69
+ for (const k of drift.removed.slice(0, 5))
70
+ body.push(` - ${k}`);
71
+ for (const k of drift.changed.slice(0, 5))
72
+ body.push(` ~ ${k} (definition changed)`);
73
+ body.push(' If this change is correct, re-run arch_query with reaccept:true to update the baseline.');
74
+ }
75
+ return [head, ...body].join('\n');
76
+ }
77
+ function formatLoc(r) {
78
+ if (r.via === 'path')
79
+ return `${r.file} [file]`;
80
+ return `${r.file}:${r.line} [${r.kind}] ${r.signature ?? ''}`.trimEnd();
81
+ }
Binary file
@@ -0,0 +1,46 @@
1
+ import { z } from 'zod';
2
+ const FUZZY_MIN_LENGTH = 3;
3
+ // Zod raw shape for the find_def tool input (registerTool accepts a raw shape).
4
+ // The .describe() text is part of the adoption surface the agent reads.
5
+ export const findDefInputShape = {
6
+ symbol: z
7
+ .string()
8
+ .min(1)
9
+ .describe("Exact symbol name to locate (case-sensitive), e.g. 'GraphReducer', 'useAuth', 'IThingRepository'."),
10
+ pathGlob: z
11
+ .string()
12
+ .optional()
13
+ .describe("Optional path glob to scope results, e.g. 'libs/features/**' or 'src/**/*.ts'."),
14
+ };
15
+ // Tool logic, independent of the MCP transport so it can be unit-tested directly.
16
+ // Re-syncs the index (lazy re-index) so results always reflect the current tree.
17
+ // Exact match is the precise default; on zero hits it falls back to close-name
18
+ // suggestions so the agent doesn't have to guess the exact symbol name.
19
+ export async function runFindDef(store, args) {
20
+ await store.sync();
21
+ const records = store.find(args.symbol, args.pathGlob);
22
+ if (records.length > 0) {
23
+ return { records, suggestions: [], text: formatExact(args.symbol, args.pathGlob, records) };
24
+ }
25
+ const suggestions = args.symbol.length >= FUZZY_MIN_LENGTH ? store.findFuzzy(args.symbol, args.pathGlob) : [];
26
+ return { records: [], suggestions, text: formatMiss(args.symbol, args.pathGlob, suggestions, store.fileCount) };
27
+ }
28
+ function formatExact(symbol, pathGlob, records) {
29
+ const scope = pathGlob ? ` within \`${pathGlob}\`` : '';
30
+ const head = `${records.length} definition${records.length === 1 ? '' : 's'} of \`${symbol}\`${scope} (precision: tree-sitter):`;
31
+ // The symbol is in the header, so exact lines omit it.
32
+ const lines = records.map((r) => ` ${r.file}:${r.line} [${r.kind}] ${r.signature}`);
33
+ return [head, ...lines].join('\n');
34
+ }
35
+ function formatMiss(symbol, pathGlob, suggestions, fileCount) {
36
+ const scope = pathGlob ? ` within \`${pathGlob}\`` : '';
37
+ if (suggestions.length === 0) {
38
+ return (`No definition of \`${symbol}\` found${scope} (searched ${fileCount} files via tree-sitter). ` +
39
+ 'It may be defined in a gitignored/untracked file, imported from a dependency, or spelled ' +
40
+ 'differently — find_def matches names exactly and is case-sensitive.');
41
+ }
42
+ const head = `No exact definition of \`${symbol}\`${scope}, but ${suggestions.length} symbol${suggestions.length === 1 ? ' has' : 's have'} a similar name (matched by name; locations are exact):`;
43
+ // Suggestions are distinct names, so each line leads with the symbol.
44
+ const lines = suggestions.map((r) => ` ${r.symbol} [${r.kind}] ${r.file}:${r.line} ${r.signature}`);
45
+ return [head, ...lines, 'Re-run find_def with one of these exact names for the full, precise result.'].join('\n');
46
+ }
@@ -0,0 +1,88 @@
1
+ import { z } from 'zod';
2
+ import { readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { ImportGraph } from '../engine/import-graph.js';
5
+ import { findOccurrences } from '../engine/ref-scan.js';
6
+ import { matchGlob } from '../engine/glob.js';
7
+ export const findRefsInputShape = {
8
+ symbol: z
9
+ .string()
10
+ .min(1)
11
+ .describe("Exact symbol name to find references to (case-sensitive), e.g. 'GraphReducer'."),
12
+ pathGlob: z
13
+ .string()
14
+ .optional()
15
+ .describe("Optional path glob to scope which referencing files are reported, e.g. 'apps/**'."),
16
+ };
17
+ // Who references a symbol — resolved through tsconfig aliases + barrel re-exports.
18
+ // Uses the import graph to find which files actually import the symbol, then scans
19
+ // only those files (+ the definition file) for usages. Same-named symbols in
20
+ // files that don't import it are excluded — that's the precision win over grep.
21
+ export async function runFindRefs(store, registry, root, args) {
22
+ await store.sync();
23
+ const defs = store.find(args.symbol);
24
+ if (defs.length === 0) {
25
+ return {
26
+ records: [],
27
+ text: `No definition of \`${args.symbol}\` found, so there is nothing to resolve references against. Try find_def first (it suggests similar names).`,
28
+ };
29
+ }
30
+ const graph = new ImportGraph(store, root);
31
+ const records = [];
32
+ let importerCount = 0;
33
+ const scanned = new Set();
34
+ for (const def of defs) {
35
+ const sites = [
36
+ { file: def.file, local: args.symbol }, // intra-file uses in the definition file
37
+ ...graph.importersOf(args.symbol, def.file),
38
+ ];
39
+ for (const site of sites) {
40
+ if (site.file !== def.file)
41
+ importerCount++;
42
+ if (scanned.has(site.file))
43
+ continue;
44
+ scanned.add(site.file);
45
+ let source;
46
+ try {
47
+ source = readFileSync(join(root, site.file), 'utf8');
48
+ }
49
+ catch {
50
+ continue;
51
+ }
52
+ for (const occ of await findOccurrences(registry, site.file, source, site.local)) {
53
+ if (args.pathGlob && !matchGlob(occ.file, args.pathGlob))
54
+ continue;
55
+ records.push({
56
+ symbol: site.local,
57
+ file: occ.file,
58
+ line: occ.line,
59
+ column: occ.column,
60
+ snippet: occ.snippet,
61
+ refKind: occ.refKind,
62
+ resolved: true,
63
+ resolvesTo: { file: def.file, line: def.line },
64
+ precisionTier: 'tree-sitter',
65
+ });
66
+ }
67
+ }
68
+ }
69
+ records.sort((a, b) => (a.file === b.file ? a.line - b.line : a.file < b.file ? -1 : 1));
70
+ const kinds = new Set(defs.map((d) => d.kind));
71
+ return { records, text: format(args.symbol, args.pathGlob, defs.length, importerCount, records, kinds) };
72
+ }
73
+ function format(symbol, pathGlob, defCount, importerCount, records, kinds) {
74
+ const scope = pathGlob ? ` within \`${pathGlob}\`` : '';
75
+ if (records.length === 0) {
76
+ // find_refs is import-resolution based, so a symbol that is never imported by
77
+ // name (a method/object-property invoked via member access, used only in its
78
+ // own file, or reached via namespace/default import) yields nothing. Say so.
79
+ const memberHint = kinds.has('method') ? ` Since \`${symbol}\` is (or includes) a method, it is invoked via member access (\`obj.${symbol}()\`) — find_refs its declaring class/interface, or grep \`.${symbol}\`.` : '';
80
+ return (`No import-resolved references to \`${symbol}\`${scope} (${defCount} definition${defCount === 1 ? '' : 's'}). find_refs follows imports, so it does not find a symbol referenced only via member access, used only within its own file, or reached through namespace/default imports.` +
81
+ memberHint);
82
+ }
83
+ const defNote = defCount > 1 ? ` (${defCount} definitions share this name; refs are split by the one they resolve to)` : '';
84
+ const head = `${records.length} reference${records.length === 1 ? '' : 's'} to \`${symbol}\`${scope}${defNote} — resolved through imports/barrels (precision: tree-sitter):`;
85
+ const lines = records.map((r) => ` ${r.file}:${r.line} [${r.refKind}] ${r.snippet}` + (r.resolvesTo ? ` -> ${r.resolvesTo.file}:${r.resolvesTo.line}` : ''));
86
+ const foot = `Resolved across ${importerCount} importing file${importerCount === 1 ? '' : 's'} + the definition; same-named symbols in files that don't import it are excluded.`;
87
+ return [head, ...lines, foot].join('\n');
88
+ }
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "arcscope",
3
+ "version": "0.0.1",
4
+ "description": "Fully-local, architecture-aware code-navigation MCP server. tree-sitter breadth + a repo-declared architecture vocabulary answered live.",
5
+ "type": "module",
6
+ "author": "Ilie Danila",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/iliedanila/arcscope.git"
10
+ },
11
+ "homepage": "https://github.com/iliedanila/arcscope#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/iliedanila/arcscope/issues"
14
+ },
15
+ "bin": {
16
+ "arcscope": "dist/index.js"
17
+ },
18
+ "files": [
19
+ "dist"
20
+ ],
21
+ "engines": {
22
+ "node": ">=20"
23
+ },
24
+ "scripts": {
25
+ "clean": "shx rm -rf dist",
26
+ "build": "shx rm -rf dist && tsc -p tsconfig.build.json && shx cp -r vendor/grammars dist/grammars && shx chmod +x dist/index.js",
27
+ "prepack": "npm run build",
28
+ "typecheck": "tsc --noEmit",
29
+ "test": "node --import tsx --test \"src/**/*.test.ts\"",
30
+ "vendor:grammars": "shx cp node_modules/web-tree-sitter/web-tree-sitter.wasm vendor/grammars/ && shx cp node_modules/tree-sitter-typescript/tree-sitter-typescript.wasm node_modules/tree-sitter-typescript/tree-sitter-tsx.wasm node_modules/tree-sitter-javascript/tree-sitter-javascript.wasm vendor/grammars/ && shx cp node_modules/tree-sitter-typescript/queries/tags.scm vendor/grammars/typescript-tags.scm && shx cp node_modules/tree-sitter-javascript/queries/tags.scm vendor/grammars/javascript-tags.scm"
31
+ },
32
+ "keywords": [
33
+ "mcp",
34
+ "tree-sitter",
35
+ "code-navigation",
36
+ "architecture",
37
+ "local"
38
+ ],
39
+ "license": "MIT",
40
+ "dependencies": {
41
+ "@modelcontextprotocol/sdk": "^1.29.0",
42
+ "js-yaml": "^4.2.0",
43
+ "web-tree-sitter": "^0.26.9",
44
+ "zod": "^3.25.0"
45
+ },
46
+ "devDependencies": {
47
+ "@types/js-yaml": "^4.0.9",
48
+ "@types/node": "^24.0.0",
49
+ "shx": "^0.4.0",
50
+ "tree-sitter-javascript": "^0.25.0",
51
+ "tree-sitter-typescript": "^0.23.2",
52
+ "tsx": "^4.22.4",
53
+ "typescript": "^5.7.0"
54
+ }
55
+ }