ai-saas-guard 0.44.0 → 0.45.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.
Files changed (45) hide show
  1. package/README.md +25 -18
  2. package/action.yml +4 -2
  3. package/dist/cli.js +5 -0
  4. package/dist/commands/scan.js +6 -13
  5. package/dist/context.js +3 -0
  6. package/dist/hosted/contracts.js +67 -35
  7. package/dist/hosted/production-adapters.js +2 -0
  8. package/dist/hosted/worker.js +7 -4
  9. package/dist/report/comment.js +26 -14
  10. package/dist/report/findings.d.ts +1 -1
  11. package/dist/report/findings.js +16 -1
  12. package/dist/report/launchGate.js +9 -4
  13. package/dist/report/markdown.js +131 -111
  14. package/dist/report/presentation.d.ts +8 -0
  15. package/dist/report/presentation.js +151 -0
  16. package/dist/report/sarif.js +4 -3
  17. package/dist/report/summary.js +52 -93
  18. package/dist/report/terminal.js +79 -71
  19. package/dist/rules/catalog.js +14 -0
  20. package/dist/scanners/actions.js +7 -2
  21. package/dist/scanners/apiRoutes.js +36 -1
  22. package/dist/scanners/gitDiff.d.ts +1 -0
  23. package/dist/scanners/gitDiff.js +40 -3
  24. package/dist/scanners/mcp.js +31 -1
  25. package/dist/scanners/secrets.js +4 -4
  26. package/dist/scanners/stripe.js +27 -5
  27. package/dist/scanners/supabase.js +92 -1
  28. package/dist/stackInventory.js +22 -11
  29. package/dist/utils/files.d.ts +1 -1
  30. package/dist/utils/files.js +44 -12
  31. package/docs/CODEX_HANDOFF.md +18 -4
  32. package/docs/CODEX_RECENT_CHANGES.md +40 -1
  33. package/docs/CODEX_STATE.md +37 -6
  34. package/docs/CODEX_TODO.md +34 -9
  35. package/docs/README.zh-CN.md +24 -17
  36. package/docs/demo-terminal-output.txt +23 -21
  37. package/docs/demo-terminal-screenshot.svg +16 -13
  38. package/docs/github-action.md +1 -1
  39. package/docs/project-handoff.md +12 -4
  40. package/docs/release-quality-audit-2026-07-15.md +172 -0
  41. package/docs/rules.md +2 -0
  42. package/docs/sample-launch-report.md +80 -86
  43. package/hosted/cloudflare-worker/README.md +6 -2
  44. package/hosted/cloudflare-worker/src/index.js +250 -22
  45. package/package.json +1 -1
package/README.md CHANGED
@@ -124,23 +124,30 @@ The demo command uses packaged public fixtures: `examples/demo-risky-saas` curre
124
124
  The report is designed to be read before launch or before merging an AI-heavy PR. A longer copy-paste example is in [docs/sample-launch-report.md](docs/sample-launch-report.md).
125
125
 
