@graphpilot-oss/graphpilot 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.
Files changed (130) hide show
  1. package/.editorconfig +15 -0
  2. package/.github/CODEOWNERS +22 -0
  3. package/.github/FUNDING.yml +1 -0
  4. package/.github/ISSUE_TEMPLATE/bug_report.md +33 -0
  5. package/.github/ISSUE_TEMPLATE/config.yml +5 -0
  6. package/.github/ISSUE_TEMPLATE/feature_request.md +23 -0
  7. package/.github/PULL_REQUEST_TEMPLATE.md +19 -0
  8. package/.github/dependabot.yml +15 -0
  9. package/.github/workflows/ci.yml +62 -0
  10. package/.github/workflows/release.yml +50 -0
  11. package/.prettierignore +19 -0
  12. package/.prettierrc.json +20 -0
  13. package/CHANGELOG.md +138 -0
  14. package/CODE_OF_CONDUCT.md +83 -0
  15. package/CONTRIBUTING.md +111 -0
  16. package/LICENSE +201 -0
  17. package/README.md +132 -0
  18. package/SECURITY.md +44 -0
  19. package/assets/logo.png +0 -0
  20. package/assets/logo.svg +1 -0
  21. package/bench/README.md +544 -0
  22. package/bench/results/agent-tier-2026-05-22.md +28 -0
  23. package/bench/results/agent-tier-summary.md +44 -0
  24. package/bench/results/baseline-tier-2026-05-22.md +23 -0
  25. package/bench/results/baseline.json +810 -0
  26. package/bench/results/baseline.md +28 -0
  27. package/bench/run-agent-tier-automated.ts +234 -0
  28. package/bench/run-agent-tier.md +125 -0
  29. package/bench/run-baseline-tier.ts +200 -0
  30. package/bench/run.ts +210 -0
  31. package/bench/runner-baseline.ts +177 -0
  32. package/bench/runner-graphpilot.ts +131 -0
  33. package/bench/score-agent-tier.ts +191 -0
  34. package/bench/score.ts +59 -0
  35. package/bench/tasks.ts +236 -0
  36. package/dist/cli.d.ts +2 -0
  37. package/dist/cli.js +162 -0
  38. package/dist/cli.js.map +1 -0
  39. package/dist/edges.d.ts +57 -0
  40. package/dist/edges.js +170 -0
  41. package/dist/edges.js.map +1 -0
  42. package/dist/git.d.ts +95 -0
  43. package/dist/git.js +247 -0
  44. package/dist/git.js.map +1 -0
  45. package/dist/graph-schema.d.ts +36 -0
  46. package/dist/graph-schema.js +208 -0
  47. package/dist/graph-schema.js.map +1 -0
  48. package/dist/impact.d.ts +99 -0
  49. package/dist/impact.js +123 -0
  50. package/dist/impact.js.map +1 -0
  51. package/dist/indexer.d.ts +28 -0
  52. package/dist/indexer.js +111 -0
  53. package/dist/indexer.js.map +1 -0
  54. package/dist/interactions.d.ts +46 -0
  55. package/dist/interactions.js +0 -0
  56. package/dist/interactions.js.map +1 -0
  57. package/dist/mcp.d.ts +3 -0
  58. package/dist/mcp.js +567 -0
  59. package/dist/mcp.js.map +1 -0
  60. package/dist/parser.d.ts +24 -0
  61. package/dist/parser.js +128 -0
  62. package/dist/parser.js.map +1 -0
  63. package/dist/provenance.d.ts +74 -0
  64. package/dist/provenance.js +95 -0
  65. package/dist/provenance.js.map +1 -0
  66. package/dist/query.d.ts +68 -0
  67. package/dist/query.js +127 -0
  68. package/dist/query.js.map +1 -0
  69. package/dist/redact.d.ts +30 -0
  70. package/dist/redact.js +117 -0
  71. package/dist/redact.js.map +1 -0
  72. package/dist/storage.d.ts +42 -0
  73. package/dist/storage.js +85 -0
  74. package/dist/storage.js.map +1 -0
  75. package/dist/symbols.d.ts +20 -0
  76. package/dist/symbols.js +140 -0
  77. package/dist/symbols.js.map +1 -0
  78. package/dist/validation.d.ts +9 -0
  79. package/dist/validation.js +65 -0
  80. package/dist/validation.js.map +1 -0
  81. package/dist/validators.d.ts +55 -0
  82. package/dist/validators.js +205 -0
  83. package/dist/validators.js.map +1 -0
  84. package/dist/watcher.d.ts +86 -0
  85. package/dist/watcher.js +310 -0
  86. package/dist/watcher.js.map +1 -0
  87. package/docs/architecture.md +311 -0
  88. package/docs/limitations.md +156 -0
  89. package/docs/mcp-setup.md +231 -0
  90. package/docs/quickstart.md +202 -0
  91. package/eslint.config.js +148 -0
  92. package/lefthook.yml +81 -0
  93. package/package.json +56 -0
  94. package/pnpm-workspace.yaml +6 -0
  95. package/scripts/smoke-stdio.mjs +97 -0
  96. package/src/cli.ts +171 -0
  97. package/src/edges.ts +202 -0
  98. package/src/git.ts +255 -0
  99. package/src/graph-schema.ts +229 -0
  100. package/src/impact.ts +218 -0
  101. package/src/indexer.ts +152 -0
  102. package/src/interactions.ts +0 -0
  103. package/src/mcp.ts +652 -0
  104. package/src/parser.ts +138 -0
  105. package/src/provenance.ts +115 -0
  106. package/src/query.ts +148 -0
  107. package/src/redact.ts +122 -0
  108. package/src/storage.ts +115 -0
  109. package/src/symbols.ts +173 -0
  110. package/src/validation.ts +69 -0
  111. package/src/validators.ts +253 -0
  112. package/src/watcher.ts +383 -0
  113. package/tests/edges.test.ts +175 -0
  114. package/tests/fixtures/sample.ts +32 -0
  115. package/tests/git.test.ts +303 -0
  116. package/tests/graph-schema.test.ts +321 -0
  117. package/tests/impact.test.ts +454 -0
  118. package/tests/interactions.test.ts +180 -0
  119. package/tests/lint-policy.test.ts +106 -0
  120. package/tests/mcp-stdio.test.ts +171 -0
  121. package/tests/mcp.test.ts +335 -0
  122. package/tests/parser.test.ts +31 -0
  123. package/tests/provenance.test.ts +132 -0
  124. package/tests/query.test.ts +160 -0
  125. package/tests/redact.test.ts +167 -0
  126. package/tests/security.test.ts +144 -0
  127. package/tests/symbols.test.ts +78 -0
  128. package/tests/validators.test.ts +193 -0
  129. package/tests/watcher.test.ts +250 -0
  130. package/tsconfig.json +18 -0
package/src/parser.ts ADDED
@@ -0,0 +1,138 @@
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
+ }
@@ -0,0 +1,115 @@
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 ADDED
@@ -0,0 +1,148 @@
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 ADDED
@@ -0,0 +1,122 @@
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 ADDED
@@ -0,0 +1,115 @@
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
+ }