@westbayberry/dg 2.0.10 → 2.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.
Files changed (43) hide show
  1. package/dist/api/analyze.js +5 -3
  2. package/dist/bin/dg.js +1 -1
  3. package/dist/commands/completion.js +2 -1
  4. package/dist/commands/config.js +11 -3
  5. package/dist/commands/decisions.js +155 -0
  6. package/dist/commands/explain.js +6 -2
  7. package/dist/commands/router.js +2 -0
  8. package/dist/commands/scan.js +2 -1
  9. package/dist/commands/status.js +5 -2
  10. package/dist/config/settings.js +144 -25
  11. package/dist/decisions/apply.js +128 -0
  12. package/dist/decisions/remember-prompt.js +97 -0
  13. package/dist/install-ui/block-render.js +21 -4
  14. package/dist/install-ui/prompt.js +14 -0
  15. package/dist/launcher/install-preflight.js +126 -13
  16. package/dist/launcher/preflight-prompt.js +29 -2
  17. package/dist/launcher/run.js +14 -3
  18. package/dist/policy/cooldown.js +104 -0
  19. package/dist/policy/evaluate.js +0 -15
  20. package/dist/presentation/provenance.js +23 -0
  21. package/dist/project/dgfile.js +307 -0
  22. package/dist/proxy/enforcement.js +2 -1
  23. package/dist/proxy/metadata-map.js +25 -1
  24. package/dist/proxy/server.js +31 -2
  25. package/dist/scan/collect.js +10 -4
  26. package/dist/scan/command.js +35 -8
  27. package/dist/scan/discovery.js +66 -4
  28. package/dist/scan/render.js +35 -4
  29. package/dist/scan/scanner-report.js +31 -4
  30. package/dist/scan/staged.js +69 -10
  31. package/dist/scan-ui/LegacyApp.js +4 -4
  32. package/dist/scan-ui/components/InteractiveResultsView.js +64 -7
  33. package/dist/scan-ui/hooks/useScan.js +31 -3
  34. package/dist/scan-ui/launch.js +4 -1
  35. package/dist/scan-ui/shims.js +3 -0
  36. package/dist/scripts/detect.js +153 -0
  37. package/dist/scripts/gate.js +170 -0
  38. package/dist/scripts/rebuild.js +28 -0
  39. package/dist/setup/plan.js +36 -1
  40. package/dist/util/json-file.js +24 -0
  41. package/dist/util/tty-prompt.js +13 -6
  42. package/dist/verify/package-check.js +12 -0
  43. package/package.json +9 -1
@@ -1,5 +1,6 @@
1
- import { writeFileSync } from "node:fs";
2
- import { resolve } from "node:path";
1
+ import { statSync, writeFileSync } from "node:fs";
2
+ import { dirname, resolve } from "node:path";
3
+ import { findProjectRoot, loadDgFile, warnUnreadableDgFile } from "../project/dgfile.js";
3
4
  import { scanProject } from "./discovery.js";
4
5
  import { renderJsonReport, renderSarifReport, renderTextReport } from "./render.js";
5
6
  import { resolvePresentation } from "../presentation/mode.js";
@@ -18,12 +19,12 @@ export function runScanCommand(context) {
18
19
  const stagedTarget = parsed.sawTarget ? parsed.targetPath : null;
19
20
  const machineOutput = parsed.format !== "text" || parsed.outputPath !== null;
20
21
  if (parsed.staged && !machineOutput) {
21
- return runStagedScan({ hook: parsed.hook, targetPath: stagedTarget });
22
+ return runStagedScan({ hook: parsed.hook, targetPath: stagedTarget, useDecisions: !parsed.noDecisions });
22
23
  }
23
24
  let report;
24
25
  let outcome;
25
26
  if (parsed.staged) {
26
- const staged = stagedScanReport({ targetPath: stagedTarget });
27
+ const staged = stagedScanReport({ targetPath: stagedTarget, useDecisions: !parsed.noDecisions });
27
28
  if ("result" in staged) {
28
29
  return staged.result;
29
30
  }
@@ -58,7 +59,7 @@ export function runScanCommand(context) {
58
59
  stderr: `dg scan failed: ${error instanceof Error ? error.message : "unknown scan error"}\n`
59
60
  };
60
61
  }
61
- outcome = runScannerScan(parsed.targetPath, report);
62
+ outcome = runScannerScan(parsed.targetPath, report, process.env, parsed.noDecisions ? null : loadScanDecisions(parsed.targetPath));
62
63
  }
