@westbayberry/dg 2.0.11 → 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 (40) 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/command.js +35 -8
  26. package/dist/scan/render.js +35 -4
  27. package/dist/scan/scanner-report.js +31 -4
  28. package/dist/scan/staged.js +69 -10
  29. package/dist/scan-ui/LegacyApp.js +4 -4
  30. package/dist/scan-ui/components/InteractiveResultsView.js +64 -7
  31. package/dist/scan-ui/hooks/useScan.js +31 -3
  32. package/dist/scan-ui/shims.js +3 -0
  33. package/dist/scripts/detect.js +153 -0
  34. package/dist/scripts/gate.js +170 -0
  35. package/dist/scripts/rebuild.js +28 -0
  36. package/dist/setup/plan.js +36 -1
  37. package/dist/util/json-file.js +24 -0
  38. package/dist/util/tty-prompt.js +13 -6
  39. package/dist/verify/package-check.js +12 -0
  40. package/package.json +9 -1
@@ -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":
@@ -5,11 +5,14 @@ import chalk from "chalk";
5
5
  import { writeFileSync } from "node:fs";
6
6
  import { resolve as resolvePath } from "node:path";
7
7
  import { isLoggedIn } from "../shims.js";
8
+ import { packageKey } from "../../decisions/apply.js";
8
9
  import { ScoreHeader, COMPACT_ROWS } from "./ScoreHeader.js";
9
10
  import { useExpandAnimation } from "../hooks/useExpandAnimation.js";
10
11
  import { useTerminalSize } from "../hooks/useTerminalSize.js";
11
12
  import { clearScreen } from "../alt-screen.js";
12
13
  import { pad, truncate, groupPackages as sharedGroupPackages, formatUsage } from "../format-helpers.js";
14
+ import { provenanceLabel, provenanceDowngradeLine } from "../../presentation/provenance.js";
15
+ const ACK_PREVIEW_LIMIT = 3;
13
16
  function groupPackages(packages) {
14
17
  return sharedGroupPackages(packages, "fingerprint");
15
18
  }
@@ -50,6 +53,19 @@ export function packageBadge(pkg) {
50
53
  return { label: "Unverified", color: chalk.yellow };
51
54
  return actionBadge(pkg.action);
52
55
  }
56
+ export function provenanceMarker(pkg) {
57
+ const prov = pkg.provenance;
58
+ if (!prov)
59
+ return " ";
60
+ if (prov.downgrade)
61
+ return chalk.yellow("◇ ");
62
+ if (prov.status === "attested")
63
+ return chalk.dim("◆ ");
64
+ return " ";
65
+ }
66
+ function packageDowngradeLine(pkg) {
67
+ return pkg.provenance ? provenanceDowngradeLine(pkg.version, pkg.provenance) : null;
68
+ }
53
69
  const EVIDENCE_LIMIT = 2;
54
70
  const BADGE_COL = "Unverified".length + 1;
55
71
  // Fixed lines outside the scrollable group area:
@@ -80,6 +96,8 @@ function findingsSummaryHeight(group) {
80
96
  let h = 0;
81
97
  if (rep.license)
82
98
  h += 1;
99
+ if (packageDowngradeLine(rep))
100
+ h += 1;
83
101
  if (isFree) {
84
102
  h += 1;
85
103
  }
@@ -140,6 +158,14 @@ function buildDetailLines(group, safeVersion, maxWidth) {
140
158
  lines.push(_jsxs(Text, { dimColor: true, children: ["Score: ", rep.score, "/100"] }, "score-info"));
141
159
  lines.push(_jsx(Text, { children: "" }, "score-gap"));
142
160
  }
161
+ if (rep.provenance) {
162
+ lines.push(_jsxs(Text, { dimColor: true, children: ["Provenance: ", provenanceLabel(rep.provenance)] }, "provenance"));
163
+ const downgrade = packageDowngradeLine(rep);
164
+ if (downgrade) {
165
+ lines.push(_jsx(Text, { wrap: "truncate-end", children: chalk.yellow(downgrade) }, "provenance-downgrade"));
166
+ }
167
+ lines.push(_jsx(Text, { children: "" }, "provenance-gap"));
168
+ }
143
169
  if (visibleFindings.length > 0) {
144
170
  for (let i = 0; i < visibleFindings.length; i++) {
145
171
  const f = visibleFindings[i];
@@ -194,7 +220,7 @@ function viewReducer(_state, action) {
194
220
  return { ..._state, expandedKey: action.expandedKey, expandLevel: action.expandLevel, viewport: action.viewport };
195
221
  }
196
222
  }
197
- export const InteractiveResultsView = ({ result, config: _config, durationMs, onExit, onBack, discoveredTotal, userStatus, scanUsage: scanUsageProp, initialView, }) => {
223
+ export const InteractiveResultsView = ({ result, config: _config, durationMs, onExit, onBack, discoveredTotal, userStatus, scanUsage: scanUsageProp, initialView, decisions, }) => {
198
224
  // Prefer the server's uniform usage block ("used / limit packages this
199
225
  // month"); fall back to the legacy freeScansRemaining field, then to the
200
226
  // generic placeholder from bin.ts. usageNearLimit drives the yellow + nudge.
@@ -212,7 +238,20 @@ export const InteractiveResultsView = ({ result, config: _config, durationMs, on
212
238
  const clean = useMemo(() => result.packages.filter((p) => (p.action ?? "pass") === "pass"), [result.packages]);
213
239
  const total = result.packages.length;
214
240
  const [searchQuery, setSearchQuery] = useState("");
215
- const allGroups = useMemo(() => groupPackages(flagged), [flagged]);
241
+ const ackByKey = useMemo(() => {
242
+ const map = new Map();
243
+ if (decisions) {
244
+ for (const [key, annotation] of Object.entries(decisions.packages)) {
245
+ if (annotation.acknowledged)
246
+ map.set(key, annotation.acknowledged);
247
+ }
248
+ }
249
+ return map;
250
+ }, [decisions]);
251
+ const activeFlagged = useMemo(() => flagged.filter((p) => !ackByKey.has(packageKey(p.name, p.version))), [flagged, ackByKey]);
252
+ const acked = useMemo(() => flagged.filter((p) => ackByKey.has(packageKey(p.name, p.version))), [flagged, ackByKey]);
253
+ const ackGroups = useMemo(() => groupPackages(acked), [acked]);
254
+ const allGroups = useMemo(() => groupPackages(activeFlagged), [activeFlagged]);
216
255
  const groups = useMemo(() => {
217
256
  if (!searchQuery)
218
257
  return allGroups;
@@ -223,13 +262,18 @@ export const InteractiveResultsView = ({ result, config: _config, durationMs, on
223
262
  if (members.length > 0)
224
263
  matched.push({ packages: members, key: g.key });
225
264
  }
265
+ for (const p of acked) {
266
+ if (p.name.toLowerCase().includes(q)) {
267
+ matched.push({ packages: [p], key: `ack|${p.name}@${p.version ?? ""}` });
268
+ }
269
+ }
226
270
  for (const p of clean) {
227
271
  if (p.name.toLowerCase().includes(q)) {
228
272
  matched.push({ packages: [p], key: `pass|${p.name}@${p.version ?? ""}` });
229
273
  }
230
274
  }
231
275
  return matched;
232
- }, [allGroups, clean, searchQuery]);
276
+ }, [allGroups, acked, clean, searchQuery]);
233
277
  const matchCount = useMemo(() => groups.reduce((n, g) => n + g.packages.length, 0), [groups]);
234
278
  const [view, dispatchView] = useReducer(viewReducer, {
235
279
  cursor: 0,
@@ -619,7 +663,10 @@ export const InteractiveResultsView = ({ result, config: _config, durationMs, on
619
663
  searchModeRef.current = searchMode;
620
664
  const { rows: termRows, cols: termCols } = useTerminalSize();
621
665
  const compact = termRows < COMPACT_ROWS;
622
- const availableRows = Math.max(5, termRows - (compact ? FIXED_CHROME_COMPACT : FIXED_CHROME));
666
+ const ackSectionLines = acked.length > 0
667
+ ? 2 + Math.min(ackGroups.length, ACK_PREVIEW_LIMIT) + (ackGroups.length > ACK_PREVIEW_LIMIT ? 1 : 0)
668
+ : 0;
669
+ const availableRows = Math.max(5, termRows - (compact ? FIXED_CHROME_COMPACT : FIXED_CHROME) - ackSectionLines);
623
670
  const innerWidth = Math.max(40, termCols - 6);
624
671
  const detailGroup = useMemo(() => {
625
672
  if (!detailPane)
@@ -1014,7 +1061,7 @@ export const InteractiveResultsView = ({ result, config: _config, durationMs, on
1014
1061
  const aboveCount = view.viewport;
1015
1062
  const belowCount = groups.length - visibleEnd;
1016
1063
  const lcCol = 16; // fixed width for license column
1017
- const nameCol = Math.max(20, innerWidth - BADGE_COL - 14 - lcCol);
1064
+ const nameCol = Math.max(20, innerWidth - BADGE_COL - 16 - lcCol);
1018
1065
  // Clamp cursor to valid range (groups may shrink via search filter)
1019
1066
  const clampedCursor = groups.length > 0 ? Math.min(view.cursor, groups.length - 1) : 0;
1020
1067
  // ── Export menu overlay ──
@@ -1156,8 +1203,14 @@ export const InteractiveResultsView = ({ result, config: _config, durationMs, on
1156
1203
  : (lcInfo.riskCategory === "no-license" || lcInfo.riskCategory === "unlicensed" || lcInfo.riskCategory === "network-copyleft") ? chalk.red
1157
1204
  : chalk.yellow;
1158
1205
  const arrow = level === "summary" ? "\u25BE" : "\u25B8"; // ▾ expanded, ▸ collapsed
1159
- return (_jsxs(Box, { flexDirection: "column", children: [isCursor ? (_jsxs(Text, { backgroundColor: "#1a1a2e", wrap: "truncate-end", children: [chalk.cyan("\u258C"), " ", chalk.cyan(arrow), " ", ` `, color(pad(label, BADGE_COL)), chalk.bold(pad(truncate(names, nameCol - 2), nameCol)), lcColor(pad(lcStr, lcCol)), color(scoreStr.padStart(3)), " "] })) : (_jsxs(Text, { wrap: "truncate-end", children: [` ${chalk.dim(arrow)} `, color(pad(label, BADGE_COL)), pad(truncate(names, nameCol - 2), nameCol), lcColor(pad(lcStr, lcCol)), color(scoreStr.padStart(3))] })), level === "summary" && (_jsx(FindingsSummary, { group: group, maxWidth: innerWidth - 8, maxLines: group.key === view.expandedKey ? animVisibleLines : undefined }))] }, group.key));
1160
- }), belowCount > 0 && (_jsxs(Text, { dimColor: true, children: [chalk.cyan(" \u2193"), " ", belowCount, " more below"] }))] })] })), searchQuery && groups.length === 0 && (_jsx(Text, { dimColor: true, children: ` No packages match "${searchQuery}"` })), !compact && _jsx(Text, { dimColor: true, children: chalk.dim("\u2500".repeat(Math.max(20, termCols - 4))) }), _jsxs(Box, { flexDirection: "column", paddingLeft: 1, paddingRight: 1, width: "100%", children: [discoveredTotal !== undefined && discoveredTotal > total && (_jsxs(Text, { dimColor: true, children: ["Scanned ", total, " of ", discoveredTotal, " packages"] })), _jsxs(Box, { justifyContent: "space-between", children: [clean.length > 0 ? (_jsxs(Text, { wrap: "truncate-end", children: [chalk.green("\u2713"), " ", chalk.green.bold(String(clean.length)), " ", chalk.dim(`package${clean.length !== 1 ? "s" : ""} passed`), " ", chalk.dim(`\u00b7 ${(durationMs / 1000).toFixed(1)}s`)] })) : (_jsxs(Text, { dimColor: true, children: [(durationMs / 1000).toFixed(1), "s"] })), result.freeScansRemaining !== undefined && (_jsx(Text, { dimColor: true, children: "Free tier \u00B7 dg login for higher scan limits" }))] })] }), !compact && _jsx(Text, { dimColor: true, children: chalk.dim("\u2500".repeat(Math.max(20, termCols - 4))) }), searchMode ? (_jsxs(Text, { wrap: "truncate-end", children: [" ", chalk.bold.cyan("/"), " ", searchQuery, chalk.cyan("\u2588"), " ", chalk.dim("Esc clear")] })) : exportMsg ? (_jsxs(Text, { wrap: "truncate-end", children: [" ", chalk.green(exportMsg)] })) : searchQuery ? (_jsxs(Text, { wrap: "truncate-end", children: [" ", chalk.bold.cyan("/"), " ", searchQuery, " ", chalk.dim(`${matchCount} of ${total} packages`), " ", chalk.bold.cyan("Esc"), " ", chalk.dim("clear"), " ", chalk.bold.cyan("q"), " ", chalk.dim("quit")] })) : (_jsxs(Text, { wrap: "truncate-end", children: [" ", groups.length > 0 && (_jsxs(_Fragment, { children: [chalk.bold.cyan("\u2191\u2193"), " ", chalk.dim("navigate"), " ", chalk.bold.cyan("\u23CE"), " ", chalk.dim("expand"), " "] })), total > 0 && (_jsxs(_Fragment, { children: [chalk.bold.cyan("/"), " ", chalk.dim("search"), " "] })), chalk.bold.cyan("l"), " ", chalk.dim("licenses"), " ", chalk.bold.cyan("e"), " ", chalk.dim("export"), " ", onBack && _jsxs(_Fragment, { children: [chalk.bold.cyan("Esc"), " ", chalk.dim("back"), " "] }), chalk.bold.cyan("q"), " ", chalk.dim("quit")] }))] }));
1206
+ return (_jsxs(Box, { flexDirection: "column", children: [isCursor ? (_jsxs(Text, { backgroundColor: "#1a1a2e", wrap: "truncate-end", children: [chalk.cyan("\u258C"), " ", chalk.cyan(arrow), " ", ` `, color(pad(label, BADGE_COL)), chalk.bold(pad(truncate(names, nameCol - 2), nameCol)), provenanceMarker(rep), lcColor(pad(lcStr, lcCol)), color(scoreStr.padStart(3)), " "] })) : (_jsxs(Text, { wrap: "truncate-end", children: [` ${chalk.dim(arrow)} `, color(pad(label, BADGE_COL)), pad(truncate(names, nameCol - 2), nameCol), provenanceMarker(rep), lcColor(pad(lcStr, lcCol)), color(scoreStr.padStart(3))] })), level === "summary" && (_jsx(FindingsSummary, { group: group, maxWidth: innerWidth - 8, maxLines: group.key === view.expandedKey ? animVisibleLines : undefined }))] }, group.key));
1207
+ }), belowCount > 0 && (_jsxs(Text, { dimColor: true, children: [chalk.cyan(" \u2193"), " ", belowCount, " more below"] }))] })] })), searchQuery && groups.length === 0 && (_jsx(Text, { dimColor: true, children: ` No packages match "${searchQuery}"` })), acked.length > 0 && !searchQuery && (_jsxs(Box, { flexDirection: "column", paddingLeft: 1, paddingRight: 1, width: "100%", children: [_jsxs(Text, { bold: true, dimColor: true, children: ["Acknowledged (", acked.length, ") \\u00b7 dg.json"] }), ackGroups.slice(0, ACK_PREVIEW_LIMIT).map((group) => {
1208
+ const rep = firstPackage(group);
1209
+ const ack = ackByKey.get(packageKey(rep.name, rep.version));
1210
+ const who = ack?.by ?? "unknown";
1211
+ const when = ack?.at ? ` on ${ack.at.slice(0, 10)}` : "";
1212
+ return (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [" ", chalk.yellow("\u25b8"), " ", groupNames(group), " ", chalk.dim(`accepted by ${who}${when}`)] }, `ack-${group.key}`));
1213
+ }), ackGroups.length > ACK_PREVIEW_LIMIT && (_jsx(Text, { dimColor: true, children: ` +${ackGroups.length - ACK_PREVIEW_LIMIT} more \u2014 dg decisions` }))] })), !compact && _jsx(Text, { dimColor: true, children: chalk.dim("\u2500".repeat(Math.max(20, termCols - 4))) }), _jsxs(Box, { flexDirection: "column", paddingLeft: 1, paddingRight: 1, width: "100%", children: [discoveredTotal !== undefined && discoveredTotal > total && (_jsxs(Text, { dimColor: true, children: ["Scanned ", total, " of ", discoveredTotal, " packages"] })), acked.length > 0 && (_jsxs(Text, { wrap: "truncate-end", children: [chalk.yellow("\u26a0"), " ", chalk.yellow(String(acked.length)), " ", chalk.dim(`acknowledged warn${acked.length !== 1 ? "s" : ""} \u00b7 dg.json \u00b7 review with 'dg decisions'`)] })), _jsxs(Box, { justifyContent: "space-between", children: [clean.length > 0 ? (_jsxs(Text, { wrap: "truncate-end", children: [chalk.green("\u2713"), " ", chalk.green.bold(String(clean.length)), " ", chalk.dim(`package${clean.length !== 1 ? "s" : ""} passed`), " ", chalk.dim(`\u00b7 ${(durationMs / 1000).toFixed(1)}s`)] })) : (_jsxs(Text, { dimColor: true, children: [(durationMs / 1000).toFixed(1), "s"] })), result.freeScansRemaining !== undefined && (_jsx(Text, { dimColor: true, children: "Free tier \u00B7 dg login for higher scan limits" }))] })] }), !compact && _jsx(Text, { dimColor: true, children: chalk.dim("\u2500".repeat(Math.max(20, termCols - 4))) }), searchMode ? (_jsxs(Text, { wrap: "truncate-end", children: [" ", chalk.bold.cyan("/"), " ", searchQuery, chalk.cyan("\u2588"), " ", chalk.dim("Esc clear")] })) : exportMsg ? (_jsxs(Text, { wrap: "truncate-end", children: [" ", chalk.green(exportMsg)] })) : searchQuery ? (_jsxs(Text, { wrap: "truncate-end", children: [" ", chalk.bold.cyan("/"), " ", searchQuery, " ", chalk.dim(`${matchCount} of ${total} packages`), " ", chalk.bold.cyan("Esc"), " ", chalk.dim("clear"), " ", chalk.bold.cyan("q"), " ", chalk.dim("quit")] })) : (_jsxs(Text, { wrap: "truncate-end", children: [" ", groups.length > 0 && (_jsxs(_Fragment, { children: [chalk.bold.cyan("\u2191\u2193"), " ", chalk.dim("navigate"), " ", chalk.bold.cyan("\u23CE"), " ", chalk.dim("expand"), " "] })), total > 0 && (_jsxs(_Fragment, { children: [chalk.bold.cyan("/"), " ", chalk.dim("search"), " "] })), chalk.bold.cyan("l"), " ", chalk.dim("licenses"), " ", chalk.bold.cyan("e"), " ", chalk.dim("export"), " ", onBack && _jsxs(_Fragment, { children: [chalk.bold.cyan("Esc"), " ", chalk.dim("back"), " "] }), chalk.bold.cyan("q"), " ", chalk.dim("quit")] }))] }));
1161
1214
  };
