@cleartrip/frontguard 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/dist/cli.js +2220 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +129 -0
- package/dist/index.js +86 -0
- package/dist/index.js.map +1 -0
- package/package.json +53 -0
- package/templates/bitbucket-pipelines.yml +44 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,2220 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import process2 from 'process';
|
|
3
|
+
import { defineCommand, runMain } from 'citty';
|
|
4
|
+
import fs from 'fs/promises';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
import { createRequire } from 'module';
|
|
7
|
+
import fs4 from 'fs';
|
|
8
|
+
import { pathToFileURL } from 'url';
|
|
9
|
+
import defu from 'defu';
|
|
10
|
+
import { exec } from 'tinyexec';
|
|
11
|
+
import fg from 'fast-glob';
|
|
12
|
+
import pc from 'picocolors';
|
|
13
|
+
|
|
14
|
+
var WORKFLOW = `name: FrontGuard
|
|
15
|
+
|
|
16
|
+
on:
|
|
17
|
+
pull_request:
|
|
18
|
+
types: [opened, synchronize, reopened]
|
|
19
|
+
|
|
20
|
+
permissions:
|
|
21
|
+
contents: read
|
|
22
|
+
pull-requests: write
|
|
23
|
+
|
|
24
|
+
concurrency:
|
|
25
|
+
group: frontguard-\${{ github.workflow }}-\${{ github.event.pull_request.number || github.ref }}
|
|
26
|
+
cancel-in-progress: true
|
|
27
|
+
|
|
28
|
+
jobs:
|
|
29
|
+
review-brief:
|
|
30
|
+
runs-on: ubuntu-latest
|
|
31
|
+
steps:
|
|
32
|
+
- uses: actions/checkout@v4
|
|
33
|
+
with:
|
|
34
|
+
fetch-depth: 0
|
|
35
|
+
|
|
36
|
+
- uses: actions/setup-node@v4
|
|
37
|
+
with:
|
|
38
|
+
node-version: 20
|
|
39
|
+
|
|
40
|
+
- name: Install dependencies
|
|
41
|
+
run: |
|
|
42
|
+
if [ -f pnpm-lock.yaml ]; then
|
|
43
|
+
corepack enable
|
|
44
|
+
pnpm install --frozen-lockfile || pnpm install
|
|
45
|
+
elif [ -f yarn.lock ]; then
|
|
46
|
+
yarn install --frozen-lockfile || yarn install
|
|
47
|
+
elif [ -f package-lock.json ]; then
|
|
48
|
+
npm ci || npm install
|
|
49
|
+
else
|
|
50
|
+
npm install
|
|
51
|
+
fi
|
|
52
|
+
|
|
53
|
+
- name: FrontGuard (Phase 1 \u2014 warn-only)
|
|
54
|
+
run: npx @cleartrip/frontguard run --ci
|
|
55
|
+
env:
|
|
56
|
+
GITHUB_TOKEN: \${{ secrets.GITHUB_TOKEN }}
|
|
57
|
+
`;
|
|
58
|
+
var CONFIG = `import { defineConfig } from '@cleartrip/frontguard'
|
|
59
|
+
|
|
60
|
+
export default defineConfig({
|
|
61
|
+
mode: 'warn', // or FrontGuard run with \`--enforce\`
|
|
62
|
+
// extends: '@your-org/frontguard-config/base',
|
|
63
|
+
|
|
64
|
+
// Example custom rules (return true when violated):
|
|
65
|
+
// rules: {
|
|
66
|
+
// 'no-inline-style': {
|
|
67
|
+
// severity: 'warn',
|
|
68
|
+
// message: 'Avoid inline style objects',
|
|
69
|
+
// check: (file) => file.content.includes('style={{'),
|
|
70
|
+
// },
|
|
71
|
+
// },
|
|
72
|
+
|
|
73
|
+
// checks: {
|
|
74
|
+
// bundle: { enabled: true, maxDeltaBytes: 50_000, maxTotalBytes: null },
|
|
75
|
+
// cycles: { enabled: true },
|
|
76
|
+
// deadCode: { enabled: true, gate: 'info' },
|
|
77
|
+
// // LLM in CI needs a key in the *runner* (GitHub secret). IDE keys (Cursor Enterprise)
|
|
78
|
+
// // are not available in Actions \u2014 use paste workflow instead:
|
|
79
|
+
// // frontguard run --append ./.frontguard/review-notes.md
|
|
80
|
+
// // or FRONTGUARD_MANUAL_APPENDIX_FILE / FRONTGUARD_MANUAL_APPENDIX
|
|
81
|
+
// llm: {
|
|
82
|
+
// enabled: true,
|
|
83
|
+
// provider: 'openai',
|
|
84
|
+
// apiKeyEnv: 'OPENAI_API_KEY',
|
|
85
|
+
// },
|
|
86
|
+
// },
|
|
87
|
+
})
|
|
88
|
+
`;
|
|
89
|
+
var PR_TEMPLATE = `## Summary
|
|
90
|
+
|
|
91
|
+
## Why
|
|
92
|
+
|
|
93
|
+
## How to test
|
|
94
|
+
|
|
95
|
+
## AI disclosure
|
|
96
|
+
<!-- FrontGuard reads this section. Mark exactly one of Yes / No. -->
|
|
97
|
+
|
|
98
|
+
- [ ] **Yes** \u2014 AI tools (Cursor, Copilot, ChatGPT, Claude, etc.) helped write or materially refactor code in this PR
|
|
99
|
+
- [ ] **No** \u2014 I did not use AI-generated code for the changes in this PR
|
|
100
|
+
|
|
101
|
+
If **Yes**, list tools and what they touched (helps reviewers run a stricter first pass):
|
|
102
|
+
|
|
103
|
+
- Tools:
|
|
104
|
+
- Areas/files or prompts (short):
|
|
105
|
+
|
|
106
|
+
## AI assistance (optional detail)
|
|
107
|
+
- [ ] I have reviewed every AI-suggested line for security, auth, and product correctness
|
|
108
|
+
`;
|
|
109
|
+
async function ensureDir(dir) {
|
|
110
|
+
await fs.mkdir(dir, { recursive: true });
|
|
111
|
+
}
|
|
112
|
+
async function initFrontGuard(cwd) {
|
|
113
|
+
const gh = path.join(cwd, ".github", "workflows");
|
|
114
|
+
await ensureDir(gh);
|
|
115
|
+
const wfPath = path.join(gh, "frontguard.yml");
|
|
116
|
+
await fs.writeFile(wfPath, WORKFLOW, "utf8");
|
|
117
|
+
const cfgPath = path.join(cwd, "frontguard.config.js");
|
|
118
|
+
try {
|
|
119
|
+
await fs.access(cfgPath);
|
|
120
|
+
} catch {
|
|
121
|
+
await fs.writeFile(cfgPath, CONFIG, "utf8");
|
|
122
|
+
}
|
|
123
|
+
const tplRoot = path.join(cwd, ".github");
|
|
124
|
+
await ensureDir(tplRoot);
|
|
125
|
+
const tplPath = path.join(tplRoot, "pull_request_template.md");
|
|
126
|
+
try {
|
|
127
|
+
await fs.access(tplPath);
|
|
128
|
+
} catch {
|
|
129
|
+
await fs.writeFile(tplPath, PR_TEMPLATE, "utf8");
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// src/ci/parse-ai-disclosure.ts
|
|
134
|
+
function extractAiSection(body) {
|
|
135
|
+
const lines = body.split(/\r?\n/);
|
|
136
|
+
const idx = lines.findIndex((l) => /^#{1,6}\s+.*\bAI\b/i.test(l.trim()));
|
|
137
|
+
if (idx < 0) return null;
|
|
138
|
+
const out = [];
|
|
139
|
+
for (let j = idx + 1; j < lines.length; j++) {
|
|
140
|
+
const line = lines[j] ?? "";
|
|
141
|
+
if (/^##\s+/.test(line)) break;
|
|
142
|
+
out.push(line);
|
|
143
|
+
}
|
|
144
|
+
const text = out.join("\n").trim();
|
|
145
|
+
return text.length ? text : null;
|
|
146
|
+
}
|
|
147
|
+
function isCheckedItem(line) {
|
|
148
|
+
return /^\s*-\s*\[[xX]\]/.test(line);
|
|
149
|
+
}
|
|
150
|
+
function parseAiDisclosure(body) {
|
|
151
|
+
let assisted = false;
|
|
152
|
+
let explicitNo = false;
|
|
153
|
+
let ambiguous = false;
|
|
154
|
+
const section = extractAiSection(body);
|
|
155
|
+
if (section) {
|
|
156
|
+
let anyChecked = false;
|
|
157
|
+
for (const raw of section.split(/\r?\n/)) {
|
|
158
|
+
const line = raw.trimEnd();
|
|
159
|
+
if (!isCheckedItem(line)) continue;
|
|
160
|
+
anyChecked = true;
|
|
161
|
+
const lower = line.toLowerCase();
|
|
162
|
+
const saysNo = /\*\*no\*\*/i.test(line) || /\bwithout ai\b/i.test(lower) || /\bdid not use ai\b/i.test(lower) || /\bno\s+ai\b/i.test(lower);
|
|
163
|
+
const saysYes = /\*\*yes\*\*/i.test(line) || /\byes\s*[—–-]\s*ai\b/i.test(lower) || /\bwere used\b/i.test(lower) || /ai tools.*used/i.test(lower) || /\b(copilot|cursor|chatgpt|claude)\b/i.test(lower);
|
|
164
|
+
if (saysNo && !saysYes) explicitNo = true;
|
|
165
|
+
else if (saysYes) assisted = true;
|
|
166
|
+
else if (/\bai\b/i.test(line) && !saysNo) assisted = true;
|
|
167
|
+
}
|
|
168
|
+
if (anyChecked && !assisted && !explicitNo) ambiguous = true;
|
|
169
|
+
}
|
|
170
|
+
if (!section && /ai tools were used/i.test(body)) {
|
|
171
|
+
const checked = /\[[xX]\]\s*AI tools were used/i.test(body);
|
|
172
|
+
if (checked) assisted = true;
|
|
173
|
+
}
|
|
174
|
+
if (explicitNo && assisted) {
|
|
175
|
+
ambiguous = true;
|
|
176
|
+
assisted = false;
|
|
177
|
+
explicitNo = false;
|
|
178
|
+
}
|
|
179
|
+
return { assisted, explicitNo, ambiguous };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// src/ci/github.ts
|
|
183
|
+
async function readGithubEvent() {
|
|
184
|
+
const p = process.env.GITHUB_EVENT_PATH;
|
|
185
|
+
if (!p) return null;
|
|
186
|
+
try {
|
|
187
|
+
const raw = await fs.readFile(p, "utf8");
|
|
188
|
+
const payload = JSON.parse(raw);
|
|
189
|
+
const pr = payload.pull_request;
|
|
190
|
+
if (!pr) return null;
|
|
191
|
+
const files = (pr.files ?? []).map((f) => f.filename ?? "").filter(Boolean);
|
|
192
|
+
const body = pr.body ?? "";
|
|
193
|
+
const ai = parseAiDisclosure(body);
|
|
194
|
+
return {
|
|
195
|
+
number: pr.number ?? 0,
|
|
196
|
+
title: pr.title ?? "",
|
|
197
|
+
body,
|
|
198
|
+
baseRef: pr.base?.ref ?? "main",
|
|
199
|
+
headRef: pr.head?.ref ?? "",
|
|
200
|
+
additions: pr.additions ?? 0,
|
|
201
|
+
deletions: pr.deletions ?? 0,
|
|
202
|
+
changedFiles: pr.changed_files ?? files.length,
|
|
203
|
+
files,
|
|
204
|
+
aiAssisted: ai.assisted,
|
|
205
|
+
aiExplicitNo: ai.explicitNo,
|
|
206
|
+
aiDisclosureAmbiguous: ai.ambiguous
|
|
207
|
+
};
|
|
208
|
+
} catch {
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
var MARKER = "<!-- frontguard:brief -->";
|
|
213
|
+
async function resolvePrNumber() {
|
|
214
|
+
const raw = process.env.FRONTGUARD_PR_NUMBER ?? process.env.PR_NUMBER;
|
|
215
|
+
const n = Number(raw);
|
|
216
|
+
if (Number.isFinite(n) && n > 0) return n;
|
|
217
|
+
const path14 = process.env.GITHUB_EVENT_PATH;
|
|
218
|
+
if (!path14) return null;
|
|
219
|
+
try {
|
|
220
|
+
const payload = JSON.parse(await fs.readFile(path14, "utf8"));
|
|
221
|
+
const num = payload.pull_request?.number;
|
|
222
|
+
return typeof num === "number" && num > 0 ? num : null;
|
|
223
|
+
} catch {
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
async function upsertBriefComment(body) {
|
|
228
|
+
const token = process.env.GITHUB_TOKEN;
|
|
229
|
+
const repo = process.env.GITHUB_REPOSITORY;
|
|
230
|
+
if (!token || !repo) {
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
const [owner, name] = repo.split("/");
|
|
234
|
+
if (!owner || !name) return;
|
|
235
|
+
const prNumber = await resolvePrNumber();
|
|
236
|
+
if (!prNumber) {
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
const apiBase = process.env.GITHUB_API_URL ?? "https://api.github.com";
|
|
240
|
+
const headers = {
|
|
241
|
+
authorization: `Bearer ${token}`,
|
|
242
|
+
accept: "application/vnd.github+json",
|
|
243
|
+
"x-github-api-version": "2022-11-28",
|
|
244
|
+
"content-type": "application/json"
|
|
245
|
+
};
|
|
246
|
+
const prefixed = `${MARKER}
|
|
247
|
+
${body}`;
|
|
248
|
+
const listUrl = `${apiBase}/repos/${owner}/${name}/issues/${prNumber}/comments?per_page=100`;
|
|
249
|
+
const listRes = await fetch(listUrl, { headers });
|
|
250
|
+
if (!listRes.ok) {
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
const comments = await listRes.json();
|
|
254
|
+
const existing = comments.find(
|
|
255
|
+
(c) => typeof c.body === "string" && c.body.includes(MARKER)
|
|
256
|
+
);
|
|
257
|
+
if (existing) {
|
|
258
|
+
const patchUrl = `${apiBase}/repos/${owner}/${name}/issues/comments/${existing.id}`;
|
|
259
|
+
await fetch(patchUrl, {
|
|
260
|
+
method: "PATCH",
|
|
261
|
+
headers,
|
|
262
|
+
body: JSON.stringify({ body: prefixed })
|
|
263
|
+
});
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
const postUrl = `${apiBase}/repos/${owner}/${name}/issues/${prNumber}/comments`;
|
|
267
|
+
await fetch(postUrl, {
|
|
268
|
+
method: "POST",
|
|
269
|
+
headers,
|
|
270
|
+
body: JSON.stringify({ body: prefixed })
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// src/config/defaults.ts
|
|
275
|
+
var defaultConfig = {
|
|
276
|
+
mode: "warn",
|
|
277
|
+
rules: {},
|
|
278
|
+
checks: {
|
|
279
|
+
eslint: { enabled: true, glob: "**/*.{js,cjs,mjs,jsx,ts,tsx}" },
|
|
280
|
+
prettier: {
|
|
281
|
+
enabled: true,
|
|
282
|
+
glob: "**/*.{js,cjs,mjs,jsx,ts,tsx,json,md,css,scss,yml,yaml}"
|
|
283
|
+
},
|
|
284
|
+
typescript: { enabled: true },
|
|
285
|
+
secrets: { enabled: true },
|
|
286
|
+
prHygiene: {
|
|
287
|
+
enabled: true,
|
|
288
|
+
minBodyLength: 80,
|
|
289
|
+
requireSections: false,
|
|
290
|
+
sectionHints: ["what", "why", "test", "how to test", "screenshot"],
|
|
291
|
+
requireAiDisclosureSection: true,
|
|
292
|
+
gateWhenAiDisclosureAmbiguous: "warn"
|
|
293
|
+
},
|
|
294
|
+
aiAssistedReview: {
|
|
295
|
+
enabled: true,
|
|
296
|
+
gate: "warn",
|
|
297
|
+
escalate: {
|
|
298
|
+
secretFindingsToBlock: true,
|
|
299
|
+
tsAnyDeltaToBlock: true
|
|
300
|
+
}
|
|
301
|
+
},
|
|
302
|
+
prSize: { warnLines: 400, softBlockLines: 800 },
|
|
303
|
+
tsAnyDelta: {
|
|
304
|
+
enabled: true,
|
|
305
|
+
gate: "warn",
|
|
306
|
+
baseRef: "main",
|
|
307
|
+
maxAdded: 0
|
|
308
|
+
},
|
|
309
|
+
cycles: {
|
|
310
|
+
enabled: false,
|
|
311
|
+
gate: "warn",
|
|
312
|
+
entries: ["src"],
|
|
313
|
+
extraArgs: []
|
|
314
|
+
},
|
|
315
|
+
deadCode: {
|
|
316
|
+
enabled: false,
|
|
317
|
+
gate: "info",
|
|
318
|
+
extraArgs: [],
|
|
319
|
+
maxReportLines: 80
|
|
320
|
+
},
|
|
321
|
+
bundle: {
|
|
322
|
+
enabled: false,
|
|
323
|
+
gate: "warn",
|
|
324
|
+
runBuild: true,
|
|
325
|
+
buildCommand: "npm run build",
|
|
326
|
+
measureGlobs: ["dist/**/*", "build/static/**/*", ".next/static/**/*"],
|
|
327
|
+
baselinePath: ".frontguard/bundle-baseline.json",
|
|
328
|
+
maxDeltaBytes: null,
|
|
329
|
+
maxTotalBytes: null
|
|
330
|
+
},
|
|
331
|
+
cwv: {
|
|
332
|
+
enabled: true,
|
|
333
|
+
gate: "warn",
|
|
334
|
+
scanGlobs: ["app/**/*.{tsx,jsx}", "pages/**/*.{tsx,jsx}", "src/**/*.{tsx,jsx}"],
|
|
335
|
+
maxFileBytes: 4e5
|
|
336
|
+
},
|
|
337
|
+
llm: {
|
|
338
|
+
enabled: false,
|
|
339
|
+
provider: "openai",
|
|
340
|
+
model: "gpt-4o-mini",
|
|
341
|
+
apiKeyEnv: "OPENAI_API_KEY",
|
|
342
|
+
maxDiffChars: 48e3,
|
|
343
|
+
timeoutMs: 6e4
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
// src/config/load.ts
|
|
349
|
+
var CONFIG_NAMES = [
|
|
350
|
+
"frontguard.config.js",
|
|
351
|
+
"frontguard.config.mjs",
|
|
352
|
+
"frontguard.config.cjs"
|
|
353
|
+
];
|
|
354
|
+
async function importConfig(absolutePath) {
|
|
355
|
+
const url = pathToFileURL(absolutePath).href;
|
|
356
|
+
return import(url);
|
|
357
|
+
}
|
|
358
|
+
function normalizeExport(mod) {
|
|
359
|
+
if (mod && typeof mod === "object" && "default" in mod && mod.default) {
|
|
360
|
+
return mod.default;
|
|
361
|
+
}
|
|
362
|
+
return mod;
|
|
363
|
+
}
|
|
364
|
+
function stripExtends(c) {
|
|
365
|
+
const { extends: _e, ...rest } = c;
|
|
366
|
+
return rest;
|
|
367
|
+
}
|
|
368
|
+
async function loadExtendsLayer(cwd, spec) {
|
|
369
|
+
if (!spec) return {};
|
|
370
|
+
const req = createRequire(path.join(cwd, "package.json"));
|
|
371
|
+
const specs = Array.isArray(spec) ? spec : [spec];
|
|
372
|
+
let merged = {};
|
|
373
|
+
for (const s of specs) {
|
|
374
|
+
try {
|
|
375
|
+
const resolved = req.resolve(s);
|
|
376
|
+
const mod = await import(pathToFileURL(resolved).href);
|
|
377
|
+
const layer = normalizeExport(
|
|
378
|
+
mod
|
|
379
|
+
);
|
|
380
|
+
merged = defu(stripExtends(layer), merged);
|
|
381
|
+
} catch {
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
return merged;
|
|
385
|
+
}
|
|
386
|
+
async function loadConfig(cwd) {
|
|
387
|
+
let userFile = null;
|
|
388
|
+
for (const name of CONFIG_NAMES) {
|
|
389
|
+
const full = path.join(cwd, name);
|
|
390
|
+
if (!fs4.existsSync(full)) continue;
|
|
391
|
+
try {
|
|
392
|
+
const mod = await importConfig(full);
|
|
393
|
+
userFile = normalizeExport(mod);
|
|
394
|
+
break;
|
|
395
|
+
} catch {
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
const extendsSpec = userFile?.extends;
|
|
400
|
+
const orgLayer = await loadExtendsLayer(cwd, extendsSpec);
|
|
401
|
+
const user = userFile ? stripExtends(userFile) : {};
|
|
402
|
+
const base = structuredClone(defaultConfig);
|
|
403
|
+
const withOrg = defu(orgLayer, base);
|
|
404
|
+
return defu(user, withOrg);
|
|
405
|
+
}
|
|
406
|
+
function allDeps(pkg) {
|
|
407
|
+
return {
|
|
408
|
+
...pkg.peerDependencies,
|
|
409
|
+
...pkg.devDependencies,
|
|
410
|
+
...pkg.dependencies
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
function hasDep(deps, name) {
|
|
414
|
+
return Object.prototype.hasOwnProperty.call(deps, name);
|
|
415
|
+
}
|
|
416
|
+
async function detectStack(cwd) {
|
|
417
|
+
let pkg = {};
|
|
418
|
+
try {
|
|
419
|
+
const raw = await fs.readFile(path.join(cwd, "package.json"), "utf8");
|
|
420
|
+
pkg = JSON.parse(raw);
|
|
421
|
+
} catch {
|
|
422
|
+
return {
|
|
423
|
+
isMonorepo: false,
|
|
424
|
+
hasTypeScript: false,
|
|
425
|
+
hasReact: false,
|
|
426
|
+
hasNext: false,
|
|
427
|
+
hasReactNative: false,
|
|
428
|
+
hasJest: false,
|
|
429
|
+
hasVitest: false,
|
|
430
|
+
hasPlaywright: false,
|
|
431
|
+
packageManager: "unknown",
|
|
432
|
+
tsStrict: null
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
const deps = allDeps(pkg);
|
|
436
|
+
const isMonorepo = Boolean(pkg.workspaces);
|
|
437
|
+
let tsStrict = null;
|
|
438
|
+
try {
|
|
439
|
+
const tsconfigPath = path.join(cwd, "tsconfig.json");
|
|
440
|
+
const tsRaw = await fs.readFile(tsconfigPath, "utf8");
|
|
441
|
+
const ts = JSON.parse(tsRaw);
|
|
442
|
+
if (typeof ts.compilerOptions?.strict === "boolean") {
|
|
443
|
+
tsStrict = ts.compilerOptions.strict;
|
|
444
|
+
}
|
|
445
|
+
} catch {
|
|
446
|
+
}
|
|
447
|
+
let pm = "unknown";
|
|
448
|
+
try {
|
|
449
|
+
await fs.access(path.join(cwd, "pnpm-lock.yaml"));
|
|
450
|
+
pm = "pnpm";
|
|
451
|
+
} catch {
|
|
452
|
+
try {
|
|
453
|
+
await fs.access(path.join(cwd, "yarn.lock"));
|
|
454
|
+
pm = "yarn";
|
|
455
|
+
} catch {
|
|
456
|
+
try {
|
|
457
|
+
await fs.access(path.join(cwd, "package-lock.json"));
|
|
458
|
+
pm = "npm";
|
|
459
|
+
} catch {
|
|
460
|
+
pm = "npm";
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
return {
|
|
465
|
+
isMonorepo,
|
|
466
|
+
hasTypeScript: hasDep(deps, "typescript") || Boolean(tsStrict !== null),
|
|
467
|
+
hasReact: hasDep(deps, "react"),
|
|
468
|
+
hasNext: hasDep(deps, "next"),
|
|
469
|
+
hasReactNative: hasDep(deps, "react-native"),
|
|
470
|
+
hasJest: hasDep(deps, "jest"),
|
|
471
|
+
hasVitest: hasDep(deps, "vitest"),
|
|
472
|
+
hasPlaywright: hasDep(deps, "@playwright/test"),
|
|
473
|
+
packageManager: pm,
|
|
474
|
+
tsStrict
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
async function pathExists(file) {
|
|
478
|
+
try {
|
|
479
|
+
await fs.access(file);
|
|
480
|
+
return true;
|
|
481
|
+
} catch {
|
|
482
|
+
return false;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
async function resolveBin(cwd, name) {
|
|
486
|
+
const local = path.join(cwd, "node_modules", ".bin", name);
|
|
487
|
+
if (await pathExists(local)) return local;
|
|
488
|
+
const win = local + ".cmd";
|
|
489
|
+
if (await pathExists(win)) return win;
|
|
490
|
+
return null;
|
|
491
|
+
}
|
|
492
|
+
async function runNpmBinary(cwd, name, args) {
|
|
493
|
+
try {
|
|
494
|
+
const bin = await resolveBin(cwd, name);
|
|
495
|
+
if (bin) {
|
|
496
|
+
const r2 = await exec(bin, args, { nodeOptions: { cwd } });
|
|
497
|
+
return {
|
|
498
|
+
exitCode: r2.exitCode ?? 0,
|
|
499
|
+
stdout: r2.stdout ?? "",
|
|
500
|
+
stderr: r2.stderr ?? ""
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
let r = await exec("npx", ["--no-install", name, ...args], { nodeOptions: { cwd } });
|
|
504
|
+
if ((r.exitCode ?? 0) !== 0) {
|
|
505
|
+
r = await exec("npx", [name, ...args], { nodeOptions: { cwd } });
|
|
506
|
+
}
|
|
507
|
+
return {
|
|
508
|
+
exitCode: r.exitCode ?? 0,
|
|
509
|
+
stdout: r.stdout ?? "",
|
|
510
|
+
stderr: r.stderr ?? ""
|
|
511
|
+
};
|
|
512
|
+
} catch (e) {
|
|
513
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
514
|
+
return {
|
|
515
|
+
exitCode: 127,
|
|
516
|
+
stdout: "",
|
|
517
|
+
stderr: msg
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
async function runNpx(cwd, args) {
|
|
522
|
+
try {
|
|
523
|
+
const r = await exec("npx", args, { nodeOptions: { cwd } });
|
|
524
|
+
return {
|
|
525
|
+
exitCode: r.exitCode ?? 0,
|
|
526
|
+
stdout: r.stdout ?? "",
|
|
527
|
+
stderr: r.stderr ?? ""
|
|
528
|
+
};
|
|
529
|
+
} catch (e) {
|
|
530
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
531
|
+
return {
|
|
532
|
+
exitCode: 127,
|
|
533
|
+
stdout: "",
|
|
534
|
+
stderr: msg
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// src/checks/eslint.ts
|
|
540
|
+
async function hasEslintDependency(cwd) {
|
|
541
|
+
try {
|
|
542
|
+
const raw = await fs.readFile(path.join(cwd, "package.json"), "utf8");
|
|
543
|
+
const p = JSON.parse(raw);
|
|
544
|
+
return Boolean(p.devDependencies?.eslint || p.dependencies?.eslint);
|
|
545
|
+
} catch {
|
|
546
|
+
return false;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
async function hasEslintConfig(cwd) {
|
|
550
|
+
const candidates = [
|
|
551
|
+
"eslint.config.js",
|
|
552
|
+
"eslint.config.mjs",
|
|
553
|
+
"eslint.config.cjs",
|
|
554
|
+
".eslintrc",
|
|
555
|
+
".eslintrc.json",
|
|
556
|
+
".eslintrc.cjs",
|
|
557
|
+
".eslintrc.yaml",
|
|
558
|
+
".eslintrc.yml"
|
|
559
|
+
];
|
|
560
|
+
for (const c of candidates) {
|
|
561
|
+
if (await pathExists(path.join(cwd, c))) return true;
|
|
562
|
+
}
|
|
563
|
+
return false;
|
|
564
|
+
}
|
|
565
|
+
function meaningfulStderr(stderr) {
|
|
566
|
+
return stderr.split("\n").filter((l) => l.trim() && !/^npm warn\b/i.test(l)).join("\n").trim();
|
|
567
|
+
}
|
|
568
|
+
async function runEslint(cwd, config, _stack) {
|
|
569
|
+
const t0 = performance.now();
|
|
570
|
+
if (!config.checks.eslint.enabled) {
|
|
571
|
+
return {
|
|
572
|
+
checkId: "eslint",
|
|
573
|
+
findings: [],
|
|
574
|
+
durationMs: 0,
|
|
575
|
+
skipped: "disabled in config"
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
const configured = await hasEslintConfig(cwd);
|
|
579
|
+
const dep = await hasEslintDependency(cwd);
|
|
580
|
+
if (!configured && !dep) {
|
|
581
|
+
return {
|
|
582
|
+
checkId: "eslint",
|
|
583
|
+
findings: [],
|
|
584
|
+
durationMs: Math.round(performance.now() - t0),
|
|
585
|
+
skipped: "ESLint not installed or configured"
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
const glob = config.checks.eslint.glob ?? "**/*.{js,cjs,mjs,jsx,ts,tsx}";
|
|
589
|
+
const args = [
|
|
590
|
+
glob,
|
|
591
|
+
"--max-warnings",
|
|
592
|
+
"0",
|
|
593
|
+
"--no-error-on-unmatched-pattern",
|
|
594
|
+
"-f",
|
|
595
|
+
"json"
|
|
596
|
+
];
|
|
597
|
+
const { exitCode, stdout, stderr } = await runNpmBinary(cwd, "eslint", args);
|
|
598
|
+
const errText = meaningfulStderr(stderr);
|
|
599
|
+
const findings = [];
|
|
600
|
+
if (exitCode === 0) {
|
|
601
|
+
if (errText) {
|
|
602
|
+
findings.push({
|
|
603
|
+
id: "eslint-stderr",
|
|
604
|
+
severity: "warn",
|
|
605
|
+
message: "ESLint wrote to stderr",
|
|
606
|
+
detail: truncate(errText, 4e3)
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
return {
|
|
610
|
+
checkId: "eslint",
|
|
611
|
+
findings,
|
|
612
|
+
durationMs: Math.round(performance.now() - t0)
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
try {
|
|
616
|
+
const rows = JSON.parse(stdout);
|
|
617
|
+
let n = 0;
|
|
618
|
+
for (const row of rows) {
|
|
619
|
+
for (const m of row.messages) {
|
|
620
|
+
if (n++ >= 40) break;
|
|
621
|
+
findings.push({
|
|
622
|
+
id: `eslint-${m.ruleId ?? "unknown"}`,
|
|
623
|
+
severity: "warn",
|
|
624
|
+
message: m.message,
|
|
625
|
+
file: row.filePath,
|
|
626
|
+
detail: m.line ? `line ${m.line}` : void 0
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
if (n === 0) {
|
|
631
|
+
findings.push({
|
|
632
|
+
id: "eslint-failed",
|
|
633
|
+
severity: "warn",
|
|
634
|
+
message: "ESLint exited with a non-zero status",
|
|
635
|
+
detail: truncate(
|
|
636
|
+
[stdout, errText].filter(Boolean).join("\n") || `exit ${exitCode}`,
|
|
637
|
+
4e3
|
|
638
|
+
)
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
} catch {
|
|
642
|
+
findings.push({
|
|
643
|
+
id: "eslint-failed",
|
|
644
|
+
severity: "warn",
|
|
645
|
+
message: "ESLint exited with errors",
|
|
646
|
+
detail: truncate(
|
|
647
|
+
[stdout, errText].filter(Boolean).join("\n") || `exit ${exitCode}`,
|
|
648
|
+
4e3
|
|
649
|
+
)
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
return {
|
|
653
|
+
checkId: "eslint",
|
|
654
|
+
findings,
|
|
655
|
+
durationMs: Math.round(performance.now() - t0)
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
function truncate(s, max) {
|
|
659
|
+
if (s.length <= max) return s;
|
|
660
|
+
return s.slice(0, max) + "\u2026";
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// src/checks/prettier.ts
|
|
664
|
+
async function runPrettier(cwd, config) {
|
|
665
|
+
const t0 = performance.now();
|
|
666
|
+
if (!config.checks.prettier.enabled) {
|
|
667
|
+
return {
|
|
668
|
+
checkId: "prettier",
|
|
669
|
+
findings: [],
|
|
670
|
+
durationMs: 0,
|
|
671
|
+
skipped: "disabled in config"
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
const glob = config.checks.prettier.glob ?? "**/*.{js,cjs,mjs,jsx,ts,tsx,json,md,css,scss,yml,yaml}";
|
|
675
|
+
const { exitCode, stdout, stderr } = await runNpmBinary(cwd, "prettier", [
|
|
676
|
+
"--check",
|
|
677
|
+
glob,
|
|
678
|
+
"--ignore-unknown"
|
|
679
|
+
]);
|
|
680
|
+
const findings = [];
|
|
681
|
+
if (exitCode === 127 || /command not found|not recognized|ENOENT/i.test(stderr)) {
|
|
682
|
+
return {
|
|
683
|
+
checkId: "prettier",
|
|
684
|
+
findings: [],
|
|
685
|
+
durationMs: Math.round(performance.now() - t0),
|
|
686
|
+
skipped: "prettier binary not found (install prettier or use npx)"
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
if (exitCode !== 0) {
|
|
690
|
+
const blob = [stdout, stderr].filter(Boolean).join("\n").trim();
|
|
691
|
+
findings.push({
|
|
692
|
+
id: "prettier-check",
|
|
693
|
+
severity: "warn",
|
|
694
|
+
message: "Prettier reported formatting differences",
|
|
695
|
+
detail: blob ? truncate2(blob, 6e3) : `exit ${exitCode}`
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
return {
|
|
699
|
+
checkId: "prettier",
|
|
700
|
+
findings,
|
|
701
|
+
durationMs: Math.round(performance.now() - t0)
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
function truncate2(s, max) {
|
|
705
|
+
if (s.length <= max) return s;
|
|
706
|
+
return s.slice(0, max) + "\u2026";
|
|
707
|
+
}
|
|
708
|
+
async function runTypeScript(cwd, config, stack) {
|
|
709
|
+
const t0 = performance.now();
|
|
710
|
+
if (!config.checks.typescript.enabled) {
|
|
711
|
+
return {
|
|
712
|
+
checkId: "typescript",
|
|
713
|
+
findings: [],
|
|
714
|
+
durationMs: 0,
|
|
715
|
+
skipped: "disabled in config"
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
const hasTs = stack.hasTypeScript || await pathExists(path.join(cwd, "tsconfig.json"));
|
|
719
|
+
if (!hasTs) {
|
|
720
|
+
return {
|
|
721
|
+
checkId: "typescript",
|
|
722
|
+
findings: [],
|
|
723
|
+
durationMs: Math.round(performance.now() - t0),
|
|
724
|
+
skipped: "no TypeScript project detected"
|
|
725
|
+
};
|
|
726
|
+
}
|
|
727
|
+
const args = ["--noEmit", ...config.checks.typescript.tscArgs ?? []];
|
|
728
|
+
const { exitCode, stdout, stderr } = await runNpmBinary(cwd, "tsc", args);
|
|
729
|
+
const findings = [];
|
|
730
|
+
if (exitCode === 127 || /command not found|not recognized|ENOENT/i.test(stderr)) {
|
|
731
|
+
return {
|
|
732
|
+
checkId: "typescript",
|
|
733
|
+
findings: [],
|
|
734
|
+
durationMs: Math.round(performance.now() - t0),
|
|
735
|
+
skipped: "typescript compiler not found"
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
if (exitCode !== 0) {
|
|
739
|
+
const out = [stdout, stderr].filter(Boolean).join("\n").trim();
|
|
740
|
+
findings.push({
|
|
741
|
+
id: "tsc",
|
|
742
|
+
severity: "warn",
|
|
743
|
+
message: "TypeScript compiler reported diagnostics",
|
|
744
|
+
detail: out ? truncate3(out, 8e3) : `exit ${exitCode}`
|
|
745
|
+
});
|
|
746
|
+
}
|
|
747
|
+
return {
|
|
748
|
+
checkId: "typescript",
|
|
749
|
+
findings,
|
|
750
|
+
durationMs: Math.round(performance.now() - t0)
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
function truncate3(s, max) {
|
|
754
|
+
if (s.length <= max) return s;
|
|
755
|
+
return s.slice(0, max) + "\u2026";
|
|
756
|
+
}
|
|
757
|
+
var PATTERNS = [
|
|
758
|
+
{
|
|
759
|
+
id: "aws-access-key",
|
|
760
|
+
re: /AKIA[0-9A-Z]{16}/,
|
|
761
|
+
message: "Possible AWS access key id"
|
|
762
|
+
},
|
|
763
|
+
{
|
|
764
|
+
id: "github-pat",
|
|
765
|
+
re: /\bghp_[a-zA-Z0-9]{20,}\b/,
|
|
766
|
+
message: "Possible GitHub personal access token"
|
|
767
|
+
},
|
|
768
|
+
{
|
|
769
|
+
id: "openai-sk",
|
|
770
|
+
re: /\bsk-[a-zA-Z0-9]{20,}\b/,
|
|
771
|
+
message: "Possible OpenAI-style API secret"
|
|
772
|
+
},
|
|
773
|
+
{
|
|
774
|
+
id: "private-key-block",
|
|
775
|
+
re: /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/,
|
|
776
|
+
message: "Private key material in source"
|
|
777
|
+
}
|
|
778
|
+
];
|
|
779
|
+
var TEXT_EXT = /* @__PURE__ */ new Set([
|
|
780
|
+
".ts",
|
|
781
|
+
".tsx",
|
|
782
|
+
".js",
|
|
783
|
+
".jsx",
|
|
784
|
+
".mjs",
|
|
785
|
+
".cjs",
|
|
786
|
+
".json",
|
|
787
|
+
".md",
|
|
788
|
+
".yml",
|
|
789
|
+
".yaml",
|
|
790
|
+
".env",
|
|
791
|
+
".txt"
|
|
792
|
+
]);
|
|
793
|
+
async function runSecrets(cwd, config, pr) {
|
|
794
|
+
const t0 = performance.now();
|
|
795
|
+
if (!config.checks.secrets.enabled) {
|
|
796
|
+
return {
|
|
797
|
+
checkId: "secrets",
|
|
798
|
+
findings: [],
|
|
799
|
+
durationMs: 0,
|
|
800
|
+
skipped: "disabled in config"
|
|
801
|
+
};
|
|
802
|
+
}
|
|
803
|
+
const findings = [];
|
|
804
|
+
let globs;
|
|
805
|
+
if (pr?.files.length) {
|
|
806
|
+
globs = pr.files.filter((f) => isProbablyTextFile(f));
|
|
807
|
+
} else {
|
|
808
|
+
globs = await fg(
|
|
809
|
+
[
|
|
810
|
+
"**/*.{ts,tsx,js,jsx,mjs,cjs,json,md,yml,yaml,env}",
|
|
811
|
+
"!**/node_modules/**",
|
|
812
|
+
"!**/dist/**",
|
|
813
|
+
"!**/.next/**",
|
|
814
|
+
"!**/build/**",
|
|
815
|
+
"!**/coverage/**"
|
|
816
|
+
],
|
|
817
|
+
{ cwd, onlyFiles: true, dot: false }
|
|
818
|
+
);
|
|
819
|
+
}
|
|
820
|
+
const maxFiles = 200;
|
|
821
|
+
let scanned = 0;
|
|
822
|
+
for (const rel of globs) {
|
|
823
|
+
if (scanned++ > maxFiles) {
|
|
824
|
+
findings.push({
|
|
825
|
+
id: "secrets-cap",
|
|
826
|
+
severity: "info",
|
|
827
|
+
message: `Secret scan capped after ${maxFiles} files (run on PR for narrower scope)`
|
|
828
|
+
});
|
|
829
|
+
break;
|
|
830
|
+
}
|
|
831
|
+
const full = path.join(cwd, rel);
|
|
832
|
+
let content;
|
|
833
|
+
try {
|
|
834
|
+
content = await fs.readFile(full, "utf8");
|
|
835
|
+
} catch {
|
|
836
|
+
continue;
|
|
837
|
+
}
|
|
838
|
+
if (content.length > 5e5) continue;
|
|
839
|
+
for (const { id, re, message } of PATTERNS) {
|
|
840
|
+
if (re.test(content)) {
|
|
841
|
+
findings.push({
|
|
842
|
+
id,
|
|
843
|
+
severity: "warn",
|
|
844
|
+
message,
|
|
845
|
+
file: rel
|
|
846
|
+
});
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
return {
|
|
851
|
+
checkId: "secrets",
|
|
852
|
+
findings,
|
|
853
|
+
durationMs: Math.round(performance.now() - t0)
|
|
854
|
+
};
|
|
855
|
+
}
|
|
856
|
+
function isProbablyTextFile(rel) {
|
|
857
|
+
const ext = path.extname(rel).toLowerCase();
|
|
858
|
+
return TEXT_EXT.has(ext);
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// src/checks/pr-hygiene.ts
|
|
862
|
+
function runPrHygiene(config, pr) {
|
|
863
|
+
const t0 = performance.now();
|
|
864
|
+
if (!config.checks.prHygiene.enabled) {
|
|
865
|
+
return {
|
|
866
|
+
checkId: "pr-hygiene",
|
|
867
|
+
findings: [],
|
|
868
|
+
durationMs: 0,
|
|
869
|
+
skipped: "disabled in config"
|
|
870
|
+
};
|
|
871
|
+
}
|
|
872
|
+
if (!pr) {
|
|
873
|
+
return {
|
|
874
|
+
checkId: "pr-hygiene",
|
|
875
|
+
findings: [],
|
|
876
|
+
durationMs: Math.round(performance.now() - t0),
|
|
877
|
+
skipped: "not running in GitHub pull_request context"
|
|
878
|
+
};
|
|
879
|
+
}
|
|
880
|
+
const findings = [];
|
|
881
|
+
const body = (pr.body ?? "").trim();
|
|
882
|
+
const min = config.checks.prHygiene.minBodyLength;
|
|
883
|
+
if (body.length < min) {
|
|
884
|
+
findings.push({
|
|
885
|
+
id: "pr-body-short",
|
|
886
|
+
severity: "warn",
|
|
887
|
+
message: `PR description is shorter than ${min} characters`,
|
|
888
|
+
detail: `Current length: ${body.length}`
|
|
889
|
+
});
|
|
890
|
+
}
|
|
891
|
+
const lower = body.toLowerCase();
|
|
892
|
+
const hints = config.checks.prHygiene.sectionHints;
|
|
893
|
+
const missing = hints.filter((h) => !sectionMentioned(lower, h));
|
|
894
|
+
if (missing.length > 0) {
|
|
895
|
+
findings.push({
|
|
896
|
+
id: "pr-sections",
|
|
897
|
+
severity: config.checks.prHygiene.requireSections ? "warn" : "info",
|
|
898
|
+
message: `PR description may be missing suggested sections: ${missing.join(", ")}`
|
|
899
|
+
});
|
|
900
|
+
}
|
|
901
|
+
if (config.checks.prHygiene.requireAiDisclosureSection) {
|
|
902
|
+
const hasAiHeader = /^#{1,6}\s+.*\bAI\b/im.test(body);
|
|
903
|
+
if (!hasAiHeader) {
|
|
904
|
+
findings.push({
|
|
905
|
+
id: "pr-ai-section-missing",
|
|
906
|
+
severity: "warn",
|
|
907
|
+
message: "PR is missing an **AI disclosure** section (e.g. `## AI disclosure`). Mark whether this PR used AI tools."
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
if (pr.aiDisclosureAmbiguous) {
|
|
912
|
+
findings.push({
|
|
913
|
+
id: "pr-ai-disclosure-ambiguous",
|
|
914
|
+
severity: config.checks.prHygiene.gateWhenAiDisclosureAmbiguous,
|
|
915
|
+
message: "AI disclosure checkboxes look ambiguous \u2014 please mark **Yes** or **No** clearly in the AI section."
|
|
916
|
+
});
|
|
917
|
+
}
|
|
918
|
+
if (pr.aiAssisted) {
|
|
919
|
+
findings.push({
|
|
920
|
+
id: "pr-ai-flag",
|
|
921
|
+
severity: "warn",
|
|
922
|
+
message: "**AI-assisted PR:** FrontGuard is applying stricter static checks and may escalate secrets / `any` deltas. Verify business logic, auth, and data handling manually."
|
|
923
|
+
});
|
|
924
|
+
}
|
|
925
|
+
if (pr.aiExplicitNo) {
|
|
926
|
+
findings.push({
|
|
927
|
+
id: "pr-ai-explicit-no",
|
|
928
|
+
severity: "info",
|
|
929
|
+
message: "Author indicated **no AI** for code generation \u2014 standard review profile."
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
return {
|
|
933
|
+
checkId: "pr-hygiene",
|
|
934
|
+
findings,
|
|
935
|
+
durationMs: Math.round(performance.now() - t0)
|
|
936
|
+
};
|
|
937
|
+
}
|
|
938
|
+
function sectionMentioned(body, hint) {
|
|
939
|
+
const h = hint.toLowerCase();
|
|
940
|
+
if (h === "what") {
|
|
941
|
+
return /\bwhat\b/.test(body) || /##\s*summary/.test(body) || /##\s*context/.test(body);
|
|
942
|
+
}
|
|
943
|
+
if (h === "why") {
|
|
944
|
+
return /\bwhy\b/.test(body) || /motivation/.test(body) || /##\s*rationale/.test(body);
|
|
945
|
+
}
|
|
946
|
+
if (h === "test" || h === "how to test") {
|
|
947
|
+
return /how to test/.test(body) || /\btesting\b/.test(body) || /##\s*test/.test(body) || /qa\s*steps/.test(body);
|
|
948
|
+
}
|
|
949
|
+
if (h === "screenshot") {
|
|
950
|
+
return /screenshot|screen recording|loom|recording/i.test(body);
|
|
951
|
+
}
|
|
952
|
+
return body.includes(h);
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
// src/checks/pr-size.ts
|
|
956
|
+
function runPrSize(config, pr) {
|
|
957
|
+
const t0 = performance.now();
|
|
958
|
+
if (!pr) {
|
|
959
|
+
return {
|
|
960
|
+
checkId: "pr-size",
|
|
961
|
+
findings: [],
|
|
962
|
+
durationMs: Math.round(performance.now() - t0),
|
|
963
|
+
skipped: "not running in GitHub pull_request context"
|
|
964
|
+
};
|
|
965
|
+
}
|
|
966
|
+
const findings = [];
|
|
967
|
+
const lines = pr.additions + pr.deletions;
|
|
968
|
+
const { warnLines, softBlockLines } = config.checks.prSize;
|
|
969
|
+
if (lines >= softBlockLines) {
|
|
970
|
+
findings.push({
|
|
971
|
+
id: "pr-size-large",
|
|
972
|
+
severity: "warn",
|
|
973
|
+
message: `PR is very large (${lines} lines changed; \u2265 ${softBlockLines})`,
|
|
974
|
+
detail: "Consider splitting to improve review quality."
|
|
975
|
+
});
|
|
976
|
+
} else if (lines >= warnLines) {
|
|
977
|
+
findings.push({
|
|
978
|
+
id: "pr-size-medium",
|
|
979
|
+
severity: "info",
|
|
980
|
+
message: `PR size is elevated (${lines} lines changed; \u2265 ${warnLines})`
|
|
981
|
+
});
|
|
982
|
+
}
|
|
983
|
+
return {
|
|
984
|
+
checkId: "pr-size",
|
|
985
|
+
findings,
|
|
986
|
+
durationMs: Math.round(performance.now() - t0)
|
|
987
|
+
};
|
|
988
|
+
}
|
|
989
|
+
async function gitOk(cwd) {
|
|
990
|
+
try {
|
|
991
|
+
const r = await exec("git", ["rev-parse", "--is-inside-work-tree"], {
|
|
992
|
+
nodeOptions: { cwd }
|
|
993
|
+
});
|
|
994
|
+
return (r.stdout ?? "").trim() === "true";
|
|
995
|
+
} catch {
|
|
996
|
+
return false;
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
async function gitDiffPatch(cwd, baseRef, pathspecs) {
|
|
1000
|
+
if (pathspecs.length === 0) return null;
|
|
1001
|
+
try {
|
|
1002
|
+
const r = await exec(
|
|
1003
|
+
"git",
|
|
1004
|
+
["diff", `${baseRef}...HEAD`, "--unified=1", "--no-color", "--", ...pathspecs],
|
|
1005
|
+
{ nodeOptions: { cwd } }
|
|
1006
|
+
);
|
|
1007
|
+
return r.stdout ?? "";
|
|
1008
|
+
} catch {
|
|
1009
|
+
return null;
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
async function gitDiffForReview(cwd, baseRef, maxChars) {
|
|
1013
|
+
try {
|
|
1014
|
+
const r = await exec(
|
|
1015
|
+
"git",
|
|
1016
|
+
["diff", `${baseRef}...HEAD`, "--unified=2", "--no-color"],
|
|
1017
|
+
{
|
|
1018
|
+
nodeOptions: { cwd }
|
|
1019
|
+
}
|
|
1020
|
+
);
|
|
1021
|
+
let s = r.stdout ?? "";
|
|
1022
|
+
if (s.length > maxChars) {
|
|
1023
|
+
s = s.slice(0, maxChars) + "\n\u2026(truncated)\n";
|
|
1024
|
+
}
|
|
1025
|
+
return s;
|
|
1026
|
+
} catch {
|
|
1027
|
+
return "";
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
async function resolveDiffBaseRef(cwd, fallback) {
|
|
1031
|
+
const gh = process.env.GITHUB_BASE_REF;
|
|
1032
|
+
if (gh) {
|
|
1033
|
+
const origin = `origin/${gh}`;
|
|
1034
|
+
try {
|
|
1035
|
+
await exec("git", ["rev-parse", "--verify", origin], { nodeOptions: { cwd } });
|
|
1036
|
+
return origin;
|
|
1037
|
+
} catch {
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
try {
|
|
1041
|
+
const cand = fallback.includes("/") ? fallback : `origin/${fallback}`;
|
|
1042
|
+
await exec("git", ["rev-parse", "--verify", cand], { nodeOptions: { cwd } });
|
|
1043
|
+
return cand;
|
|
1044
|
+
} catch {
|
|
1045
|
+
try {
|
|
1046
|
+
await exec("git", ["rev-parse", "--verify", fallback], { nodeOptions: { cwd } });
|
|
1047
|
+
return fallback;
|
|
1048
|
+
} catch {
|
|
1049
|
+
return fallback;
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
// src/checks/ts-any-delta.ts
|
|
1055
|
+
function gateSeverity(g) {
|
|
1056
|
+
return g === "block" ? "block" : g === "info" ? "info" : "warn";
|
|
1057
|
+
}
|
|
1058
|
+
function countAddedAnyInPatch(patch) {
|
|
1059
|
+
let n = 0;
|
|
1060
|
+
for (const raw of patch.split("\n")) {
|
|
1061
|
+
if (!raw.startsWith("+") || raw.startsWith("+++")) continue;
|
|
1062
|
+
const line = raw.slice(1);
|
|
1063
|
+
if (/^\s*\/(\/|\*)/.test(line)) continue;
|
|
1064
|
+
const patterns = [
|
|
1065
|
+
/:\s*any\b/g,
|
|
1066
|
+
/\bas\s+any\b/g,
|
|
1067
|
+
/<\s*any\s*>/g,
|
|
1068
|
+
/\bReadonlyArray\s*<\s*any\s*>/g,
|
|
1069
|
+
/\bArray\s*<\s*any\s*>/g,
|
|
1070
|
+
/\bRecord\s*<\s*[^,]+,\s*any\s*>/g
|
|
1071
|
+
];
|
|
1072
|
+
for (const p of patterns) {
|
|
1073
|
+
const m = line.match(p);
|
|
1074
|
+
if (m) n += m.length;
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
return n;
|
|
1078
|
+
}
|
|
1079
|
+
async function runTsAnyDelta(cwd, config, stack) {
|
|
1080
|
+
const t0 = performance.now();
|
|
1081
|
+
const cfg = config.checks.tsAnyDelta;
|
|
1082
|
+
if (!cfg.enabled || !stack.hasTypeScript) {
|
|
1083
|
+
return {
|
|
1084
|
+
checkId: "ts-any-delta",
|
|
1085
|
+
findings: [],
|
|
1086
|
+
durationMs: 0,
|
|
1087
|
+
skipped: !cfg.enabled ? "disabled in config" : "no TypeScript"
|
|
1088
|
+
};
|
|
1089
|
+
}
|
|
1090
|
+
if (!await gitOk(cwd)) {
|
|
1091
|
+
return {
|
|
1092
|
+
checkId: "ts-any-delta",
|
|
1093
|
+
findings: [],
|
|
1094
|
+
durationMs: Math.round(performance.now() - t0),
|
|
1095
|
+
skipped: "not a git repository"
|
|
1096
|
+
};
|
|
1097
|
+
}
|
|
1098
|
+
const base = await resolveDiffBaseRef(cwd, cfg.baseRef);
|
|
1099
|
+
const patch = await gitDiffPatch(cwd, base, ["*.ts", "*.tsx", "*.mts", "*.cts"]) ?? "";
|
|
1100
|
+
if (!patch.trim()) {
|
|
1101
|
+
return {
|
|
1102
|
+
checkId: "ts-any-delta",
|
|
1103
|
+
findings: [],
|
|
1104
|
+
durationMs: Math.round(performance.now() - t0),
|
|
1105
|
+
skipped: "no diff vs base (fetch depth or base ref?)"
|
|
1106
|
+
};
|
|
1107
|
+
}
|
|
1108
|
+
const added = countAddedAnyInPatch(patch);
|
|
1109
|
+
const findings = [];
|
|
1110
|
+
if (added === 0) {
|
|
1111
|
+
return {
|
|
1112
|
+
checkId: "ts-any-delta",
|
|
1113
|
+
findings,
|
|
1114
|
+
durationMs: Math.round(performance.now() - t0)
|
|
1115
|
+
};
|
|
1116
|
+
}
|
|
1117
|
+
const overBudget = cfg.maxAdded > 0 && added > cfg.maxAdded;
|
|
1118
|
+
const shouldGate = cfg.maxAdded === 0 ? added > 0 : overBudget;
|
|
1119
|
+
const severity = shouldGate ? gateSeverity(cfg.gate) : "info";
|
|
1120
|
+
findings.push({
|
|
1121
|
+
id: "ts-any-added",
|
|
1122
|
+
severity,
|
|
1123
|
+
message: shouldGate ? cfg.maxAdded > 0 ? `Added ~${added} new \`any\` usages (budget ${cfg.maxAdded})` : `Added ~${added} new \`any\` usages in the PR diff` : `Added ~${added} new \`any\` usages (within budget ${cfg.maxAdded})`,
|
|
1124
|
+
detail: `merge-base: ${base}`
|
|
1125
|
+
});
|
|
1126
|
+
return {
|
|
1127
|
+
checkId: "ts-any-delta",
|
|
1128
|
+
findings,
|
|
1129
|
+
durationMs: Math.round(performance.now() - t0)
|
|
1130
|
+
};
|
|
1131
|
+
}
|
|
1132
|
+
function gateSeverity2(g) {
|
|
1133
|
+
return g === "block" ? "block" : g === "info" ? "info" : "warn";
|
|
1134
|
+
}
|
|
1135
|
+
async function runCycles(cwd, config, stack) {
|
|
1136
|
+
const t0 = performance.now();
|
|
1137
|
+
const cfg = config.checks.cycles;
|
|
1138
|
+
if (!cfg.enabled || !stack.hasTypeScript) {
|
|
1139
|
+
return {
|
|
1140
|
+
checkId: "cycles",
|
|
1141
|
+
findings: [],
|
|
1142
|
+
durationMs: 0,
|
|
1143
|
+
skipped: !cfg.enabled ? "disabled in config" : "no TypeScript"
|
|
1144
|
+
};
|
|
1145
|
+
}
|
|
1146
|
+
let entry = cfg.entries[0] ?? "src";
|
|
1147
|
+
for (const e of cfg.entries) {
|
|
1148
|
+
if (await pathExists(path.join(cwd, e))) {
|
|
1149
|
+
entry = e;
|
|
1150
|
+
break;
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
if (!await pathExists(path.join(cwd, entry))) {
|
|
1154
|
+
return {
|
|
1155
|
+
checkId: "cycles",
|
|
1156
|
+
findings: [],
|
|
1157
|
+
durationMs: Math.round(performance.now() - t0),
|
|
1158
|
+
skipped: `entry path not found (${entry})`
|
|
1159
|
+
};
|
|
1160
|
+
}
|
|
1161
|
+
const args = [
|
|
1162
|
+
"-y",
|
|
1163
|
+
"madge@6",
|
|
1164
|
+
entry,
|
|
1165
|
+
"--extensions",
|
|
1166
|
+
"ts,tsx,js,jsx",
|
|
1167
|
+
"--circular",
|
|
1168
|
+
...cfg.extraArgs
|
|
1169
|
+
];
|
|
1170
|
+
const { exitCode, stdout, stderr } = await runNpx(cwd, args);
|
|
1171
|
+
const out = [stdout, stderr].filter(Boolean).join("\n").trim();
|
|
1172
|
+
if (exitCode === 127 || /command not found|not recognized|ENOENT/i.test(stderr)) {
|
|
1173
|
+
return {
|
|
1174
|
+
checkId: "cycles",
|
|
1175
|
+
findings: [],
|
|
1176
|
+
durationMs: Math.round(performance.now() - t0),
|
|
1177
|
+
skipped: "npx/madge not available"
|
|
1178
|
+
};
|
|
1179
|
+
}
|
|
1180
|
+
const findings = [];
|
|
1181
|
+
const cyclic = exitCode !== 0 && /circular|Circular|\(circular\)/i.test(out);
|
|
1182
|
+
if (cyclic) {
|
|
1183
|
+
findings.push({
|
|
1184
|
+
id: "import-cycle",
|
|
1185
|
+
severity: gateSeverity2(cfg.gate),
|
|
1186
|
+
message: "Circular dependencies detected (madge)",
|
|
1187
|
+
detail: truncate4(out || `exit ${exitCode}`, 12e3)
|
|
1188
|
+
});
|
|
1189
|
+
} else if (exitCode !== 0) {
|
|
1190
|
+
findings.push({
|
|
1191
|
+
id: "madge-error",
|
|
1192
|
+
severity: "info",
|
|
1193
|
+
message: "madge exited non-zero \u2014 verify entry/extensions or install deps locally",
|
|
1194
|
+
detail: truncate4(out || `exit ${exitCode}`, 8e3)
|
|
1195
|
+
});
|
|
1196
|
+
}
|
|
1197
|
+
return {
|
|
1198
|
+
checkId: "cycles",
|
|
1199
|
+
findings,
|
|
1200
|
+
durationMs: Math.round(performance.now() - t0)
|
|
1201
|
+
};
|
|
1202
|
+
}
|
|
1203
|
+
function truncate4(s, max) {
|
|
1204
|
+
if (s.length <= max) return s;
|
|
1205
|
+
return s.slice(0, max) + "\u2026";
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
// src/checks/dead-code.ts
|
|
1209
|
+
function gateSeverity3(g) {
|
|
1210
|
+
return g === "block" ? "block" : g === "info" ? "info" : "warn";
|
|
1211
|
+
}
|
|
1212
|
+
async function runDeadCode(cwd, config, stack, pr) {
|
|
1213
|
+
const t0 = performance.now();
|
|
1214
|
+
const cfg = config.checks.deadCode;
|
|
1215
|
+
if (!cfg.enabled || !stack.hasTypeScript) {
|
|
1216
|
+
return {
|
|
1217
|
+
checkId: "dead-code",
|
|
1218
|
+
findings: [],
|
|
1219
|
+
durationMs: 0,
|
|
1220
|
+
skipped: !cfg.enabled ? "disabled in config" : "no TypeScript"
|
|
1221
|
+
};
|
|
1222
|
+
}
|
|
1223
|
+
const args = ["-y", "ts-prune", ...cfg.extraArgs];
|
|
1224
|
+
const { exitCode, stdout, stderr } = await runNpx(cwd, args);
|
|
1225
|
+
if (exitCode === 127 || /command not found|not recognized|ENOENT/i.test(stderr)) {
|
|
1226
|
+
return {
|
|
1227
|
+
checkId: "dead-code",
|
|
1228
|
+
findings: [],
|
|
1229
|
+
durationMs: Math.round(performance.now() - t0),
|
|
1230
|
+
skipped: "npx/ts-prune not available"
|
|
1231
|
+
};
|
|
1232
|
+
}
|
|
1233
|
+
const raw = (stdout || "").trim();
|
|
1234
|
+
const lines = raw.split("\n").map((l) => l.trim()).filter((l) => l && /^[\w./\\~-]+[^\s]*\s*-\s*.+/.test(l));
|
|
1235
|
+
if (lines.length === 0) {
|
|
1236
|
+
return {
|
|
1237
|
+
checkId: "dead-code",
|
|
1238
|
+
findings: [],
|
|
1239
|
+
durationMs: Math.round(performance.now() - t0)
|
|
1240
|
+
};
|
|
1241
|
+
}
|
|
1242
|
+
const prFiles = pr?.files?.length ? new Set(pr.files.map((f) => f.replace(/\\/g, "/"))) : null;
|
|
1243
|
+
const relevant = prFiles ? lines.filter((l) => {
|
|
1244
|
+
const file = l.split(/\s*-\s*/)[0]?.trim();
|
|
1245
|
+
if (!file) return false;
|
|
1246
|
+
return [...prFiles].some((p) => p === file || p.endsWith(file));
|
|
1247
|
+
}) : lines;
|
|
1248
|
+
const report = (relevant.length ? relevant : lines).slice(0, cfg.maxReportLines).join("\n");
|
|
1249
|
+
const findings = [
|
|
1250
|
+
{
|
|
1251
|
+
id: "ts-prune",
|
|
1252
|
+
severity: gateSeverity3(cfg.gate),
|
|
1253
|
+
message: prFiles ? `Potentially unused exports (${relevant.length} on touched files; ${lines.length} total)` : `Potentially unused exports reported by ts-prune (${lines.length})`,
|
|
1254
|
+
detail: report
|
|
1255
|
+
}
|
|
1256
|
+
];
|
|
1257
|
+
return {
|
|
1258
|
+
checkId: "dead-code",
|
|
1259
|
+
findings,
|
|
1260
|
+
durationMs: Math.round(performance.now() - t0)
|
|
1261
|
+
};
|
|
1262
|
+
}
|
|
1263
|
+
function gateSeverity4(g) {
|
|
1264
|
+
return g === "block" ? "block" : g === "info" ? "info" : "warn";
|
|
1265
|
+
}
|
|
1266
|
+
async function sumGlobBytes(cwd, patterns) {
|
|
1267
|
+
let total = 0;
|
|
1268
|
+
for (const pattern of patterns) {
|
|
1269
|
+
const files = await fg(pattern, {
|
|
1270
|
+
cwd,
|
|
1271
|
+
onlyFiles: true,
|
|
1272
|
+
dot: false,
|
|
1273
|
+
ignore: ["**/node_modules/**"]
|
|
1274
|
+
});
|
|
1275
|
+
for (const rel of files) {
|
|
1276
|
+
try {
|
|
1277
|
+
const st = await fs.stat(path.join(cwd, rel));
|
|
1278
|
+
total += st.size;
|
|
1279
|
+
} catch {
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
return total;
|
|
1284
|
+
}
|
|
1285
|
+
async function readBaseline(cwd, relPath, baseRef) {
|
|
1286
|
+
const disk = path.join(cwd, relPath);
|
|
1287
|
+
try {
|
|
1288
|
+
const raw = await fs.readFile(disk, "utf8");
|
|
1289
|
+
return JSON.parse(raw);
|
|
1290
|
+
} catch {
|
|
1291
|
+
}
|
|
1292
|
+
if (!baseRef) return null;
|
|
1293
|
+
try {
|
|
1294
|
+
const r = await exec("git", ["show", `${baseRef}:${relPath}`], {
|
|
1295
|
+
nodeOptions: { cwd }
|
|
1296
|
+
});
|
|
1297
|
+
if ((r.exitCode ?? 0) !== 0) return null;
|
|
1298
|
+
return JSON.parse((r.stdout ?? "").trim());
|
|
1299
|
+
} catch {
|
|
1300
|
+
return null;
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
async function gitOkQuick(cwd) {
|
|
1304
|
+
try {
|
|
1305
|
+
const r = await exec("git", ["rev-parse", "--is-inside-work-tree"], {
|
|
1306
|
+
nodeOptions: { cwd }
|
|
1307
|
+
});
|
|
1308
|
+
return (r.stdout ?? "").trim() === "true";
|
|
1309
|
+
} catch {
|
|
1310
|
+
return false;
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
function tokenizeCommand(cmd) {
|
|
1314
|
+
return cmd.trim().split(/\s+/).map((t) => t.trim()).filter(Boolean);
|
|
1315
|
+
}
|
|
1316
|
+
async function runBundle(cwd, config, stack) {
|
|
1317
|
+
const t0 = performance.now();
|
|
1318
|
+
const cfg = config.checks.bundle;
|
|
1319
|
+
if (!cfg.enabled) {
|
|
1320
|
+
return {
|
|
1321
|
+
checkId: "bundle",
|
|
1322
|
+
findings: [],
|
|
1323
|
+
durationMs: 0,
|
|
1324
|
+
skipped: "disabled in config"
|
|
1325
|
+
};
|
|
1326
|
+
}
|
|
1327
|
+
if (stack.hasReactNative && !stack.hasNext) {
|
|
1328
|
+
return {
|
|
1329
|
+
checkId: "bundle",
|
|
1330
|
+
findings: [],
|
|
1331
|
+
durationMs: Math.round(performance.now() - t0),
|
|
1332
|
+
skipped: "skipped for React Native (configure web artifacts if needed)"
|
|
1333
|
+
};
|
|
1334
|
+
}
|
|
1335
|
+
if (cfg.runBuild) {
|
|
1336
|
+
const parts = tokenizeCommand(cfg.buildCommand);
|
|
1337
|
+
if (parts.length === 0) {
|
|
1338
|
+
return {
|
|
1339
|
+
checkId: "bundle",
|
|
1340
|
+
findings: [
|
|
1341
|
+
{
|
|
1342
|
+
id: "bundle-cmd",
|
|
1343
|
+
severity: "warn",
|
|
1344
|
+
message: "`checks.bundle.buildCommand` is empty"
|
|
1345
|
+
}
|
|
1346
|
+
],
|
|
1347
|
+
durationMs: Math.round(performance.now() - t0)
|
|
1348
|
+
};
|
|
1349
|
+
}
|
|
1350
|
+
const [bin, ...args] = parts;
|
|
1351
|
+
const res = await exec(bin, args, { nodeOptions: { cwd } });
|
|
1352
|
+
if ((res.exitCode ?? 0) !== 0) {
|
|
1353
|
+
return {
|
|
1354
|
+
checkId: "bundle",
|
|
1355
|
+
findings: [
|
|
1356
|
+
{
|
|
1357
|
+
id: "bundle-build",
|
|
1358
|
+
severity: gateSeverity4(cfg.gate),
|
|
1359
|
+
message: "Build command failed \u2014 cannot measure bundle",
|
|
1360
|
+
detail: [res.stdout, res.stderr].filter(Boolean).join("\n").slice(0, 8e3)
|
|
1361
|
+
}
|
|
1362
|
+
],
|
|
1363
|
+
durationMs: Math.round(performance.now() - t0)
|
|
1364
|
+
};
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
const total = await sumGlobBytes(cwd, cfg.measureGlobs);
|
|
1368
|
+
if (total === 0) {
|
|
1369
|
+
return {
|
|
1370
|
+
checkId: "bundle",
|
|
1371
|
+
findings: [
|
|
1372
|
+
{
|
|
1373
|
+
id: "bundle-empty",
|
|
1374
|
+
severity: "info",
|
|
1375
|
+
message: "No bundle artifacts matched `measureGlobs` \u2014 configure paths or run a web build"
|
|
1376
|
+
}
|
|
1377
|
+
],
|
|
1378
|
+
durationMs: Math.round(performance.now() - t0)
|
|
1379
|
+
};
|
|
1380
|
+
}
|
|
1381
|
+
const baseRef = await gitOkQuick(cwd) ? await resolveDiffBaseRef(cwd, config.checks.tsAnyDelta.baseRef) : null;
|
|
1382
|
+
const baseline = await readBaseline(cwd, cfg.baselinePath, baseRef);
|
|
1383
|
+
const findings = [];
|
|
1384
|
+
const infoLines = [
|
|
1385
|
+
`Measured ${total} bytes (${(total / 1024 / 1024).toFixed(2)} MiB)`,
|
|
1386
|
+
baseline ? `Baseline from \`${cfg.baselinePath}\`: ${baseline.totalBytes} bytes` : `No baseline at \`${cfg.baselinePath}\` (commit a baseline JSON to compare)`
|
|
1387
|
+
].join("\n");
|
|
1388
|
+
if (cfg.maxTotalBytes != null && total > cfg.maxTotalBytes) {
|
|
1389
|
+
findings.push({
|
|
1390
|
+
id: "bundle-max-total",
|
|
1391
|
+
severity: gateSeverity4(cfg.gate),
|
|
1392
|
+
message: `Bundle size ${total} bytes exceeds maxTotalBytes (${cfg.maxTotalBytes})`,
|
|
1393
|
+
detail: infoLines
|
|
1394
|
+
});
|
|
1395
|
+
}
|
|
1396
|
+
if (baseline && cfg.maxDeltaBytes != null) {
|
|
1397
|
+
const delta = total - baseline.totalBytes;
|
|
1398
|
+
if (delta > cfg.maxDeltaBytes) {
|
|
1399
|
+
findings.push({
|
|
1400
|
+
id: "bundle-delta",
|
|
1401
|
+
severity: gateSeverity4(cfg.gate),
|
|
1402
|
+
message: `Bundle grew by ${delta} bytes vs baseline (limit ${cfg.maxDeltaBytes})`,
|
|
1403
|
+
detail: infoLines
|
|
1404
|
+
});
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
if (findings.length === 0 && !baseline && (cfg.maxDeltaBytes != null || cfg.maxTotalBytes != null)) {
|
|
1408
|
+
findings.push({
|
|
1409
|
+
id: "bundle-baseline-missing",
|
|
1410
|
+
severity: "info",
|
|
1411
|
+
message: "Baseline file missing \u2014 delta/total gates need a committed `.frontguard/bundle-baseline.json`",
|
|
1412
|
+
detail: infoLines
|
|
1413
|
+
});
|
|
1414
|
+
}
|
|
1415
|
+
if (findings.length === 0) {
|
|
1416
|
+
findings.push({
|
|
1417
|
+
id: "bundle-ok",
|
|
1418
|
+
severity: "info",
|
|
1419
|
+
message: "Bundle measurement complete",
|
|
1420
|
+
detail: infoLines
|
|
1421
|
+
});
|
|
1422
|
+
}
|
|
1423
|
+
return {
|
|
1424
|
+
checkId: "bundle",
|
|
1425
|
+
findings,
|
|
1426
|
+
durationMs: Math.round(performance.now() - t0)
|
|
1427
|
+
};
|
|
1428
|
+
}
|
|
1429
|
+
function gateSeverity5(g) {
|
|
1430
|
+
return g === "block" ? "block" : g === "info" ? "info" : "warn";
|
|
1431
|
+
}
|
|
1432
|
+
async function runCwv(cwd, config, stack, pr) {
|
|
1433
|
+
const t0 = performance.now();
|
|
1434
|
+
const cfg = config.checks.cwv;
|
|
1435
|
+
if (!cfg.enabled) {
|
|
1436
|
+
return {
|
|
1437
|
+
checkId: "cwv",
|
|
1438
|
+
findings: [],
|
|
1439
|
+
durationMs: 0,
|
|
1440
|
+
skipped: "disabled in config"
|
|
1441
|
+
};
|
|
1442
|
+
}
|
|
1443
|
+
if (stack.hasReactNative && !stack.hasNext) {
|
|
1444
|
+
return {
|
|
1445
|
+
checkId: "cwv",
|
|
1446
|
+
findings: [],
|
|
1447
|
+
durationMs: Math.round(performance.now() - t0),
|
|
1448
|
+
skipped: "skipped for React Native"
|
|
1449
|
+
};
|
|
1450
|
+
}
|
|
1451
|
+
const prSet = pr?.files?.length ? new Set(pr.files.map((f) => f.replace(/\\/g, "/"))) : null;
|
|
1452
|
+
const files = await fg(cfg.scanGlobs, {
|
|
1453
|
+
cwd,
|
|
1454
|
+
onlyFiles: true,
|
|
1455
|
+
ignore: ["**/node_modules/**", "**/*.test.*", "**/*.spec.*"]
|
|
1456
|
+
});
|
|
1457
|
+
const toScan = prSet ? files.filter((f) => [...prSet].some((p) => p === f || p.endsWith(f))) : files;
|
|
1458
|
+
const findings = [];
|
|
1459
|
+
const sev2 = gateSeverity5(cfg.gate);
|
|
1460
|
+
for (const rel of toScan.slice(0, 400)) {
|
|
1461
|
+
const full = path.join(cwd, rel);
|
|
1462
|
+
let text;
|
|
1463
|
+
try {
|
|
1464
|
+
text = await fs.readFile(full, "utf8");
|
|
1465
|
+
} catch {
|
|
1466
|
+
continue;
|
|
1467
|
+
}
|
|
1468
|
+
if (text.length > cfg.maxFileBytes) continue;
|
|
1469
|
+
if (stack.hasNext && /<img\b/i.test(text) && !/from\s+['"]next\/image['"]/.test(text)) {
|
|
1470
|
+
findings.push({
|
|
1471
|
+
id: "cwv-img-tag",
|
|
1472
|
+
severity: sev2,
|
|
1473
|
+
message: "Raw `<img>` detected \u2014 prefer `next/image` for LCP-friendly delivery",
|
|
1474
|
+
file: rel
|
|
1475
|
+
});
|
|
1476
|
+
}
|
|
1477
|
+
if (/dangerouslySetInnerHTML/i.test(text)) {
|
|
1478
|
+
findings.push({
|
|
1479
|
+
id: "cwv-dsh",
|
|
1480
|
+
severity: "warn",
|
|
1481
|
+
message: "`dangerouslySetInnerHTML` can impact main-thread work \u2014 validate necessity",
|
|
1482
|
+
file: rel
|
|
1483
|
+
});
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
return {
|
|
1487
|
+
checkId: "cwv",
|
|
1488
|
+
findings: dedupeFindings(findings).slice(0, 40),
|
|
1489
|
+
durationMs: Math.round(performance.now() - t0)
|
|
1490
|
+
};
|
|
1491
|
+
}
|
|
1492
|
+
function dedupeFindings(f) {
|
|
1493
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1494
|
+
const out = [];
|
|
1495
|
+
for (const x of f) {
|
|
1496
|
+
const k = `${x.id}:${x.file ?? ""}`;
|
|
1497
|
+
if (seen.has(k)) continue;
|
|
1498
|
+
seen.add(k);
|
|
1499
|
+
out.push(x);
|
|
1500
|
+
}
|
|
1501
|
+
return out;
|
|
1502
|
+
}
|
|
1503
|
+
var DEFAULT_GLOB = "**/*.{ts,tsx,js,jsx,mjs,cjs}";
|
|
1504
|
+
async function runCustomRules(cwd, config, restrictToFiles) {
|
|
1505
|
+
const t0 = performance.now();
|
|
1506
|
+
const rules = config.rules;
|
|
1507
|
+
if (!rules || Object.keys(rules).length === 0) {
|
|
1508
|
+
return {
|
|
1509
|
+
checkId: "custom-rules",
|
|
1510
|
+
findings: [],
|
|
1511
|
+
durationMs: 0,
|
|
1512
|
+
skipped: "no rules configured"
|
|
1513
|
+
};
|
|
1514
|
+
}
|
|
1515
|
+
const active = Object.entries(rules).filter(([, v]) => v !== "off");
|
|
1516
|
+
if (active.length === 0) {
|
|
1517
|
+
return {
|
|
1518
|
+
checkId: "custom-rules",
|
|
1519
|
+
findings: [],
|
|
1520
|
+
durationMs: 0,
|
|
1521
|
+
skipped: "all rules off"
|
|
1522
|
+
};
|
|
1523
|
+
}
|
|
1524
|
+
let files;
|
|
1525
|
+
if (restrictToFiles?.length) {
|
|
1526
|
+
files = restrictToFiles.filter((f) => /\.(tsx?|jsx?|mjs|cjs)$/i.test(f));
|
|
1527
|
+
} else {
|
|
1528
|
+
files = await fg(DEFAULT_GLOB, {
|
|
1529
|
+
cwd,
|
|
1530
|
+
ignore: ["**/node_modules/**", "**/dist/**", "**/.next/**", "**/build/**"],
|
|
1531
|
+
onlyFiles: true
|
|
1532
|
+
});
|
|
1533
|
+
}
|
|
1534
|
+
const findings = [];
|
|
1535
|
+
const maxFiles = 300;
|
|
1536
|
+
let n = 0;
|
|
1537
|
+
for (const rel of files) {
|
|
1538
|
+
if (n++ > maxFiles) {
|
|
1539
|
+
findings.push({
|
|
1540
|
+
id: "custom-rules-cap",
|
|
1541
|
+
severity: "info",
|
|
1542
|
+
message: `Custom rules scan capped after ${maxFiles} files`
|
|
1543
|
+
});
|
|
1544
|
+
break;
|
|
1545
|
+
}
|
|
1546
|
+
const full = path.join(cwd, rel);
|
|
1547
|
+
let content;
|
|
1548
|
+
try {
|
|
1549
|
+
content = await fs.readFile(full, "utf8");
|
|
1550
|
+
} catch {
|
|
1551
|
+
continue;
|
|
1552
|
+
}
|
|
1553
|
+
if (content.length > 6e5) continue;
|
|
1554
|
+
const ctx = { path: rel, content };
|
|
1555
|
+
for (const [id, def] of active) {
|
|
1556
|
+
if (typeof def.check !== "function") continue;
|
|
1557
|
+
try {
|
|
1558
|
+
if (def.check(ctx)) {
|
|
1559
|
+
findings.push({
|
|
1560
|
+
id: `rule:${id}`,
|
|
1561
|
+
severity: def.severity,
|
|
1562
|
+
message: def.message,
|
|
1563
|
+
file: rel
|
|
1564
|
+
});
|
|
1565
|
+
}
|
|
1566
|
+
} catch (e) {
|
|
1567
|
+
findings.push({
|
|
1568
|
+
id: `rule-error:${id}`,
|
|
1569
|
+
severity: "warn",
|
|
1570
|
+
message: `Rule \`${id}\` threw`,
|
|
1571
|
+
detail: e instanceof Error ? e.message : String(e),
|
|
1572
|
+
file: rel
|
|
1573
|
+
});
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
return {
|
|
1578
|
+
checkId: "custom-rules",
|
|
1579
|
+
findings,
|
|
1580
|
+
durationMs: Math.round(performance.now() - t0)
|
|
1581
|
+
};
|
|
1582
|
+
}
|
|
1583
|
+
function sev(gate) {
|
|
1584
|
+
return gate === "block" ? "block" : gate === "info" ? "info" : "warn";
|
|
1585
|
+
}
|
|
1586
|
+
var CODE_EXT = /\.(tsx?|jsx?|mjs|cjs)$/i;
|
|
1587
|
+
var PATTERNS2 = [
|
|
1588
|
+
{
|
|
1589
|
+
id: "ai-eval",
|
|
1590
|
+
re: /\beval\s*\(/,
|
|
1591
|
+
message: "`eval()` \u2014 high risk; AI output often slips this in. Replace with safe parsing.",
|
|
1592
|
+
forceBlock: true
|
|
1593
|
+
},
|
|
1594
|
+
{
|
|
1595
|
+
id: "ai-new-function",
|
|
1596
|
+
re: /\bnew\s+Function\s*\(/,
|
|
1597
|
+
message: "`new Function()` is eval-like \u2014 verify necessity and input trust.",
|
|
1598
|
+
forceBlock: true
|
|
1599
|
+
},
|
|
1600
|
+
{
|
|
1601
|
+
id: "ai-dsh",
|
|
1602
|
+
re: /dangerouslySetInnerHTML\s*=/,
|
|
1603
|
+
message: "`dangerouslySetInnerHTML` \u2014 XSS risk. Ensure trusted/sanitized HTML only."
|
|
1604
|
+
},
|
|
1605
|
+
{
|
|
1606
|
+
id: "ai-inner-html-assign",
|
|
1607
|
+
re: /\.innerHTML\s*=/,
|
|
1608
|
+
message: "DOM `innerHTML` assignment \u2014 XSS risk; prefer textContent or sanitization."
|
|
1609
|
+
},
|
|
1610
|
+
{
|
|
1611
|
+
id: "ai-document-write",
|
|
1612
|
+
re: /\bdocument\.write\s*\(/,
|
|
1613
|
+
message: "`document.write` \u2014 brittle and unsafe in modern apps."
|
|
1614
|
+
},
|
|
1615
|
+
{
|
|
1616
|
+
id: "ai-ts-ignore",
|
|
1617
|
+
re: /@ts-ignore\b/,
|
|
1618
|
+
message: "`@ts-ignore` hides errors (AI often uses instead of fixing). Prefer `@ts-expect-error` with reason."
|
|
1619
|
+
},
|
|
1620
|
+
{
|
|
1621
|
+
id: "ai-empty-catch",
|
|
1622
|
+
re: /catch\s*(?:\([^)]*\))?\s*\{\s*\}/,
|
|
1623
|
+
message: "Empty `catch` \u2014 errors swallowed; at least log or rethrow intentionally."
|
|
1624
|
+
},
|
|
1625
|
+
{
|
|
1626
|
+
id: "ai-http-url",
|
|
1627
|
+
re: /(['"])http:\/\//,
|
|
1628
|
+
message: "Plain `http://` URL \u2014 mixed content / MITM risk in browser code."
|
|
1629
|
+
},
|
|
1630
|
+
{
|
|
1631
|
+
id: "ai-token-storage",
|
|
1632
|
+
re: /(?:localStorage|sessionStorage)\.(?:setItem|getItem)\s*\(\s*['"][^'"]*token[^'"]*['"]/i,
|
|
1633
|
+
message: "Token in web storage \u2014 confirm threat model (XSS exfiltration)."
|
|
1634
|
+
},
|
|
1635
|
+
{
|
|
1636
|
+
id: "ai-child-process",
|
|
1637
|
+
re: /(?:from\s+['"]child_process['"]|require\s*\(\s*['"]child_process['"]\s*\))/,
|
|
1638
|
+
message: "`child_process` in app code \u2014 unusual for browser bundles; confirm this is server-only tooling."
|
|
1639
|
+
},
|
|
1640
|
+
{
|
|
1641
|
+
id: "ai-sql-template",
|
|
1642
|
+
re: /(?:query|execute|raw)\s*\(\s*[`'"][^`'"]*\$\{/i,
|
|
1643
|
+
message: "Possible dynamic SQL/string \u2014 ensure parameterization, not string concat."
|
|
1644
|
+
}
|
|
1645
|
+
];
|
|
1646
|
+
async function runAiAssistedStrict(cwd, config, pr) {
|
|
1647
|
+
const t0 = performance.now();
|
|
1648
|
+
const cfg = config.checks.aiAssistedReview;
|
|
1649
|
+
if (!cfg.enabled) {
|
|
1650
|
+
return {
|
|
1651
|
+
checkId: "ai-assisted-strict",
|
|
1652
|
+
findings: [],
|
|
1653
|
+
durationMs: 0,
|
|
1654
|
+
skipped: "disabled in config"
|
|
1655
|
+
};
|
|
1656
|
+
}
|
|
1657
|
+
if (!pr) {
|
|
1658
|
+
return {
|
|
1659
|
+
checkId: "ai-assisted-strict",
|
|
1660
|
+
findings: [],
|
|
1661
|
+
durationMs: Math.round(performance.now() - t0),
|
|
1662
|
+
skipped: "not a pull request context"
|
|
1663
|
+
};
|
|
1664
|
+
}
|
|
1665
|
+
if (!pr.aiAssisted) {
|
|
1666
|
+
return {
|
|
1667
|
+
checkId: "ai-assisted-strict",
|
|
1668
|
+
findings: [],
|
|
1669
|
+
durationMs: Math.round(performance.now() - t0),
|
|
1670
|
+
skipped: "PR does not indicate AI-assisted code"
|
|
1671
|
+
};
|
|
1672
|
+
}
|
|
1673
|
+
const files = (pr.files ?? []).filter((f) => CODE_EXT.test(f)).slice(0, 150);
|
|
1674
|
+
const gate = cfg.gate;
|
|
1675
|
+
const findings = [];
|
|
1676
|
+
for (const rel of files) {
|
|
1677
|
+
const full = path.join(cwd, rel);
|
|
1678
|
+
let content;
|
|
1679
|
+
try {
|
|
1680
|
+
content = await fs.readFile(full, "utf8");
|
|
1681
|
+
} catch {
|
|
1682
|
+
continue;
|
|
1683
|
+
}
|
|
1684
|
+
if (content.length > 5e5) continue;
|
|
1685
|
+
for (const { id, re, message, forceBlock } of PATTERNS2) {
|
|
1686
|
+
if (!re.test(content)) continue;
|
|
1687
|
+
findings.push({
|
|
1688
|
+
id,
|
|
1689
|
+
severity: forceBlock ? "block" : sev(gate),
|
|
1690
|
+
message: `[AI-assisted strict] ${message}`,
|
|
1691
|
+
file: rel
|
|
1692
|
+
});
|
|
1693
|
+
}
|
|
1694
|
+
}
|
|
1695
|
+
return {
|
|
1696
|
+
checkId: "ai-assisted-strict",
|
|
1697
|
+
findings: dedupe(findings),
|
|
1698
|
+
durationMs: Math.round(performance.now() - t0)
|
|
1699
|
+
};
|
|
1700
|
+
}
|
|
1701
|
+
function dedupe(f) {
|
|
1702
|
+
const s = /* @__PURE__ */ new Set();
|
|
1703
|
+
const out = [];
|
|
1704
|
+
for (const x of f) {
|
|
1705
|
+
const k = `${x.id}:${x.file ?? ""}`;
|
|
1706
|
+
if (s.has(k)) continue;
|
|
1707
|
+
s.add(k);
|
|
1708
|
+
out.push(x);
|
|
1709
|
+
}
|
|
1710
|
+
return out;
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
// src/runner/ai-escalation.ts
|
|
1714
|
+
function applyAiAssistedEscalation(results, pr, config) {
|
|
1715
|
+
const cfg = config.checks.aiAssistedReview;
|
|
1716
|
+
if (!pr?.aiAssisted || !cfg.enabled) return;
|
|
1717
|
+
const { escalate } = cfg;
|
|
1718
|
+
for (const r of results) {
|
|
1719
|
+
if (r.skipped) continue;
|
|
1720
|
+
if (escalate.secretFindingsToBlock && r.checkId === "secrets") {
|
|
1721
|
+
for (const f of r.findings) {
|
|
1722
|
+
if (f.severity === "warn" || f.severity === "info") f.severity = "block";
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
if (escalate.tsAnyDeltaToBlock && r.checkId === "ts-any-delta") {
|
|
1726
|
+
for (const f of r.findings) {
|
|
1727
|
+
if (f.severity === "warn" || f.severity === "info") f.severity = "block";
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
function buildReport(stack, pr, results, options) {
|
|
1733
|
+
const mode = options?.mode ?? "warn";
|
|
1734
|
+
const allFindings = results.flatMap(
|
|
1735
|
+
(r) => r.findings.map((f) => ({ ...f, checkId: r.checkId }))
|
|
1736
|
+
);
|
|
1737
|
+
const warns = allFindings.filter((f) => f.severity === "warn").length;
|
|
1738
|
+
const infos = allFindings.filter((f) => f.severity === "info").length;
|
|
1739
|
+
const blocks = allFindings.filter((f) => f.severity === "block").length;
|
|
1740
|
+
const lines = pr != null ? pr.additions + pr.deletions : null;
|
|
1741
|
+
const riskScore = scoreRisk(blocks, warns, lines, pr?.changedFiles ?? 0);
|
|
1742
|
+
const markdown = formatMarkdown({
|
|
1743
|
+
riskScore,
|
|
1744
|
+
mode,
|
|
1745
|
+
stack,
|
|
1746
|
+
pr,
|
|
1747
|
+
results,
|
|
1748
|
+
warns,
|
|
1749
|
+
infos,
|
|
1750
|
+
blocks,
|
|
1751
|
+
lines,
|
|
1752
|
+
llmAppendix: options?.llmAppendix ?? null
|
|
1753
|
+
});
|
|
1754
|
+
const consoleText = formatConsole({
|
|
1755
|
+
riskScore,
|
|
1756
|
+
mode,
|
|
1757
|
+
stack,
|
|
1758
|
+
pr,
|
|
1759
|
+
results,
|
|
1760
|
+
warns,
|
|
1761
|
+
infos,
|
|
1762
|
+
blocks
|
|
1763
|
+
});
|
|
1764
|
+
return { riskScore, stack, pr, results, markdown, consoleText };
|
|
1765
|
+
}
|
|
1766
|
+
function formatStackOneLiner(s) {
|
|
1767
|
+
const bits = [];
|
|
1768
|
+
if (s.hasNext) bits.push("Next.js");
|
|
1769
|
+
if (s.hasReactNative) bits.push("React Native");
|
|
1770
|
+
else if (s.hasReact) bits.push("React");
|
|
1771
|
+
if (s.hasTypeScript) bits.push("TypeScript");
|
|
1772
|
+
if (s.tsStrict === true) bits.push("strict TS");
|
|
1773
|
+
bits.push(`pkg: ${s.packageManager}`);
|
|
1774
|
+
return bits.join(" \xB7 ") || "unknown";
|
|
1775
|
+
}
|
|
1776
|
+
function scoreRisk(blocks, warns, lines, files) {
|
|
1777
|
+
let score = 0;
|
|
1778
|
+
if (blocks > 0) score += 3;
|
|
1779
|
+
if (warns >= 8) score += 2;
|
|
1780
|
+
else if (warns >= 3) score += 1;
|
|
1781
|
+
if (lines != null) {
|
|
1782
|
+
if (lines >= 800) score += 2;
|
|
1783
|
+
else if (lines >= 400) score += 1;
|
|
1784
|
+
}
|
|
1785
|
+
if (files >= 25) score += 1;
|
|
1786
|
+
if (score >= 5) return "HIGH";
|
|
1787
|
+
if (score >= 2) return "MEDIUM";
|
|
1788
|
+
return "LOW";
|
|
1789
|
+
}
|
|
1790
|
+
function formatMarkdown(p) {
|
|
1791
|
+
const {
|
|
1792
|
+
riskScore,
|
|
1793
|
+
mode,
|
|
1794
|
+
stack,
|
|
1795
|
+
pr,
|
|
1796
|
+
results,
|
|
1797
|
+
warns,
|
|
1798
|
+
infos,
|
|
1799
|
+
blocks,
|
|
1800
|
+
lines,
|
|
1801
|
+
llmAppendix
|
|
1802
|
+
} = p;
|
|
1803
|
+
const sb = [];
|
|
1804
|
+
sb.push("## FrontGuard review brief");
|
|
1805
|
+
sb.push("");
|
|
1806
|
+
sb.push(
|
|
1807
|
+
`**Risk:** ${riskScore} \xB7 **Mode:** ${mode === "enforce" ? "enforce (CI may fail on `block`)" : "warn-only"}`
|
|
1808
|
+
);
|
|
1809
|
+
if (pr?.aiAssisted) {
|
|
1810
|
+
sb.push("");
|
|
1811
|
+
sb.push(
|
|
1812
|
+
"**AI-assisted PR:** Stricter static pass is active (security / footgun patterns on changed files; secrets & `any` deltas may be escalated). **Human review** still required for correctness and product rules."
|
|
1813
|
+
);
|
|
1814
|
+
}
|
|
1815
|
+
sb.push("");
|
|
1816
|
+
sb.push(`**Stack:** ${formatStackOneLiner(stack)}`);
|
|
1817
|
+
if (pr && lines != null) {
|
|
1818
|
+
sb.push(
|
|
1819
|
+
`**Size:** ${lines} lines (+${pr.additions} / -${pr.deletions}) across ${pr.changedFiles} files`
|
|
1820
|
+
);
|
|
1821
|
+
}
|
|
1822
|
+
sb.push("");
|
|
1823
|
+
sb.push(
|
|
1824
|
+
blocks > 0 ? `**Blocking (\`block\`) findings:** ${blocks}` : "**Blocking (`block`) findings:** 0"
|
|
1825
|
+
);
|
|
1826
|
+
sb.push(`**Warnings:** ${warns} \xB7 **Info:** ${infos}`);
|
|
1827
|
+
sb.push("");
|
|
1828
|
+
sb.push("### Check summary");
|
|
1829
|
+
for (const r of results) {
|
|
1830
|
+
const status = r.skipped ? `\u23ED\uFE0F skipped (${r.skipped})` : r.findings.length === 0 ? "\u2705 clean" : `\u26A0\uFE0F ${r.findings.length} finding(s)`;
|
|
1831
|
+
sb.push(`- **${r.checkId}** \u2014 ${status} (${r.durationMs}ms)`);
|
|
1832
|
+
}
|
|
1833
|
+
sb.push("");
|
|
1834
|
+
const blockFindings = results.flatMap(
|
|
1835
|
+
(r) => r.findings.filter((f) => f.severity === "block").map((f) => ({ r, f }))
|
|
1836
|
+
);
|
|
1837
|
+
if (blockFindings.length) {
|
|
1838
|
+
sb.push("### Blocking");
|
|
1839
|
+
for (const { f } of blockFindings.slice(0, 40)) {
|
|
1840
|
+
const loc = f.file ? ` \`${f.file}\`` : "";
|
|
1841
|
+
sb.push(`- ${f.message}${loc}`);
|
|
1842
|
+
if (f.detail) {
|
|
1843
|
+
sb.push(
|
|
1844
|
+
` - <details><summary>detail</summary>
|
|
1845
|
+
|
|
1846
|
+
\`\`\`text
|
|
1847
|
+
${f.detail}
|
|
1848
|
+
\`\`\`
|
|
1849
|
+
|
|
1850
|
+
</details>`
|
|
1851
|
+
);
|
|
1852
|
+
}
|
|
1853
|
+
}
|
|
1854
|
+
sb.push("");
|
|
1855
|
+
}
|
|
1856
|
+
const warnFindings = results.flatMap(
|
|
1857
|
+
(r) => r.findings.filter((f) => f.severity === "warn").map((f) => ({ r, f }))
|
|
1858
|
+
);
|
|
1859
|
+
if (warnFindings.length) {
|
|
1860
|
+
sb.push("### Warnings");
|
|
1861
|
+
for (const { f } of warnFindings.slice(0, 30)) {
|
|
1862
|
+
const loc = f.file ? ` \`${f.file}\`` : "";
|
|
1863
|
+
sb.push(`- ${f.message}${loc}`);
|
|
1864
|
+
if (f.detail) {
|
|
1865
|
+
sb.push(
|
|
1866
|
+
` - <details><summary>detail</summary>
|
|
1867
|
+
|
|
1868
|
+
\`\`\`text
|
|
1869
|
+
${f.detail}
|
|
1870
|
+
\`\`\`
|
|
1871
|
+
|
|
1872
|
+
</details>`
|
|
1873
|
+
);
|
|
1874
|
+
}
|
|
1875
|
+
}
|
|
1876
|
+
if (warnFindings.length > 30) {
|
|
1877
|
+
sb.push(`- _\u2026and ${warnFindings.length - 30} more_`);
|
|
1878
|
+
}
|
|
1879
|
+
sb.push("");
|
|
1880
|
+
}
|
|
1881
|
+
const infoFindings = results.flatMap(
|
|
1882
|
+
(r) => r.findings.filter((f) => f.severity === "info").map((f) => ({ r, f }))
|
|
1883
|
+
);
|
|
1884
|
+
if (infoFindings.length) {
|
|
1885
|
+
sb.push("### Notes");
|
|
1886
|
+
for (const { f } of infoFindings.slice(0, 20)) {
|
|
1887
|
+
sb.push(`- ${f.message}`);
|
|
1888
|
+
}
|
|
1889
|
+
sb.push("");
|
|
1890
|
+
}
|
|
1891
|
+
if (llmAppendix?.trim()) {
|
|
1892
|
+
sb.push(llmAppendix.trim());
|
|
1893
|
+
sb.push("");
|
|
1894
|
+
}
|
|
1895
|
+
sb.push("---");
|
|
1896
|
+
sb.push("_Generated by FrontGuard \u2014 configure with `frontguard.config.js`_");
|
|
1897
|
+
return sb.join("\n");
|
|
1898
|
+
}
|
|
1899
|
+
function formatConsole(p) {
|
|
1900
|
+
const { riskScore, mode, stack, pr, results, warns, infos, blocks } = p;
|
|
1901
|
+
const lines = [];
|
|
1902
|
+
lines.push(pc.bold("\u250C\u2500 FrontGuard review brief \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510"));
|
|
1903
|
+
lines.push(`\u2502 ${pc.dim("Risk:")} ${riskScore.padEnd(43)} \u2502`);
|
|
1904
|
+
lines.push(`\u2502 ${pc.dim("Mode:")} ${mode.padEnd(42)} \u2502`);
|
|
1905
|
+
lines.push(
|
|
1906
|
+
`\u2502 ${pc.dim("Stack:")} ${(stack.hasNext ? "Next.js " : "") + (stack.hasReactNative ? "RN " : "") + (stack.hasReact ? "React " : "") + (stack.hasTypeScript ? "TS" : "JS")}`.padEnd(56).slice(0, 56) + " \u2502"
|
|
1907
|
+
);
|
|
1908
|
+
if (pr) {
|
|
1909
|
+
const sz = `${pr.additions + pr.deletions} lines (+${pr.additions}/-${pr.deletions}) \xB7 ${pr.changedFiles} files`;
|
|
1910
|
+
lines.push(`\u2502 ${pc.dim("PR:")} ${sz.slice(0, 49).padEnd(49)} \u2502`);
|
|
1911
|
+
}
|
|
1912
|
+
lines.push("\u2502 " + "".padEnd(53) + " \u2502");
|
|
1913
|
+
const statusLine = blocks > 0 ? pc.red(`\u2716 ${blocks} blocking`) : warns === 0 && infos === 0 ? pc.green("\u2713 No findings") : pc.yellow(`\u26A0 ${warns} warnings \xB7 ${infos} info`);
|
|
1914
|
+
lines.push(`\u2502 ${statusLine}`.padEnd(64).slice(0, 64) + " \u2502");
|
|
1915
|
+
for (const r of results) {
|
|
1916
|
+
const label = r.skipped ? pc.dim(` ${r.checkId}: skipped`) : ` ${r.checkId}: ${r.findings.length} issues`;
|
|
1917
|
+
lines.push(`\u2502${label.slice(0, 54).padEnd(54)}\u2502`);
|
|
1918
|
+
}
|
|
1919
|
+
lines.push(pc.bold("\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518"));
|
|
1920
|
+
return lines.join("\n");
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
// src/llm/review.ts
|
|
1924
|
+
function safeGetEnv(name) {
|
|
1925
|
+
const v = process.env[name];
|
|
1926
|
+
return v && v.trim() ? v : void 0;
|
|
1927
|
+
}
|
|
1928
|
+
async function runLlmReview(opts) {
|
|
1929
|
+
const { cwd, config, pr, results } = opts;
|
|
1930
|
+
const cfg = config.checks.llm;
|
|
1931
|
+
if (!cfg.enabled) return null;
|
|
1932
|
+
const apiKey = safeGetEnv(cfg.apiKeyEnv);
|
|
1933
|
+
if (!apiKey) {
|
|
1934
|
+
if (process.env.FRONTGUARD_LLM_SHOW_NO_KEY_HINT !== "1") {
|
|
1935
|
+
return null;
|
|
1936
|
+
}
|
|
1937
|
+
return [
|
|
1938
|
+
"### AI review (automated CI)",
|
|
1939
|
+
"",
|
|
1940
|
+
"_No API key in this environment._ IDE credentials do not reach GitHub Actions.",
|
|
1941
|
+
"",
|
|
1942
|
+
"**Options:**",
|
|
1943
|
+
"1. **Manual** \u2014 Paste notes from Cursor/ChatGPT/Claude into a file, then `frontguard run --append ./notes.md` (or `FRONTGUARD_MANUAL_APPENDIX_FILE`).",
|
|
1944
|
+
`2. **Org CI** \u2014 Map an approved inference key to \`${cfg.apiKeyEnv}\` via your secret store.`,
|
|
1945
|
+
"3. **Docs in PR** \u2014 Rely on the PR template \u201CAI assistance\u201D section for reviewer context."
|
|
1946
|
+
].join("\n");
|
|
1947
|
+
}
|
|
1948
|
+
if (!await gitOk(cwd)) {
|
|
1949
|
+
return "_LLM review skipped: not a git repository_";
|
|
1950
|
+
}
|
|
1951
|
+
const base = await resolveDiffBaseRef(cwd, config.checks.tsAnyDelta.baseRef);
|
|
1952
|
+
const diff = await gitDiffForReview(cwd, base, cfg.maxDiffChars);
|
|
1953
|
+
if (!diff.trim()) {
|
|
1954
|
+
return "_LLM review skipped: empty diff vs base_";
|
|
1955
|
+
}
|
|
1956
|
+
const summaryLines = results.flatMap((r) => r.findings.map((f) => `- [${f.severity}] ${r.checkId}: ${f.message}`)).slice(0, 40).join("\n");
|
|
1957
|
+
const prompt = [
|
|
1958
|
+
"You are a senior frontend reviewer. Respond in Markdown with short sections:",
|
|
1959
|
+
"### Summary",
|
|
1960
|
+
"### Risk hotspots (files / themes)",
|
|
1961
|
+
"### Logic / correctness smells",
|
|
1962
|
+
"### Tests & regressions",
|
|
1963
|
+
"",
|
|
1964
|
+
"Constraints:",
|
|
1965
|
+
"- Be specific to this diff; avoid generic advice.",
|
|
1966
|
+
"- If uncertain, say what you need to verify manually.",
|
|
1967
|
+
"",
|
|
1968
|
+
pr ? `PR title: ${pr.title}
|
|
1969
|
+
PR body excerpt:
|
|
1970
|
+
${pr.body.slice(0, 2e3)}` : "No GitHub PR context (local run).",
|
|
1971
|
+
"",
|
|
1972
|
+
"Existing automated findings (may be incomplete):",
|
|
1973
|
+
summaryLines || "(none)",
|
|
1974
|
+
"",
|
|
1975
|
+
"Git diff (may be truncated):",
|
|
1976
|
+
"```diff",
|
|
1977
|
+
diff,
|
|
1978
|
+
"```"
|
|
1979
|
+
].join("\n");
|
|
1980
|
+
const controller = new AbortController();
|
|
1981
|
+
const t = setTimeout(() => controller.abort(), cfg.timeoutMs);
|
|
1982
|
+
try {
|
|
1983
|
+
if (cfg.provider === "anthropic") {
|
|
1984
|
+
const text2 = await callAnthropic(cfg.model, apiKey, prompt, controller.signal);
|
|
1985
|
+
return `### AI review (non-binding)
|
|
1986
|
+
|
|
1987
|
+
${text2}`;
|
|
1988
|
+
}
|
|
1989
|
+
const text = await callOpenAI(cfg.model, apiKey, prompt, controller.signal);
|
|
1990
|
+
return `### AI review (non-binding)
|
|
1991
|
+
|
|
1992
|
+
${text}`;
|
|
1993
|
+
} catch (e) {
|
|
1994
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
1995
|
+
return `_LLM request failed: ${msg}_`;
|
|
1996
|
+
} finally {
|
|
1997
|
+
clearTimeout(t);
|
|
1998
|
+
}
|
|
1999
|
+
}
|
|
2000
|
+
async function callOpenAI(model, apiKey, prompt, signal) {
|
|
2001
|
+
const res = await fetch("https://api.openai.com/v1/chat/completions", {
|
|
2002
|
+
method: "POST",
|
|
2003
|
+
signal,
|
|
2004
|
+
headers: {
|
|
2005
|
+
"Content-Type": "application/json",
|
|
2006
|
+
Authorization: `Bearer ${apiKey}`
|
|
2007
|
+
},
|
|
2008
|
+
body: JSON.stringify({
|
|
2009
|
+
model,
|
|
2010
|
+
temperature: 0.2,
|
|
2011
|
+
messages: [
|
|
2012
|
+
{
|
|
2013
|
+
role: "system",
|
|
2014
|
+
content: "You audit frontend pull requests. Output concise Markdown. No preamble about being an AI."
|
|
2015
|
+
},
|
|
2016
|
+
{ role: "user", content: prompt }
|
|
2017
|
+
]
|
|
2018
|
+
})
|
|
2019
|
+
});
|
|
2020
|
+
if (!res.ok) {
|
|
2021
|
+
const t = await res.text();
|
|
2022
|
+
throw new Error(`OpenAI HTTP ${res.status}: ${t.slice(0, 500)}`);
|
|
2023
|
+
}
|
|
2024
|
+
const data = await res.json();
|
|
2025
|
+
const text = data.choices?.[0]?.message?.content?.trim();
|
|
2026
|
+
if (!text) throw new Error("OpenAI returned empty content");
|
|
2027
|
+
return text;
|
|
2028
|
+
}
|
|
2029
|
+
async function callAnthropic(model, apiKey, prompt, signal) {
|
|
2030
|
+
const res = await fetch("https://api.anthropic.com/v1/messages", {
|
|
2031
|
+
method: "POST",
|
|
2032
|
+
signal,
|
|
2033
|
+
headers: {
|
|
2034
|
+
"Content-Type": "application/json",
|
|
2035
|
+
"x-api-key": apiKey,
|
|
2036
|
+
"anthropic-version": "2023-06-01"
|
|
2037
|
+
},
|
|
2038
|
+
body: JSON.stringify({
|
|
2039
|
+
model,
|
|
2040
|
+
max_tokens: 4096,
|
|
2041
|
+
temperature: 0.2,
|
|
2042
|
+
messages: [{ role: "user", content: prompt }]
|
|
2043
|
+
})
|
|
2044
|
+
});
|
|
2045
|
+
if (!res.ok) {
|
|
2046
|
+
const t = await res.text();
|
|
2047
|
+
throw new Error(`Anthropic HTTP ${res.status}: ${t.slice(0, 500)}`);
|
|
2048
|
+
}
|
|
2049
|
+
const data = await res.json();
|
|
2050
|
+
const text = data.content?.map((b) => b.type === "text" ? b.text : "").join("").trim();
|
|
2051
|
+
if (!text) throw new Error("Anthropic returned empty content");
|
|
2052
|
+
return text;
|
|
2053
|
+
}
|
|
2054
|
+
var MAX_CHARS = 2e5;
|
|
2055
|
+
async function loadManualAppendix(opts) {
|
|
2056
|
+
const { cwd, filePath } = opts;
|
|
2057
|
+
const envFile = process.env.FRONTGUARD_MANUAL_APPENDIX_FILE?.trim();
|
|
2058
|
+
const resolvedPath = filePath?.trim() || envFile;
|
|
2059
|
+
if (resolvedPath) {
|
|
2060
|
+
const abs = path.isAbsolute(resolvedPath) ? resolvedPath : path.join(cwd, resolvedPath);
|
|
2061
|
+
try {
|
|
2062
|
+
let text = await fs.readFile(abs, "utf8");
|
|
2063
|
+
if (text.length > MAX_CHARS) {
|
|
2064
|
+
text = text.slice(0, MAX_CHARS) + "\n\n_(truncated)_\n";
|
|
2065
|
+
}
|
|
2066
|
+
const t = text.trim();
|
|
2067
|
+
if (t) {
|
|
2068
|
+
return `### Contributed review notes
|
|
2069
|
+
|
|
2070
|
+
_Pasted or file-based (no CI API key)._
|
|
2071
|
+
|
|
2072
|
+
${t}`;
|
|
2073
|
+
}
|
|
2074
|
+
} catch {
|
|
2075
|
+
}
|
|
2076
|
+
}
|
|
2077
|
+
const inline = process.env.FRONTGUARD_MANUAL_APPENDIX?.trim();
|
|
2078
|
+
if (inline) {
|
|
2079
|
+
let text = inline;
|
|
2080
|
+
if (text.length > MAX_CHARS) {
|
|
2081
|
+
text = text.slice(0, MAX_CHARS) + "\n\n_(truncated)_\n";
|
|
2082
|
+
}
|
|
2083
|
+
return `### Contributed review notes
|
|
2084
|
+
|
|
2085
|
+
${text.trim()}`;
|
|
2086
|
+
}
|
|
2087
|
+
return null;
|
|
2088
|
+
}
|
|
2089
|
+
|
|
2090
|
+
// src/commands/run.ts
|
|
2091
|
+
async function runFrontGuard(opts) {
|
|
2092
|
+
const config = await loadConfig(opts.cwd);
|
|
2093
|
+
const mode = opts.enforce ? "enforce" : config.mode;
|
|
2094
|
+
const stack = await detectStack(opts.cwd);
|
|
2095
|
+
const pr = await readGithubEvent();
|
|
2096
|
+
const restrictFiles = pr?.files?.length ? pr.files : null;
|
|
2097
|
+
const [
|
|
2098
|
+
eslint,
|
|
2099
|
+
prettier,
|
|
2100
|
+
typescript,
|
|
2101
|
+
secrets,
|
|
2102
|
+
tsAnyDelta,
|
|
2103
|
+
cycles,
|
|
2104
|
+
deadCode,
|
|
2105
|
+
cwv,
|
|
2106
|
+
customRules,
|
|
2107
|
+
aiStrict
|
|
2108
|
+
] = await Promise.all([
|
|
2109
|
+
runEslint(opts.cwd, config),
|
|
2110
|
+
runPrettier(opts.cwd, config),
|
|
2111
|
+
runTypeScript(opts.cwd, config, stack),
|
|
2112
|
+
runSecrets(opts.cwd, config, pr),
|
|
2113
|
+
runTsAnyDelta(opts.cwd, config, stack),
|
|
2114
|
+
runCycles(opts.cwd, config, stack),
|
|
2115
|
+
runDeadCode(opts.cwd, config, stack, pr),
|
|
2116
|
+
runCwv(opts.cwd, config, stack, pr),
|
|
2117
|
+
runCustomRules(opts.cwd, config, restrictFiles),
|
|
2118
|
+
runAiAssistedStrict(opts.cwd, config, pr)
|
|
2119
|
+
]);
|
|
2120
|
+
const bundle = await runBundle(opts.cwd, config, stack);
|
|
2121
|
+
const prHygiene = runPrHygiene(config, pr);
|
|
2122
|
+
const prSize = runPrSize(config, pr);
|
|
2123
|
+
const results = [
|
|
2124
|
+
eslint,
|
|
2125
|
+
prettier,
|
|
2126
|
+
typescript,
|
|
2127
|
+
secrets,
|
|
2128
|
+
tsAnyDelta,
|
|
2129
|
+
cycles,
|
|
2130
|
+
deadCode,
|
|
2131
|
+
bundle,
|
|
2132
|
+
cwv,
|
|
2133
|
+
customRules,
|
|
2134
|
+
aiStrict,
|
|
2135
|
+
prHygiene,
|
|
2136
|
+
prSize
|
|
2137
|
+
];
|
|
2138
|
+
applyAiAssistedEscalation(results, pr, config);
|
|
2139
|
+
const manualAppendix = await loadManualAppendix({
|
|
2140
|
+
cwd: opts.cwd,
|
|
2141
|
+
filePath: opts.append ?? null
|
|
2142
|
+
});
|
|
2143
|
+
const automatedAppendix = await runLlmReview({
|
|
2144
|
+
cwd: opts.cwd,
|
|
2145
|
+
config,
|
|
2146
|
+
pr,
|
|
2147
|
+
results
|
|
2148
|
+
});
|
|
2149
|
+
const llmAppendix = [manualAppendix, automatedAppendix].filter(Boolean).join("\n\n") || null;
|
|
2150
|
+
const report = buildReport(stack, pr, results, { mode, llmAppendix });
|
|
2151
|
+
if (opts.markdown) {
|
|
2152
|
+
process2.stdout.write(report.markdown + "\n");
|
|
2153
|
+
} else {
|
|
2154
|
+
process2.stdout.write(report.consoleText + "\n\n");
|
|
2155
|
+
process2.stdout.write(report.markdown + "\n");
|
|
2156
|
+
}
|
|
2157
|
+
if (opts.ci && process2.env.GITHUB_TOKEN) {
|
|
2158
|
+
await upsertBriefComment(report.markdown);
|
|
2159
|
+
}
|
|
2160
|
+
const hasBlock = results.some((r) => r.findings.some((f) => f.severity === "block"));
|
|
2161
|
+
process2.exitCode = mode === "enforce" && hasBlock ? 1 : 0;
|
|
2162
|
+
}
|
|
2163
|
+
|
|
2164
|
+
// src/cli.ts
|
|
2165
|
+
var init = defineCommand({
|
|
2166
|
+
meta: {
|
|
2167
|
+
name: "init",
|
|
2168
|
+
description: "Add workflow, PR template, and frontguard.config.js"
|
|
2169
|
+
},
|
|
2170
|
+
run: async () => {
|
|
2171
|
+
const cwd = process2.cwd();
|
|
2172
|
+
await initFrontGuard(cwd);
|
|
2173
|
+
process2.stdout.write(
|
|
2174
|
+
"FrontGuard initialized.\n\nNext: add the package as a devDependency so CI matches local runs:\n npm install -D @cleartrip/frontguard\n yarn add -D @cleartrip/frontguard\n"
|
|
2175
|
+
);
|
|
2176
|
+
}
|
|
2177
|
+
});
|
|
2178
|
+
var run = defineCommand({
|
|
2179
|
+
meta: {
|
|
2180
|
+
name: "run",
|
|
2181
|
+
description: "Run checks and print the review brief"
|
|
2182
|
+
},
|
|
2183
|
+
args: {
|
|
2184
|
+
ci: {
|
|
2185
|
+
type: "boolean",
|
|
2186
|
+
description: "Upsert PR comment when GITHUB_TOKEN is available",
|
|
2187
|
+
default: false
|
|
2188
|
+
},
|
|
2189
|
+
markdown: {
|
|
2190
|
+
type: "boolean",
|
|
2191
|
+
description: "Print markdown only",
|
|
2192
|
+
default: false
|
|
2193
|
+
},
|
|
2194
|
+
enforce: {
|
|
2195
|
+
type: "boolean",
|
|
2196
|
+
description: "Exit non-zero if any finding has severity block",
|
|
2197
|
+
default: false
|
|
2198
|
+
},
|
|
2199
|
+
append: {
|
|
2200
|
+
type: "string",
|
|
2201
|
+
description: "Append markdown from a file (paste from IDE/ChatGPT/Claude; no CI API key needed)"
|
|
2202
|
+
}
|
|
2203
|
+
},
|
|
2204
|
+
run: async ({ args }) => {
|
|
2205
|
+
await runFrontGuard({
|
|
2206
|
+
cwd: process2.cwd(),
|
|
2207
|
+
ci: Boolean(args.ci),
|
|
2208
|
+
markdown: Boolean(args.markdown),
|
|
2209
|
+
enforce: Boolean(args.enforce),
|
|
2210
|
+
append: typeof args.append === "string" ? args.append : null
|
|
2211
|
+
});
|
|
2212
|
+
}
|
|
2213
|
+
});
|
|
2214
|
+
var main = defineCommand({
|
|
2215
|
+
meta: { name: "frontguard", description: "FrontGuard \u2014 frontend PR guardrails" },
|
|
2216
|
+
subCommands: { init, run }
|
|
2217
|
+
});
|
|
2218
|
+
runMain(main);
|
|
2219
|
+
//# sourceMappingURL=cli.js.map
|
|
2220
|
+
//# sourceMappingURL=cli.js.map
|