126
126
  ```text
127
- ai-saas-guard scan summary
128
- Findings: 20 findings: 2 critical, 6 high, 8 medium, 3 low, 1 info
129
- Launch gate: blocked: critical launch-readiness findings need review before inviting users
130
-
131
- Top risks:
132
- - CRITICAL stripe.webhook.missing-signature at app/api/stripe/webhook/route.ts:1 - Stripe webhook does not verify the Stripe signature
133
- - CRITICAL supabase.rls.broad-policy at supabase/migrations/001_accounts.sql:10 - Broad Supabase RLS policy on public.accounts
134
- - HIGH silent-success.swallowed-error at app/api/billing/checkout/route.ts:4 - Catch block may turn upstream failure into success
135
-
136
- Manual proof to run next:
137
- - Send a request without a valid Stripe signature and confirm the handler rejects it before changing entitlement state.
138
- - Run the generated two-account IDOR test and confirm User B cannot read, update, or delete User A resources.
139
- - Force the upstream billing call to fail and confirm the route returns an error, not fake success.
140
-
141
- Next steps
142
- - Fix critical and high trust-boundary findings first.
143
- - Run the manual proof steps in staging and confirm each risky path fails closed.
127
+ ai-saas-guard | SCAN SUMMARY
128
+ ----------------------------------------
129
+ Target /path/to/your-saas
130
+ Launch gate blocked: critical launch-readiness findings need review before inviting users
131
+ Findings 20 findings | 2 critical | 6 high | 8 medium | 3 low | 1 info
132
+ Decision Can a real user get access they should not have? Review auth, tenant ownership, Supabase RLS, webhook entitlement, and data mutation findings first.
133
+
134
+ TOP RISKS
135
+ 1. CRITICAL stripe.webhook.missing-signature at app/api/stripe/webhook/route.ts:1 - Stripe webhook does not verify the Stripe signature
136
+ 2. CRITICAL supabase.rls.broad-policy at supabase/migrations/001_accounts.sql:10 - Broad Supabase RLS policy on public.accounts
137
+ 3. HIGH silent-success.swallowed-error at app/api/billing/checkout/route.ts:4 - Catch block may turn upstream failure into success: app/api/billing/checkout/route.ts
138
+
139
+ MANUAL PROOF
140
+ 1. Send a request without a valid Stripe signature and confirm the handler rejects it before changing entitlement state.
141
+ 2. Run the generated two-account IDOR test and confirm User B cannot read, update, or delete User A resources.
142
+ 3. Force the upstream API, auth provider, billing provider, or database call to fail and confirm the route returns an error or disclosed degraded mode, not a fake success.
143
+
144
+ NEXT STEPS
145
+ 1. Fix critical and high trust-boundary findings first: auth/session, billing/webhook, tenant data, and silent-success paths.
146
+ 2. Run the manual proof steps above in staging and confirm each risky path fails closed.
147
+ 3. Treat low and info deploy/CI hygiene hints as cleanup after critical, high, and medium launch paths are understood.
148
+
149
+ FULL REPORT
150
+ Rerun without --summary, or use --json, --sarif, or --markdown where supported.
144
151
  ```
145
152
 
146
153
  ## What You Get
@@ -338,7 +345,7 @@ node dist/cli.js pr-risk --root /path/to/your-saas --base origin/main --json
338
345
  node dist/cli.js pr-risk --root /path/to/your-saas --base origin/main --markdown
339
346
  ```
340
347
 
341
- If `--base` cannot be resolved, `pr-risk` emits `pr-risk.diff-unavailable` instead of silently reporting a clean or empty diff. In GitHub Actions, use `actions/checkout` with `fetch-depth: 0` when you need merge-base comparison against `origin/main`.
348
+ If `--base` cannot be resolved, `pr-risk` emits `pr-risk.diff-unavailable` instead of silently reporting a clean or empty diff. `--base` accepts branch, tag, full ref, or commit SHA names only (letters, numbers, dots, underscores, slashes, hyphens); revision expressions such as `HEAD~3` are rejected so option-like or malformed values never reach git argument parsing. In GitHub Actions, use `actions/checkout` with `fetch-depth: 0` when you need merge-base comparison against `origin/main`.
342
349
 
343
350
  ## Commands
344
351
 
package/action.yml CHANGED
@@ -12,7 +12,7 @@ inputs:
12
12
  required: false
13
13
  default: ${{ github.workspace }}
14
14
  format:
15
- description: "Output format: terminal, json, sarif, markdown, or summary. Use summary for first-run CLI output, markdown for PR summaries, and sarif for code scanning."
15
+ description: "Output format: terminal, json, sarif, markdown, summary, or comment. Use summary for first-run CLI output, markdown for PR summaries, sarif for code scanning, and comment for PR-comment-ready markdown (pr-risk)."
16
16
  required: false
17
17
  default: terminal
18
18
  fail-on:
@@ -67,7 +67,7 @@ runs:
67
67
  esac
68
68
 
69
69
  case "${INPUT_FORMAT}" in
70
- terminal|json|sarif|markdown|summary) ;;
70
+ terminal|json|sarif|markdown|summary|comment) ;;
71
71
  *)
72
72
  echo "Invalid format input: ${INPUT_FORMAT}" >&2
73
73
  exit 2
@@ -102,6 +102,8 @@ runs:
102
102
  args+=("--markdown")
103
103
  elif [ "${INPUT_FORMAT}" = "summary" ]; then
104
104
  args+=("--summary")
105
+ elif [ "${INPUT_FORMAT}" = "comment" ]; then
106
+ args+=("--format" "comment")
105
107
  fi
106
108
 
107
109
  if [ "${INPUT_FAIL_ON}" != "none" ]; then
package/dist/cli.js CHANGED
@@ -8,6 +8,7 @@ import { formatMarkdownReport } from "./report/markdown.js";
8
8
  import { formatSarifReport } from "./report/sarif.js";
9
9
  import { formatSummaryReport } from "./report/summary.js";
10
10
  import { formatTerminalReport } from "./report/terminal.js";
11
+ import { isSafeGitBaseRef } from "./scanners/gitDiff.js";
11
12
  async function main(argv) {
12
13
  const args = parseArgs(argv);
13
14
  if (!args.command || args.command === "help") {
@@ -120,6 +121,9 @@ function parseArgs(argv) {
120
121
  const value = argv[index + 1];
121
122
  if (!value)
122
123
  throw new Error("--base requires a branch or ref");
124
+ if (!isSafeGitBaseRef(value)) {
125
+ throw new Error("--base must be a safe branch or ref using letters, numbers, dots, underscores, slashes, or hyphens");
126
+ }
123
127
  result.base = value;
124
128
  index += 1;
125
129
  continue;
@@ -202,6 +206,7 @@ Defaults:
202
206
  - first-run launch summary with --summary
203
207
  - project config auto-loaded from .ai-saas-guard.json when present
204
208
  - --doctor is deprecated: the Supabase RLS doctor section is always included in check-supabase output; the flag is still accepted but has no effect
209
+ - --base accepts branch, tag, full ref, or commit SHA names only (letters, numbers, dots, underscores, slashes, hyphens); revisions like HEAD~3 are rejected
205
210
  `;
