dependency-radar 0.5.0 → 0.6.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.
package/dist/cli.js CHANGED
@@ -13,6 +13,7 @@ const npmAudit_1 = require("./runners/npmAudit");
13
13
  const npmLs_1 = require("./runners/npmLs");
14
14
  const npmOutdated_1 = require("./runners/npmOutdated");
15
15
  const report_1 = require("./report");
16
+ const failOn_1 = require("./failOn");
16
17
  const promises_1 = __importDefault(require("fs/promises"));
17
18
  const utils_1 = require("./utils");
18
19
  function normalizeSlashes(p) {
@@ -908,6 +909,16 @@ function buildCombinedDependencyGraph(rootPath, packageMetas, dependencyGraphs)
908
909
  }
909
910
  return { name: "dependency-radar-workspace", version: "0.0.0", dependencies };
910
911
  }
912
+ /**
913
+ * Parse command-line tokens into a populated CliOptions object.
914
+ *
915
+ * Recognizes a leading non-flag token as the command and the following flags:
916
+ * --project, --out, --keep-temp, --offline, --json, --open, --no-report, --fail-on, and --help / -h.
917
+ * The --offline flag disables both audit and outdated checks.
918
+ *
919
+ * @param argv - Array of CLI tokens (typically process.argv.slice(2))
920
+ * @returns The resolved CliOptions with defaults applied and values overridden by argv
921
+ */
911
922
  function parseArgs(argv) {
912
923
  const opts = {
913
924
  command: "scan",
@@ -918,6 +929,8 @@ function parseArgs(argv) {
918
929
  outdated: true,
919
930
  json: false,
920
931
  open: false,
932
+ noReport: false,
933
+ failOn: new Set(),
921
934
  };
922
935
  const args = [...argv];
923
936
  if (args[0] && !args[0].startsWith("-")) {
@@ -941,6 +954,27 @@ function parseArgs(argv) {
941
954
  opts.json = true;
942
955
  else if (arg === "--open")
943
956
  opts.open = true;
957
+ else if (arg === "--no-report")
958
+ opts.noReport = true;
959
+ else if (arg === "--fail-on") {
960
+ const value = args.shift();
961
+ if (!value) {
962
+ console.error("Missing value for --fail-on. Provide a comma-separated list of rules.");
963
+ process.exit(1);
964
+ }
965
+ let rules;
966
+ try {
967
+ rules = (0, failOn_1.parseFailOnRules)(value);
968
+ }
969
+ catch (err) {
970
+ console.error(err instanceof Error ? err.message : "Invalid --fail-on rules.");
971
+ process.exit(1);
972
+ return opts;
973
+ }
974
+ for (const rule of rules) {
975
+ opts.failOn.add(rule);
976
+ }
977
+ }
944
978
  else if (arg === "--help" || arg === "-h") {
945
979
  printHelp();
946
980
  process.exit(0);
@@ -948,6 +982,12 @@ function parseArgs(argv) {
948
982
  }
949
983
  return opts;
950
984
  }
985
+ /**
986
+ * Print the CLI usage and available options to the console.
987
+ *
988
+ * Displays the command synopsis and descriptions for supported flags including
989
+ * --project, --out, --json, --no-report, --keep-temp, --offline, --open, and --fail-on.
990
+ */
951
991
  function printHelp() {
952
992
  console.log(`dependency-radar [scan] [options]
953
993
 
@@ -957,11 +997,22 @@ Options:
957
997
  --project <path> Project folder (default: cwd)
958
998
  --out <path> Output HTML file (default: dependency-radar.html)
959
999
  --json Write aggregated data to JSON (default filename: dependency-radar.json)
1000
+ --no-report Do not write HTML/JSON report files or temp artifacts to disk
960
1001
  --keep-temp Keep .dependency-radar folder
961
1002
  --offline Skip npm audit and npm outdated (useful for offline scans)
962
1003
  --open Open the generated report using the system default application
1004
+ --fail-on <rules> Fail with exit code 1 when selected rules are violated
1005
+ Supported: reachable-vuln, production-vuln, high-severity-vuln,
1006
+ licence-mismatch, copyleft-detected, unknown-licence
963
1007
  `);
964
1008
  }
1009
+ /**
1010
+ * Attempts to open the given file in the system's default application.
1011
+ *
1012
+ * Spawns a detached OS-specific opener process (so the function returns immediately). If the spawn fails, a warning is logged to the console.
1013
+ *
1014
+ * @param filePath - Path (absolute or relative) to the file to open
1015
+ */
965
1016
  function openInBrowser(filePath) {
966
1017
  const normalizedPath = filePath.replace(/\\/g, "/");
967
1018
  let child;
@@ -993,14 +1044,267 @@ function openInBrowser(filePath) {
993
1044
  });
994
1045
  child.unref();
995
1046
  }
1047
+ const ANSI = {
1048
+ reset: "\x1b[0m",
1049
+ bold: "\x1b[1m",
1050
+ green: "\x1b[32m",
1051
+ red: "\x1b[31m",
1052
+ yellow: "\x1b[33m",
1053
+ cyan: "\x1b[36m",
1054
+ };
1055
+ /**
1056
+ * Determine whether ANSI color output should be enabled for the current process.
1057
+ *
1058
+ * Considers the `NO_COLOR` and `FORCE_COLOR` environment variables and falls back to whether `stdout` is a TTY.
1059
+ *
1060
+ * @returns `true` if ANSI color output should be enabled, `false` otherwise.
1061
+ */
1062
+ function shouldUseColor() {
1063
+ if (process.env.NO_COLOR !== undefined)
1064
+ return false;
1065
+ const forceColor = process.env.FORCE_COLOR;
1066
+ if (forceColor === "0")
1067
+ return false;
1068
+ if (forceColor !== undefined)
1069
+ return true;
1070
+ return Boolean(process.stdout.isTTY);
1071
+ }
1072
+ const COLOR_ENABLED = shouldUseColor();
1073
+ /**
1074
+ * Wraps text with ANSI color or style escape sequences when terminal coloring is enabled.
1075
+ *
1076
+ * @param value - The text to style
1077
+ * @param color - The style to apply; one of `'bold'`, `'green'`, `'red'`, `'yellow'`, or `'cyan'`
1078
+ * @returns The input string wrapped with the selected ANSI escape codes if colors are enabled, otherwise the original `value`
1079
+ */
1080
+ function styleText(value, color) {
1081
+ if (!COLOR_ENABLED)
1082
+ return value;
1083
+ return `${ANSI[color]}${value}${ANSI.reset}`;
1084
+ }
1085
+ /**
1086
+ * Extracts the first Unicode character from a string and the remaining substring.
1087
+ *
1088
+ * @param value - The input string to split
1089
+ * @returns An object with `head` set to the first character (empty string if input is empty) and `tail` set to the remainder of the string after `head`
1090
+ */
1091
+ function splitFirstGlyph(value) {
1092
+ const chars = Array.from(value);
1093
+ const head = chars[0] || "";
1094
+ const tail = value.slice(head.length);
1095
+ return { head, tail };
1096
+ }
1097
+ /**
1098
+ * Apply ANSI color styling to recognized status glyphs at the start of a string.
1099
+ *
1100
+ * Recognized leading glyphs are colored as follows: "✔" → green, "✖" → red, "⚠" → yellow,
1101
+ * "↗", "ℹ", "📦" → cyan, and "📉" → yellow. If the first grapheme is not one of these,
1102
+ * the input is returned unchanged.
1103
+ *
1104
+ * @param symbol - The string whose leading glyph should be colorized (if recognized)
1105
+ * @returns The input string with the leading glyph wrapped in color styling when recognized, otherwise the original string
1106
+ */
1107
+ function colorSymbol(symbol) {
1108
+ const { head, tail } = splitFirstGlyph(symbol);
1109
+ if (!head)
1110
+ return symbol;
1111
+ if (head === "✔")
1112
+ return `${styleText(head, "green")}${tail}`;
1113
+ if (head === "✖")
1114
+ return `${styleText(head, "red")}${tail}`;
1115
+ if (head === "⚠")
1116
+ return `${styleText(head, "yellow")}${tail}`;
1117
+ if (head === "↗" || head === "ℹ" || head === "📦") {
1118
+ return `${styleText(head, "cyan")}${tail}`;
1119
+ }
1120
+ if (head === "📉")
1121
+ return `${styleText(head, "yellow")}${tail}`;
1122
+ return symbol;
1123
+ }
1124
+ /**
1125
+ * Applies ANSI color styling to a leading status glyph in a text line when present.
1126
+ *
1127
+ * @param line - The input line; if it starts with a recognized status glyph (e.g., ✔, ✖, ⚠, ↗, ℹ, 📦, 📉), that glyph will be replaced with its colored equivalent.
1128
+ * @returns The line with the leading glyph colorized when applicable, or the original line unchanged.
1129
+ */
1130
+ function colorLeadingSymbol(line) {
1131
+ const { head } = splitFirstGlyph(line);
1132
+ if (!head)
1133
+ return line;
1134
+ if (head !== "✔" &&
1135
+ head !== "✖" &&
1136
+ head !== "⚠" &&
1137
+ head !== "↗" &&
1138
+ head !== "ℹ" &&
1139
+ head !== "📦" &&
1140
+ head !== "📉") {
1141
+ return line;
1142
+ }
1143
+ return `${colorSymbol(head)}${line.slice(head.length)}`;
1144
+ }
1145
+ /**
1146
+ * Format a CLI status line with a colored leading symbol and message.
1147
+ *
1148
+ * @param symbol - The single-character or glyph to display as the leading symbol
1149
+ * @param message - The text message that follows the symbol
1150
+ * @returns The formatted status line with the colored symbol, a single separating space, and the message
1151
+ */
1152
+ function statusLine(symbol, message) {
1153
+ return `${colorSymbol(symbol)} ${message}`;
1154
+ }
1155
+ /**
1156
+ * Print policy violation messages to stdout as a human-readable list when any exist.
1157
+ *
1158
+ * @param violations - An array of policy violations to display; each violation's `message` will be printed as a list item. If the array is empty, nothing is printed.
1159
+ */
1160
+ function printPolicyViolations(violations) {
1161
+ if (violations.length === 0)
1162
+ return;
1163
+ console.log("");
1164
+ console.log(colorLeadingSymbol("✖ Policy violations detected:"));
1165
+ for (const violation of violations) {
1166
+ console.log(`- ${violation.message}`);
1167
+ }
1168
+ }
1169
+ /**
1170
+ * Produce a concise CLI summary from aggregated workspace data.
1171
+ *
1172
+ * @param aggregated - Aggregated workspace data produced by the scan
1173
+ * @param options.importGraphComplete - `true` when import graph collection completed for all packages; affects unused dependency counting
1174
+ * @returns An object with:
1175
+ * - `directDeps`: number of direct dependencies in the workspace
1176
+ * - `transitiveDeps`: number of transitive dependencies in the workspace
1177
+ * - `vulnerablePackages`: count of dependencies with at least one reported vulnerability
1178
+ * - `reachableVulnerablePackages`: count of vulnerable dependencies that are reachable according to import usage
1179
+ * - `unusedInstalledDeps`: count of direct runtime dependencies that appear unused (only when `importGraphComplete` is `true`)
1180
+ * - `licenseMismatches`: count of dependencies whose license status is `mismatch`
1181
+ * - `majorUpgradeBlockers`: count of dependencies that have one or more upgrade blockers
1182
+ * - `majorUpgradeBlockerBreakdown`: object with counts for specific blocker types (`peerDependency`, `nodeEngine`, `deprecated`, `nativeBindings`, `installScripts`)
1183
+ */
1184
+ function buildCliSummary(aggregated, options) {
1185
+ var _a;
1186
+ let vulnerablePackages = 0;
1187
+ let reachableVulnerablePackages = 0;
1188
+ let unusedInstalledDeps = 0;
1189
+ let licenseMismatches = 0;
1190
+ let majorUpgradeBlockers = 0;
1191
+ const majorUpgradeBlockerBreakdown = {
1192
+ peerDependency: 0,
1193
+ nodeEngine: 0,
1194
+ deprecated: 0,
1195
+ nativeBindings: 0,
1196
+ installScripts: 0,
1197
+ };
1198
+ const deps = Object.values(aggregated.dependencies || {});
1199
+ for (const dep of deps) {
1200
+ const vulnTotal = (dep.security.summary.critical || 0) +
1201
+ (dep.security.summary.high || 0) +
1202
+ (dep.security.summary.moderate || 0) +
1203
+ (dep.security.summary.low || 0);
1204
+ if (vulnTotal > 0) {
1205
+ vulnerablePackages += 1;
1206
+ if ((((_a = dep.usage.importUsage) === null || _a === void 0 ? void 0 : _a.fileCount) || 0) > 0) {
1207
+ reachableVulnerablePackages += 1;
1208
+ }
1209
+ }
1210
+ // Count "unused" only when import graph collection succeeded for all packages.
1211
+ // `importUsage` is an optional object (or undefined), not a boolean/string state.
1212
+ // Otherwise, missing importUsage can mean "unknown" rather than "unused".
1213
+ if (options.importGraphComplete &&
1214
+ dep.usage.direct &&
1215
+ dep.usage.scope === "runtime" &&
1216
+ !dep.usage.importUsage) {
1217
+ unusedInstalledDeps += 1;
1218
+ }
1219
+ if (dep.compliance.license.status === "mismatch") {
1220
+ licenseMismatches += 1;
1221
+ }
1222
+ const blockers = dep.upgrade.blockers || [];
1223
+ if (blockers.length > 0) {
1224
+ majorUpgradeBlockers += 1;
1225
+ }
1226
+ if (blockers.includes("peerDependency")) {
1227
+ majorUpgradeBlockerBreakdown.peerDependency += 1;
1228
+ }
1229
+ if (blockers.includes("nodeEngine")) {
1230
+ majorUpgradeBlockerBreakdown.nodeEngine += 1;
1231
+ }
1232
+ if (blockers.includes("deprecated")) {
1233
+ majorUpgradeBlockerBreakdown.deprecated += 1;
1234
+ }
1235
+ if (blockers.includes("nativeBindings")) {
1236
+ majorUpgradeBlockerBreakdown.nativeBindings += 1;
1237
+ }
1238
+ if (blockers.includes("installScripts")) {
1239
+ majorUpgradeBlockerBreakdown.installScripts += 1;
1240
+ }
1241
+ }
1242
+ return {
1243
+ directDeps: aggregated.summary.directCount,
1244
+ transitiveDeps: aggregated.summary.transitiveCount,
1245
+ vulnerablePackages,
1246
+ reachableVulnerablePackages,
1247
+ unusedInstalledDeps,
1248
+ licenseMismatches,
1249
+ majorUpgradeBlockers,
1250
+ majorUpgradeBlockerBreakdown,
1251
+ };
1252
+ }
1253
+ /**
1254
+ * Choose the correct singular or plural form based on a numeric count.
1255
+ *
1256
+ * @param value - The numeric count that determines which form to use
1257
+ * @param singular - The singular form to use when `value` equals 1
1258
+ * @param plural - The plural form to use for any other `value`
1259
+ * @returns The `singular` string if `value` is 1, `plural` otherwise
1260
+ */
1261
+ function pluralize(value, singular, plural) {
1262
+ return value === 1 ? singular : plural;
1263
+ }
1264
+ /**
1265
+ * Print a concise, human-readable CLI summary of scan results to standard output.
1266
+ *
1267
+ * @param summary - Aggregated counts and breakdowns (dependencies, vulnerabilities, unused deps, license mismatches, and major-upgrade blocker details) used to compose the printed summary
1268
+ */
1269
+ function printCliSummary(summary) {
1270
+ const bullet = "•";
1271
+ console.log("");
1272
+ console.log("Summary:");
1273
+ console.log(`${bullet} Direct deps scanned: ${summary.directDeps}`);
1274
+ console.log(`${bullet} Transitive deps scanned: ${summary.transitiveDeps}`);
1275
+ console.log(`${bullet} Vulnerable packages: ${summary.vulnerablePackages} (${summary.reachableVulnerablePackages} reachable)`);
1276
+ console.log(`${bullet} Unused installed deps: ${summary.unusedInstalledDeps}`);
1277
+ console.log(`${bullet} License mismatches: ${summary.licenseMismatches}`);
1278
+ console.log(`${bullet} Major upgrade blockers: ${summary.majorUpgradeBlockers}`);
1279
+ const blockerDetails = [];
1280
+ if (summary.majorUpgradeBlockerBreakdown.peerDependency > 0) {
1281
+ blockerDetails.push(` - ${summary.majorUpgradeBlockerBreakdown.peerDependency} ${pluralize(summary.majorUpgradeBlockerBreakdown.peerDependency, "strict peer dependency constraint", "strict peer dependency constraints")}`);
1282
+ }
1283
+ if (summary.majorUpgradeBlockerBreakdown.nodeEngine > 0) {
1284
+ blockerDetails.push(` - ${summary.majorUpgradeBlockerBreakdown.nodeEngine} ${pluralize(summary.majorUpgradeBlockerBreakdown.nodeEngine, "narrow engine range", "narrow engine ranges")}`);
1285
+ }
1286
+ if (summary.majorUpgradeBlockerBreakdown.deprecated > 0) {
1287
+ blockerDetails.push(` - ${summary.majorUpgradeBlockerBreakdown.deprecated} ${pluralize(summary.majorUpgradeBlockerBreakdown.deprecated, "deprecated package", "deprecated packages")}`);
1288
+ }
1289
+ if (summary.majorUpgradeBlockerBreakdown.nativeBindings > 0) {
1290
+ blockerDetails.push(` - ${summary.majorUpgradeBlockerBreakdown.nativeBindings} ${pluralize(summary.majorUpgradeBlockerBreakdown.nativeBindings, "native binding", "native bindings")}`);
1291
+ }
1292
+ if (summary.majorUpgradeBlockerBreakdown.installScripts > 0) {
1293
+ blockerDetails.push(` - ${summary.majorUpgradeBlockerBreakdown.installScripts} ${pluralize(summary.majorUpgradeBlockerBreakdown.installScripts, "install lifecycle script", "install lifecycle scripts")}`);
1294
+ }
1295
+ for (const line of blockerDetails) {
1296
+ console.log(line);
1297
+ }
1298
+ console.log("");
1299
+ }
996
1300
  /**
997
- * Orchestrates the CLI "scan" command to collect, merge, and output dependency data for a project or workspace.
1301
+ * Run the CLI "scan" command to collect and aggregate dependency data for a project or workspace.
998
1302
  *
999
1303
  * Detects workspace type and package manager, runs per-package collectors (audit, dependency tree, import graph, outdated),
1000
- * merges collected signals into a workspace-level model, and writes a JSON or HTML report to the configured output path.
1001
- * Manages a temporary working directory (created under the project as .dependency-radar), respects CLI options such as
1002
- * JSON output, audit/outdated toggles, keeping the temp directory, and optionally opening the generated output with the
1003
- * system default application. Exits the process with a non-zero code on fatal errors. */
1304
+ * merges collected signals into a workspace-level model, and writes a JSON or HTML report according to CLI options.
1305
+ * Manages a temporary working directory and optionally opens the generated report. Exits the process with a non-zero code
1306
+ * on fatal errors or when configured policy violations are detected.
1307
+ */
1004
1308
  async function run() {
1005
1309
  var _a;
1006
1310
  const opts = parseArgs(process.argv.slice(2));
@@ -1009,7 +1313,13 @@ async function run() {
1009
1313
  process.exit(1);
1010
1314
  return;
1011
1315
  }
1316
+ const shouldWriteArtifacts = !opts.noReport;
1012
1317
  const projectPath = path_1.default.resolve(opts.project);
1318
+ let summary;
1319
+ let policyViolations = [];
1320
+ if (opts.noReport && opts.keepTemp) {
1321
+ console.log(statusLine("⚠", "--keep-temp is ignored when --no-report is enabled."));
1322
+ }
1013
1323
  if (opts.json && opts.out === "dependency-radar.html") {
1014
1324
  opts.out = "dependency-radar.json";
1015
1325
  }
@@ -1017,18 +1327,20 @@ async function run() {
1017
1327
  const startTime = Date.now();
1018
1328
  let dependencyCount = 0;
1019
1329
  let outputCreated = false;
1020
- try {
1021
- const stat = await promises_1.default.stat(outputPath).catch(() => undefined);
1022
- const endsWithSeparator = opts.out.endsWith("/") || opts.out.endsWith("\\");
1023
- const hasExtension = Boolean(path_1.default.extname(outputPath));
1024
- if ((stat && stat.isDirectory()) ||
1025
- endsWithSeparator ||
1026
- (!stat && !hasExtension)) {
1027
- outputPath = path_1.default.join(outputPath, opts.json ? "dependency-radar.json" : "dependency-radar.html");
1330
+ if (shouldWriteArtifacts) {
1331
+ try {
1332
+ const stat = await promises_1.default.stat(outputPath).catch(() => undefined);
1333
+ const endsWithSeparator = opts.out.endsWith("/") || opts.out.endsWith("\\");
1334
+ const hasExtension = Boolean(path_1.default.extname(outputPath));
1335
+ if ((stat && stat.isDirectory()) ||
1336
+ endsWithSeparator ||
1337
+ (!stat && !hasExtension)) {
1338
+ outputPath = path_1.default.join(outputPath, opts.json ? "dependency-radar.json" : "dependency-radar.html");
1339
+ }
1340
+ }
1341
+ catch (e) {
1342
+ // ignore, best-effort path normalization
1028
1343
  }
1029
- }
1030
- catch (e) {
1031
- // ignore, best-effort path normalization
1032
1344
  }
1033
1345
  const tempDir = path_1.default.join(projectPath, ".dependency-radar");
1034
1346
  // Stage 1: detect workspace/package-manager context and collect tool versions.
@@ -1048,7 +1360,7 @@ async function run() {
1048
1360
  const yarnHint = yarnPnP
1049
1361
  ? " Yarn Plug'n'Play appears enabled; Dependency Radar currently requires node_modules linker."
1050
1362
  : "";
1051
- console.warn(`⚠ node_modules was not found at ${projectPath}. Scan completeness may be reduced for this ${workspaceHint}. Run your package manager install (npm install, pnpm install, or yarn install) before scanning.${yarnHint}`);
1363
+ console.warn(colorLeadingSymbol(`⚠ node_modules was not found at ${projectPath}. Scan completeness may be reduced for this ${workspaceHint}. Run your package manager install (npm install, pnpm install, or yarn install) before scanning.${yarnHint}`));
1052
1364
  }
1053
1365
  const rootPkg = await readJsonFile(path_1.default.join(projectPath, "package.json"));
1054
1366
  const projectDependencyPolicy = workspace.pnpmWorkspaceOverrides
@@ -1087,13 +1399,15 @@ async function run() {
1087
1399
  const workspaceLabel = workspace.type === "none"
1088
1400
  ? "Single project"
1089
1401
  : `${workspace.type.toUpperCase()} workspace`;
1090
- console.log(`✔ ${workspaceLabel} detected`);
1402
+ console.log(statusLine("✔", `${workspaceLabel} detected`));
1091
1403
  if (workspace.type !== "none" && scanManager !== workspace.type) {
1092
- console.log(`✔ Using ${scanManager.toUpperCase()} for dependency data (lockfile detected)`);
1404
+ console.log(statusLine("✔", `Using ${scanManager.toUpperCase()} for dependency data (lockfile detected)`));
1093
1405
  }
1094
1406
  const spinner = startSpinner(`Scanning ${workspaceLabel} at ${projectPath}`);
1095
1407
  try {
1096
- await (0, utils_1.ensureDir)(tempDir);
1408
+ if (shouldWriteArtifacts) {
1409
+ await (0, utils_1.ensureDir)(tempDir);
1410
+ }
1097
1411
  // Stage 2: run per-package collectors and persist raw tool outputs.
1098
1412
  const packageMetas = await readWorkspacePackageMeta(projectPath, packagePaths);
1099
1413
  const workspaceClassification = buildWorkspaceClassification(projectPath, packageMetas);
@@ -1104,19 +1418,24 @@ async function run() {
1104
1418
  for (const meta of packageMetas) {
1105
1419
  spinner.update(`Scanning ${workspaceLabel} (${perPackageLs.length + 1}/${packageMetas.length}) at ${projectPath}`);
1106
1420
  const pkgTempDir = path_1.default.join(tempDir, meta.name.replace(/[^a-zA-Z0-9._-]/g, "_"));
1107
- await (0, utils_1.ensureDir)(pkgTempDir);
1421
+ if (shouldWriteArtifacts) {
1422
+ await (0, utils_1.ensureDir)(pkgTempDir);
1423
+ }
1108
1424
  const [a, l, ig, o] = await Promise.all([
1109
1425
  opts.audit
1110
- ? (0, npmAudit_1.runPackageAudit)(meta.path, pkgTempDir, scanManager, yarnVersion).catch((err) => ({ ok: false, error: String(err) }))
1426
+ ? (0, npmAudit_1.runPackageAudit)(meta.path, pkgTempDir, scanManager, yarnVersion, { persistToDisk: shouldWriteArtifacts }).catch((err) => ({ ok: false, error: String(err) }))
1111
1427
  : Promise.resolve(undefined),
1112
1428
  (0, npmLs_1.runNpmLs)(meta.path, pkgTempDir, scanManager, {
1113
1429
  contextLabel: meta.name,
1114
1430
  lockfileSearchRoot: projectPath,
1115
1431
  onProgress: (line) => spinner.log(line),
1432
+ persistToDisk: shouldWriteArtifacts,
1433
+ }).catch((err) => ({ ok: false, error: String(err) })),
1434
+ (0, importGraphRunner_1.runImportGraph)(meta.path, pkgTempDir, {
1435
+ persistToDisk: shouldWriteArtifacts,
1116
1436
  }).catch((err) => ({ ok: false, error: String(err) })),
1117
- (0, importGraphRunner_1.runImportGraph)(meta.path, pkgTempDir).catch((err) => ({ ok: false, error: String(err) })),
1118
1437
  opts.outdated
1119
- ? (0, npmOutdated_1.runPackageOutdated)(meta.path, pkgTempDir, scanManager).catch((err) => ({ ok: false, error: String(err) }))
1438
+ ? (0, npmOutdated_1.runPackageOutdated)(meta.path, pkgTempDir, scanManager, { persistToDisk: shouldWriteArtifacts }).catch((err) => ({ ok: false, error: String(err) }))
1120
1439
  : Promise.resolve(undefined),
1121
1440
  ]);
1122
1441
  perPackageAudit.push(a);
@@ -1128,19 +1447,19 @@ async function run() {
1128
1447
  if (opts.audit) {
1129
1448
  const auditOk = perPackageAudit.every((r) => r && r.ok);
1130
1449
  if (auditOk) {
1131
- spinner.log(`✔ ${scanManager.toUpperCase()} audit data collected`);
1450
+ spinner.log(statusLine("✔", `${scanManager.toUpperCase()} audit data collected`));
1132
1451
  }
1133
1452
  else {
1134
- spinner.log(`✖ ${scanManager.toUpperCase()} audit data unavailable`);
1453
+ spinner.log(statusLine("✖", `${scanManager.toUpperCase()} audit data unavailable`));
1135
1454
  }
1136
1455
  }
1137
1456
  if (opts.outdated) {
1138
1457
  const outdatedOk = perPackageOutdated.every((r) => r.result && r.result.ok);
1139
1458
  if (outdatedOk) {
1140
- spinner.log(`✔ ${scanManager.toUpperCase()} outdated data collected`);
1459
+ spinner.log(statusLine("✔", `${scanManager.toUpperCase()} outdated data collected`));
1141
1460
  }
1142
1461
  else {
1143
- spinner.log(`✖ ${scanManager.toUpperCase()} outdated data unavailable`);
1462
+ spinner.log(statusLine("✖", `${scanManager.toUpperCase()} outdated data unavailable`));
1144
1463
  }
1145
1464
  }
1146
1465
  const mergedAuditData = mergeAuditResults(perPackageAudit.map((r) => (r && r.ok ? r.data : undefined)));
@@ -1213,10 +1532,15 @@ async function run() {
1213
1532
  ...(toolVersions ? { toolVersions } : {}),
1214
1533
  });
1215
1534
  dependencyCount = Object.keys(aggregated.dependencies).length;
1535
+ const importGraphComplete = perPackageImportGraph.every((result) => result.ok);
1536
+ summary = buildCliSummary(aggregated, {
1537
+ importGraphComplete,
1538
+ });
1539
+ policyViolations = (0, failOn_1.evaluatePolicyViolations)(aggregated, opts.failOn);
1216
1540
  if (workspace.type !== "none") {
1217
1541
  console.log(`Detected ${workspace.type.toUpperCase()} workspace with ${packagePaths.length} package${packagePaths.length === 1 ? "" : "s"}.`);
1218
1542
  }
1219
- if (dependencyCount > 0) {
1543
+ if (dependencyCount > 0 && shouldWriteArtifacts) {
1220
1544
  if (opts.json) {
1221
1545
  await promises_1.default.mkdir(path_1.default.dirname(outputPath), { recursive: true });
1222
1546
  await promises_1.default.writeFile(outputPath, JSON.stringify(aggregated, null, 2), "utf8");
@@ -1228,12 +1552,15 @@ async function run() {
1228
1552
  }
1229
1553
  spinner.stop(true);
1230
1554
  const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
1231
- console.log(`✔ Scan complete: ${dependencyCount} dependencies analysed in ${elapsed}s`);
1232
- if (outputCreated) {
1233
- console.log(`✔ ${opts.json ? "JSON" : "Report"} written to ${outputPath}`);
1555
+ console.log(statusLine("✔", `Scan complete: ${dependencyCount} dependencies analysed in ${elapsed}s`));
1556
+ if (!shouldWriteArtifacts) {
1557
+ console.log(statusLine("", "Report output disabled (--no-report); no report artifacts written."));
1558
+ }
1559
+ else if (outputCreated) {
1560
+ console.log(statusLine("✔", `${opts.json ? "JSON" : "Report"} written to ${outputPath}`));
1234
1561
  }
1235
1562
  else {
1236
- console.log(`✖ No dependencies were found - ${opts.json ? "JSON file" : "Report"} not created`);
1563
+ console.log(statusLine("✖", `No dependencies were found - ${opts.json ? "JSON file" : "Report"} not created`));
1237
1564
  }
1238
1565
  }
1239
1566
  catch (err) {
@@ -1242,25 +1569,48 @@ async function run() {
1242
1569
  process.exit(1);
1243
1570
  }
1244
1571
  finally {
1245
- if (!opts.keepTemp) {
1246
- await (0, utils_1.removeDir)(tempDir);
1247
- }
1248
- else {
1249
- console.log(`✔ Temporary data kept at ${tempDir}`);
1572
+ if (shouldWriteArtifacts) {
1573
+ if (!opts.keepTemp) {
1574
+ await (0, utils_1.removeDir)(tempDir);
1575
+ }
1576
+ else {
1577
+ console.log(statusLine("✔", `Temporary data kept at ${tempDir}`));
1578
+ }
1250
1579
  }
1251
1580
  }
1252
- if (opts.open && outputCreated && !isCI()) {
1253
- console.log(`↗ Opening ${path_1.default.basename(outputPath)} using system default ${opts.json ? "application" : "browser"}.`);
1581
+ if (opts.open && !shouldWriteArtifacts) {
1582
+ console.log(statusLine("✖", "Skipping auto-open because --no-report is enabled."));
1583
+ }
1584
+ else if (opts.open && outputCreated && !isCI()) {
1585
+ console.log(statusLine("↗", `Opening ${path_1.default.basename(outputPath)} using system default ${opts.json ? "application" : "browser"}.`));
1254
1586
  openInBrowser(outputPath);
1255
1587
  }
1256
1588
  else if (opts.open && outputCreated && isCI()) {
1257
- console.log("✖ Skipping auto-open in CI environment.");
1589
+ console.log(statusLine("✖", "Skipping auto-open in CI environment."));
1590
+ }
1591
+ if (summary) {
1592
+ printCliSummary(summary);
1258
1593
  }
1594
+ else {
1595
+ console.log("");
1596
+ }
1597
+ printPolicyViolations(policyViolations);
1259
1598
  // Always show CTA as the last output
1260
- console.log("");
1261
- console.log("Get additional risk analysis and a management-ready summary at https://dependency-radar.com");
1599
+ console.log("Enrich this scan with maintenance signals, upgrade readiness, and risk modelling at dependency-radar.com");
1600
+ if (policyViolations.length > 0) {
1601
+ process.exit(1);
1602
+ }
1262
1603
  }
1263
1604
  run();
1605
+ /**
1606
+ * Displays a rotating CLI spinner with a message and returns controls to stop, update, or log lines.
1607
+ *
1608
+ * @param text - Initial message shown next to the spinner.
1609
+ * @returns An object with control methods:
1610
+ * - `stop(success?)` - Stops the spinner and writes a final line using a check mark when `success` is `true` or a cross when `false` (defaults to `true`).
1611
+ * - `update(nextText)` - Replaces the spinner's message with `nextText`.
1612
+ * - `log(line)` - Writes `line` as a new output line above the active spinner without stopping it.
1613
+ */
1264
1614
  function startSpinner(text) {
1265
1615
  const frames = ["|", "/", "-", "\\"];
1266
1616
  let i = 0;
@@ -1281,20 +1631,21 @@ function startSpinner(text) {
1281
1631
  return `${head}…/${tail}`;
1282
1632
  };
1283
1633
  const formatLine = (prefix, value) => {
1634
+ const coloredPrefix = colorSymbol(prefix);
1284
1635
  if (!process.stdout.isTTY)
1285
- return `${prefix} ${value}`;
1636
+ return `${coloredPrefix} ${value}`;
1286
1637
  const displayValue = shortenPathInMessage(value);
1287
1638
  const columns = process.stdout.columns || 0;
1288
1639
  if (columns <= 0)
1289
- return `${prefix} ${displayValue}`;
1640
+ return `${coloredPrefix} ${displayValue}`;
1290
1641
  const max = columns - (prefix.length + 1);
1291
1642
  if (max <= 0)
1292
- return prefix;
1643
+ return coloredPrefix;
1293
1644
  if (displayValue.length <= max)
1294
- return `${prefix} ${displayValue}`;
1645
+ return `${coloredPrefix} ${displayValue}`;
1295
1646
  const ellipsis = "…";
1296
1647
  const keep = Math.max(0, max - ellipsis.length);
1297
- return `${prefix} ${displayValue.slice(0, keep)}${ellipsis}`;
1648
+ return `${coloredPrefix} ${displayValue.slice(0, keep)}${ellipsis}`;
1298
1649
  };
1299
1650
  process.stdout.write(formatLine(frames[i], currentText));
1300
1651
  const timer = setInterval(() => {
@@ -1316,11 +1667,12 @@ function startSpinner(text) {
1316
1667
  process.stdout.write(`\r\x1b[K${formatLine(frames[i], currentText)}`);
1317
1668
  };
1318
1669
  const log = (line) => {
1670
+ const renderedLine = colorLeadingSymbol(line);
1319
1671
  if (stopped) {
1320
- process.stdout.write(`${line}\n`);
1672
+ process.stdout.write(`${renderedLine}\n`);
1321
1673
  return;
1322
1674
  }
1323
- process.stdout.write(`\r\x1b[K${line}\n`);
1675
+ process.stdout.write(`\r\x1b[K${renderedLine}\n`);
1324
1676
  process.stdout.write(formatLine(frames[i], currentText));
1325
1677
  };
1326
1678
  return { stop, update, log };