@timidan/rite 0.1.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.
@@ -0,0 +1,411 @@
1
+ /**
2
+ * src/github/server.js — Full GitHub App Express server.
3
+ *
4
+ * Start: node src/cli/rite.js server [--port 3001]
5
+ *
6
+ * Routes:
7
+ * GET /api/health Health check + env status
8
+ * GET /github/login Redirect to GitHub OAuth
9
+ * GET /github/install Start installation with CSRF state
10
+ * GET /github/callback OAuth callback → session
11
+ * GET /github/installations List repos user authorised
12
+ * GET /github/runs?repoId=&fullName= Recent Rite workflow runs
13
+ * GET /github/report?repoId=&fullName=&runId= Fetch+validate artifact
14
+ * POST /github/logout Clear session
15
+ *
16
+ * Security:
17
+ * - CSRF state checked on callback.
18
+ * - repoId resolved from GitHub installation list — never trusted from query.
19
+ * - Installation token is short-lived (1hr), obtained server-side only.
20
+ * - Private key, session secret, client secret are env vars only.
21
+ * - Report SHA bound to workflow run head SHA before display.
22
+ *
23
+ * Required env vars (see docs/github-app-setup.md):
24
+ * GITHUB_APP_ID, GITHUB_APP_CLIENT_ID, GITHUB_APP_CLIENT_SECRET,
25
+ * GITHUB_APP_PRIVATE_KEY or GITHUB_APP_PRIVATE_KEY_FILE,
26
+ * RITE_SESSION_SECRET, RITE_PUBLIC_URL
27
+ *
28
+ * Optional:
29
+ * PORT (default 3001)
30
+ */
31
+
32
+ import { randomBytes } from 'node:crypto';
33
+ import { readFileSync } from 'node:fs';
34
+ import { fileURLToPath } from 'node:url';
35
+ import { dirname, join } from 'node:path';
36
+
37
+ let express, session, AdmZip, createAppAuth;
38
+
39
+ try {
40
+ ({ default: express } = await import('express'));
41
+ ({ default: session } = await import('express-session'));
42
+ ({ default: AdmZip } = await import('adm-zip'));
43
+ ({ createAppAuth } = await import('@octokit/auth-app'));
44
+ } catch (e) {
45
+ throw new Error(
46
+ `Missing server dependencies. Run: npm install express express-session adm-zip @octokit/auth-app\n${e.message}`
47
+ );
48
+ }
49
+
50
+ import { checkEnv, buildAuthUrl, buildInstallUrl, exchangeCode, listAuthorizedRepos } from './app.js';
51
+ import { SCHEMA_VERSION } from '../core/engine.js';
52
+
53
+ const __dirname = dirname(fileURLToPath(import.meta.url));
54
+
55
+ // ------------------------------------------------------------------ //
56
+ // Environment //
57
+ // ------------------------------------------------------------------ //
58
+
59
+ function requireEnv(name) {
60
+ const val = process.env[name];
61
+ if (!val) throw new Error(`Required env var not set: ${name}`);
62
+ return val;
63
+ }
64
+
65
+ // ------------------------------------------------------------------ //
66
+ // Auth helper — creates a short-lived installation token //
67
+ // ------------------------------------------------------------------ //
68
+
69
+ /**
70
+ * Get a short-lived installation token using the App private key.
71
+ * Token is valid for ~1 hour; obtain fresh per request.
72
+ * @param {number} installationId
73
+ * @returns {Promise<string>}
74
+ */
75
+ async function getInstallationToken(installationId) {
76
+ const appId = requireEnv('GITHUB_APP_ID');
77
+ const privateKey = process.env.GITHUB_APP_PRIVATE_KEY
78
+ ? process.env.GITHUB_APP_PRIVATE_KEY.replace(/\\n/g, '\n')
79
+ : readFileSync(requireEnv('GITHUB_APP_PRIVATE_KEY_FILE'), 'utf8');
80
+
81
+ const auth = createAppAuth({ appId: Number(appId), privateKey });
82
+ const { token } = await auth({ type: 'installation', installationId });
83
+ return token;
84
+ }
85
+
86
+ // ------------------------------------------------------------------ //
87
+ // Report fetcher //
88
+ // ------------------------------------------------------------------ //
89
+
90
+ /**
91
+ * Download, unzip, parse, and validate a rite-report artifact.
92
+ * Binds report to workflow run head SHA.
93
+ * @param {string} installationToken
94
+ * @param {string} fullName — owner/repo
95
+ * @param {number} runId
96
+ * @param {string} expectedSha
97
+ * @returns {Promise<{ valid: boolean, report?: object, mismatch?: string }>}
98
+ */
99
+ async function fetchAndValidateReport(installationToken, fullName, runId, expectedSha) {
100
+ const headers = {
101
+ Authorization: `Bearer ${installationToken}`,
102
+ Accept: 'application/vnd.github+json',
103
+ 'X-GitHub-Api-Version': '2022-11-28',
104
+ 'User-Agent': 'rite-app/0.1.0',
105
+ };
106
+
107
+ // 1. List artifacts
108
+ const artResp = await fetch(
109
+ `https://api.github.com/repos/${fullName}/actions/runs/${runId}/artifacts`,
110
+ { headers }
111
+ );
112
+ if (!artResp.ok) {
113
+ return { valid: false, mismatch: `GitHub artifact list failed: ${artResp.status} ${artResp.statusText}` };
114
+ }
115
+ const artData = await artResp.json();
116
+ const artifact = artData.artifacts?.find(a => a.name === 'rite-report');
117
+ if (!artifact) {
118
+ return { valid: false, mismatch: 'No rite-report artifact found for this run.' };
119
+ }
120
+ if (artifact.expired) {
121
+ return { valid: false, mismatch: 'Artifact has expired (GitHub 90-day retention limit).' };
122
+ }
123
+
124
+ // 2. Download the artifact zip (GitHub redirects to a signed URL)
125
+ const dlResp = await fetch(artifact.archive_download_url, {
126
+ headers: { Authorization: `Bearer ${installationToken}`, 'User-Agent': 'rite-app/0.1.0' },
127
+ redirect: 'follow',
128
+ });
129
+ if (!dlResp.ok) {
130
+ return { valid: false, mismatch: `Artifact download failed: ${dlResp.status}` };
131
+ }
132
+ const zipBuffer = Buffer.from(await dlResp.arrayBuffer());
133
+
134
+ // 3. Unzip and extract rite-report.json
135
+ let reportJson;
136
+ try {
137
+ const zip = new AdmZip(zipBuffer);
138
+ const entry = zip.getEntry('rite-report.json');
139
+ if (!entry) {
140
+ return { valid: false, mismatch: 'rite-report.json not found inside artifact zip.' };
141
+ }
142
+ reportJson = zip.readAsText(entry);
143
+ } catch (e) {
144
+ return { valid: false, mismatch: `Failed to read zip: ${e.message}` };
145
+ }
146
+
147
+ // 4. Parse
148
+ let report;
149
+ try {
150
+ report = JSON.parse(reportJson);
151
+ } catch (e) {
152
+ return { valid: false, mismatch: `rite-report.json is not valid JSON: ${e.message}` };
153
+ }
154
+
155
+ return validateReportBinding(report, expectedSha);
156
+ }
157
+
158
+ export function validateReportBinding(report, expectedSha) {
159
+ if (!report.schemaVersion || !report.status || !Array.isArray(report.results)) {
160
+ return { valid: false, mismatch: 'Report missing required fields (schemaVersion, status, results).' };
161
+ }
162
+ if (report.schemaVersion !== SCHEMA_VERSION) {
163
+ return { valid: false, mismatch: `Report schemaVersion ${report.schemaVersion} does not match engine ${SCHEMA_VERSION}.` };
164
+ }
165
+ if (!report.commitSha || report.commitSha !== expectedSha) {
166
+ return {
167
+ valid: false,
168
+ mismatch: `Report commitSha (${report.commitSha ?? 'missing'}) does not match run headSha (${expectedSha}). Do not display.`,
169
+ };
170
+ }
171
+ return { valid: true, report };
172
+ }
173
+
174
+ export function hasRiteWorkflow(workflows) {
175
+ return workflows.some(workflow => workflow.path === '.github/workflows/rite.yml');
176
+ }
177
+
178
+ // ------------------------------------------------------------------ //
179
+ // Express app //
180
+ // ------------------------------------------------------------------ //
181
+
182
+ export async function startGitHubServer({ port = 3001 } = {}) {
183
+ const envCheck = checkEnv();
184
+ if (!envCheck.ok) {
185
+ throw new Error(
186
+ `GitHub server cannot start — missing env vars: ${envCheck.missing.join(', ')}\n` +
187
+ 'See docs/github-app-setup.md.'
188
+ );
189
+ }
190
+
191
+ const app = express();
192
+ if (process.env.NODE_ENV === 'production') app.set('trust proxy', 1);
193
+ app.use(express.json());
194
+ app.use(express.urlencoded({ extended: false }));
195
+
196
+ // Session — secret from env, never hardcoded
197
+ app.use(session({
198
+ secret: requireEnv('RITE_SESSION_SECRET'),
199
+ resave: false,
200
+ saveUninitialized: false,
201
+ cookie: {
202
+ httpOnly: true,
203
+ secure: process.env.NODE_ENV === 'production',
204
+ sameSite: 'lax',
205
+ maxAge: 8 * 60 * 60 * 1000, // 8 hours
206
+ },
207
+ }));
208
+
209
+ // ---- Health ----
210
+ app.get('/api/health', (req, res) => {
211
+ res.json({
212
+ name: 'Rite GitHub server',
213
+ version: '0.1.0',
214
+ authed: !!req.session.userToken,
215
+ env: checkEnv(),
216
+ });
217
+ });
218
+
219
+ // ---- Login — redirect to GitHub OAuth ----
220
+ app.get('/github/login', (req, res) => {
221
+ const state = randomBytes(16).toString('hex');
222
+ req.session.oauthState = state;
223
+ res.redirect(buildAuthUrl(state));
224
+ });
225
+
226
+ // ---- Install — preserve CSRF state through GitHub's OAuth-on-install flow ----
227
+ app.get('/github/install', (req, res) => {
228
+ const state = randomBytes(16).toString('hex');
229
+ req.session.oauthState = state;
230
+ res.redirect(buildInstallUrl(state));
231
+ });
232
+
233
+ // ---- Callback — exchange code for user token ----
234
+ app.get('/github/callback', async (req, res) => {
235
+ const { code, state } = req.query;
236
+
237
+ if (!state || state !== req.session.oauthState) {
238
+ return res.status(400).json({ error: 'Invalid OAuth state. Possible CSRF.' });
239
+ }
240
+ delete req.session.oauthState;
241
+
242
+ if (!code) {
243
+ return res.status(400).json({ error: 'Missing OAuth code.' });
244
+ }
245
+
246
+ try {
247
+ const { token } = await exchangeCode(String(code));
248
+ req.session.userToken = token;
249
+
250
+ // Fetch user identity to store in session
251
+ const userResp = await fetch('https://api.github.com/user', {
252
+ headers: {
253
+ Authorization: `Bearer ${token}`,
254
+ Accept: 'application/vnd.github+json',
255
+ 'X-GitHub-Api-Version': '2022-11-28',
256
+ 'User-Agent': 'rite-app/0.1.0',
257
+ },
258
+ });
259
+ if (userResp.ok) {
260
+ const user = await userResp.json();
261
+ req.session.githubLogin = user.login;
262
+ req.session.githubAvatarUrl = user.avatar_url;
263
+ }
264
+
265
+ // Redirect back to the app
266
+ res.redirect(`${process.env.RITE_PUBLIC_URL ?? ''}/#github-connected`);
267
+ } catch (e) {
268
+ res.status(500).json({ error: `OAuth exchange failed: ${e.message}` });
269
+ }
270
+ });
271
+
272
+ // ---- Installations — list authorised repos ----
273
+ app.get('/github/installations', async (req, res) => {
274
+ if (!req.session.userToken) {
275
+ return res.status(401).json({ error: 'Not authenticated. Visit /github/login first.' });
276
+ }
277
+ try {
278
+ const repos = await listAuthorizedRepos(req.session.userToken);
279
+ res.json({ login: req.session.githubLogin, repos });
280
+ } catch (e) {
281
+ res.status(502).json({ error: `GitHub API error: ${e.message}` });
282
+ }
283
+ });
284
+
285
+ // ---- Runs — list recent Rite workflow runs for a repo ----
286
+ app.get('/github/runs', async (req, res) => {
287
+ if (!req.session.userToken) {
288
+ return res.status(401).json({ error: 'Not authenticated.' });
289
+ }
290
+ const { repoId, fullName } = req.query;
291
+ if (!repoId || !fullName) {
292
+ return res.status(400).json({ error: 'repoId and fullName required.' });
293
+ }
294
+
295
+ // Security: verify repo is in the user's authorised list — do not trust query param
296
+ try {
297
+ const authorised = await listAuthorizedRepos(req.session.userToken);
298
+ if (!authorised.find(r => String(r.id) === String(repoId) && r.fullName === fullName)) {
299
+ return res.status(403).json({ error: 'Repo not in authorised installation list.' });
300
+ }
301
+ } catch (e) {
302
+ return res.status(502).json({ error: `Could not verify authorisation: ${e.message}` });
303
+ }
304
+
305
+ try {
306
+ const headers = {
307
+ Authorization: `Bearer ${req.session.userToken}`,
308
+ Accept: 'application/vnd.github+json',
309
+ 'X-GitHub-Api-Version': '2022-11-28',
310
+ 'User-Agent': 'rite-app/0.1.0',
311
+ };
312
+ const [runsResp, workflowsResp] = await Promise.all([
313
+ fetch(`https://api.github.com/repos/${fullName}/actions/runs?per_page=20`, { headers }),
314
+ fetch(`https://api.github.com/repos/${fullName}/actions/workflows?per_page=100`, { headers }),
315
+ ]);
316
+ if (!runsResp.ok) {
317
+ return res.status(502).json({ error: `GitHub runs API: ${runsResp.status}` });
318
+ }
319
+ const data = await runsResp.json();
320
+ const runs = (data.workflow_runs ?? []).map(r => ({
321
+ runId: r.id,
322
+ headSha: r.head_sha,
323
+ status: r.status,
324
+ conclusion: r.conclusion,
325
+ createdAt: r.created_at,
326
+ htmlUrl: r.html_url,
327
+ name: r.name,
328
+ }));
329
+ let workflowInstalled = null;
330
+ if (workflowsResp.ok) {
331
+ const workflows = await workflowsResp.json();
332
+ workflowInstalled = hasRiteWorkflow(workflows.workflows ?? []);
333
+ }
334
+ res.json({ runs, workflowInstalled });
335
+ } catch (e) {
336
+ res.status(502).json({ error: `GitHub API error: ${e.message}` });
337
+ }
338
+ });
339
+
340
+ // ---- Report — fetch and validate artifact ----
341
+ app.get('/github/report', async (req, res) => {
342
+ if (!req.session.userToken) {
343
+ return res.status(401).json({ error: 'Not authenticated.' });
344
+ }
345
+ const { repoId, fullName, runId } = req.query;
346
+ if (!repoId || !fullName || !runId) {
347
+ return res.status(400).json({ error: 'repoId, fullName, and runId are required.' });
348
+ }
349
+
350
+ // Security: verify authorisation first
351
+ try {
352
+ const authorised = await listAuthorizedRepos(req.session.userToken);
353
+ const repo = authorised.find(r => String(r.id) === String(repoId) && r.fullName === fullName);
354
+ if (!repo) {
355
+ return res.status(403).json({ error: 'Repo not in authorised installation list.' });
356
+ }
357
+
358
+ // Get installation token (short-lived, server-side only)
359
+ const installToken = await getInstallationToken(repo.installationId);
360
+
361
+ // Resolve the commit SHA from GitHub. Never trust one supplied by the browser.
362
+ const runResp = await fetch(
363
+ `https://api.github.com/repos/${fullName}/actions/runs/${Number(runId)}`,
364
+ {
365
+ headers: {
366
+ Authorization: `Bearer ${installToken}`,
367
+ Accept: 'application/vnd.github+json',
368
+ 'X-GitHub-Api-Version': '2022-11-28',
369
+ 'User-Agent': 'rite-app/0.1.0',
370
+ },
371
+ }
372
+ );
373
+ if (!runResp.ok) {
374
+ return res.status(502).json({ error: `GitHub run API: ${runResp.status}` });
375
+ }
376
+ const run = await runResp.json();
377
+
378
+ const result = await fetchAndValidateReport(
379
+ installToken,
380
+ String(fullName),
381
+ Number(runId),
382
+ String(run.head_sha)
383
+ );
384
+
385
+ if (!result.valid) {
386
+ return res.status(422).json({ valid: false, mismatch: result.mismatch });
387
+ }
388
+
389
+ res.json({ valid: true, report: result.report });
390
+ } catch (e) {
391
+ res.status(502).json({ error: `Report fetch failed: ${e.message}` });
392
+ }
393
+ });
394
+
395
+ // ---- Logout ----
396
+ app.post('/github/logout', (req, res) => {
397
+ req.session.destroy(() => res.json({ ok: true }));
398
+ });
399
+
400
+ // Production serves the built workbench and GitHub API from one origin.
401
+ app.use(express.static(join(__dirname, '..', '..', 'dist')));
402
+
403
+ // Start
404
+ return new Promise((resolve, reject) => {
405
+ const server = app.listen(port, () => {
406
+ process.stderr.write(`rite-server: listening on http://localhost:${port}\n`);
407
+ resolve(server);
408
+ });
409
+ server.on('error', reject);
410
+ });
411
+ }
@@ -0,0 +1,220 @@
1
+ /**
2
+ * src/graph/walker.js — Lightweight JS call-graph walker.
3
+ *
4
+ * Traces call paths from named entry functions to a named sink within
5
+ * a single JS/TS file using regex-based heuristics. Not a full AST parser —
6
+ * it reads function bodies and detects direct function calls by name.
7
+ *
8
+ * Limitations (stated explicitly):
9
+ * - Single-file scope only; does not follow cross-file imports.
10
+ * - Detects direct named calls: foo(), this.foo(), self.foo().
11
+ * - Does not resolve aliases, computed calls, prototype chains, or closures
12
+ * unless the called name literally appears in the source.
13
+ * - Works well for module-pattern code (factory functions with closures).
14
+ * - For large multi-file codebases, use a proper AST tool (acorn, ts-morph).
15
+ *
16
+ * Returns a PathTrace suitable for docs/path-map.md generation.
17
+ */
18
+
19
+ import { readFileSync } from 'node:fs';
20
+
21
+ // ------------------------------------------------------------------ //
22
+ // Source reader //
23
+ // ------------------------------------------------------------------ //
24
+
25
+ /**
26
+ * Extract named function/method bodies from JS source.
27
+ * Handles: function foo() {}, const foo = function() {}, const foo = () => {},
28
+ * foo(...) { ... } (method shorthand), async variants.
29
+ * @param {string} source
30
+ * @returns {Map<string, { body: string, startLine: number }>}
31
+ */
32
+ function extractFunctions(source) {
33
+ const fns = new Map();
34
+ const lines = source.split('\n');
35
+
36
+ // Pattern: function name(...) { OR const name = (...) => { OR async function name
37
+ const funcPattern = /(?:async\s+)?function\s+(\w+)\s*\(/g;
38
+ const arrowPattern = /(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s+)?\(?[^)]*\)?\s*=>/g;
39
+ const methodPattern = /^\s*(?:async\s+)?(\w+)\s*\([^)]*\)\s*\{/gm;
40
+
41
+ for (const [pattern, nameIdx] of [
42
+ [funcPattern, 1],
43
+ [arrowPattern, 1],
44
+ [methodPattern, 1],
45
+ ]) {
46
+ let match;
47
+ while ((match = pattern.exec(source)) !== null) {
48
+ const name = match[nameIdx];
49
+ if (!name || fns.has(name)) continue;
50
+ // Find the line number
51
+ const upTo = source.slice(0, match.index);
52
+ const startLine = upTo.split('\n').length;
53
+ // Extract body: find matching braces from match position
54
+ const bodyStart = source.indexOf('{', match.index);
55
+ if (bodyStart === -1) continue;
56
+ let depth = 0;
57
+ let bodyEnd = bodyStart;
58
+ for (let i = bodyStart; i < source.length; i++) {
59
+ if (source[i] === '{') depth++;
60
+ else if (source[i] === '}') {
61
+ depth--;
62
+ if (depth === 0) { bodyEnd = i; break; }
63
+ }
64
+ }
65
+ fns.set(name, { body: source.slice(bodyStart, bodyEnd + 1), startLine });
66
+ }
67
+ }
68
+ return fns;
69
+ }
70
+
71
+ /**
72
+ * Find all function names called inside a function body.
73
+ * @param {string} body
74
+ * @returns {string[]}
75
+ */
76
+ function findCalls(body) {
77
+ const calls = new Set();
78
+ // Match: word( OR this.word( OR self.word(
79
+ const callPattern = /(?:this\.|self\.)?(\w+)\s*\(/g;
80
+ let m;
81
+ while ((m = callPattern.exec(body)) !== null) {
82
+ const name = m[1];
83
+ // Filter out JS keywords and common built-ins
84
+ if (!['if', 'for', 'while', 'switch', 'catch', 'new', 'return', 'typeof',
85
+ 'instanceof', 'await', 'async', 'function', 'class', 'import',
86
+ 'console', 'Object', 'Array', 'String', 'Number', 'Promise',
87
+ 'Error', 'Map', 'Set', 'JSON', 'Math'].includes(name)) {
88
+ calls.add(name);
89
+ }
90
+ }
91
+ return [...calls];
92
+ }
93
+
94
+ // ------------------------------------------------------------------ //
95
+ // Path trace //
96
+ // ------------------------------------------------------------------ //
97
+
98
+ /**
99
+ * @typedef {{
100
+ * entry: string,
101
+ * sink: string,
102
+ * path: string[], // function call chain entry → … → sink
103
+ * found: boolean,
104
+ * startLine: number,
105
+ * }} PathTrace
106
+ */
107
+
108
+ /**
109
+ * BFS from entry toward sink, using extracted function bodies.
110
+ * @param {string} entry
111
+ * @param {string} sink
112
+ * @param {Map<string, { body: string, startLine: number }>} fns
113
+ * @returns {PathTrace}
114
+ */
115
+ function tracePath(entry, sink, fns) {
116
+ const entryInfo = fns.get(entry);
117
+ if (!entryInfo) return { entry, sink, path: [entry], found: false, startLine: 0 };
118
+
119
+ // BFS
120
+ const queue = [[entry]];
121
+ const visited = new Set([entry]);
122
+
123
+ while (queue.length > 0) {
124
+ const currentPath = queue.shift();
125
+ const current = currentPath[currentPath.length - 1];
126
+ const fnInfo = fns.get(current);
127
+ if (!fnInfo) continue;
128
+
129
+ const calls = findCalls(fnInfo.body);
130
+ for (const called of calls) {
131
+ if (called === sink) {
132
+ return {
133
+ entry,
134
+ sink,
135
+ path: [...currentPath, sink],
136
+ found: true,
137
+ startLine: entryInfo.startLine,
138
+ };
139
+ }
140
+ if (!visited.has(called) && fns.has(called)) {
141
+ visited.add(called);
142
+ queue.push([...currentPath, called]);
143
+ }
144
+ }
145
+ }
146
+
147
+ return { entry, sink, path: [entry], found: false, startLine: entryInfo.startLine };
148
+ }
149
+
150
+ // ------------------------------------------------------------------ //
151
+ // Public API //
152
+ // ------------------------------------------------------------------ //
153
+
154
+ /**
155
+ * @typedef {{
156
+ * file: string,
157
+ * entries: string[],
158
+ * sink: string,
159
+ * traces: PathTrace[],
160
+ * functions: string[],
161
+ * note: string,
162
+ * }} WalkResult
163
+ */
164
+
165
+ /**
166
+ * Walk a JS file and trace all named entry paths to a sink.
167
+ * @param {{ file: string, entries: string[], sink: string }} opts
168
+ * @returns {WalkResult}
169
+ */
170
+ export function walkFile({ file, entries, sink }) {
171
+ const source = readFileSync(file, 'utf-8');
172
+ const fns = extractFunctions(source);
173
+ const traces = entries.map(entry => tracePath(entry, sink, fns));
174
+ return {
175
+ file,
176
+ entries,
177
+ sink,
178
+ traces,
179
+ functions: [...fns.keys()],
180
+ note: 'Regex-based single-file walker. Does not follow cross-file imports or resolve aliases.',
181
+ };
182
+ }
183
+
184
+ /**
185
+ * Format a WalkResult as Markdown for docs/path-map.md.
186
+ * @param {WalkResult} result
187
+ * @returns {string}
188
+ */
189
+ export function formatWalkResult(result) {
190
+ const lines = [];
191
+ lines.push(`## Auto-discovered paths in \`${result.file}\``);
192
+ lines.push('');
193
+ lines.push(`> ${result.note}`);
194
+ lines.push('');
195
+
196
+ for (const trace of result.traces) {
197
+ const status = trace.found ? '✓ reaches sink' : '✗ no path to sink found';
198
+ lines.push(`### \`${trace.entry}\` → \`${trace.sink}\` (${status})`);
199
+ if (trace.found) {
200
+ lines.push('');
201
+ lines.push('Call chain:');
202
+ lines.push('```');
203
+ lines.push(trace.path.join(' → '));
204
+ lines.push('```');
205
+ } else {
206
+ lines.push('');
207
+ lines.push('No direct call chain detected. The sink may be reached via a closure,');
208
+ lines.push('dynamic dispatch, or a cross-file import outside this walker\'s scope.');
209
+ }
210
+ lines.push('');
211
+ }
212
+
213
+ lines.push('### All functions detected');
214
+ lines.push('');
215
+ for (const fn of result.functions) {
216
+ const reachesSink = result.traces.some(t => t.path.includes(fn) && t.found);
217
+ lines.push(`- \`${fn}\`${reachesSink ? ' ← on path to sink' : ''}`);
218
+ }
219
+ return lines.join('\n');
220
+ }