63
64
  if (outcome.kind === "report") {
64
65
  report = outcome.report;
@@ -99,6 +100,7 @@ function parseScanArgs(args) {
99
100
  let sawTarget = false;
100
101
  let staged = false;
101
102
  let hook = false;
103
+ let noDecisions = false;
102
104
  for (let index = 0; index < args.length; index += 1) {
103
105
  const arg = args[index];
104
106
  if (!arg) {
@@ -112,6 +114,10 @@ function parseScanArgs(args) {
112
114
  hook = true;
113
115
  continue;
114
116
  }
117
+ if (arg === "--no-decisions") {
118
+ noDecisions = true;
119
+ continue;
120
+ }
115
121
  if (arg === "--json") {
116
122
  if (format !== "text") {
117
123
  return { error: "choose only one output format" };
@@ -150,16 +156,35 @@ function parseScanArgs(args) {
150
156
  targetPath,
151
157
  sawTarget,
152
158
  staged,
153
- hook
159
+ hook,
160
+ noDecisions
154
161
  };
155
162
  }
156
163
  function usageError(message) {
157
164
  return {
158
165
  exitCode: EXIT_USAGE_VERDICT,
159
166
  stdout: "",
160
- stderr: `dg scan: ${message}. Usage: dg scan [path] [--json|--sarif] [--output <path>]\n`
167
+ stderr: `dg scan: ${message}. Usage: dg scan [path] [--json|--sarif] [--output <path>] [--no-decisions]\n`
161
168
  };
162
169
  }
170
+ function loadScanDecisions(targetPath, env = process.env) {
171
+ let dir = resolve(targetPath);
172
+ try {
173
+ if (!statSync(dir).isDirectory()) {
174
+ dir = dirname(dir);
175
+ }
176
+ }
177
+ catch {
178
+ return null;
179
+ }
180
+ const root = findProjectRoot(dir, env);
181
+ if (!root) {
182
+ return null;
183
+ }
184
+ const file = loadDgFile(root);
185
+ warnUnreadableDgFile(file);
186
+ return file.readable ? file : null;
187
+ }
163
188
  function degradeReport(report, error) {
164
189
  const status = report.status === "block" || report.status === "warn" ? report.status : "unknown";
165
190
  return { ...report, status, scannerError: error };
@@ -184,7 +209,9 @@ function renderReport(report, format, scannerUnavailable, skipNotice) {
184
209
  }
185
210
  function exitCodeForReport(report) {
186
211
  if (report.scanner) {
187
- return scanExitCode(report.scanner.action, loadUserConfig().policy.mode);
212
+ const mode = loadUserConfig().policy.mode;
213
+ const action = mode === "strict" ? report.scanner.action : report.decisions?.effectiveAction ?? report.scanner.action;
214
+ return scanExitCode(action, mode);
188
215
  }
189
216
  if (report.status === "block") {
190
217
  return 2;
@@ -1,3 +1,4 @@
1
+ import { execFileSync } from "node:child_process";
1
2
  import { readdirSync, readFileSync, statSync } from "node:fs";
2
3
  import { basename, dirname, relative, resolve, sep } from "node:path";
3
4
  const IGNORED_DIRECTORIES = new Set([
@@ -75,10 +76,71 @@ function resolveStatus(counts) {
75
76
  }
76
77
  function discoverPackageManifests(root) {
77
78
  const manifests = [];
78
- walk(root, 0, manifests);
79
+ walk(root, 0, manifests, gitIgnoredDirectories(root));
79
80
  return manifests.sort((left, right) => displayPath(root, left).localeCompare(displayPath(root, right)));
80
81
  }
81
- function walk(directory, depth, manifests) {
82
+ export function gitIgnoredDirectories(root) {
83
+ const ignored = new Set();
84
+ if (!insideGitWorkTree(root)) {
85
+ return ignored;
86
+ }
87
+ let level = [root];
88
+ for (let depth = 0; depth <= MAX_DISCOVERY_DEPTH && level.length > 0; depth++) {
89
+ const candidates = [];
90
+ for (const directory of level) {
91
+ let entries;
92
+ try {
93
+ entries = readdirSync(directory, { withFileTypes: true });
94
+ }
95
+ catch {
96
+ continue;
97
+ }
98
+ for (const entry of entries) {
99
+ if (entry.isDirectory() && !IGNORED_DIRECTORIES.has(entry.name)) {
100
+ candidates.push(resolve(directory, entry.name));
101
+ }
102
+ }
103
+ }
104
+ if (candidates.length === 0) {
105
+ break;
106
+ }
107
+ const flagged = checkIgnoreBatch(root, candidates);
108
+ for (const directory of flagged) {
109
+ ignored.add(directory);
110
+ }
111
+ level = candidates.filter((directory) => !flagged.has(directory));
112
+ }
113
+ return ignored;
114
+ }
115
+ function insideGitWorkTree(root) {
116
+ try {
117
+ const out = execFileSync("git", ["-C", root, "rev-parse", "--is-inside-work-tree"], {
118
+ encoding: "utf8",
119
+ timeout: 3000,
120
+ stdio: ["ignore", "pipe", "ignore"]
121
+ });
122
+ return out.trim() === "true";
123
+ }
124
+ catch {
125
+ return false;
126
+ }
127
+ }
128
+ function checkIgnoreBatch(root, directories) {
129
+ try {
130
+ const out = execFileSync("git", ["-C", root, "check-ignore", "--stdin", "-z"], {
131
+ input: directories.join("\0"),
132
+ encoding: "utf8",
133
+ timeout: 5000,
134
+ maxBuffer: 16 * 1024 * 1024,
135
+ stdio: ["pipe", "pipe", "ignore"]
136
+ });
137
+ return new Set(out.split("\0").filter(Boolean));
138
+ }
139
+ catch {
140
+ return new Set();
141
+ }
142
+ }
143
+ function walk(directory, depth, manifests, gitIgnored) {
82
144
  if (depth > MAX_DISCOVERY_DEPTH) {
83
145
  return;
84
146
  }
@@ -94,8 +156,8 @@ function walk(directory, depth, manifests) {
94
156
  for (const entry of entries) {
95
157
  const absolutePath = resolve(directory, entry.name);
96
158
  if (entry.isDirectory()) {
97
- if (!IGNORED_DIRECTORIES.has(entry.name)) {
98
- walk(absolutePath, depth + 1, manifests);
159
+ if (!IGNORED_DIRECTORIES.has(entry.name) && !gitIgnored.has(absolutePath)) {
160
+ walk(absolutePath, depth + 1, manifests, gitIgnored);
99
161
  }
100
162
  continue;
101
163
  }
@@ -21,17 +21,23 @@ export function renderTextReport(report, terminalWidth = terminalWidthFromEnv(),
21
21
  const cleanProjects = report.projects.filter((project) => project.findings.length === 0);
22
22
  const findingProjects = report.projects.filter((project) => project.findings.length > 0);
23
23
  const shouldCollapseProjects = report.projects.length > DETAIL_PROJECT_LIMIT;
24
+ const acknowledgedCount = report.decisions?.acknowledgedCount ?? 0;
25
+ const activeFindings = report.findings.filter((finding) => !finding.acknowledged);
24
26
  const lines = [
25
27
  "Dependency Guardian scan",
26
28
  `Target: ${report.target}`,
27
29
  `Scanning: checked ${report.summary.projectCount} project manifest${report.summary.projectCount === 1 ? "" : "s"}.`,
28
- `Status: ${theme.paint(SCAN_STATUS_ROLE[report.status], displayScanStatus(report))}`,
30
+ `Status: ${paintEffectiveStatus(report, theme)}`,
29
31
  `Projects: ${report.summary.projectCount}`,
30
32
  `Dependencies: ${report.summary.dependencyCount}`,
31
- `Findings: ${report.summary.findingCount} (${report.summary.warnCount} warn, ${report.summary.blockCount} block)`,
33
+ `Findings: ${report.summary.findingCount} (${report.summary.warnCount} warn, ${report.summary.blockCount} block)${acknowledgedCount > 0 ? ` · ${acknowledgedCount} acknowledged` : ""}`,
34
+ ...(acknowledgedCount > 0
35
+ ? [`Acknowledged: ${acknowledgedCount} warn verdict${acknowledgedCount === 1 ? "" : "s"} accepted in dg.json — run 'dg decisions' to review`]
36
+ : []),
32
37
  ...(report.scanner
33
38
  ? [`Scanner: score ${report.scanner.score}, ${report.scanner.packages.length} packages verified`]
34
39
  : []),
40
+ ...(report.scanner ? provenanceDowngradeLines(report.scanner.packages, theme) : []),
35
41
  ""
36
42
  ];
37
43
  if (report.scannerError) {
@@ -52,7 +58,7 @@ export function renderTextReport(report, terminalWidth = terminalWidthFromEnv(),
52
58
  }
53
59
  if (findingProjects.length > 0) {
54
60
  lines.push("Finding groups:");
55
- for (const group of groupFindings(report.findings)) {
61
+ for (const group of groupFindings(activeFindings)) {
56
62
  const severityLabel = theme.paint(group.severity === "block" ? "block" : "warn", group.severity.toUpperCase());
57
63
  lines.push(` ${severityLabel} ${group.id}: ${group.count} finding${group.count === 1 ? "" : "s"} across ${group.projectCount} project${group.projectCount === 1 ? "" : "s"}`);
58
64
  for (const finding of group.examples) {
@@ -94,6 +100,21 @@ export function renderTextReport(report, terminalWidth = terminalWidthFromEnv(),
94
100
  }
95
101
  return `${lines.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd()}\n`;
96
102
  }
103
+ function provenanceDowngradeLines(packages, theme) {
104
+ return packages
105
+ .filter((pkg) => pkg.provenance?.downgrade)
106
+ .map((pkg) => theme.paint("warn", `Provenance downgrades: ${pkg.name}@${pkg.version} (was attested at ${pkg.provenance.downgrade.fromVersion})`));
107
+ }
108
+ function paintEffectiveStatus(report, theme) {
109
+ if (report.scanner && report.decisions && !report.scannerError) {
110
+ const action = report.decisions.effectiveAction;
111
+ if (action === "analysis_incomplete") {
112
+ return theme.paint("unknown", "analysis_incomplete");
113
+ }
114
+ return theme.paint(SCAN_STATUS_ROLE[action], action);
115
+ }
116
+ return theme.paint(SCAN_STATUS_ROLE[report.status], displayScanStatus(report));
117
+ }
97
118
  function formatProject(project, width) {
98
119
  const lines = [];
99
120
  const version = project.version ? `@${project.version}` : "";
@@ -154,7 +175,17 @@ export function renderSarifReport(report) {
154
175
  }
155
176
  }
156
177
  }
157
- ]
178
+ ],
179
+ ...(finding.acknowledged
180
+ ? {
181
+ suppressions: [
182
+ {
183
+ kind: "external",
184
+ justification: finding.acknowledged.reason || `accepted by ${finding.acknowledged.by}`
185
+ }
186
+ ]
187
+ }
188
+ : {})
158
189
  }))
159
190
  }
160
191
  ]
@@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto";
3
3
  import { existsSync } from "node:fs";
4
4
  import { resolve } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
+ import { applyDecisions, packageKey } from "../decisions/apply.js";
6
7
  import { sanitize } from "../security/sanitize.js";
7
8
  import { collectScanPackages, discoverScanProjects } from "./collect.js";
8
9
  const WORKER_TIMEOUT_BASE_MS = 180_000;
@@ -12,7 +13,7 @@ const WORKER_MAX_BUFFER = 64 * 1024 * 1024;
12
13
  export function scanWorkerTimeoutMs(packageCount) {
13
14
  return WORKER_TIMEOUT_BASE_MS + packageCount * Math.ceil(SERVER_PER_PACKAGE_WORST_CASE_MS / SERVER_SCAN_CONCURRENCY);
14
15
  }
15
- export function runScannerScan(targetPath, localReport, env = process.env) {
16
+ export function runScannerScan(targetPath, localReport, env = process.env, decisionsFile = null) {
16
17
  const projects = discoverScanProjects(resolve(targetPath));
17
18
  if (projects.length === 0) {
18
19
  return { kind: "skipped", reason: "no_lockfiles" };
@@ -58,12 +59,38 @@ export function runScannerScan(targetPath, localReport, env = process.env) {
58
59
  error: { kind: "invalid_response", message: "scanner worker returned unreadable output" }
59
60
  };
60
61
  }
61
- return { kind: "report", report: buildScannerReport(localReport, response, total) };
62
+ const report = buildScannerReport(localReport, response, total);
63
+ if (!decisionsFile?.readable) {
64
+ return { kind: "report", report };
65
+ }
66
+ const ecosystems = new Map();
67
+ for (const group of groups) {
68
+ for (const pkg of group.packages) {
69
+ ecosystems.set(packageKey(pkg.name, pkg.version), group.ecosystem);
70
+ }
71
+ }
72
+ return { kind: "report", report: annotateReportWithDecisions(report, decisionsFile, ecosystems) };
62
73
  }
63
- export function tryScannerScan(targetPath, localReport, env = process.env) {
64
- const outcome = runScannerScan(targetPath, localReport, env);
74
+ export function tryScannerScan(targetPath, localReport, env = process.env, decisionsFile = null) {
75
+ const outcome = runScannerScan(targetPath, localReport, env, decisionsFile);
65
76
  return outcome.kind === "report" ? outcome.report : null;
66
77
  }
78
+ export function annotateReportWithDecisions(report, decisionsFile, ecosystems) {
79
+ if (!report.scanner || !decisionsFile.readable) {
80
+ return report;
81
+ }
82
+ const applied = applyDecisions(report.scanner.packages, (pkg) => ecosystems.get(packageKey(pkg.name, pkg.version)), decisionsFile, report.scanner.action);
83
+ const findings = report.findings.map((finding) => {
84
+ const acknowledged = applied.packages[finding.location]?.acknowledged;
85
+ return acknowledged && finding.severity === "warn" ? { ...finding, acknowledged } : finding;
86
+ });
87
+ return {
88
+ ...report,
89
+ findings,
90
+ summary: { ...report.summary, acknowledgedCount: applied.acknowledgedCount },
91
+ decisions: applied
92
+ };
93
+ }
67
94
  export function workerFailure(worker, timeoutMs, packageCount) {
68
95
  if (worker.error?.code === "ETIMEDOUT") {
69
96
  return {
@@ -4,6 +4,9 @@ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "nod
4
4
  import { createTheme } from "../presentation/theme.js";
5
5
  import { resolvePresentation } from "../presentation/mode.js";
6
6
  import { loadUserConfig } from "../config/settings.js";
7
+ import { offerRememberSync } from "../decisions/remember-prompt.js";
8
+ import { packageKey } from "../decisions/apply.js";
9
+ import { loadDgFile, resolveAcceptedBy, warnUnreadableDgFile } from "../project/dgfile.js";
7
10
  import { gitSync, gitTrimmed } from "../util/git.js";
8
11
  import { promptYesNo } from "../util/tty-prompt.js";
9
12
  import { GUARD_SELFTEST_ENV } from "../setup/git-hook.js";
@@ -85,16 +88,21 @@ export function runStagedScan(options) {
85
88
  if (scoped.length === 0) {
86
89
  return { exitCode: 0, stdout: "", stderr: "" };
87
90
  }
91
+ const dgFile = options.useDecisions === false ? null : loadDgFile(root);
92
+ if (dgFile) {
93
+ warnUnreadableDgFile(dgFile);
94
+ }
95
+ const usableDgFile = dgFile?.readable ? dgFile : null;
88
96
  const { dir, count } = materializeStaged(scoped, cwd, env);
89
97
  try {
90
98
  if (count === 0) {
91
99
  return failOpen(theme, "could not read the staged lockfile contents");
92
100
  }
93
- const report = tryScannerScan(dir, emptyLocalReport(dir), env);
101
+ const report = tryScannerScan(dir, emptyLocalReport(dir), env, usableDgFile);
94
102
  if (!report || !report.scanner) {
95
103
  return failOpen(theme, "could not reach the scanner");
96
104
  }
97
- return decideStagedVerdict(report, env, options.hook);
105
+ return decideStagedVerdict(report, env, options.hook, usableDgFile ? { root, file: usableDgFile } : undefined);
98
106
  }
99
107
  finally {
100
108
  rmSync(dir, { recursive: true, force: true });
@@ -119,12 +127,16 @@ export function stagedScanReport(options) {
119
127
  if (scoped.length === 0) {
120
128
  return { report: base, outcome: { kind: "skipped", reason: "no_lockfiles" } };
121
129
  }
130
+ const dgFile = options.useDecisions === false ? null : loadDgFile(root);
131
+ if (dgFile) {
132
+ warnUnreadableDgFile(dgFile);
133
+ }
122
134
  const { dir, count } = materializeStaged(scoped, cwd, env);
123
135
  try {
124
136
  if (count === 0) {
125
137
  return { report: base, outcome: { kind: "failed", error: { kind: "worker", message: "could not read the staged lockfile contents" } } };
126
138
  }
127
- return { report: base, outcome: runScannerScan(dir, base, env) };
139
+ return { report: base, outcome: runScannerScan(dir, base, env, dgFile?.readable ? dgFile : null) };
128
140
  }
129
141
  finally {
130
142
  rmSync(dir, { recursive: true, force: true });
@@ -144,11 +156,13 @@ function notARepoResult() {
144
156
  function outsideRepoResult(targetPath) {
145
157
  return { exitCode: EXIT_USAGE_VERDICT, stdout: "", stderr: `dg scan --staged: ${targetPath} is outside this repository.\n` };
146
158
  }
147
- export function decideStagedVerdict(report, env = process.env, hook = false) {
159
+ export function decideStagedVerdict(report, env = process.env, hook = false, decisionContext) {
148
160
  const theme = createTheme(resolvePresentation().color);
149
161
  const action = report.scanner?.action ?? report.status;
150
162
  const config = loadUserConfig(env);
151
163
  const count = report.summary.dependencyCount;
164
+ const effective = config.policy.mode === "strict" ? action : report.decisions?.effectiveAction ?? action;
165
+ const acknowledgedCount = report.decisions?.acknowledgedCount ?? 0;
152
166
  if (action === "block") {
153
167
  return { exitCode: 2, stdout: "", stderr: renderBlock(report.findings, theme) };
154
168
  }
@@ -156,17 +170,61 @@ export function decideStagedVerdict(report, env = process.env, hook = false) {
156
170
  return scannerUnavailable(theme);
157
171
  }
158
172
  if (action === "warn") {
159
- return decideWarn(report.findings, theme, config.gitHook.onWarn, config.policy.mode, hook);
173
+ if (acknowledgedCount > 0 && effective === "pass") {
174
+ return {
175
+ exitCode: 0,
176
+ stdout: "",
177
+ stderr: ` ${theme.paint("pass", "✓")} ${theme.paint("muted", `DG verified ${count} staged package${count === 1 ? "" : "s"} — ${acknowledgedCount} warning${acknowledgedCount === 1 ? "" : "s"} previously accepted (dg.json)`)}\n`
178
+ };
179
+ }
180
+ if (acknowledgedCount > 0 && effective === "analysis_incomplete") {
181
+ return decideIncomplete(config.gitHook.onIncomplete, theme);
182
+ }
183
+ const activeFindings = report.findings.filter((finding) => !finding.acknowledged);
184
+ return decideWarn(activeFindings, theme, config.gitHook.onWarn, config.policy.mode, hook, stagedRememberOffer(report, decisionContext, env));
160
185
  }
161
186
  if (action === "analysis_incomplete") {
162
- if (config.gitHook.onIncomplete === "block") {
163
- return { exitCode: 1, stdout: "", stderr: incompleteNotice(theme, true) };
164
- }
165
- return { exitCode: 0, stdout: "", stderr: incompleteNotice(theme, false) };
187
+ return decideIncomplete(config.gitHook.onIncomplete, theme);
166
188
  }
167
189
  return { exitCode: 0, stdout: "", stderr: ` ${theme.paint("pass", "✓")} ${theme.paint("muted", `DG verified ${count} staged package${count === 1 ? "" : "s"} — clean`)}\n` };
168
190
  }
169
- function decideWarn(findings, theme, onWarn, policyMode, hook) {
191
+ function decideIncomplete(onIncomplete, theme) {
192
+ if (onIncomplete === "block") {
193
+ return { exitCode: 1, stdout: "", stderr: incompleteNotice(theme, true) };
194
+ }
195
+ return { exitCode: 0, stdout: "", stderr: incompleteNotice(theme, false) };
196
+ }
197
+ export function stagedRememberOffer(report, decisionContext, env) {
198
+ if (!decisionContext || !report.scanner || !report.decisions) {
199
+ return undefined;
200
+ }
201
+ const annotations = report.decisions.packages;
202
+ const packages = [];
203
+ for (const pkg of report.scanner.packages) {
204
+ if ((pkg.action ?? "pass") !== "warn") {
205
+ continue;
206
+ }
207
+ const annotation = annotations[packageKey(pkg.name, pkg.version)];
208
+ if (!annotation || annotation.acknowledged) {
209
+ continue;
210
+ }
211
+ packages.push({ ecosystem: annotation.ecosystem, name: pkg.name, version: pkg.version, findings: pkg.findings });
212
+ }
213
+ if (packages.length === 0) {
214
+ return undefined;
215
+ }
216
+ return () => {
217
+ offerRememberSync({
218
+ file: decisionContext.file,
219
+ packages,
220
+ acceptedBy: resolveAcceptedBy(decisionContext.root, env),
221
+ surface: "guard-commit",
222
+ env,
223
+ ...(decisionContext.prompts ? { prompts: decisionContext.prompts } : {})
224
+ });
225
+ };
226
+ }
227
+ function decideWarn(findings, theme, onWarn, policyMode, hook, onAccepted) {
170
228
  const summary = warnSummary(findings, theme);
171
229
  if (onWarn === "allow") {
172
230
  return { exitCode: 0, stdout: "", stderr: `${summary} ${theme.paint("muted", "proceeding (gitHook.onWarn=allow)")}\n` };
@@ -183,6 +241,7 @@ function decideWarn(findings, theme, onWarn, policyMode, hook) {
183
241
  return { exitCode: 1, stdout: "", stderr: `${summary} ${theme.paint("muted", `commit blocked (no terminal; policy ${policyMode}). Use`)} ${theme.paint("accent", "git commit --no-verify")} ${theme.paint("muted", "to override.")}\n` };
184
242
  }
185
243
  if (answer) {
244
+ onAccepted?.();
186
245
  return { exitCode: 0, stdout: "", stderr: "" };
187
246
  }
188
247
  return { exitCode: 1, stdout: "", stderr: ` ${theme.paint("muted", "Nothing was committed.")}\n` };
@@ -9,7 +9,7 @@ import { ErrorView } from "./components/ErrorView.js";
9
9
  import { ProjectSelector } from "./components/ProjectSelector.js";
10
10
  import { SetupBanner } from "./components/SetupBanner.js";
11
11
  import { useTerminalSize } from "./hooks/useTerminalSize.js";
12
- import { scanExitCode } from "./shims.js";
12
+ import { effectiveScanAction, scanExitCode } from "./shims.js";
13
13
  import { leaveTui, showCursor, tuiIsActive } from "./alt-screen.js";
14
14
  import { formatResetDate } from "../install-ui/block-render.js";
15
15
  export const App = ({ config, userStatus, scanUsage, setupIssues = [], initialView, updateAvailable }) => {
@@ -27,7 +27,7 @@ export const App = ({ config, userStatus, scanUsage, setupIssues = [], initialVi
27
27
  useEffect(() => () => leaveAltScreen(), [leaveAltScreen]);
28
28
  const handleResultsExit = useCallback(() => {
29
29
  if (state.phase === "results") {
30
- process.exitCode = scanExitCode(state.result.action, config.mode);
30
+ process.exitCode = scanExitCode(effectiveScanAction(state.result.action, state.decisions?.effectiveAction, config.mode), config.mode);
31
31
  }
32
32
  leaveAltScreen();
33
33
  exit();
@@ -62,7 +62,7 @@ export const App = ({ config, userStatus, scanUsage, setupIssues = [], initialVi
62
62
  // not only when the user dismisses the view — a piped/killed/non-TTY exit
63
63
  // must still carry the right code (an unverified scan is never a silent 0).
64
64
  if (state.phase === "results") {
65
- process.exitCode = scanExitCode(state.result.action, config.mode);
65
+ process.exitCode = scanExitCode(effectiveScanAction(state.result.action, state.decisions?.effectiveAction, config.mode), config.mode);
66
66
  }
67
67
  }, [state, config, exitWithMessage]);
68
68
  useInput((input, key) => {
@@ -97,7 +97,7 @@ export const App = ({ config, userStatus, scanUsage, setupIssues = [], initialVi
97
97
  ? `batch ${state.batchIndex}/${state.batchCount}`
98
98
  : undefined }));
99
99
  case "results":
100
- return (_jsx(InteractiveResultsView, { result: state.result, config: config, durationMs: state.durationMs, onExit: handleResultsExit, onBack: restartSelection ?? undefined, discoveredTotal: state.discoveredTotal, userStatus: userStatus, scanUsage: scanUsage, initialView: initialView }));
100
+ return (_jsx(InteractiveResultsView, { result: state.result, config: config, durationMs: state.durationMs, onExit: handleResultsExit, onBack: restartSelection ?? undefined, discoveredTotal: state.discoveredTotal, userStatus: userStatus, scanUsage: scanUsage, initialView: initialView, decisions: state.decisions }));
101
101
  case "empty":
102
102
  return _jsx(Text, { dimColor: true, children: state.message });
103
103
  case "error":