@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
@@ -0,0 +1,132 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ symbolProvenance,
4
+ edgeProvenance,
5
+ formatProvenance,
6
+ formatEvidenceTag,
7
+ } from '../src/provenance.js';
8
+ import type { SymbolRecord } from '../src/symbols.js';
9
+ import type { CallEdge } from '../src/edges.js';
10
+
11
+ const SAMPLE_SYMBOL: SymbolRecord = {
12
+ id: 'src/auth.ts#parseToken@42',
13
+ name: 'parseToken',
14
+ kind: 'function',
15
+ file: 'src/auth.ts',
16
+ line: 42,
17
+ column: 1,
18
+ endLine: 58,
19
+ signature: 'function parseToken(token: string): Claims {',
20
+ exported: true,
21
+ };
22
+
23
+ const SAMPLE_EDGE: CallEdge = {
24
+ fromId: 'src/api.ts#handleLogin@10',
25
+ toId: 'src/auth.ts#parseToken@42',
26
+ toName: 'parseToken',
27
+ file: 'src/api.ts',
28
+ line: 17,
29
+ column: 12,
30
+ };
31
+
32
+ describe('symbolProvenance', () => {
33
+ it('captures the symbol location + signature excerpt + sha', () => {
34
+ const p = symbolProvenance(SAMPLE_SYMBOL, 'abc1234');
35
+ expect(p).toMatchObject({
36
+ file: 'src/auth.ts',
37
+ line: 42,
38
+ column: 1,
39
+ endLine: 58,
40
+ sha: 'abc1234',
41
+ });
42
+ expect(p.excerpt).toMatch(/parseToken/);
43
+ });
44
+
45
+ it('sets sha to null when no git sha is available', () => {
46
+ const p = symbolProvenance(SAMPLE_SYMBOL, null);
47
+ expect(p.sha).toBeNull();
48
+ });
49
+
50
+ it('clips a long excerpt with an ellipsis', () => {
51
+ const long = 'function ' + 'x'.repeat(500);
52
+ const p = symbolProvenance({ ...SAMPLE_SYMBOL, signature: long }, null);
53
+ expect(p.excerpt!.length).toBeLessThanOrEqual(200);
54
+ expect(p.excerpt!.endsWith('…')).toBe(true);
55
+ });
56
+
57
+ it('drops the excerpt when the signature is empty', () => {
58
+ const p = symbolProvenance({ ...SAMPLE_SYMBOL, signature: '' }, null);
59
+ expect(p.excerpt).toBeUndefined();
60
+ });
61
+ });
62
+
63
+ describe('edgeProvenance', () => {
64
+ it('captures the call-site location + sha (no excerpt — v0.1)', () => {
65
+ const p = edgeProvenance(SAMPLE_EDGE, 'def5678');
66
+ expect(p).toMatchObject({
67
+ file: 'src/api.ts',
68
+ line: 17,
69
+ column: 12,
70
+ sha: 'def5678',
71
+ });
72
+ expect(p.excerpt).toBeUndefined();
73
+ });
74
+
75
+ it('sets sha to null when not in a git repo', () => {
76
+ const p = edgeProvenance(SAMPLE_EDGE, null);
77
+ expect(p.sha).toBeNull();
78
+ });
79
+ });
80
+
81
+ describe('formatProvenance', () => {
82
+ it('formats a minimal provenance as file:line', () => {
83
+ expect(
84
+ formatProvenance({
85
+ file: 'src/auth.ts',
86
+ line: 42,
87
+ }),
88
+ ).toBe('src/auth.ts:42');
89
+ });
90
+
91
+ it('includes column when present', () => {
92
+ expect(
93
+ formatProvenance({
94
+ file: 'src/auth.ts',
95
+ line: 42,
96
+ column: 5,
97
+ }),
98
+ ).toBe('src/auth.ts:42:5');
99
+ });
100
+
101
+ it('appends sha when present', () => {
102
+ expect(
103
+ formatProvenance({
104
+ file: 'src/auth.ts',
105
+ line: 42,
106
+ sha: 'abc1234',
107
+ }),
108
+ ).toBe('src/auth.ts:42 @ abc1234');
109
+ });
110
+
111
+ it('omits sha when null or undefined', () => {
112
+ expect(
113
+ formatProvenance({
114
+ file: 'src/auth.ts',
115
+ line: 42,
116
+ sha: null,
117
+ }),
118
+ ).toBe('src/auth.ts:42');
119
+ });
120
+ });
121
+
122
+ describe('formatEvidenceTag', () => {
123
+ it('wraps the provenance in [evidence: ...]', () => {
124
+ expect(
125
+ formatEvidenceTag({
126
+ file: 'src/auth.ts',
127
+ line: 42,
128
+ sha: 'abc1234',
129
+ }),
130
+ ).toBe('[evidence: src/auth.ts:42 @ abc1234]');
131
+ });
132
+ });
@@ -0,0 +1,160 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { GraphIndex } from '../src/query.js';
3
+ import type { Graph } from '../src/storage.js';
4
+ import type { SymbolRecord } from '../src/symbols.js';
5
+ import type { CallEdge } from '../src/edges.js';
6
+
7
+ // Tiny hand-built fixture so we exercise the index without parsing real code.
8
+ function sym(id: string, name: string, file: string, line: number): SymbolRecord {
9
+ return {
10
+ id,
11
+ name,
12
+ kind: 'function',
13
+ file,
14
+ line,
15
+ column: 1,
16
+ endLine: line + 2,
17
+ signature: `function ${name}() {}`,
18
+ exported: false,
19
+ };
20
+ }
21
+
22
+ function edge(fromId: string, toName: string, toId: string | null, file: string): CallEdge {
23
+ return { fromId, toName, toId, file, line: 1, column: 1 };
24
+ }
25
+
26
+ function buildGraph(): Graph {
27
+ const symbols: SymbolRecord[] = [
28
+ sym('a.ts#parseToken@1', 'parseToken', 'a.ts', 1),
29
+ sym('a.ts#validateJwt@10', 'validateJwt', 'a.ts', 10),
30
+ sym('b.ts#authenticate@5', 'authenticate', 'b.ts', 5),
31
+ sym('b.ts#parseToken@20', 'parseToken', 'b.ts', 20),
32
+ sym('c.ts#ParseTokenAsync@1', 'ParseTokenAsync', 'c.ts', 1),
33
+ ];
34
+ const edges: CallEdge[] = [
35
+ edge('b.ts#authenticate@5', 'parseToken', 'a.ts#parseToken@1', 'b.ts'),
36
+ edge('b.ts#authenticate@5', 'validateJwt', 'a.ts#validateJwt@10', 'b.ts'),
37
+ edge('b.ts#authenticate@5', 'unknownExternal', null, 'b.ts'),
38
+ edge('a.ts#parseToken@1', 'trim', null, 'a.ts'),
39
+ ];
40
+ return {
41
+ version: 1,
42
+ repoId: 'test',
43
+ rootPath: '/fake',
44
+ indexedAt: new Date().toISOString(),
45
+ filesIndexed: 3,
46
+ symbolCount: symbols.length,
47
+ edgeCount: edges.length,
48
+ symbols,
49
+ edges,
50
+ };
51
+ }
52
+
53
+ describe('GraphIndex.findByName', () => {
54
+ const idx = new GraphIndex(buildGraph());
55
+
56
+ it('exact match is case-insensitive by default', () => {
57
+ const r = idx.findByName('parsetoken');
58
+ expect(r.map((s) => s.id)).toEqual(['a.ts#parseToken@1', 'b.ts#parseToken@20']);
59
+ });
60
+
61
+ it('exact-case match ranks above case-folded matches', () => {
62
+ const r = idx.findByName('parseToken');
63
+ // Both `parseToken` (exact case) entries should come before any case-folded
64
+ // hits. Here both candidates are exact-case so order is just insertion.
65
+ expect(r.map((s) => s.name)).toEqual(['parseToken', 'parseToken']);
66
+ });
67
+
68
+ it('substring mode finds partial matches', () => {
69
+ const r = idx.findByName('parse', { substring: true });
70
+ const names = r.map((s) => s.name);
71
+ expect(names).toContain('parseToken');
72
+ expect(names).toContain('ParseTokenAsync');
73
+ });
74
+
75
+ it('respects limit', () => {
76
+ const r = idx.findByName('parse', { substring: true, limit: 1 });
77
+ expect(r.length).toBe(1);
78
+ });
79
+
80
+ it('caps limit at the hard ceiling', () => {
81
+ const r = idx.findByName('parse', { substring: true, limit: 10_000 });
82
+ // We only have 3 substring matches anyway, but verify no throw.
83
+ expect(r.length).toBeLessThanOrEqual(100);
84
+ });
85
+
86
+ it('returns [] for empty query', () => {
87
+ expect(idx.findByName('')).toEqual([]);
88
+ });
89
+
90
+ it('returns [] when nothing matches', () => {
91
+ expect(idx.findByName('definitelyNotHere')).toEqual([]);
92
+ });
93
+ });
94
+
95
+ describe('GraphIndex.findById / resolveSymbol', () => {
96
+ const idx = new GraphIndex(buildGraph());
97
+
98
+ it('findById returns the exact symbol', () => {
99
+ expect(idx.findById('a.ts#parseToken@1')?.name).toBe('parseToken');
100
+ });
101
+
102
+ it('findById returns null for unknown id', () => {
103
+ expect(idx.findById('nope')).toBeNull();
104
+ });
105
+
106
+ it('resolveSymbol accepts a full id', () => {
107
+ expect(idx.resolveSymbol('b.ts#authenticate@5')?.name).toBe('authenticate');
108
+ });
109
+
110
+ it('resolveSymbol accepts a bare name', () => {
111
+ expect(idx.resolveSymbol('authenticate')?.id).toBe('b.ts#authenticate@5');
112
+ });
113
+
114
+ it('resolveSymbol returns first match for ambiguous names', () => {
115
+ const s = idx.resolveSymbol('parseToken');
116
+ expect(s).not.toBeNull();
117
+ expect(s!.name).toBe('parseToken');
118
+ });
119
+ });
120
+
121
+ describe('GraphIndex.callers', () => {
122
+ const idx = new GraphIndex(buildGraph());
123
+
124
+ it('returns callers of a symbol', () => {
125
+ const c = idx.callers('a.ts#parseToken@1');
126
+ expect(c.length).toBe(1);
127
+ expect(c[0].fromId).toBe('b.ts#authenticate@5');
128
+ });
129
+
130
+ it('returns [] when nothing calls the symbol', () => {
131
+ expect(idx.callers('c.ts#ParseTokenAsync@1')).toEqual([]);
132
+ });
133
+
134
+ it('returns [] for unknown id', () => {
135
+ expect(idx.callers('not-a-real-id')).toEqual([]);
136
+ });
137
+ });
138
+
139
+ describe('GraphIndex.callees', () => {
140
+ const idx = new GraphIndex(buildGraph());
141
+
142
+ it('returns everything a symbol calls', () => {
143
+ const c = idx.callees('b.ts#authenticate@5');
144
+ const names = c.map((e) => e.toName).sort();
145
+ expect(names).toEqual(['parseToken', 'unknownExternal', 'validateJwt']);
146
+ });
147
+
148
+ it('can hide unresolved edges', () => {
149
+ const c = idx.callees('b.ts#authenticate@5', { includeUnresolved: false });
150
+ const names = c.map((e) => e.toName).sort();
151
+ expect(names).toEqual(['parseToken', 'validateJwt']);
152
+ });
153
+ });
154
+
155
+ describe('GraphIndex.stats', () => {
156
+ it('reports counts including resolved-edges total', () => {
157
+ const idx = new GraphIndex(buildGraph());
158
+ expect(idx.stats).toEqual({ symbols: 5, edges: 4, resolvedEdges: 2 });
159
+ });
160
+ });
@@ -0,0 +1,167 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { redactSecrets, detectSecrets } from '../src/redact.js';
3
+ import { parseFile } from '../src/parser.js';
4
+ import { extractSymbols } from '../src/symbols.js';
5
+ import { writeFileSync, mkdtempSync, rmSync, existsSync } from 'node:fs';
6
+ import { tmpdir } from 'node:os';
7
+ import { join } from 'node:path';
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Unit tests — direct redactor
11
+ // ---------------------------------------------------------------------------
12
+
13
+ describe('redactSecrets — known patterns', () => {
14
+ it('redacts OpenAI / Anthropic sk- keys', () => {
15
+ const out = redactSecrets('const API_KEY = "sk-abcdefghij1234567890ABCDEF";');
16
+ expect(out).toContain('sk-***REDACTED***');
17
+ expect(out).not.toContain('abcdefghij1234567890');
18
+ });
19
+
20
+ it('redacts sk-ant- prefix variant', () => {
21
+ const out = redactSecrets('const k = "sk-ant-api03-abc123XYZ_thisIsAFakeKey-1234567890";');
22
+ expect(out).toContain('sk-***REDACTED***');
23
+ expect(out).not.toContain('api03-abc123');
24
+ });
25
+
26
+ it('redacts GitHub PATs', () => {
27
+ const out = redactSecrets('TOKEN = "ghp_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";');
28
+ expect(out).toContain('ghp_***REDACTED***');
29
+ });
30
+
31
+ it('redacts AWS access key IDs (AKIA)', () => {
32
+ const out = redactSecrets('const aws = "AKIAIOSFODNN7EXAMPLE";');
33
+ expect(out).toContain('AKIA***REDACTED***');
34
+ expect(out).not.toContain('IOSFODNN7');
35
+ });
36
+
37
+ it('redacts JWT tokens (three base64url segments)', () => {
38
+ const jwt =
39
+ 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9' +
40
+ '.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4ifQ' +
41
+ '.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c';
42
+ const out = redactSecrets(`const auth = "${jwt}";`);
43
+ expect(out).toContain('***JWT-REDACTED***');
44
+ expect(out).not.toContain(jwt);
45
+ });
46
+
47
+ it('redacts PEM private-key headers', () => {
48
+ const out = redactSecrets('"-----BEGIN RSA PRIVATE KEY-----\\n..."');
49
+ expect(out).toContain('***REDACTED***');
50
+ expect(out).toContain('PRIVATE KEY');
51
+ });
52
+
53
+ it('redacts Slack bot tokens (xoxb-...)', () => {
54
+ const out = redactSecrets('"xoxb-12345-67890-abcdefghij"');
55
+ expect(out).toContain('xox*-***REDACTED***');
56
+ });
57
+
58
+ it('redacts Stripe sk_live keys', () => {
59
+ const out = redactSecrets('"sk_live_abcdefghijklmnopqrstuv1234"');
60
+ expect(out).toContain('sk_live_***REDACTED***');
61
+ });
62
+
63
+ it('redacts a generic long high-entropy token inside quotes', () => {
64
+ const out = redactSecrets('const s = "X9aZ8bC7dE6fG5hI4jK3lM2nO1pQ0rS9tU8vW7xY6zA";');
65
+ expect(out).toContain('***REDACTED-LONG-TOKEN***');
66
+ expect(out).not.toContain('X9aZ8bC7dE6fG5hI4jK3lM2nO1pQ0rS9tU8vW7xY6zA');
67
+ });
68
+ });
69
+
70
+ describe('redactSecrets — preserves non-secret content', () => {
71
+ it('leaves function signatures untouched', () => {
72
+ const sig = 'function parseToken(token: string): Claims {';
73
+ expect(redactSecrets(sig)).toBe(sig);
74
+ });
75
+
76
+ it('leaves short identifiers untouched (no false positive on short alphanumerics)', () => {
77
+ const sig = 'const apiKey = config.apiKey;';
78
+ expect(redactSecrets(sig)).toBe(sig);
79
+ });
80
+
81
+ it('leaves a normal-length string literal untouched', () => {
82
+ const sig = 'const greeting = "hello, world";';
83
+ expect(redactSecrets(sig)).toBe(sig);
84
+ });
85
+
86
+ it('returns the input unchanged for empty / null-ish strings', () => {
87
+ expect(redactSecrets('')).toBe('');
88
+ // @ts-expect-error: function tolerates non-strings defensively
89
+ expect(redactSecrets(undefined)).toBe(undefined);
90
+ });
91
+ });
92
+
93
+ describe('detectSecrets — diagnostics', () => {
94
+ it('returns matching labels', () => {
95
+ const labels = detectSecrets('"sk-abcdefghij1234567890ABCDEF"');
96
+ expect(labels).toContain('sk-token');
97
+ });
98
+
99
+ it('returns empty list for clean input', () => {
100
+ expect(detectSecrets('function foo(x) { return x + 1; }')).toEqual([]);
101
+ });
102
+
103
+ it('does not mutate state between calls (regex .lastIndex hygiene)', () => {
104
+ const sample = '"AKIAIOSFODNN7EXAMPLE"';
105
+ // Run twice — should give same result.
106
+ expect(detectSecrets(sample)).toEqual(detectSecrets(sample));
107
+ });
108
+ });
109
+
110
+ // ---------------------------------------------------------------------------
111
+ // Integration — secrets in real source code DO NOT leak into the index
112
+ // ---------------------------------------------------------------------------
113
+
114
+ describe('integration: secrets in signatures get redacted at extraction', () => {
115
+ let workDir: string;
116
+
117
+ beforeEach(() => {
118
+ workDir = mkdtempSync(join(tmpdir(), 'graphpilot-redact-'));
119
+ });
120
+
121
+ afterEach(() => {
122
+ if (existsSync(workDir)) rmSync(workDir, { recursive: true, force: true });
123
+ });
124
+
125
+ it('redacts secrets in arrow-const initializers', () => {
126
+ const filePath = join(workDir, 'secrets.ts');
127
+ writeFileSync(
128
+ filePath,
129
+ `export const API_KEY = "sk-abcdefghij1234567890ABCDEFGHIJ";\n` +
130
+ `export const TOKEN = "ghp_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";\n`,
131
+ );
132
+ const parsed = parseFile(filePath)!;
133
+ const syms = extractSymbols(parsed);
134
+ const apiKeySym = syms.find((s) => s.name === 'API_KEY');
135
+ const tokenSym = syms.find((s) => s.name === 'TOKEN');
136
+ // These are variable initializers, not function expressions — they are
137
+ // NOT extracted as SymbolRecords in v1 (we only emit function-like
138
+ // const initializers). So this test mainly proves the integration plumbing
139
+ // doesn't crash on secret-y source.
140
+ // What we CAN check: parseFile + extractSymbols don't throw on a file
141
+ // that contains a real-looking secret literal.
142
+ expect(syms).toBeDefined();
143
+ // If we ever broaden extractor to emit non-function consts, the redaction
144
+ // path is wired and these would already be safe.
145
+ if (apiKeySym) expect(apiKeySym.signature).not.toContain('abcdefghij1234567890');
146
+ if (tokenSym) expect(tokenSym.signature).not.toContain('aaaaaaaaaaaaaaaa');
147
+ });
148
+
149
+ it('redacts secrets in arrow-function defaults / bodies (signature first line)', () => {
150
+ const filePath = join(workDir, 'arrowfn.ts');
151
+ writeFileSync(
152
+ filePath,
153
+ `export const buildAuth = (token = "sk-abcdefghij1234567890ABCDEFGHIJ") => {\n` +
154
+ ` return token;\n` +
155
+ `};\n`,
156
+ );
157
+ const parsed = parseFile(filePath)!;
158
+ const syms = extractSymbols(parsed);
159
+ const sym = syms.find((s) => s.name === 'buildAuth');
160
+ expect(sym).toBeDefined();
161
+ expect(sym!.signature).toContain('sk-***REDACTED***');
162
+ expect(sym!.signature).not.toContain('abcdefghij1234567890');
163
+ });
164
+ });
165
+
166
+ // Imports for beforeEach/afterEach used in the integration block
167
+ import { beforeEach, afterEach } from 'vitest';
@@ -0,0 +1,144 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import {
3
+ writeFileSync,
4
+ mkdtempSync,
5
+ mkdirSync,
6
+ symlinkSync,
7
+ rmSync,
8
+ statSync,
9
+ existsSync,
10
+ } from 'node:fs';
11
+ import { tmpdir, homedir } from 'node:os';
12
+ import { join } from 'node:path';
13
+ import { parseFile } from '../src/parser.js';
14
+ import { indexDirectory } from '../src/indexer.js';
15
+ import { saveGraph, type Graph } from '../src/storage.js';
16
+ import { validateRootPath, MAX_FILE_BYTES, MAX_FILES_PER_INDEX } from '../src/validation.js';
17
+
18
+ const isWindows = process.platform === 'win32';
19
+
20
+ let workDir: string;
21
+
22
+ beforeEach(() => {
23
+ workDir = mkdtempSync(join(tmpdir(), 'graphpilot-sec-'));
24
+ });
25
+
26
+ afterEach(() => {
27
+ if (existsSync(workDir)) rmSync(workDir, { recursive: true, force: true });
28
+ });
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // T1 — file size cap
32
+ // ---------------------------------------------------------------------------
33
+
34
+ describe('T1: file size cap', () => {
35
+ it('skips files larger than MAX_FILE_BYTES', () => {
36
+ const bigFile = join(workDir, 'huge.ts');
37
+ // Write MAX_FILE_BYTES + 1KB of harmless TypeScript.
38
+ const oversize = 'export const x = 1;\n'.repeat(Math.ceil(MAX_FILE_BYTES / 20)) + '\n';
39
+ writeFileSync(bigFile, oversize);
40
+ expect(statSync(bigFile).size).toBeGreaterThan(MAX_FILE_BYTES);
41
+
42
+ const result = parseFile(bigFile);
43
+ expect(result).toBeNull();
44
+ });
45
+
46
+ it('parses files just under MAX_FILE_BYTES', () => {
47
+ const smallFile = join(workDir, 'small.ts');
48
+ writeFileSync(smallFile, 'export function ok() { return 1; }');
49
+ const result = parseFile(smallFile);
50
+ expect(result).not.toBeNull();
51
+ });
52
+ });
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // T2 — symlink escape
56
+ // ---------------------------------------------------------------------------
57
+
58
+ describe.skipIf(isWindows)('T2: symlink escape protection', () => {
59
+ it('does not follow a symlink that escapes the indexed root', async () => {
60
+ // Layout:
61
+ // workDir/
62
+ // project/
63
+ // good.ts <- legit file inside the project
64
+ // outside/
65
+ // secret.ts <- a file the attacker wants to leak
66
+ // project/escape -> ../outside <- malicious symlink inside project
67
+ const project = join(workDir, 'project');
68
+ const outside = join(workDir, 'outside');
69
+ mkdirSync(project);
70
+ mkdirSync(outside);
71
+ writeFileSync(join(project, 'good.ts'), 'export function good() {}');
72
+ writeFileSync(join(outside, 'secret.ts'), 'export function leakedSecret() {}');
73
+ symlinkSync(outside, join(project, 'escape'));
74
+
75
+ const result = await indexDirectory(project);
76
+
77
+ const names = result.symbols.map((s) => s.name);
78
+ expect(names).toContain('good');
79
+ expect(names).not.toContain('leakedSecret');
80
+ });
81
+ });
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // T7 — index file permissions
85
+ // ---------------------------------------------------------------------------
86
+
87
+ describe.skipIf(isWindows)('T7: restrictive permissions on saved graph', () => {
88
+ it('writes graph.json with mode 0600 and parent dir 0700', () => {
89
+ const fakeRoot = join(workDir, 'pretend-repo');
90
+ mkdirSync(fakeRoot);
91
+ const graph: Graph = {
92
+ version: 1,
93
+ repoId: 'testrepo000000',
94
+ rootPath: fakeRoot,
95
+ indexedAt: new Date().toISOString(),
96
+ filesIndexed: 0,
97
+ symbolCount: 0,
98
+ symbols: [],
99
+ };
100
+ const savedPath = saveGraph(graph);
101
+ expect(existsSync(savedPath)).toBe(true);
102
+
103
+ const fileMode = statSync(savedPath).mode & 0o777;
104
+ expect(fileMode).toBe(0o600);
105
+
106
+ const dirMode = statSync(savedPath.replace(/\/graph\.json$/, '')).mode & 0o777;
107
+ expect(dirMode).toBe(0o700);
108
+ });
109
+ });
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // T10 — refuse dangerous paths + max-files cap
113
+ // ---------------------------------------------------------------------------
114
+
115
+ describe('T10: dangerous path rejection', () => {
116
+ it('refuses to index / (root)', () => {
117
+ expect(validateRootPath('/')).toMatch(/system path/i);
118
+ });
119
+
120
+ it.skipIf(isWindows)('refuses to index /etc', () => {
121
+ expect(validateRootPath('/etc')).toMatch(/system path/i);
122
+ });
123
+
124
+ it('refuses to index the home directory directly', () => {
125
+ expect(validateRootPath(homedir())).toMatch(/home directory/i);
126
+ });
127
+
128
+ it('allows a normal project path', () => {
129
+ expect(validateRootPath(workDir)).toBeNull();
130
+ });
131
+
132
+ it('returns an error for a path that does not exist', () => {
133
+ expect(validateRootPath(join(workDir, 'definitely-not-here'))).toMatch(
134
+ /does not exist|not accessible/i,
135
+ );
136
+ });
137
+ });
138
+
139
+ describe('T10: max-files cap is set sanely', () => {
140
+ it('MAX_FILES_PER_INDEX is a reasonable ceiling', () => {
141
+ expect(MAX_FILES_PER_INDEX).toBeGreaterThanOrEqual(10_000);
142
+ expect(MAX_FILES_PER_INDEX).toBeLessThanOrEqual(1_000_000);
143
+ });
144
+ });
@@ -0,0 +1,78 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { parseFile } from '../src/parser.js';
3
+ import { extractSymbols, type SymbolRecord } from '../src/symbols.js';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { dirname, join } from 'node:path';
6
+
7
+ const here = dirname(fileURLToPath(import.meta.url));
8
+ const fixture = (name: string) => join(here, 'fixtures', name);
9
+
10
+ function symbolsOf(filename: string): SymbolRecord[] {
11
+ const parsed = parseFile(fixture(filename));
12
+ if (!parsed) throw new Error('parse failed');
13
+ return extractSymbols(parsed);
14
+ }
15
+
16
+ function findOne(syms: SymbolRecord[], name: string, kind?: string) {
17
+ const matches = syms.filter((s) => s.name === name && (!kind || s.kind === kind));
18
+ if (matches.length !== 1) {
19
+ throw new Error(`expected 1 match for ${name}/${kind ?? '*'}, got ${matches.length}`);
20
+ }
21
+ return matches[0];
22
+ }
23
+
24
+ describe('extractSymbols', () => {
25
+ const syms = symbolsOf('sample.ts');
26
+
27
+ it('finds top-level functions', () => {
28
+ const s = findOne(syms, 'parseToken', 'function');
29
+ expect(s.exported).toBe(true);
30
+ expect(s.line).toBe(1);
31
+ expect(s.signature).toContain('parseToken');
32
+ });
33
+
34
+ it('finds arrow-function consts as variables', () => {
35
+ const s = findOne(syms, 'validateJwt', 'variable');
36
+ expect(s.exported).toBe(true);
37
+ expect(s.signature).toContain('validateJwt');
38
+ });
39
+
40
+ it('finds non-exported function expressions', () => {
41
+ const s = findOne(syms, 'internalHelper', 'variable');
42
+ expect(s.exported).toBe(false);
43
+ });
44
+
45
+ it('finds classes', () => {
46
+ const s = findOne(syms, 'AuthService', 'class');
47
+ expect(s.exported).toBe(true);
48
+ });
49
+
50
+ it('finds class methods with parent set', () => {
51
+ const auth = findOne(syms, 'authenticate', 'method');
52
+ expect(auth.parent).toBe('AuthService');
53
+ const fetch = findOne(syms, 'fetchUser', 'method');
54
+ expect(fetch.parent).toBe('AuthService');
55
+ });
56
+
57
+ it('finds interfaces', () => {
58
+ const s = findOne(syms, 'Repository', 'interface');
59
+ expect(s.exported).toBe(true);
60
+ });
61
+
62
+ it('finds type aliases', () => {
63
+ const s = findOne(syms, 'UserId', 'type');
64
+ expect(s.exported).toBe(true);
65
+ });
66
+
67
+ it('finds enums (non-exported)', () => {
68
+ const s = findOne(syms, 'Role', 'enum');
69
+ expect(s.exported).toBe(false);
70
+ });
71
+
72
+ it('assigns stable ids', () => {
73
+ const s = findOne(syms, 'parseToken', 'function');
74
+ expect(s.id).toBe(`${s.file}#parseToken@1`);
75
+ const m = findOne(syms, 'authenticate', 'method');
76
+ expect(m.id).toBe(`${m.file}#AuthService.authenticate@${m.line}`);
77
+ });
78
+ });