206
211
  }
207
212
  main(process.argv.slice(2)).then((code) => {
@@ -16,7 +16,12 @@ export async function scanRepository(options) {
16
16
  scanSecrets(context),
17
17
  scanNextPublicEnv(context),
18
18
  checkStripe(context),
19
- stackInventory.databases.includes("supabase") ? checkSupabase(context) : createSkippedSupabaseReport(context.rootDir),
19
+ // Never gate on the stack inventory: it only recognizes standard
20
+ // layouts (supabase/ paths, package deps, policy syntax), so SQL
21
+ // migrations in non-standard paths would silently skip every Supabase
22
+ // rule while the scan still reported clear. checkSupabase already
23
+ // returns an empty report when it finds no Supabase context.
24
+ checkSupabase(context),
20
25
  checkMcp(context),
21
26
  scanApiRoutes(context),
22
27
  scanDeployConfig(context),
@@ -35,15 +40,3 @@ export async function scanRepository(options) {
35
40
  ...actionsReport.findings
36
41
  ]), { stackInventory, fileCollection: context.fileCollection });
37
42
  }
38
- function createSkippedSupabaseReport(rootDir) {
39
- return createReport("check-supabase", rootDir, [], {
40
- riskyTables: [],
41
- riskyPolicies: [],
42
- manualAuthorizationTest: [],
43
- doctor: {
44
- staticChecks: [],
45
- twoAccountVerificationSteps: [],
46
- sqlCookbook: []
47
- }
48
- });
49
- }
package/dist/context.js CHANGED
@@ -1,6 +1,9 @@
1
1
  import { collectTextFilesWithDiagnostics } from "./utils/files.js";
