@cleartrip/frontguard 0.2.1 → 0.2.3
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 +471 -22
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +9 -2
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
package/dist/cli.js
CHANGED
|
@@ -13,6 +13,7 @@ import fs2 from 'fs';
|
|
|
13
13
|
import { pipeline } from 'stream/promises';
|
|
14
14
|
import { PassThrough } from 'stream';
|
|
15
15
|
import fg from 'fast-glob';
|
|
16
|
+
import * as ts from 'typescript';
|
|
16
17
|
|
|
17
18
|
var __create = Object.create;
|
|
18
19
|
var __defProp = Object.defineProperty;
|
|
@@ -2425,6 +2426,8 @@ export default defineConfig({
|
|
|
2425
2426
|
// { minLines: 500, severity: 'info', message: 'Consider splitting (\${lines} lines)' },
|
|
2426
2427
|
// ],
|
|
2427
2428
|
// },
|
|
2429
|
+
// // AI strict: only // @frontguard-ai:start \u2026 :end (or // written by AI: start \u2026 :end) in PR files
|
|
2430
|
+
// // aiAssistedReview: { strictScanMode: 'decorator' },
|
|
2428
2431
|
// cycles: { enabled: true },
|
|
2429
2432
|
// deadCode: { enabled: true, gate: 'info' },
|
|
2430
2433
|
// // LLM: cloud keys in CI, or local Ollama (no API key) on dev/self-hosted runners:
|
|
@@ -2789,6 +2792,7 @@ var defaultConfig = {
|
|
|
2789
2792
|
aiAssistedReview: {
|
|
2790
2793
|
enabled: true,
|
|
2791
2794
|
gate: "warn",
|
|
2795
|
+
strictScanMode: "both",
|
|
2792
2796
|
escalate: {
|
|
2793
2797
|
secretFindingsToBlock: true,
|
|
2794
2798
|
tsAnyDeltaToBlock: true
|
|
@@ -2950,9 +2954,9 @@ async function detectStack(cwd) {
|
|
|
2950
2954
|
try {
|
|
2951
2955
|
const tsconfigPath = path5.join(cwd, "tsconfig.json");
|
|
2952
2956
|
const tsRaw = await fs.readFile(tsconfigPath, "utf8");
|
|
2953
|
-
const
|
|
2954
|
-
if (typeof
|
|
2955
|
-
tsStrict =
|
|
2957
|
+
const ts2 = JSON.parse(tsRaw);
|
|
2958
|
+
if (typeof ts2.compilerOptions?.strict === "boolean") {
|
|
2959
|
+
tsStrict = ts2.compilerOptions.strict;
|
|
2956
2960
|
}
|
|
2957
2961
|
} catch {
|
|
2958
2962
|
}
|
|
@@ -5010,6 +5014,287 @@ async function runCustomRules(cwd, config, restrictToFiles) {
|
|
|
5010
5014
|
durationMs: Math.round(performance.now() - t0)
|
|
5011
5015
|
};
|
|
5012
5016
|
}
|
|
5017
|
+
var BIG_LITERAL_MIN = 9e3;
|
|
5018
|
+
function scanDiscardedExpensiveCalls(regionText, fileNameHint, regionStartLine) {
|
|
5019
|
+
const kind = /\.(tsx|jsx)$/i.test(fileNameHint) ? ts.ScriptKind.TSX : ts.ScriptKind.TS;
|
|
5020
|
+
const sf = ts.createSourceFile(
|
|
5021
|
+
fileNameHint,
|
|
5022
|
+
regionText,
|
|
5023
|
+
ts.ScriptTarget.Latest,
|
|
5024
|
+
true,
|
|
5025
|
+
kind
|
|
5026
|
+
);
|
|
5027
|
+
const parseDiags = sf.parseDiagnostics;
|
|
5028
|
+
if (parseDiags && parseDiags.length > 12) {
|
|
5029
|
+
return [];
|
|
5030
|
+
}
|
|
5031
|
+
const stack = [/* @__PURE__ */ new Map()];
|
|
5032
|
+
const lines = [];
|
|
5033
|
+
function current() {
|
|
5034
|
+
return stack[stack.length - 1];
|
|
5035
|
+
}
|
|
5036
|
+
function lookupExpensive(name) {
|
|
5037
|
+
for (let i3 = stack.length - 1; i3 >= 0; i3--) {
|
|
5038
|
+
const v3 = stack[i3].get(name);
|
|
5039
|
+
if (v3 !== void 0) return v3;
|
|
5040
|
+
}
|
|
5041
|
+
return false;
|
|
5042
|
+
}
|
|
5043
|
+
function lineOf(node) {
|
|
5044
|
+
const lc = ts.getLineAndCharacterOfPosition(sf, node.getStart(sf, false));
|
|
5045
|
+
return regionStartLine + lc.line;
|
|
5046
|
+
}
|
|
5047
|
+
function visitFunctionLike(fn) {
|
|
5048
|
+
stack.push(/* @__PURE__ */ new Map());
|
|
5049
|
+
for (const p2 of fn.parameters) {
|
|
5050
|
+
if (ts.isIdentifier(p2.name)) current().set(p2.name.text, false);
|
|
5051
|
+
}
|
|
5052
|
+
const body = fn.body;
|
|
5053
|
+
if (!body) {
|
|
5054
|
+
stack.pop();
|
|
5055
|
+
return;
|
|
5056
|
+
}
|
|
5057
|
+
if (ts.isBlock(body)) {
|
|
5058
|
+
for (const st of body.statements) visitStmt(st);
|
|
5059
|
+
} else if (ts.isExpression(body)) {
|
|
5060
|
+
visitStmt(ts.factory.createExpressionStatement(body));
|
|
5061
|
+
}
|
|
5062
|
+
stack.pop();
|
|
5063
|
+
}
|
|
5064
|
+
function visitBlock(block) {
|
|
5065
|
+
stack.push(/* @__PURE__ */ new Map());
|
|
5066
|
+
for (const st of block.statements) visitStmt(st);
|
|
5067
|
+
stack.pop();
|
|
5068
|
+
}
|
|
5069
|
+
function visitMaybeBlock(s3) {
|
|
5070
|
+
if (ts.isBlock(s3)) visitBlock(s3);
|
|
5071
|
+
else visitStmt(s3);
|
|
5072
|
+
}
|
|
5073
|
+
function visitStmt(st) {
|
|
5074
|
+
if (ts.isVariableStatement(st)) {
|
|
5075
|
+
for (const decl of st.declarationList.declarations) {
|
|
5076
|
+
if (!ts.isIdentifier(decl.name) || !decl.initializer) continue;
|
|
5077
|
+
const name = decl.name.text;
|
|
5078
|
+
const init3 = decl.initializer;
|
|
5079
|
+
if (ts.isArrowFunction(init3) || ts.isFunctionExpression(init3)) {
|
|
5080
|
+
current().set(name, isBodyExpensive(init3.body));
|
|
5081
|
+
visitFunctionLike(init3);
|
|
5082
|
+
}
|
|
5083
|
+
}
|
|
5084
|
+
return;
|
|
5085
|
+
}
|
|
5086
|
+
if (ts.isFunctionDeclaration(st) && st.name && st.body) {
|
|
5087
|
+
current().set(st.name.text, isBodyExpensive(st.body));
|
|
5088
|
+
visitFunctionLike(st);
|
|
5089
|
+
return;
|
|
5090
|
+
}
|
|
5091
|
+
if (ts.isExpressionStatement(st)) {
|
|
5092
|
+
const ex = st.expression;
|
|
5093
|
+
if (ts.isCallExpression(ex) && !ex.questionDotToken && ts.isIdentifier(ex.expression) && lookupExpensive(ex.expression.text)) {
|
|
5094
|
+
lines.push(lineOf(ex));
|
|
5095
|
+
}
|
|
5096
|
+
return;
|
|
5097
|
+
}
|
|
5098
|
+
if (ts.isBlock(st)) {
|
|
5099
|
+
visitBlock(st);
|
|
5100
|
+
return;
|
|
5101
|
+
}
|
|
5102
|
+
if (ts.isIfStatement(st)) {
|
|
5103
|
+
visitMaybeBlock(st.thenStatement);
|
|
5104
|
+
if (st.elseStatement) visitMaybeBlock(st.elseStatement);
|
|
5105
|
+
return;
|
|
5106
|
+
}
|
|
5107
|
+
if (ts.isForStatement(st) || ts.isForOfStatement(st) || ts.isForInStatement(st) || ts.isWhileStatement(st) || ts.isDoStatement(st)) {
|
|
5108
|
+
visitMaybeBlock(st.statement);
|
|
5109
|
+
return;
|
|
5110
|
+
}
|
|
5111
|
+
if (ts.isTryStatement(st)) {
|
|
5112
|
+
visitBlock(st.tryBlock);
|
|
5113
|
+
if (st.catchClause?.block) visitBlock(st.catchClause.block);
|
|
5114
|
+
if (st.finallyBlock) visitBlock(st.finallyBlock);
|
|
5115
|
+
return;
|
|
5116
|
+
}
|
|
5117
|
+
if (ts.isSwitchStatement(st)) {
|
|
5118
|
+
for (const clause of st.caseBlock.clauses) {
|
|
5119
|
+
for (const s3 of clause.statements) visitStmt(s3);
|
|
5120
|
+
}
|
|
5121
|
+
return;
|
|
5122
|
+
}
|
|
5123
|
+
if (ts.isClassDeclaration(st) && st.members) {
|
|
5124
|
+
for (const member of st.members) {
|
|
5125
|
+
if (ts.isMethodDeclaration(member) && member.body) {
|
|
5126
|
+
const nm = member.name;
|
|
5127
|
+
if (ts.isIdentifier(nm)) {
|
|
5128
|
+
current().set(nm.text, isBodyExpensive(member.body));
|
|
5129
|
+
}
|
|
5130
|
+
visitFunctionLike(member);
|
|
5131
|
+
}
|
|
5132
|
+
}
|
|
5133
|
+
}
|
|
5134
|
+
}
|
|
5135
|
+
for (const st of sf.statements) {
|
|
5136
|
+
if (ts.isImportDeclaration(st) || ts.isImportEqualsDeclaration(st)) continue;
|
|
5137
|
+
if (ts.isExportDeclaration(st)) continue;
|
|
5138
|
+
if (ts.isExportAssignment(st)) {
|
|
5139
|
+
const ex = st.expression;
|
|
5140
|
+
if (ts.isArrowFunction(ex) || ts.isFunctionExpression(ex)) {
|
|
5141
|
+
visitFunctionLike(ex);
|
|
5142
|
+
}
|
|
5143
|
+
continue;
|
|
5144
|
+
}
|
|
5145
|
+
visitStmt(st);
|
|
5146
|
+
}
|
|
5147
|
+
return dedupeSorted(lines);
|
|
5148
|
+
}
|
|
5149
|
+
function dedupeSorted(nums) {
|
|
5150
|
+
return [...new Set(nums)].sort((a3, b3) => a3 - b3);
|
|
5151
|
+
}
|
|
5152
|
+
function isBodyExpensive(body) {
|
|
5153
|
+
if (!body) return false;
|
|
5154
|
+
if (ts.isBlock(body)) return blockHasExpensiveLoop(body);
|
|
5155
|
+
return nodeHasExpensiveLoop(body);
|
|
5156
|
+
}
|
|
5157
|
+
function blockHasExpensiveLoop(block) {
|
|
5158
|
+
return nodeHasExpensiveLoop(block);
|
|
5159
|
+
}
|
|
5160
|
+
function nodeHasExpensiveLoop(node) {
|
|
5161
|
+
let found = false;
|
|
5162
|
+
const visit = (n3) => {
|
|
5163
|
+
if (found) return;
|
|
5164
|
+
if (ts.isForStatement(n3) || ts.isForOfStatement(n3) || ts.isForInStatement(n3) || ts.isWhileStatement(n3) || ts.isDoStatement(n3)) {
|
|
5165
|
+
if (subtreeHasBigNumeric(n3, BIG_LITERAL_MIN)) found = true;
|
|
5166
|
+
return;
|
|
5167
|
+
}
|
|
5168
|
+
ts.forEachChild(n3, visit);
|
|
5169
|
+
};
|
|
5170
|
+
visit(node);
|
|
5171
|
+
return found;
|
|
5172
|
+
}
|
|
5173
|
+
function subtreeHasBigNumeric(node, min) {
|
|
5174
|
+
let found = false;
|
|
5175
|
+
const visit = (n3) => {
|
|
5176
|
+
if (found) return;
|
|
5177
|
+
if (ts.isNumericLiteral(n3)) {
|
|
5178
|
+
const v3 = Number(n3.text.replace(/_/g, ""));
|
|
5179
|
+
if (Number.isFinite(v3) && v3 >= min) {
|
|
5180
|
+
found = true;
|
|
5181
|
+
return;
|
|
5182
|
+
}
|
|
5183
|
+
}
|
|
5184
|
+
ts.forEachChild(n3, visit);
|
|
5185
|
+
};
|
|
5186
|
+
visit(node);
|
|
5187
|
+
return found;
|
|
5188
|
+
}
|
|
5189
|
+
|
|
5190
|
+
// src/lib/ai-decorators.ts
|
|
5191
|
+
var FILE_SCAN_HEAD_LINES = 40;
|
|
5192
|
+
function commentInner(line) {
|
|
5193
|
+
const t3 = line.trim();
|
|
5194
|
+
const mLine = /^\/\/\s*(.*)$/.exec(t3);
|
|
5195
|
+
if (mLine) return mLine[1]?.trim() ?? "";
|
|
5196
|
+
const mBlock = /^\/\*\s*(.*?)\s*\*\/\s*$/.exec(t3);
|
|
5197
|
+
if (mBlock) return mBlock[1]?.trim() ?? "";
|
|
5198
|
+
return null;
|
|
5199
|
+
}
|
|
5200
|
+
function lineMarkerKind(line) {
|
|
5201
|
+
const inner = commentInner(line);
|
|
5202
|
+
if (!inner) return null;
|
|
5203
|
+
if (/@(?:frontguard-ai|ai-written)\s*:\s*file\b/i.test(inner) || /^written\s+by\s+ai\s*:?\s*file\b/i.test(inner))
|
|
5204
|
+
return "file";
|
|
5205
|
+
if (/@(?:frontguard-ai|ai-written)\s*:\s*start\b/i.test(inner) || /^written\s+by\s+ai\s*:?\s*start\b/i.test(inner))
|
|
5206
|
+
return "start";
|
|
5207
|
+
if (/@(?:frontguard-ai|ai-written)\s*:\s*end\b/i.test(inner) || /^written\s+by\s+ai\s*:?\s*end\b/i.test(inner))
|
|
5208
|
+
return "end";
|
|
5209
|
+
return null;
|
|
5210
|
+
}
|
|
5211
|
+
function isAiFileDirectiveLine(line) {
|
|
5212
|
+
return lineMarkerKind(line) === "file";
|
|
5213
|
+
}
|
|
5214
|
+
function parseAiMarkedRegions(source, fileHint) {
|
|
5215
|
+
const lines = source.split(/\r?\n/);
|
|
5216
|
+
const parseWarnings = [];
|
|
5217
|
+
const regions = [];
|
|
5218
|
+
let headNonEmpty = 0;
|
|
5219
|
+
for (let i3 = 0; i3 < lines.length && headNonEmpty < FILE_SCAN_HEAD_LINES; i3++) {
|
|
5220
|
+
if (!lines[i3]?.trim()) continue;
|
|
5221
|
+
headNonEmpty++;
|
|
5222
|
+
if (isAiFileDirectiveLine(lines[i3] ?? "")) {
|
|
5223
|
+
const bodyLines = lines.filter((_4, idx) => idx !== i3);
|
|
5224
|
+
regions.push({
|
|
5225
|
+
startLine: 1,
|
|
5226
|
+
endLine: lines.length,
|
|
5227
|
+
text: bodyLines.join("\n")
|
|
5228
|
+
});
|
|
5229
|
+
return { regions, parseWarnings };
|
|
5230
|
+
}
|
|
5231
|
+
}
|
|
5232
|
+
let open = false;
|
|
5233
|
+
let contentStart = null;
|
|
5234
|
+
const buf = [];
|
|
5235
|
+
for (let i3 = 0; i3 < lines.length; i3++) {
|
|
5236
|
+
const line = lines[i3] ?? "";
|
|
5237
|
+
const kind = lineMarkerKind(line);
|
|
5238
|
+
if (kind === "file") {
|
|
5239
|
+
parseWarnings.push(`${fileHint}:${i3 + 1}: @file directive ignored (only honored in first ${FILE_SCAN_HEAD_LINES} non-empty lines)`);
|
|
5240
|
+
if (open) buf.push(line);
|
|
5241
|
+
continue;
|
|
5242
|
+
}
|
|
5243
|
+
if (kind === "start") {
|
|
5244
|
+
if (open) {
|
|
5245
|
+
parseWarnings.push(`${fileHint}:${i3 + 1}: nested AI:start ignored (flatten your regions)`);
|
|
5246
|
+
buf.push(line);
|
|
5247
|
+
continue;
|
|
5248
|
+
}
|
|
5249
|
+
open = true;
|
|
5250
|
+
contentStart = i3 + 2;
|
|
5251
|
+
buf.length = 0;
|
|
5252
|
+
continue;
|
|
5253
|
+
}
|
|
5254
|
+
if (kind === "end") {
|
|
5255
|
+
if (!open) {
|
|
5256
|
+
parseWarnings.push(`${fileHint}:${i3 + 1}: stray AI:end`);
|
|
5257
|
+
continue;
|
|
5258
|
+
}
|
|
5259
|
+
open = false;
|
|
5260
|
+
if (contentStart !== null) {
|
|
5261
|
+
regions.push({
|
|
5262
|
+
startLine: contentStart,
|
|
5263
|
+
endLine: i3,
|
|
5264
|
+
text: buf.join("\n")
|
|
5265
|
+
});
|
|
5266
|
+
}
|
|
5267
|
+
contentStart = null;
|
|
5268
|
+
buf.length = 0;
|
|
5269
|
+
continue;
|
|
5270
|
+
}
|
|
5271
|
+
if (open) buf.push(line);
|
|
5272
|
+
}
|
|
5273
|
+
if (open && contentStart !== null) {
|
|
5274
|
+
parseWarnings.push(`${fileHint}: unclosed AI:start \u2014 treating region as ending at EOF`);
|
|
5275
|
+
regions.push({
|
|
5276
|
+
startLine: contentStart,
|
|
5277
|
+
endLine: lines.length,
|
|
5278
|
+
text: buf.join("\n")
|
|
5279
|
+
});
|
|
5280
|
+
}
|
|
5281
|
+
return { regions, parseWarnings };
|
|
5282
|
+
}
|
|
5283
|
+
function matchLineNumbersInRegion(regionText, regionStartLine, re) {
|
|
5284
|
+
const flags = re.flags.includes("g") ? re.flags : `${re.flags}g`;
|
|
5285
|
+
const g4 = new RegExp(re.source, flags);
|
|
5286
|
+
const out = [];
|
|
5287
|
+
let m3;
|
|
5288
|
+
const text = regionText;
|
|
5289
|
+
while ((m3 = g4.exec(text)) !== null) {
|
|
5290
|
+
const lineInRegion = text.slice(0, m3.index).split("\n").length;
|
|
5291
|
+
out.push(regionStartLine + lineInRegion - 1);
|
|
5292
|
+
if (m3.index === g4.lastIndex) g4.lastIndex++;
|
|
5293
|
+
}
|
|
5294
|
+
return out;
|
|
5295
|
+
}
|
|
5296
|
+
|
|
5297
|
+
// src/checks/ai-assisted-strict.ts
|
|
5013
5298
|
function sev(gate) {
|
|
5014
5299
|
return gate === "block" ? "block" : gate === "info" ? "info" : "warn";
|
|
5015
5300
|
}
|
|
@@ -5071,8 +5356,47 @@ var PATTERNS2 = [
|
|
|
5071
5356
|
id: "ai-sql-template",
|
|
5072
5357
|
re: /(?:query|execute|raw)\s*\(\s*[`'"][^`'"]*\$\{/i,
|
|
5073
5358
|
message: "Possible dynamic SQL/string \u2014 ensure parameterization, not string concat."
|
|
5359
|
+
},
|
|
5360
|
+
/**
|
|
5361
|
+
* Classic C-style loop with `<= ...length` — often an off-by-one with `charAt(i)` / array indexing
|
|
5362
|
+
* (index `length` is out of range). Prefer `< length`, `for...of`, or `Array.from`.
|
|
5363
|
+
*/
|
|
5364
|
+
{
|
|
5365
|
+
id: "ai-for-lte-length",
|
|
5366
|
+
re: /for\s*\(\s*(?:let|var|const)\s+\w+\s*=\s*\d+\s*;\s*\w+\s*<=\s*[^;)]+\.length\s*;/,
|
|
5367
|
+
message: "Loop condition uses `<= ...length` \u2014 often an extra iteration (e.g. `charAt(length)` is empty). Prefer `< ...length` or a safer iteration style."
|
|
5368
|
+
},
|
|
5369
|
+
/** AI often pastes broad eslint suppression without review. */
|
|
5370
|
+
{
|
|
5371
|
+
id: "ai-eslint-disable",
|
|
5372
|
+
re: /eslint-disable(?:-next-line|-line)?\b/i,
|
|
5373
|
+
message: "ESLint disable in AI-marked code \u2014 confirm the rule violation is understood and cannot be fixed properly."
|
|
5074
5374
|
}
|
|
5075
5375
|
];
|
|
5376
|
+
function scanRegion(rel, regionText, regionStartLine, gate, tag, findings) {
|
|
5377
|
+
for (const { id, re, message, forceBlock } of PATTERNS2) {
|
|
5378
|
+
const lines = matchLineNumbersInRegion(regionText, regionStartLine, re);
|
|
5379
|
+
for (const line of lines) {
|
|
5380
|
+
findings.push({
|
|
5381
|
+
id,
|
|
5382
|
+
severity: forceBlock ? "block" : sev(gate),
|
|
5383
|
+
message: `${tag} ${message}`,
|
|
5384
|
+
file: rel,
|
|
5385
|
+
detail: `line ${line}`
|
|
5386
|
+
});
|
|
5387
|
+
}
|
|
5388
|
+
}
|
|
5389
|
+
const astLines = scanDiscardedExpensiveCalls(regionText, rel, regionStartLine);
|
|
5390
|
+
for (const line of astLines) {
|
|
5391
|
+
findings.push({
|
|
5392
|
+
id: "ai-discarded-expensive-call",
|
|
5393
|
+
severity: sev(gate),
|
|
5394
|
+
message: `${tag} Call discards the return value of a local function whose body includes a loop with a very large numeric bound \u2014 likely wasted CPU on every run (e.g. remove the call or move work to an effect / memo with a real dependency).`,
|
|
5395
|
+
file: rel,
|
|
5396
|
+
detail: `line ${line}`
|
|
5397
|
+
});
|
|
5398
|
+
}
|
|
5399
|
+
}
|
|
5076
5400
|
async function runAiAssistedStrict(cwd, config, pr) {
|
|
5077
5401
|
const t0 = performance.now();
|
|
5078
5402
|
const cfg = config.checks.aiAssistedReview;
|
|
@@ -5092,35 +5416,62 @@ async function runAiAssistedStrict(cwd, config, pr) {
|
|
|
5092
5416
|
skipped: "no PR context (Bitbucket PR pipeline + BITBUCKET_PR_ID required)"
|
|
5093
5417
|
};
|
|
5094
5418
|
}
|
|
5095
|
-
|
|
5419
|
+
const mode = cfg.strictScanMode ?? "both";
|
|
5420
|
+
const gate = cfg.gate;
|
|
5421
|
+
const files = (pr.files ?? []).filter((f4) => CODE_EXT.test(f4)).slice(0, 150);
|
|
5422
|
+
const byRel = /* @__PURE__ */ new Map();
|
|
5423
|
+
let anyDecoratorInPr = false;
|
|
5424
|
+
for (const rel of files) {
|
|
5425
|
+
const full = path5.join(cwd, rel);
|
|
5426
|
+
try {
|
|
5427
|
+
const content = await fs.readFile(full, "utf8");
|
|
5428
|
+
if (content.length > 5e5) continue;
|
|
5429
|
+
const parsed = parseAiMarkedRegions(content, rel);
|
|
5430
|
+
byRel.set(rel, { content, parsed });
|
|
5431
|
+
if (parsed.regions.length > 0) anyDecoratorInPr = true;
|
|
5432
|
+
} catch {
|
|
5433
|
+
continue;
|
|
5434
|
+
}
|
|
5435
|
+
}
|
|
5436
|
+
if (mode === "decorator" && !anyDecoratorInPr) {
|
|
5096
5437
|
return {
|
|
5097
5438
|
checkId: "ai-assisted-strict",
|
|
5098
5439
|
findings: [],
|
|
5099
5440
|
durationMs: Math.round(performance.now() - t0),
|
|
5100
|
-
skipped: "
|
|
5441
|
+
skipped: "strictScanMode=decorator \u2014 no `@frontguard-ai:start` / `written by AI: start` regions in PR files"
|
|
5101
5442
|
};
|
|
5102
5443
|
}
|
|
5103
|
-
|
|
5104
|
-
|
|
5444
|
+
if (mode === "pr-disclosure" && !pr.aiAssisted) {
|
|
5445
|
+
return {
|
|
5446
|
+
checkId: "ai-assisted-strict",
|
|
5447
|
+
findings: [],
|
|
5448
|
+
durationMs: Math.round(performance.now() - t0),
|
|
5449
|
+
skipped: "strictScanMode=pr-disclosure \u2014 PR does not indicate AI-assisted code"
|
|
5450
|
+
};
|
|
5451
|
+
}
|
|
5452
|
+
const useWholeFileFallback = (mode === "pr-disclosure" || mode === "both") && Boolean(pr.aiAssisted);
|
|
5105
5453
|
const findings = [];
|
|
5106
5454
|
for (const rel of files) {
|
|
5107
|
-
const
|
|
5108
|
-
|
|
5109
|
-
|
|
5110
|
-
|
|
5111
|
-
} catch {
|
|
5112
|
-
continue;
|
|
5113
|
-
}
|
|
5114
|
-
if (content.length > 5e5) continue;
|
|
5115
|
-
for (const { id, re, message, forceBlock } of PATTERNS2) {
|
|
5116
|
-
if (!re.test(content)) continue;
|
|
5455
|
+
const entry = byRel.get(rel);
|
|
5456
|
+
if (!entry) continue;
|
|
5457
|
+
const { content, parsed } = entry;
|
|
5458
|
+
for (const w3 of parsed.parseWarnings) {
|
|
5117
5459
|
findings.push({
|
|
5118
|
-
id,
|
|
5119
|
-
severity:
|
|
5120
|
-
message: `[AI
|
|
5460
|
+
id: "ai-decorator-parse",
|
|
5461
|
+
severity: "info",
|
|
5462
|
+
message: `[AI markers] ${w3}`,
|
|
5121
5463
|
file: rel
|
|
5122
5464
|
});
|
|
5123
5465
|
}
|
|
5466
|
+
if (parsed.regions.length > 0) {
|
|
5467
|
+
for (const r4 of parsed.regions) {
|
|
5468
|
+
scanRegion(rel, r4.text, r4.startLine, gate, "[AI-marked code]", findings);
|
|
5469
|
+
}
|
|
5470
|
+
continue;
|
|
5471
|
+
}
|
|
5472
|
+
if (useWholeFileFallback) {
|
|
5473
|
+
scanRegion(rel, content, 1, gate, "[AI-assisted strict]", findings);
|
|
5474
|
+
}
|
|
5124
5475
|
}
|
|
5125
5476
|
return {
|
|
5126
5477
|
checkId: "ai-assisted-strict",
|
|
@@ -5132,7 +5483,7 @@ function dedupe(f4) {
|
|
|
5132
5483
|
const s3 = /* @__PURE__ */ new Set();
|
|
5133
5484
|
const out = [];
|
|
5134
5485
|
for (const x3 of f4) {
|
|
5135
|
-
const k3 = `${x3.id}:${x3.file ?? ""}`;
|
|
5486
|
+
const k3 = `${x3.id}:${x3.file ?? ""}:${x3.detail ?? ""}`;
|
|
5136
5487
|
if (s3.has(k3)) continue;
|
|
5137
5488
|
s3.add(k3);
|
|
5138
5489
|
out.push(x3);
|
|
@@ -5168,6 +5519,26 @@ function escapeHtml(s3) {
|
|
|
5168
5519
|
return s3.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
5169
5520
|
}
|
|
5170
5521
|
|
|
5522
|
+
// src/report/check-descriptions.ts
|
|
5523
|
+
var CHECK_DESCRIPTIONS = {
|
|
5524
|
+
eslint: "Runs ESLint using your project config. Flags style, correctness, and framework-specific issues in the repo or PR-scoped files.",
|
|
5525
|
+
prettier: "Checks that files match Prettier formatting. Catches unformatted edits so the codebase stays consistent.",
|
|
5526
|
+
typescript: "Runs the TypeScript compiler (tsc --noEmit) on the project. Surfaces type errors before merge.",
|
|
5527
|
+
secrets: "Scans changed files for patterns that look like leaked secrets (tokens, keys). Heuristic \u2014 review each hit.",
|
|
5528
|
+
cycles: "Runs madge for circular dependencies on TypeScript/JavaScript entry points. Import cycles can cause brittle builds and load order bugs.",
|
|
5529
|
+
"dead-code": "Runs ts-prune to find unused exports in the TypeScript project. Helps trim dead surface area.",
|
|
5530
|
+
bundle: "Measures total size of configured build artifacts (glob) and compares to a checked-in baseline. Flags large regressions in shipped JS/CSS.",
|
|
5531
|
+
"core-web-vitals": "Static hints in JSX/TSX related to Core Web Vitals (e.g. LCP-friendly images, main-thread hygiene). Not a substitute for real field metrics.",
|
|
5532
|
+
"ai-assisted-strict": "When the PR is AI-assisted or code is marked with @frontguard-ai decorators, scans those regions for risky patterns (eval, XSS sinks, etc.) and a few AST heuristics (e.g. discarded hot loops).",
|
|
5533
|
+
"pr-hygiene": "Validates PR metadata when CI provides PR context: description length, checklist items, and similar hygiene rules from config.",
|
|
5534
|
+
"pr-size": "Compares PR diff size (lines/files) against configured budgets to discourage oversized changes.",
|
|
5535
|
+
"ts-any-delta": "Diffs the branch against a base ref and counts newly added uses of the TypeScript any type. Helps stop gradual loss of type safety.",
|
|
5536
|
+
"custom-rules": "Runs optional file/content rules you define in FrontGuard config (regex or structured checks on paths). Skipped when no rules are configured."
|
|
5537
|
+
};
|
|
5538
|
+
function getCheckDescription(checkId) {
|
|
5539
|
+
return CHECK_DESCRIPTIONS[checkId] ?? `FrontGuard check "${checkId}". See your frontguard config and docs for behavior.`;
|
|
5540
|
+
}
|
|
5541
|
+
|
|
5171
5542
|
// src/report/html-report.ts
|
|
5172
5543
|
function parseLineHint(detail) {
|
|
5173
5544
|
if (!detail) return 0;
|
|
@@ -5233,7 +5604,10 @@ function buildHtmlReport(p2) {
|
|
|
5233
5604
|
const riskClass = riskScore === "LOW" ? "risk-low" : riskScore === "MEDIUM" ? "risk-med" : "risk-high";
|
|
5234
5605
|
const checkRows = results.map((r4) => {
|
|
5235
5606
|
const status = r4.skipped ? `Skipped \u2014 ${escapeHtml(r4.skipped)}` : r4.findings.length === 0 ? "Pass" : `${r4.findings.length} issue(s)`;
|
|
5236
|
-
|
|
5607
|
+
const help = escapeHtml(getCheckDescription(r4.checkId));
|
|
5608
|
+
const ariaWhat = escapeHtml(`What does the ${r4.checkId} check do?`);
|
|
5609
|
+
const checkTitle = `<span class="check-title-cell"><strong class="check-name">${escapeHtml(r4.checkId)}</strong><span class="check-info-wrap"><button type="button" class="check-info" title="${help}" aria-label="${ariaWhat}">i</button><span class="check-tooltip" role="tooltip">${help}</span></span></span>`;
|
|
5610
|
+
return `<tr><td class="td-icon">${statusDot(r4)}</td><td class="td-check">${checkTitle}</td><td class="td-status">${status}</td><td class="td-num">${r4.skipped ? "\u2014" : r4.findings.length}</td><td class="td-time">${formatDuration(r4.durationMs)}</td></tr>`;
|
|
5237
5611
|
}).join("\n");
|
|
5238
5612
|
const blockItems = sortFindings(
|
|
5239
5613
|
cwd,
|
|
@@ -5403,8 +5777,83 @@ function buildHtmlReport(p2) {
|
|
|
5403
5777
|
letter-spacing: 0.04em;
|
|
5404
5778
|
}
|
|
5405
5779
|
.td-icon { width: 2rem; vertical-align: middle; }
|
|
5780
|
+
.td-check { vertical-align: middle; }
|
|
5406
5781
|
.td-num, .td-time { color: var(--muted); font-variant-numeric: tabular-nums; }
|
|
5782
|
+
.check-title-cell {
|
|
5783
|
+
display: inline-flex;
|
|
5784
|
+
align-items: center;
|
|
5785
|
+
gap: 0.35rem;
|
|
5786
|
+
flex-wrap: nowrap;
|
|
5787
|
+
}
|
|
5407
5788
|
.check-name { font-weight: 600; }
|
|
5789
|
+
.check-info-wrap {
|
|
5790
|
+
position: relative;
|
|
5791
|
+
display: inline-flex;
|
|
5792
|
+
align-items: center;
|
|
5793
|
+
flex-shrink: 0;
|
|
5794
|
+
}
|
|
5795
|
+
.check-info {
|
|
5796
|
+
display: inline-flex;
|
|
5797
|
+
align-items: center;
|
|
5798
|
+
justify-content: center;
|
|
5799
|
+
width: 1.125rem;
|
|
5800
|
+
height: 1.125rem;
|
|
5801
|
+
padding: 0;
|
|
5802
|
+
margin: 0;
|
|
5803
|
+
border: 1px solid var(--border);
|
|
5804
|
+
border-radius: 50%;
|
|
5805
|
+
background: #f1f5f9;
|
|
5806
|
+
color: var(--muted);
|
|
5807
|
+
font-size: 0.62rem;
|
|
5808
|
+
font-weight: 700;
|
|
5809
|
+
font-style: normal;
|
|
5810
|
+
line-height: 1;
|
|
5811
|
+
cursor: help;
|
|
5812
|
+
flex-shrink: 0;
|
|
5813
|
+
}
|
|
5814
|
+
.check-info:hover,
|
|
5815
|
+
.check-info:focus-visible {
|
|
5816
|
+
border-color: var(--accent);
|
|
5817
|
+
color: var(--accent);
|
|
5818
|
+
background: var(--accent-soft);
|
|
5819
|
+
outline: none;
|
|
5820
|
+
}
|
|
5821
|
+
.check-tooltip {
|
|
5822
|
+
position: absolute;
|
|
5823
|
+
left: 50%;
|
|
5824
|
+
bottom: calc(100% + 8px);
|
|
5825
|
+
transform: translateX(-50%);
|
|
5826
|
+
min-width: 12rem;
|
|
5827
|
+
max-width: min(22rem, 86vw);
|
|
5828
|
+
padding: 0.55rem 0.65rem;
|
|
5829
|
+
background: var(--text);
|
|
5830
|
+
color: #f8fafc;
|
|
5831
|
+
font-size: 0.78rem;
|
|
5832
|
+
font-weight: 400;
|
|
5833
|
+
line-height: 1.45;
|
|
5834
|
+
border-radius: 6px;
|
|
5835
|
+
box-shadow: 0 4px 14px rgba(15, 23, 42, 0.18);
|
|
5836
|
+
z-index: 50;
|
|
5837
|
+
opacity: 0;
|
|
5838
|
+
visibility: hidden;
|
|
5839
|
+
pointer-events: none;
|
|
5840
|
+
transition: opacity 0.12s ease, visibility 0.12s ease;
|
|
5841
|
+
text-align: left;
|
|
5842
|
+
}
|
|
5843
|
+
.check-info-wrap:hover .check-tooltip,
|
|
5844
|
+
.check-info-wrap:focus-within .check-tooltip {
|
|
5845
|
+
opacity: 1;
|
|
5846
|
+
visibility: visible;
|
|
5847
|
+
}
|
|
5848
|
+
.check-tooltip::after {
|
|
5849
|
+
content: '';
|
|
5850
|
+
position: absolute;
|
|
5851
|
+
top: 100%;
|
|
5852
|
+
left: 50%;
|
|
5853
|
+
margin-left: -6px;
|
|
5854
|
+
border: 6px solid transparent;
|
|
5855
|
+
border-top-color: var(--text);
|
|
5856
|
+
}
|
|
5408
5857
|
.dot {
|
|
5409
5858
|
display: inline-block;
|
|
5410
5859
|
width: 8px;
|