1162
1215
  const T = {
1163
1216
  branch: chalk.dim("\u251C\u2500\u2500"),
@@ -1197,6 +1250,10 @@ const FindingsSummary = ({ group, maxWidth, maxLines }) => {
1197
1250
  const lcLine = licenseLine(rep);
1198
1251
  if (lcLine)
1199
1252
  allLines.push(lcLine);
1253
+ const downgrade = packageDowngradeLine(rep);
1254
+ if (downgrade) {
1255
+ allLines.push(_jsxs(Text, { wrap: "truncate-end", children: [T.branch, " ", chalk.yellow(downgrade)] }, "provenance-downgrade"));
1256
+ }
1200
1257
  // Render findings — API returns tier-gated data:
1201
1258
  // Free: { category, severity } — don't show raw IDs, just upgrade prompt
1202
1259
  // Pro: { category, severity, title } — show category + title
@@ -1,6 +1,8 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { useReducer, useEffect, useRef, useCallback, useState } from "react";
3
3
  import { analyzePackages, AnalyzeError, mergeAnalyzeResponses } from "../../api/analyze.js";
4
+ import { applyDecisions, packageKey } from "../../decisions/apply.js";
5
+ import { findProjectRoot, loadDgFile } from "../../project/dgfile.js";
4
6
  import { collectScanPackages, discoverScanProjectsAsync } from "../../scan/collect.js";
5
7
  function reducer(_state, action) {
6
8
  switch (action.type) {
@@ -21,7 +23,8 @@ function reducer(_state, action) {
21
23
  result: action.result,
22
24
  durationMs: action.durationMs,
23
25
  skippedCount: action.skippedCount,
24
- ...(action.discoveredTotal !== undefined ? { discoveredTotal: action.discoveredTotal } : {})
26
+ ...(action.discoveredTotal !== undefined ? { discoveredTotal: action.discoveredTotal } : {}),
27
+ ...(action.decisions !== undefined ? { decisions: action.decisions } : {})
25
28
  };
26
29
  case "ERROR":
27
30
  return { phase: "error", error: action.error };
@@ -79,6 +82,28 @@ export function useScan(config) {
79
82
  restartSelection: multiProjects ? restartSelection : null
80
83
  };
81
84
  }
85
+ function computeProjectDecisions(result, entries) {
86
+ try {
87
+ const root = findProjectRoot(process.cwd());
88
+ if (!root) {
89
+ return undefined;
90
+ }
91
+ const file = loadDgFile(root);
92
+ if (!file.readable) {
93
+ return undefined;
94
+ }
95
+ const ecosystems = new Map();
96
+ for (const [ecosystem, packages] of entries) {
97
+ for (const pkg of packages) {
98
+ ecosystems.set(packageKey(pkg.name, pkg.version), ecosystem);
99
+ }
100
+ }
101
+ return applyDecisions(result.packages, (pkg) => ecosystems.get(packageKey(pkg.name, pkg.version)), file, result.action);
102
+ }
103
+ catch {
104
+ return undefined;
105
+ }
106
+ }
82
107
  async function scanProjects(projects, dispatch, signal) {
83
108
  const startMs = Date.now();
84
109
  let skipped = 0;
@@ -134,12 +159,15 @@ async function scanProjects(projects, dispatch, signal) {
134
159
  const firstFailure = outcomes.find((outcome) => "error" in outcome);
135
160
  if (responses.length > 0) {
136
161
  const merged = mergeAnalyzeResponses(responses);
162
+ const result = firstFailure && merged.action === "pass" ? { ...merged, action: "analysis_incomplete" } : merged;
163
+ const decisions = computeProjectDecisions(result, entries);
137
164
  dispatch({
138
165
  type: "SCAN_COMPLETE",
139
- result: firstFailure && merged.action === "pass" ? { ...merged, action: "analysis_incomplete" } : merged,
166
+ result,
140
167
  durationMs: Date.now() - startMs,
141
168
  skippedCount: skipped,
142
- discoveredTotal: total
169
+ discoveredTotal: total,
170
+ ...(decisions ? { decisions } : {})
143
171
  });
144
172
  return;
145
173
  }
@@ -16,6 +16,9 @@ export function getStoredApiKey() {
16
16
  return null;
17
17
  }
18
18
  }
19
+ export function effectiveScanAction(raw, effective, mode) {
20
+ return mode === "strict" || effective === undefined ? raw : effective;
21
+ }
19
22
  export function scanExitCode(action, mode) {
20
23
  if (action === "block") {
21
24
  return 2;