@graphpilot-oss/graphpilot 0.0.1 → 1.0.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/CHANGELOG.md +72 -126
- package/README.md +290 -102
- package/dist/cli.js +41 -1
- package/dist/cli.js.map +1 -1
- package/dist/edges.js +22 -11
- package/dist/edges.js.map +1 -1
- package/dist/indexer.js +3 -3
- package/dist/indexer.js.map +1 -1
- package/dist/init.d.ts +28 -0
- package/dist/init.js +112 -0
- package/dist/init.js.map +1 -0
- package/dist/interactions.d.ts +5 -4
- package/dist/interactions.js +0 -0
- package/dist/interactions.js.map +1 -1
- package/dist/mcp.js +119 -90
- package/dist/mcp.js.map +1 -1
- package/dist/repo-resolve.d.ts +47 -0
- package/dist/repo-resolve.js +195 -0
- package/dist/repo-resolve.js.map +1 -0
- package/dist/storage.js +10 -1
- package/dist/storage.js.map +1 -1
- package/dist/symbols.js +26 -2
- package/dist/symbols.js.map +1 -1
- package/dist/validation.js +30 -4
- package/dist/validation.js.map +1 -1
- package/dist/validators.d.ts +1 -5
- package/dist/validators.js +0 -11
- package/dist/validators.js.map +1 -1
- package/dist/watcher.d.ts +10 -0
- package/dist/watcher.js +70 -7
- package/dist/watcher.js.map +1 -1
- package/examples/README.md +105 -0
- package/examples/claude-code/README.md +125 -0
- package/examples/claude-code/claude-routing.md +102 -0
- package/examples/claude-code/claude_config.json +8 -0
- package/examples/cline/.clinerules +39 -0
- package/examples/cline/README.md +104 -0
- package/examples/cline/cline_mcp_settings.json +10 -0
- package/examples/continue/.continuerules +39 -0
- package/examples/continue/README.md +98 -0
- package/examples/continue/config.json +13 -0
- package/examples/cursor/.cursorrules +39 -0
- package/examples/cursor/README.md +98 -0
- package/examples/cursor/mcp.json +11 -0
- package/examples/windsurf/.windsurfrules +39 -0
- package/examples/windsurf/README.md +85 -0
- package/examples/windsurf/mcp_config.json +8 -0
- package/package.json +14 -4
- package/.editorconfig +0 -15
- package/.github/CODEOWNERS +0 -22
- package/.github/FUNDING.yml +0 -1
- package/.github/ISSUE_TEMPLATE/bug_report.md +0 -33
- package/.github/ISSUE_TEMPLATE/config.yml +0 -5
- package/.github/ISSUE_TEMPLATE/feature_request.md +0 -23
- package/.github/PULL_REQUEST_TEMPLATE.md +0 -19
- package/.github/dependabot.yml +0 -15
- package/.github/workflows/ci.yml +0 -62
- package/.github/workflows/release.yml +0 -50
- package/.prettierignore +0 -19
- package/.prettierrc.json +0 -20
- package/CODE_OF_CONDUCT.md +0 -83
- package/CONTRIBUTING.md +0 -111
- package/bench/README.md +0 -544
- package/bench/results/agent-tier-2026-05-22.md +0 -28
- package/bench/results/agent-tier-summary.md +0 -44
- package/bench/results/baseline-tier-2026-05-22.md +0 -23
- package/bench/results/baseline.json +0 -810
- package/bench/results/baseline.md +0 -28
- package/bench/run-agent-tier-automated.ts +0 -234
- package/bench/run-agent-tier.md +0 -125
- package/bench/run-baseline-tier.ts +0 -200
- package/bench/run.ts +0 -210
- package/bench/runner-baseline.ts +0 -177
- package/bench/runner-graphpilot.ts +0 -131
- package/bench/score-agent-tier.ts +0 -191
- package/bench/score.ts +0 -59
- package/bench/tasks.ts +0 -236
- package/dist/provenance.d.ts +0 -74
- package/dist/provenance.js +0 -95
- package/dist/provenance.js.map +0 -1
- package/docs/architecture.md +0 -311
- package/docs/limitations.md +0 -156
- package/docs/mcp-setup.md +0 -231
- package/docs/quickstart.md +0 -202
- package/eslint.config.js +0 -148
- package/lefthook.yml +0 -81
- package/pnpm-workspace.yaml +0 -6
- package/scripts/smoke-stdio.mjs +0 -97
- package/src/cli.ts +0 -171
- package/src/edges.ts +0 -202
- package/src/git.ts +0 -255
- package/src/graph-schema.ts +0 -229
- package/src/impact.ts +0 -218
- package/src/indexer.ts +0 -152
- package/src/interactions.ts +0 -0
- package/src/mcp.ts +0 -652
- package/src/parser.ts +0 -138
- package/src/provenance.ts +0 -115
- package/src/query.ts +0 -148
- package/src/redact.ts +0 -122
- package/src/storage.ts +0 -115
- package/src/symbols.ts +0 -173
- package/src/validation.ts +0 -69
- package/src/validators.ts +0 -253
- package/src/watcher.ts +0 -383
- package/tests/edges.test.ts +0 -175
- package/tests/fixtures/sample.ts +0 -32
- package/tests/git.test.ts +0 -303
- package/tests/graph-schema.test.ts +0 -321
- package/tests/impact.test.ts +0 -454
- package/tests/interactions.test.ts +0 -180
- package/tests/lint-policy.test.ts +0 -106
- package/tests/mcp-stdio.test.ts +0 -171
- package/tests/mcp.test.ts +0 -335
- package/tests/parser.test.ts +0 -31
- package/tests/provenance.test.ts +0 -132
- package/tests/query.test.ts +0 -160
- package/tests/redact.test.ts +0 -167
- package/tests/security.test.ts +0 -144
- package/tests/symbols.test.ts +0 -78
- package/tests/validators.test.ts +0 -193
- package/tests/watcher.test.ts +0 -250
- package/tsconfig.json +0 -18
package/src/parser.ts
DELETED
|
@@ -1,138 +0,0 @@
|
|
|
1
|
-
import Parser from 'tree-sitter';
|
|
2
|
-
// @ts-ignore — tree-sitter-typescript ships JS, has no types
|
|
3
|
-
import TS from 'tree-sitter-typescript';
|
|
4
|
-
import { readFileSync, statSync } from 'node:fs';
|
|
5
|
-
import { extname } from 'node:path';
|
|
6
|
-
import { MAX_FILE_BYTES } from './validation.js';
|
|
7
|
-
|
|
8
|
-
export interface ParsedFile {
|
|
9
|
-
path: string;
|
|
10
|
-
lang: 'typescript' | 'tsx' | 'javascript' | 'jsx';
|
|
11
|
-
tree: Parser.Tree;
|
|
12
|
-
source: string;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
const PARSER_CACHE = new Map<string, Parser>();
|
|
16
|
-
|
|
17
|
-
function getParser(lang: ParsedFile['lang']): Parser {
|
|
18
|
-
if (PARSER_CACHE.has(lang)) return PARSER_CACHE.get(lang)!;
|
|
19
|
-
const p = new Parser();
|
|
20
|
-
// Cast around the peer-dep type-version skew between tree-sitter and
|
|
21
|
-
// tree-sitter-typescript. Runtime is fine; only the .d.ts files disagree.
|
|
22
|
-
const langs = TS as { typescript: Parser.Language; tsx: Parser.Language };
|
|
23
|
-
switch (lang) {
|
|
24
|
-
case 'typescript':
|
|
25
|
-
p.setLanguage(langs.typescript);
|
|
26
|
-
break;
|
|
27
|
-
case 'tsx':
|
|
28
|
-
p.setLanguage(langs.tsx);
|
|
29
|
-
break;
|
|
30
|
-
case 'javascript':
|
|
31
|
-
case 'jsx':
|
|
32
|
-
p.setLanguage(langs.typescript);
|
|
33
|
-
break;
|
|
34
|
-
}
|
|
35
|
-
PARSER_CACHE.set(lang, p);
|
|
36
|
-
return p;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export function detectLang(path: string): ParsedFile['lang'] | null {
|
|
40
|
-
switch (extname(path).toLowerCase()) {
|
|
41
|
-
case '.ts':
|
|
42
|
-
return 'typescript';
|
|
43
|
-
case '.tsx':
|
|
44
|
-
return 'tsx';
|
|
45
|
-
case '.js':
|
|
46
|
-
case '.mjs':
|
|
47
|
-
case '.cjs':
|
|
48
|
-
return 'javascript';
|
|
49
|
-
case '.jsx':
|
|
50
|
-
return 'jsx';
|
|
51
|
-
default:
|
|
52
|
-
return null;
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export function parseFile(path: string): ParsedFile | null {
|
|
57
|
-
const lang = detectLang(path);
|
|
58
|
-
if (!lang) return null;
|
|
59
|
-
// Defence against T1 (resource exhaustion): skip oversized files rather than
|
|
60
|
-
// OOM the process. 5 MB is enough for any real source file.
|
|
61
|
-
let size: number;
|
|
62
|
-
try {
|
|
63
|
-
size = statSync(path).size;
|
|
64
|
-
} catch {
|
|
65
|
-
return null;
|
|
66
|
-
}
|
|
67
|
-
if (size > MAX_FILE_BYTES) return null;
|
|
68
|
-
const source = readFileSync(path, 'utf8');
|
|
69
|
-
return parseSource(path, source, lang);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
export function parseSource(path: string, source: string, lang: ParsedFile['lang']): ParsedFile {
|
|
73
|
-
const tree = getParser(lang).parse(source);
|
|
74
|
-
return { path, lang, tree, source };
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* Walk the tree and yield every node. Depth-first, pre-order.
|
|
79
|
-
*
|
|
80
|
-
* Iterative (not recursive) so a deeply-nested AST can't blow the JS stack.
|
|
81
|
-
* Tree-sitter trees on real codebases hit ~50–80 depth; pathological generated
|
|
82
|
-
* code can go much deeper. Defence against T1.
|
|
83
|
-
*/
|
|
84
|
-
export function* walk(node: Parser.SyntaxNode): Generator<Parser.SyntaxNode> {
|
|
85
|
-
const stack: Parser.SyntaxNode[] = [node];
|
|
86
|
-
while (stack.length > 0) {
|
|
87
|
-
const cur = stack.pop()!;
|
|
88
|
-
yield cur;
|
|
89
|
-
// Push children in reverse so they pop in original order (preserves
|
|
90
|
-
// pre-order traversal — matters for callers that rely on source ordering).
|
|
91
|
-
for (let i = cur.childCount - 1; i >= 0; i--) {
|
|
92
|
-
const child = cur.child(i);
|
|
93
|
-
if (child) stack.push(child);
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/**
|
|
99
|
-
* Day-2 deliverable: list every function name in a parsed file.
|
|
100
|
-
* Catches: function declarations, arrow functions assigned to consts,
|
|
101
|
-
* class methods, function expressions assigned to variables.
|
|
102
|
-
*/
|
|
103
|
-
export function listFunctions(parsed: ParsedFile): string[] {
|
|
104
|
-
const names: string[] = [];
|
|
105
|
-
for (const node of walk(parsed.tree.rootNode)) {
|
|
106
|
-
const name = functionNameOf(node);
|
|
107
|
-
if (name) names.push(name);
|
|
108
|
-
}
|
|
109
|
-
return names;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
function functionNameOf(node: Parser.SyntaxNode): string | null {
|
|
113
|
-
switch (node.type) {
|
|
114
|
-
case 'function_declaration':
|
|
115
|
-
case 'generator_function_declaration':
|
|
116
|
-
case 'method_definition':
|
|
117
|
-
case 'function_signature': {
|
|
118
|
-
const nameNode = node.childForFieldName('name');
|
|
119
|
-
return nameNode?.text ?? null;
|
|
120
|
-
}
|
|
121
|
-
case 'variable_declarator': {
|
|
122
|
-
// const foo = () => ... | const foo = function() {}
|
|
123
|
-
const valueNode = node.childForFieldName('value');
|
|
124
|
-
if (
|
|
125
|
-
valueNode &&
|
|
126
|
-
(valueNode.type === 'arrow_function' ||
|
|
127
|
-
valueNode.type === 'function_expression' ||
|
|
128
|
-
valueNode.type === 'function')
|
|
129
|
-
) {
|
|
130
|
-
const nameNode = node.childForFieldName('name');
|
|
131
|
-
return nameNode?.text ?? null;
|
|
132
|
-
}
|
|
133
|
-
return null;
|
|
134
|
-
}
|
|
135
|
-
default:
|
|
136
|
-
return null;
|
|
137
|
-
}
|
|
138
|
-
}
|
package/src/provenance.ts
DELETED
|
@@ -1,115 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Provenance / evidence anchors for tool responses.
|
|
3
|
-
*
|
|
4
|
-
* Why this exists: agents hallucinate. The most common Cursor / Claude
|
|
5
|
-
* Code failure pattern is "the model called a function that doesn't
|
|
6
|
-
* exist" — and the user has no quick way to verify a tool's claim
|
|
7
|
-
* before acting on it.
|
|
8
|
-
*
|
|
9
|
-
* Solution: every result we return carries an evidence anchor — a
|
|
10
|
-
* structured `{file, line, sha, excerpt}` reference that the agent
|
|
11
|
-
* can include verbatim in its reply. The user (or the agent itself)
|
|
12
|
-
* can then jump to the file/line and confirm the cited code matches
|
|
13
|
-
* the claim. If we ever fabricate, the anchor exposes us instantly.
|
|
14
|
-
*
|
|
15
|
-
* Format design:
|
|
16
|
-
* - Path is relative to the indexed root (portable).
|
|
17
|
-
* - Line is 1-indexed (matches editor convention).
|
|
18
|
-
* - SHA is optional — null when the indexed repo isn't a git repo.
|
|
19
|
-
* When present, it's the 7-char short SHA of the index time.
|
|
20
|
-
* - Excerpt is optional and short (~200 chars) — for symbol records
|
|
21
|
-
* it's the signature line. For edges it's the call expression.
|
|
22
|
-
*
|
|
23
|
-
* Per the differentiation research, this is the SINGLE feature no
|
|
24
|
-
* competitor in the 15+ landscape has shipped. See
|
|
25
|
-
* .notes/differentiation-research-2026-05-21.md §1.3.
|
|
26
|
-
*/
|
|
27
|
-
|
|
28
|
-
import type { SymbolRecord } from './symbols.js';
|
|
29
|
-
import type { CallEdge } from './edges.js';
|
|
30
|
-
|
|
31
|
-
/** A single evidence anchor pointing at a specific location in the repo. */
|
|
32
|
-
export interface Provenance {
|
|
33
|
-
/** Relative path from the indexed repo root, e.g. "src/auth.ts". */
|
|
34
|
-
file: string;
|
|
35
|
-
/** 1-indexed line number. */
|
|
36
|
-
line: number;
|
|
37
|
-
/** Optional 1-indexed column. */
|
|
38
|
-
column?: number;
|
|
39
|
-
/** End line, when the entity spans multiple lines. */
|
|
40
|
-
endLine?: number;
|
|
41
|
-
/** Short git SHA (7 chars) at index time. Null if not a git repo. */
|
|
42
|
-
sha?: string | null;
|
|
43
|
-
/** Short text excerpt — symbol signature, call expression, etc. */
|
|
44
|
-
excerpt?: string;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
const MAX_EXCERPT_LEN = 200;
|
|
48
|
-
|
|
49
|
-
function clipExcerpt(s: string | undefined): string | undefined {
|
|
50
|
-
if (!s) return undefined;
|
|
51
|
-
const trimmed = s.trim();
|
|
52
|
-
if (trimmed.length === 0) return undefined;
|
|
53
|
-
return trimmed.length > MAX_EXCERPT_LEN ? trimmed.slice(0, MAX_EXCERPT_LEN - 1) + '…' : trimmed;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* Make provenance for a symbol. Excerpt is the symbol's stored
|
|
58
|
-
* signature (already secret-redacted by T3).
|
|
59
|
-
*/
|
|
60
|
-
export function symbolProvenance(s: SymbolRecord, sha: string | null): Provenance {
|
|
61
|
-
return {
|
|
62
|
-
file: s.file,
|
|
63
|
-
line: s.line,
|
|
64
|
-
column: s.column,
|
|
65
|
-
endLine: s.endLine,
|
|
66
|
-
sha: sha ?? null,
|
|
67
|
-
excerpt: clipExcerpt(s.signature),
|
|
68
|
-
};
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* Make provenance for a call edge. Points at the CALL SITE (where the
|
|
73
|
-
* call happens), not the callee's definition. The callee's own
|
|
74
|
-
* provenance is available via `symbolProvenance(idx.findById(edge.toId))`
|
|
75
|
-
* when toId is non-null.
|
|
76
|
-
*/
|
|
77
|
-
export function edgeProvenance(e: CallEdge, sha: string | null): Provenance {
|
|
78
|
-
return {
|
|
79
|
-
file: e.file,
|
|
80
|
-
line: e.line,
|
|
81
|
-
column: e.column,
|
|
82
|
-
sha: sha ?? null,
|
|
83
|
-
// No excerpt for edges — we don't store the call line text.
|
|
84
|
-
// (Future v0.2: capture the call line at index time so the agent
|
|
85
|
-
// sees the actual call expression.)
|
|
86
|
-
};
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* Format a Provenance as a single-line human/agent-readable string.
|
|
91
|
-
* Used inline in tool text responses.
|
|
92
|
-
*
|
|
93
|
-
* Examples:
|
|
94
|
-
* src/auth.ts:42 (no sha — not a git repo)
|
|
95
|
-
* src/auth.ts:42 @ ab12cd3 (with sha)
|
|
96
|
-
* src/auth.ts:42:5 @ ab12cd3 (with column)
|
|
97
|
-
*/
|
|
98
|
-
export function formatProvenance(p: Provenance): string {
|
|
99
|
-
let out = p.file + ':' + p.line;
|
|
100
|
-
if (p.column !== undefined) out += ':' + p.column;
|
|
101
|
-
if (p.sha) out += ' @ ' + p.sha;
|
|
102
|
-
return out;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* Format a Provenance as a verifiable evidence tag the agent can
|
|
107
|
-
* include in its reply. The agent's user (or a downstream tool) can
|
|
108
|
-
* paste this directly into a search.
|
|
109
|
-
*
|
|
110
|
-
* Example:
|
|
111
|
-
* [evidence: src/auth.ts:42 @ ab12cd3]
|
|
112
|
-
*/
|
|
113
|
-
export function formatEvidenceTag(p: Provenance): string {
|
|
114
|
-
return `[evidence: ${formatProvenance(p)}]`;
|
|
115
|
-
}
|
package/src/query.ts
DELETED
|
@@ -1,148 +0,0 @@
|
|
|
1
|
-
import type { Graph } from './storage.js';
|
|
2
|
-
import type { SymbolRecord } from './symbols.js';
|
|
3
|
-
import type { CallEdge } from './edges.js';
|
|
4
|
-
|
|
5
|
-
export interface RecallOptions {
|
|
6
|
-
/** Max results. Default 10. Capped at 100. */
|
|
7
|
-
limit?: number;
|
|
8
|
-
/**
|
|
9
|
-
* If true, match if the query is a substring of the symbol name.
|
|
10
|
-
* If false (default), require exact case-insensitive match.
|
|
11
|
-
*/
|
|
12
|
-
substring?: boolean;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export interface EdgeQueryOptions {
|
|
16
|
-
/** Max edges to return. Default 50. Capped at 500. */
|
|
17
|
-
limit?: number;
|
|
18
|
-
/**
|
|
19
|
-
* If true, include edges where the callee is unresolved (toId === null).
|
|
20
|
-
* Default true — agents usually want to see those too.
|
|
21
|
-
*/
|
|
22
|
-
includeUnresolved?: boolean;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
const HARD_RESULT_CAP = 100;
|
|
26
|
-
const HARD_EDGE_CAP = 500;
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Pre-computed lookup tables over a Graph. Build once after loading; every
|
|
30
|
-
* query is then O(1) or O(k) (k = result count).
|
|
31
|
-
*
|
|
32
|
-
* The whole index is built in one pass on construction. For graphpilot's own
|
|
33
|
-
* code (50 symbols, 155 edges) build time is <1ms. For a 50k-symbol repo
|
|
34
|
-
* we'd expect ~20ms — still negligible compared to a Claude Code round trip.
|
|
35
|
-
*/
|
|
36
|
-
export class GraphIndex {
|
|
37
|
-
private readonly byNameLower: Map<string, SymbolRecord[]> = new Map();
|
|
38
|
-
private readonly byId: Map<string, SymbolRecord> = new Map();
|
|
39
|
-
/** Edges keyed by callee id — answers "who calls X?". */
|
|
40
|
-
private readonly callersOf: Map<string, CallEdge[]> = new Map();
|
|
41
|
-
/** Edges keyed by caller id — answers "what does X call?". */
|
|
42
|
-
private readonly calleesOf: Map<string, CallEdge[]> = new Map();
|
|
43
|
-
|
|
44
|
-
constructor(public readonly graph: Graph) {
|
|
45
|
-
for (const s of graph.symbols) {
|
|
46
|
-
this.byId.set(s.id, s);
|
|
47
|
-
const key = s.name.toLowerCase();
|
|
48
|
-
const list = this.byNameLower.get(key);
|
|
49
|
-
if (list) list.push(s);
|
|
50
|
-
else this.byNameLower.set(key, [s]);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
for (const e of graph.edges) {
|
|
54
|
-
// callers index — only resolved edges have a toId
|
|
55
|
-
if (e.toId) {
|
|
56
|
-
const list = this.callersOf.get(e.toId);
|
|
57
|
-
if (list) list.push(e);
|
|
58
|
-
else this.callersOf.set(e.toId, [e]);
|
|
59
|
-
}
|
|
60
|
-
// callees index — every edge has a fromId
|
|
61
|
-
const list = this.calleesOf.get(e.fromId);
|
|
62
|
-
if (list) list.push(e);
|
|
63
|
-
else this.calleesOf.set(e.fromId, [e]);
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* Find symbols by name. Default behaviour is exact case-insensitive match;
|
|
69
|
-
* pass `substring: true` to enable substring search.
|
|
70
|
-
*
|
|
71
|
-
* Returns ranked roughly by "best first" — exact case match before
|
|
72
|
-
* case-folded matches.
|
|
73
|
-
*/
|
|
74
|
-
findByName(query: string, opts: RecallOptions = {}): SymbolRecord[] {
|
|
75
|
-
const limit = Math.min(opts.limit ?? 10, HARD_RESULT_CAP);
|
|
76
|
-
if (!query) return [];
|
|
77
|
-
|
|
78
|
-
if (opts.substring) {
|
|
79
|
-
const q = query.toLowerCase();
|
|
80
|
-
const results: SymbolRecord[] = [];
|
|
81
|
-
for (const [name, syms] of this.byNameLower) {
|
|
82
|
-
if (!name.includes(q)) continue;
|
|
83
|
-
for (const s of syms) {
|
|
84
|
-
results.push(s);
|
|
85
|
-
if (results.length >= limit) return results;
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
return results;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
// Exact case-insensitive — fast path through the map.
|
|
92
|
-
const candidates = this.byNameLower.get(query.toLowerCase()) ?? [];
|
|
93
|
-
if (candidates.length === 0) return [];
|
|
94
|
-
|
|
95
|
-
// Prefer exact case match first, then the rest.
|
|
96
|
-
const exact = candidates.filter((s) => s.name === query);
|
|
97
|
-
const rest = candidates.filter((s) => s.name !== query);
|
|
98
|
-
return [...exact, ...rest].slice(0, limit);
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/** Look up a symbol by its id. Returns null if not found. */
|
|
102
|
-
findById(id: string): SymbolRecord | null {
|
|
103
|
-
return this.byId.get(id) ?? null;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* Resolve a name (or id) to a unique symbol. Used by tools that take a
|
|
108
|
-
* "symbol" argument — accepts either a bare name or a full id.
|
|
109
|
-
*
|
|
110
|
-
* Ambiguity policy: if more than one symbol matches the name, returns the
|
|
111
|
-
* first one (same heuristic as the resolver). Caller can disambiguate by
|
|
112
|
-
* passing the full id.
|
|
113
|
-
*/
|
|
114
|
-
resolveSymbol(nameOrId: string): SymbolRecord | null {
|
|
115
|
-
if (nameOrId.includes('#') && nameOrId.includes('@')) {
|
|
116
|
-
return this.findById(nameOrId);
|
|
117
|
-
}
|
|
118
|
-
const matches = this.findByName(nameOrId);
|
|
119
|
-
return matches[0] ?? null;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/** Edges where this symbol is the target — "who calls X?". */
|
|
123
|
-
callers(symbolId: string, opts: EdgeQueryOptions = {}): CallEdge[] {
|
|
124
|
-
const limit = Math.min(opts.limit ?? 50, HARD_EDGE_CAP);
|
|
125
|
-
return (this.callersOf.get(symbolId) ?? []).slice(0, limit);
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/** Edges where this symbol is the source — "what does X call?". */
|
|
129
|
-
callees(symbolId: string, opts: EdgeQueryOptions = {}): CallEdge[] {
|
|
130
|
-
const limit = Math.min(opts.limit ?? 50, HARD_EDGE_CAP);
|
|
131
|
-
const all = this.calleesOf.get(symbolId) ?? [];
|
|
132
|
-
if (opts.includeUnresolved === false) {
|
|
133
|
-
return all.filter((e) => e.toId !== null).slice(0, limit);
|
|
134
|
-
}
|
|
135
|
-
return all.slice(0, limit);
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/** Convenience: how many symbols / edges are indexed. */
|
|
139
|
-
get stats(): { symbols: number; edges: number; resolvedEdges: number } {
|
|
140
|
-
let resolved = 0;
|
|
141
|
-
for (const e of this.graph.edges) if (e.toId) resolved++;
|
|
142
|
-
return {
|
|
143
|
-
symbols: this.graph.symbols.length,
|
|
144
|
-
edges: this.graph.edges.length,
|
|
145
|
-
resolvedEdges: resolved,
|
|
146
|
-
};
|
|
147
|
-
}
|
|
148
|
-
}
|
package/src/redact.ts
DELETED
|
@@ -1,122 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Secret-pattern redaction for symbol signatures before they're stored.
|
|
3
|
-
*
|
|
4
|
-
* Code occasionally contains literal secrets — `const API_KEY = "sk-..."`,
|
|
5
|
-
* embedded JWTs, AWS access keys, GitHub tokens. When we extract a symbol's
|
|
6
|
-
* signature line into graph.json, those literals come with it. We redact
|
|
7
|
-
* them here so:
|
|
8
|
-
* 1. The on-disk graph.json doesn't carry plaintext secrets
|
|
9
|
-
* 2. The MCP tool output sent to the agent doesn't expose them either
|
|
10
|
-
*
|
|
11
|
-
* This is NOT a full secret scanner — it covers well-known fixed-prefix
|
|
12
|
-
* formats. Determined leakage (custom-format secrets) will still escape,
|
|
13
|
-
* but the common ones are covered.
|
|
14
|
-
*
|
|
15
|
-
* Pattern coverage and replacement tokens are intentionally short so a
|
|
16
|
-
* signature stays readable after redaction.
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
interface SecretPattern {
|
|
20
|
-
pattern: RegExp;
|
|
21
|
-
replacement: string;
|
|
22
|
-
label: string;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
const SECRET_PATTERNS: readonly SecretPattern[] = [
|
|
26
|
-
// PEM private-key headers — match the BEGIN line before generic long-token
|
|
27
|
-
// catches it, so we get the precise label.
|
|
28
|
-
{
|
|
29
|
-
pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----/g,
|
|
30
|
-
replacement: '-----BEGIN ***REDACTED*** PRIVATE KEY-----',
|
|
31
|
-
label: 'pem-private-key',
|
|
32
|
-
},
|
|
33
|
-
// JSON Web Tokens: three base64url segments separated by dots.
|
|
34
|
-
{
|
|
35
|
-
pattern: /eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g,
|
|
36
|
-
replacement: '***JWT-REDACTED***',
|
|
37
|
-
label: 'jwt',
|
|
38
|
-
},
|
|
39
|
-
// OpenAI / Anthropic style API keys: `sk-` then 20+ alphanumerics.
|
|
40
|
-
// Also covers `sk-ant-...`, `sk-proj-...` etc.
|
|
41
|
-
{
|
|
42
|
-
pattern: /\bsk-[A-Za-z0-9_-]{20,}\b/g,
|
|
43
|
-
replacement: 'sk-***REDACTED***',
|
|
44
|
-
label: 'sk-token',
|
|
45
|
-
},
|
|
46
|
-
// GitHub personal access tokens.
|
|
47
|
-
{ pattern: /\bghp_[A-Za-z0-9]{30,}\b/g, replacement: 'ghp_***REDACTED***', label: 'github-pat' },
|
|
48
|
-
// GitHub server tokens.
|
|
49
|
-
{
|
|
50
|
-
pattern: /\bghs_[A-Za-z0-9]{30,}\b/g,
|
|
51
|
-
replacement: 'ghs_***REDACTED***',
|
|
52
|
-
label: 'github-server',
|
|
53
|
-
},
|
|
54
|
-
// GitHub user / OAuth tokens.
|
|
55
|
-
{
|
|
56
|
-
pattern: /\bgho_[A-Za-z0-9]{30,}\b/g,
|
|
57
|
-
replacement: 'gho_***REDACTED***',
|
|
58
|
-
label: 'github-oauth',
|
|
59
|
-
},
|
|
60
|
-
// GitHub refresh tokens.
|
|
61
|
-
{
|
|
62
|
-
pattern: /\bghr_[A-Za-z0-9]{30,}\b/g,
|
|
63
|
-
replacement: 'ghr_***REDACTED***',
|
|
64
|
-
label: 'github-refresh',
|
|
65
|
-
},
|
|
66
|
-
// AWS access key IDs.
|
|
67
|
-
{ pattern: /\bAKIA[0-9A-Z]{16}\b/g, replacement: 'AKIA***REDACTED***', label: 'aws-akia' },
|
|
68
|
-
// AWS short-term session tokens (less specific, but the leading prefix is unique).
|
|
69
|
-
{ pattern: /\bASIA[0-9A-Z]{16}\b/g, replacement: 'ASIA***REDACTED***', label: 'aws-asia' },
|
|
70
|
-
// Slack tokens.
|
|
71
|
-
{
|
|
72
|
-
pattern: /\bxox[abprs]-[A-Za-z0-9-]{10,}\b/g,
|
|
73
|
-
replacement: 'xox*-***REDACTED***',
|
|
74
|
-
label: 'slack',
|
|
75
|
-
},
|
|
76
|
-
// Stripe live secret keys.
|
|
77
|
-
{
|
|
78
|
-
pattern: /\bsk_live_[A-Za-z0-9]{20,}\b/g,
|
|
79
|
-
replacement: 'sk_live_***REDACTED***',
|
|
80
|
-
label: 'stripe-live',
|
|
81
|
-
},
|
|
82
|
-
// Generic long high-entropy token inside a string literal.
|
|
83
|
-
// Heuristic: 32+ chars of alphanumeric / underscore / hyphen / equals,
|
|
84
|
-
// immediately surrounded by matching quotes. Keeps false positives down
|
|
85
|
-
// because random function names rarely live inside quotes.
|
|
86
|
-
{
|
|
87
|
-
pattern: /(["'])([A-Za-z0-9_+/=-]{40,})\1/g,
|
|
88
|
-
replacement: '$1***REDACTED-LONG-TOKEN***$1',
|
|
89
|
-
label: 'long-token-in-string',
|
|
90
|
-
},
|
|
91
|
-
];
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* Run every secret pattern over the input string. Returns the redacted text.
|
|
95
|
-
* Safe for any input length: regex operations are linear in input size.
|
|
96
|
-
*
|
|
97
|
-
* Order matters — more specific patterns run first so a JWT doesn't get
|
|
98
|
-
* eaten by the generic long-token catch-all.
|
|
99
|
-
*/
|
|
100
|
-
export function redactSecrets(text: string): string {
|
|
101
|
-
if (!text) return text;
|
|
102
|
-
let out = text;
|
|
103
|
-
for (const { pattern, replacement } of SECRET_PATTERNS) {
|
|
104
|
-
out = out.replace(pattern, replacement);
|
|
105
|
-
}
|
|
106
|
-
return out;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* Test helper / introspection: which patterns matched in this input?
|
|
111
|
-
* Useful for diagnostics; not on a hot path.
|
|
112
|
-
*/
|
|
113
|
-
export function detectSecrets(text: string): string[] {
|
|
114
|
-
if (!text) return [];
|
|
115
|
-
const hits: string[] = [];
|
|
116
|
-
for (const { pattern, label } of SECRET_PATTERNS) {
|
|
117
|
-
// Build a fresh regex from the source to avoid lastIndex state on /g.
|
|
118
|
-
const fresh = new RegExp(pattern.source, pattern.flags);
|
|
119
|
-
if (fresh.test(text)) hits.push(label);
|
|
120
|
-
}
|
|
121
|
-
return hits;
|
|
122
|
-
}
|
package/src/storage.ts
DELETED
|
@@ -1,115 +0,0 @@
|
|
|
1
|
-
import { createHash } from 'node:crypto';
|
|
2
|
-
import { homedir } from 'node:os';
|
|
3
|
-
import { join } from 'node:path';
|
|
4
|
-
import { mkdirSync, writeFileSync, readFileSync, existsSync, chmodSync, renameSync } from 'node:fs';
|
|
5
|
-
import type { SymbolRecord } from './symbols.js';
|
|
6
|
-
import type { CallEdge } from './edges.js';
|
|
7
|
-
import { validateGraph } from './graph-schema.js';
|
|
8
|
-
|
|
9
|
-
const isWindows = process.platform === 'win32';
|
|
10
|
-
|
|
11
|
-
export interface Graph {
|
|
12
|
-
version: 1;
|
|
13
|
-
repoId: string;
|
|
14
|
-
rootPath: string;
|
|
15
|
-
indexedAt: string;
|
|
16
|
-
filesIndexed: number;
|
|
17
|
-
symbolCount: number;
|
|
18
|
-
edgeCount: number;
|
|
19
|
-
symbols: SymbolRecord[];
|
|
20
|
-
edges: CallEdge[];
|
|
21
|
-
/**
|
|
22
|
-
* Optional git provenance — set when the indexed root lives inside a
|
|
23
|
-
* git worktree. Both fields may be null even within a git repo (e.g.
|
|
24
|
-
* detached HEAD has no branch; an empty repo has no SHA). Older
|
|
25
|
-
* graph.json files written before the v0.1.5 pivot won't have these
|
|
26
|
-
* fields; the schema validator treats them as optional so old graphs
|
|
27
|
-
* still load.
|
|
28
|
-
*/
|
|
29
|
-
indexedSha?: string | null;
|
|
30
|
-
indexedBranch?: string | null;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export function repoIdFor(absRootPath: string): string {
|
|
34
|
-
return createHash('sha256').update(absRootPath).digest('hex').slice(0, 16);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export function repoDir(absRootPath: string): string {
|
|
38
|
-
return join(homedir(), '.graphpilot', repoIdFor(absRootPath));
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export function graphPath(absRootPath: string): string {
|
|
42
|
-
return join(repoDir(absRootPath), 'graph.json');
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export function saveGraph(graph: Graph): string {
|
|
46
|
-
const dir = repoDir(graph.rootPath);
|
|
47
|
-
// T7 defence: 0700 dir + 0600 file so other users on shared machines can't
|
|
48
|
-
// read the index. The mkdir/writeFileSync `mode` option only applies on
|
|
49
|
-
// creation, so we explicitly chmod afterwards to fix permissions on any
|
|
50
|
-
// pre-existing files (e.g. an index written before this protection landed).
|
|
51
|
-
// On Windows these modes are silently ignored, which is fine — NTFS ACLs
|
|
52
|
-
// are handled by the user profile boundary.
|
|
53
|
-
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
54
|
-
if (!isWindows) chmodSync(dir, 0o700);
|
|
55
|
-
const path = graphPath(graph.rootPath);
|
|
56
|
-
|
|
57
|
-
// Atomic write — write to a sibling .tmp file and rename. Crash-safe:
|
|
58
|
-
// a partial write never produces a corrupt graph.json that would later
|
|
59
|
-
// fail T4's schema validator and force a full re-index. Defends watch
|
|
60
|
-
// mode (which writes many times) against ungraceful shutdowns.
|
|
61
|
-
const tmpPath = path + '.tmp';
|
|
62
|
-
writeFileSync(tmpPath, JSON.stringify(graph, null, 2), {
|
|
63
|
-
encoding: 'utf8',
|
|
64
|
-
mode: 0o600,
|
|
65
|
-
});
|
|
66
|
-
if (!isWindows) chmodSync(tmpPath, 0o600);
|
|
67
|
-
renameSync(tmpPath, path);
|
|
68
|
-
return path;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* Load and validate a graph from disk. Returns null if the file is missing,
|
|
73
|
-
* unparseable, has a wrong schema version, or fails structural validation.
|
|
74
|
-
*
|
|
75
|
-
* T4 defence: anything from disk is untrusted. We re-parse and re-shape
|
|
76
|
-
* every field before exposing the result to query / MCP layers. String
|
|
77
|
-
* fields are sanitized (control chars stripped, lengths capped) so a
|
|
78
|
-
* crafted graph.json can't smuggle prompt-injection payloads or fake JSON
|
|
79
|
-
* Lines into tool output.
|
|
80
|
-
*
|
|
81
|
-
* Validation errors are written to stderr for diagnostics. The function
|
|
82
|
-
* never throws on bad data — it returns null so the MCP tool layer can
|
|
83
|
-
* surface "no index" cleanly.
|
|
84
|
-
*/
|
|
85
|
-
export function loadGraph(absRootPath: string): Graph | null {
|
|
86
|
-
const path = graphPath(absRootPath);
|
|
87
|
-
if (!existsSync(path)) return null;
|
|
88
|
-
|
|
89
|
-
let raw: string;
|
|
90
|
-
try {
|
|
91
|
-
raw = readFileSync(path, 'utf8');
|
|
92
|
-
} catch {
|
|
93
|
-
return null;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
let parsed: unknown;
|
|
97
|
-
try {
|
|
98
|
-
parsed = JSON.parse(raw);
|
|
99
|
-
} catch {
|
|
100
|
-
process.stderr.write(`[graphpilot] graph.json is not valid JSON: ${path}\n`);
|
|
101
|
-
return null;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
const errors: string[] = [];
|
|
105
|
-
const validated = validateGraph(parsed, errors);
|
|
106
|
-
if (!validated) {
|
|
107
|
-
process.stderr.write(
|
|
108
|
-
`[graphpilot] graph.json failed schema validation: ${path}\n` +
|
|
109
|
-
errors.map((e) => ` - ${e}`).join('\n') +
|
|
110
|
-
'\n',
|
|
111
|
-
);
|
|
112
|
-
return null;
|
|
113
|
-
}
|
|
114
|
-
return validated;
|
|
115
|
-
}
|