2
2
  export async function createScanContext(rootDir) {
3
3
  const collection = await collectTextFilesWithDiagnostics(rootDir);
4
+ if (collection.diagnostics.unreadableDirectories.includes(".")) {
5
+ throw new Error(`Could not read scan root: ${rootDir}`);
6
+ }
4
7
  const { files } = collection;
5
8
  const filesByPath = new Map(files.map((file) => [file.path, file]));
6
9
  return {
@@ -477,7 +477,7 @@ export function createCompactHostedReport(input) {
477
477
  export function createHostedCheckRunSummary(input) {
478
478
  const { report } = input;
479
479
  const totalFindings = getHostedReportFindingTotal(report);
480
- const localCliCommand = `npx ai-saas-guard@${report.scannerVersion} pr-risk --root . --base ${report.baseSha} --json`;
480
+ const localCliCommand = `npx ai-saas-guard@${safeHostedCliVersion(report.scannerVersion)} pr-risk --root . --base ${safeHostedBaseSha(report.baseSha)} --json`;
481
481
  const conclusion = resolveCheckRunConclusion(report, input.failOnSeverity);
482
482
  const launchGate = hostedLaunchGateVerdict(report);
483
483
  return {
@@ -486,7 +486,7 @@ export function createHostedCheckRunSummary(input) {
486
486
  output: {
487
487
  title: formatCheckRunTitle(totalFindings, conclusion, input.failOnSeverity),
488
488
  summary: [
489
- `Launch-risk gate: ${launchGate}. Launch gate: ${launchGate}.`,
489
+ `Launch gate: ${launchGate}. ${totalFindings} finding${totalFindings === 1 ? "" : "s"}.`,
490
490
  "Review task: inspect risk areas and files before merge.",
491
491
  "Manual proof: prove changed auth, billing, data, deploy, or tests fail closed.",
492
492
  "Boundary: selected repository only; not an AI reviewer, pentest, full audit, or certification."
@@ -1146,54 +1146,61 @@ function formatCheckRunMarkdown(report, conclusion, localCliCommand, launchGate)
1146
1146
  const filesToReview = getHostedCheckRunFiles(report);
1147
1147
  const findingLines = report.evidence.length === 0
1148
1148
  ? ["No findings in the compact hosted report."]
1149
- : [
1150
- "| Severity | Rule | Evidence |",
1151
- "| --- | --- | --- |",
1152
- ...report.evidence.map((finding) => `| ${escapeMarkdownTableCell(finding.severity)} | ${escapeMarkdownTableCell(finding.ruleId)} | ${escapeMarkdownTableCell(formatFindingLocation(finding))} |`)
1153
- ];
1149
+ : report.evidence.flatMap((finding, index) => [
1150
+ `${index + 1}. **${escapeHostedMarkdownInline(finding.severity.toUpperCase())}** ${hostedMarkdownCode(finding.ruleId)}`,
1151
+ ` - Location: ${hostedMarkdownCode(formatFindingLocation(finding))}`
1152
+ ]);
1154
1153
  return [
1155
- "### AI SaaS Guard Launch-risk gate",
1154
+ "## AI SaaS Guard",
1156
1155
  "",
1157
- "Review task: inspect risk areas and files before merge.",
1158
- "Manual proof: prove changed auth, billing, data, deploy, or tests fail closed.",
1159
- "Boundary: selected repository only; not an AI reviewer, pentest, full audit, or certification.",
1156
+ `**Launch gate:** ${escapeHostedMarkdownInline(launchGate)}`,
1160
1157
  "",
1161
- `Launch gate: ${launchGate}`,
1162
- `Conclusion: ${conclusion}`,
1163
- `Local CLI: \`${localCliCommand}\``,
1164
- `Retention: compact report ${report.retentionDays} days; no raw source, diffs, secrets, or customer payloads.`,
1158
+ `**Conclusion:** ${escapeHostedMarkdownInline(conclusion)}`,
1165
1159
  "",
1166
- "Review categories:",
1167
- ...(categories.length === 0 ? ["- None"] : categories.map((category) => `- ${category}`)),
1160
+ `**Review categories:** ${categories.length === 0 ? "None" : categories.map(hostedMarkdownCode).join(", ")}`,
1168
1161
  "",
1169
- "Verification steps:",
1170
- "- Inspect the listed files locally before release or merge.",
1171
- "- Reproduce locally with the CLI command above.",
1172
- "- Prove changed auth, billing, data, deploy, or tests fail closed.",
1162
+ "> Review task: inspect the risk areas and files below before merge.",
1163
+ "> Manual proof: prove changed auth, billing, data, deploy, or tests fail closed.",
1173
1164
  "",
1174
- "Risk areas:",
1165
+ `**Reproduce locally:** ${hostedMarkdownCode(localCliCommand)}`,
1166
+ "",
1167
+ "**Scope:** Selected repository only. No raw source, diffs, secrets, installation tokens, or customer payloads are included.",
1168
+ "",
1169
+ "### Review First",
1175
1170
  ...(riskAreas.length === 0
1176
- ? ["- None"]
1177
- : riskAreas.map((area) => `- ${area.name}: ${area.count} finding(s). Proof: ${area.proof}`)),
1171
+ ? ["No risk areas in the compact hosted report."]
1172
+ : riskAreas.map((area, index) => `${index + 1}. **${escapeHostedMarkdownInline(area.name)}** - ${area.count} finding(s). ${escapeHostedMarkdownInline(area.proof)}`)),
1173
+ "",
1174
+ "### Files",
1175
+ ...(filesToReview.length === 0
1176
+ ? ["No files in the review queue."]
1177
+ : filesToReview.map((file, index) => `${index + 1}. ${hostedMarkdownCode(file)}`)),
1178
1178
  "",
1179
- "Launch decision queue:",
1179
+ "### Findings",
1180
+ ...findingLines,
1181
+ "",
1182
+ "### Manual Proof",
1183
+ "1. Inspect each listed file locally before release or merge.",
1184
+ "2. Reproduce the result with the local CLI command below.",
1185
+ "3. Prove changed auth, billing, data, deploy, or tests fail closed.",
1186
+ "",
1187
+ "### Launch Decision Queue",
1180
1188
  "- Can a real user get access they should not have?",
1181
1189
  "- Can the app claim success when something failed?",
1182
1190
  "- Can launch infrastructure do too much damage?",
1183
1191
  "",
1184
- "Summary:",
1192
+ "### Scan Summary",
1185
1193
  ...severityOrder.map((severity) => `- ${capitalize(severity)}: ${report.summaryCounts[severity] ?? 0}`),
1186
1194
  "",
1187
- "Files to review first:",
1188
- ...(filesToReview.length === 0 ? ["- None"] : filesToReview.map((file) => `- ${file}`)),
1189
- "",
1190
- "Launch-boundary reviewer checklist:",
1195
+ "### Reviewer Checklist",
1191
1196
  "- What changed at the launch boundary?",
1192
1197
  "- Why this auth billing data or deploy decision is safe?",
1193
1198
  "- What manual test proves it fails closed?",
1194
1199
  "",
1195
- "Findings:",
1196
- ...findingLines
1200
+ "### Scope And Privacy",
1201
+ "- Boundary: selected repository only; not an AI reviewer, pentest, full audit, or certification.",
1202
+ `- Retention: compact report ${report.retentionDays} days.`,
1203
+ "- No raw source, diffs, secrets, installation tokens, or customer payloads are included."
1197
1204
  ].join("\n");
1198
1205
  }
1199
1206
  function truncateMarkdown(markdown, maxMarkdownChars) {
@@ -1207,7 +1214,11 @@ function truncateMarkdown(markdown, maxMarkdownChars) {
1207
1214
  if (maxChars <= suffix.length) {
1208
1215
  return suffix.slice(0, maxChars);
1209
1216
  }
1210
- return `${markdown.slice(0, maxChars - suffix.length).trimEnd()}${suffix}`;
1217
+ const availableChars = maxChars - suffix.length;
1218
+ const candidate = markdown.slice(0, availableChars);
1219
+ const lastLineBreak = candidate.lastIndexOf("\n");
1220
+ const safePrefix = lastLineBreak > 0 ? candidate.slice(0, lastLineBreak) : candidate;
1221
+ return `${safePrefix.trimEnd()}${suffix}`;
1211
1222
  }
1212
1223
  function severityRank(severity) {
1213
1224
  const normalized = severity.toLowerCase();
@@ -1312,8 +1323,29 @@ function categoryForRuleId(ruleId) {
1312
1323
  function getHostedCheckRunFiles(report) {
1313
1324
  return [...new Set(report.evidence.map((finding) => finding.file))].slice(0, 10);
1314
1325
  }
1315
- function escapeMarkdownTableCell(value) {
1316
- return value.replaceAll("\\", "\\\\").replaceAll("|", "\\|").replaceAll("\r", " ").replaceAll("\n", " ");
1326
+ function escapeHostedMarkdownInline(value) {
1327
+ return normalizeHostedMarkdownInline(value)
1328
+ .replaceAll("&", "&amp;")
1329
+ .replaceAll("<", "&lt;")
1330
+ .replaceAll(">", "&gt;")
1331
+ .replace(/([\\`*_[\]])/g, "\\$1")
1332
+ .replaceAll("|", "\\|");
1333
+ }
1334
+ function hostedMarkdownCode(value) {
1335
+ return `\`${normalizeHostedMarkdownInline(value).replaceAll("`", "'")}\``;
1336
+ }
1337
+ function normalizeHostedMarkdownInline(value) {
1338
+ return value
1339
+ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g, "")
1340
+ .replace(/[\r\n]+/g, " ")
1341
+ .replace(/\s+/g, " ")
1342
+ .trim();
1343
+ }
1344
+ function safeHostedCliVersion(value) {
1345
+ return /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(value) ? value : "latest";
1346
+ }
1347
+ function safeHostedBaseSha(value) {
1348
+ return /^[a-f0-9]{40}$/i.test(value) ? value : "<base-sha>";
1317
1349
  }
1318
1350
  function capitalize(value) {
1319
1351
  return `${value.charAt(0).toUpperCase()}${value.slice(1)}`;
@@ -198,6 +198,8 @@ function safeGitHubApiBaseUrl(apiBaseUrl) {
198
198
  url.password ||
199
199
  url.search ||
200
200
  url.hash ||
201
+ url.port ||
202
+ url.hostname.toLowerCase() !== "api.github.com" ||
201
203
  !isRootPath(url.pathname) ||
202
204
  isUnsafeHostedHostname(url.hostname)) {
203
205
  return undefined;
@@ -462,10 +462,13 @@ function parseRepositoryFullName(value) {
462
462
  const match = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/.exec(value);
463
463
  if (!match)
464
464
  return undefined;
465
- return {
466
- owner: match[1],
467
- repo: match[2]
468
- };
465
+ const owner = match[1];
466
+ const repo = match[2];
467
+ // Reject path-traversal segments: `.`/`..` in the clone URL path could
468
+ // resolve outside the intended repository location.
469
+ if (owner === "." || owner === ".." || repo === "." || repo === "..")
470
+ return undefined;
471
+ return { owner, repo };
469
472
  }
470
473
  function normalizeSafeCloneBaseUrl(value) {
471
474
  try {
@@ -1,4 +1,5 @@
1
1
  import { launchGateVerdict, nextSteps, reviewFirst, trustStatement } from "./launchGate.js";
2
+ import { escapeMarkdownInline, formatScanCoverage, markdownCode } from "./presentation.js";
2
3
  /**
3
4
  * `--format comment` renders a compact PR-comment-ready markdown body.
4
5
  * It is optimized for `pr-risk` (paste into a PR review), but works for
@@ -9,14 +10,27 @@ export function formatCommentReport(report) {
9
10
  return `${formatPrRiskComment(report)}\n`;
10
11
  return `${formatGenericComment(report)}\n`;
11
12
  }
13
+ function appendCoverageWarning(lines, report) {
14
+ const coverage = formatScanCoverage(report);
15
+ if (coverage) {
16
+ lines.push("");
17
+ lines.push(`**Coverage:** ${escapeMarkdownInline(coverage)}`);
18
+ }
19
+ }
12
20
  function formatPrRiskComment(report) {
13
21
  const lines = [];
14
22
  lines.push("## \u{1F6E1}\uFE0F ai-saas-guard PR risk");
15
23
  lines.push("");
16
- lines.push(`**Verdict:** ${escapeInline(launchGateVerdict(report))}`);
24
+ lines.push(`**Verdict:** ${escapeMarkdownInline(launchGateVerdict(report))}`);
25
+ appendCoverageWarning(lines, report);
26
+ const diffUnavailable = report.findings.filter((finding) => finding.ruleId === "pr-risk.diff-unavailable");
27
+ if (diffUnavailable.length > 0) {
28
+ lines.push("");
29
+ lines.push(`> \u26A0\uFE0F ${escapeMarkdownInline(diffUnavailable[0].title)}. ${escapeMarkdownInline(diffUnavailable[0].suggestedFix)}`);
30
+ }
17
31
  if (report.categories.length > 0) {
18
32
  lines.push("");
19
- lines.push(`**This PR touches:** ${report.categories.map((category) => `\`${escapeInline(category)}\``).join(", ")}`);
33
+ lines.push(`**This PR touches:** ${report.categories.map((category) => markdownCode(category)).join(", ")}`);
20
34
  }
21
35
  const topFiles = report.topRiskyFiles.slice(0, 3);
22
36
  lines.push("");
@@ -28,7 +42,7 @@ function formatPrRiskComment(report) {
28
42
  else {
29
43
  lines.push("");
30
44
  for (const [index, file] of topFiles.entries()) {
31
- lines.push(`${index + 1}. \`${escapeInline(file.path)}\` — ${file.categories.map((category) => `\`${escapeInline(category)}\``).join(", ")} (+${file.added}/-${file.removed})`);
45
+ lines.push(`${index + 1}. ${markdownCode(file.path)} — ${file.categories.map((category) => markdownCode(category)).join(", ")} (+${file.added}/-${file.removed})`);
32
46
  }
33
47
  }
34
48
  const verification = report.requiredTests.length > 0 ? report.requiredTests : report.reviewChecklist;
@@ -37,7 +51,7 @@ function formatPrRiskComment(report) {
37
51
  lines.push("### \u2705 Verify before merge");
38
52
  lines.push("");
39
53
  for (const item of verification.slice(0, 4)) {
40
- lines.push(`- ${escapeInline(item)}`);
54
+ lines.push(`- ${escapeMarkdownInline(item)}`);
41
55
  }
42
56
  }
43
57
  const split = report.suggestedSplit.filter((item) => !/no split required/i.test(item));
@@ -46,36 +60,34 @@ function formatPrRiskComment(report) {
46
60
  lines.push("### \u2702\uFE0F Suggested split");
47
61
  lines.push("");
48
62
  for (const item of split.slice(0, 3)) {
49
- lines.push(`- ${escapeInline(item)}`);
63
+ lines.push(`- ${escapeMarkdownInline(item)}`);
50
64
  }
51
65
  }
52
66
  lines.push("");
53
67
  lines.push("---");
54
- lines.push(`<sub>Posted by \`ai-saas-guard pr-risk --format comment\`. ${trustStatement().map(escapeInline).join(" ")}</sub>`);
68
+ lines.push(`<sub>Posted by \`ai-saas-guard pr-risk --format comment\`. ${trustStatement().map(escapeMarkdownInline).join(" ")}</sub>`);
55
69
  return lines.join("\n");
56
70
  }
57
71
  function formatGenericComment(report) {
58
72
  const lines = [];
59
- lines.push(`## \u{1F6E1}\uFE0F ai-saas-guard ${escapeInline(report.command)}`);
73
+ lines.push(`## \u{1F6E1}\uFE0F ai-saas-guard ${escapeMarkdownInline(report.command)}`);
60
74
  lines.push("");
61
- lines.push(`**Verdict:** ${escapeInline(launchGateVerdict(report))}`);
75
+ lines.push(`**Verdict:** ${escapeMarkdownInline(launchGateVerdict(report))}`);
76
+ appendCoverageWarning(lines, report);
62
77
  lines.push("");
63
78
  lines.push("### Review first");
64
79
  lines.push("");
65
80
  for (const item of reviewFirst(report.findings, 3)) {
66
- lines.push(`- ${escapeInline(item)}`);
81
+ lines.push(`- ${escapeMarkdownInline(item)}`);
67
82
  }
68
83
  lines.push("");
69
84
  lines.push("### Next");
70
85
  lines.push("");
71
86
  for (const item of nextSteps(report.findings).slice(0, 3)) {
72
- lines.push(`- ${escapeInline(item)}`);
87
+ lines.push(`- ${escapeMarkdownInline(item)}`);
73
88
  }
74
89
  lines.push("");
75
90
  lines.push("---");
76
- lines.push(`<sub>${trustStatement().map(escapeInline).join(" ")}</sub>`);
91
+ lines.push(`<sub>${trustStatement().map(escapeMarkdownInline).join(" ")}</sub>`);
77
92
  return lines.join("\n");
78
93
  }
79
- function escapeInline(value) {
80
- return value.replace(/\r?\n/g, " ").replaceAll("|", "\\|").trim();
81
- }
@@ -1,6 +1,6 @@
1
1
  import type { BaseReport, CommandName, Finding, Summary } from "../types.js";
2
2
  export declare function summarizeFindings(findings: Finding[]): Summary;
3
- export declare function createReport<T extends BaseReport>(command: CommandName, rootDir: string, findings: Finding[], extra: Omit<T, keyof BaseReport | "command">): T;
3
+ export declare function createReport<T extends BaseReport>(command: CommandName, rootDir: string, findings: Finding[], extra: Omit<T, keyof BaseReport | "command"> & Partial<Pick<BaseReport, "stackInventory" | "fileCollection">>): T;
4
4
  export declare function sortFindings(findings: Finding[]): Finding[];
5
5
  export declare function finding(input: Finding): Finding;
6
6
  export declare function uniqueFindings(findings: Finding[]): Finding[];
@@ -28,7 +28,19 @@ export function sortFindings(findings) {
28
28
  const severityDelta = severities.indexOf(a.severity) - severities.indexOf(b.severity);
29
29
  if (severityDelta !== 0)
30
30
  return severityDelta;
31
- return a.ruleId.localeCompare(b.ruleId);
31
+ const ruleDelta = compareText(a.ruleId, b.ruleId);
32
+ if (ruleDelta !== 0)
33
+ return ruleDelta;
34
+ const fileDelta = compareText(a.evidence[0]?.file ?? "", b.evidence[0]?.file ?? "");
35
+ if (fileDelta !== 0)
36
+ return fileDelta;
37
+ const lineDelta = (a.evidence[0]?.line ?? 0) - (b.evidence[0]?.line ?? 0);
38
+ if (lineDelta !== 0)
39
+ return lineDelta;
40
+ const columnDelta = (a.evidence[0]?.column ?? 0) - (b.evidence[0]?.column ?? 0);
41
+ if (columnDelta !== 0)
42
+ return columnDelta;
43
+ return compareText(a.title, b.title);
32
44
  });
33
45
  }
34
46
  export function finding(input) {
@@ -47,3 +59,6 @@ export function uniqueFindings(findings) {
47
59
  }
48
60
  return result;
49
61
  }
62
+ function compareText(left, right) {
63
+ return left < right ? -1 : left > right ? 1 : 0;
64
+ }
@@ -1,15 +1,20 @@
1
+ import { hasIncompleteScanCoverage } from "./presentation.js";
1
2
  export function launchGateVerdict(report) {
3
+ const coverageNote = hasIncompleteScanCoverage(report) ? "; scan coverage is incomplete" : "";
2
4
  if (report.summary.critical > 0) {
3
- return "blocked: critical launch-readiness findings need review before inviting users";
5
+ return `blocked: critical launch-readiness findings need review before inviting users${coverageNote}`;
4
6
  }
5
7
  if (report.summary.high > 0) {
6
- return "review required: high-risk launch paths need manual verification before launch";
8
+ return `review required: high-risk launch paths need manual verification before launch${coverageNote}`;
7
9
  }
8
10
  if (report.summary.medium > 0) {
9
- return "check before launch: medium-risk findings should be verified by an owner";
11
+ return `check before launch: medium-risk findings should be verified by an owner${coverageNote}`;
10
12
  }
11
13
  if (report.summary.low > 0 || report.summary.info > 0) {
12
- return "low-noise review: confirm these hints against the launch checklist";
14
+ return `low-noise review: confirm these hints against the launch checklist${coverageNote}`;
15
+ }
16
+ if (hasIncompleteScanCoverage(report)) {
17
+ return "incomplete: scan coverage was limited; no clear launch verdict is available";
13
18
  }
14
19
  return "clear from current heuristics: no findings from this command, not a certification";
15
20
  }