@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 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 ts = JSON.parse(tsRaw);
2954
- if (typeof ts.compilerOptions?.strict === "boolean") {
2955
- tsStrict = ts.compilerOptions.strict;
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
- if (!pr.aiAssisted) {
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: "PR does not indicate AI-assisted code"
5441
+ skipped: "strictScanMode=decorator \u2014 no `@frontguard-ai:start` / `written by AI: start` regions in PR files"
5101
5442
  };
5102
5443
  }
5103
- const files = (pr.files ?? []).filter((f4) => CODE_EXT.test(f4)).slice(0, 150);
5104
- const gate = cfg.gate;
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 full = path5.join(cwd, rel);
5108
- let content;
5109
- try {
5110
- content = await fs.readFile(full, "utf8");
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: forceBlock ? "block" : sev(gate),
5120
- message: `[AI-assisted strict] ${message}`,
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
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
- return `<tr><td class="td-icon">${statusDot(r4)}</td><td><strong class="check-name">${escapeHtml(r4.checkId)}</strong></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>`;
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;