@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.
- package/LICENSE +21 -0
- package/README.md +195 -0
- package/dist/assets/favicon.ico +0 -0
- package/dist/assets/index-CUOHpkRl.js +18 -0
- package/dist/assets/index-kPEQ2Fln.css +1 -0
- package/dist/assets/rite-180.png +0 -0
- package/dist/assets/rite-logo-primary-1200.png +0 -0
- package/dist/assets/rite-og-1200x630.png +0 -0
- package/dist/evidence/proof.json +3473 -0
- package/dist/index.html +30 -0
- package/package.json +70 -0
- package/src/cli/rite.js +510 -0
- package/src/core/engine.js +263 -0
- package/src/core/loader.js +72 -0
- package/src/core/sarif.js +126 -0
- package/src/github/app.js +134 -0
- package/src/github/server.js +411 -0
- package/src/graph/walker.js +220 -0
- package/src/instrument/auto-adapter.js +237 -0
- package/src/instrument/effect-schema.js +168 -0
- package/src/mcp/server.js +185 -0
- package/src/watsonx/draft.js +252 -0
- package/templates/rite.yml +43 -0
|
@@ -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
|
+
}
|