@mh-alikhani/bunready 0.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 (54) hide show
  1. package/CHANGELOG.md +140 -0
  2. package/LICENSE +21 -0
  3. package/README.md +129 -0
  4. package/action.yml +89 -0
  5. package/docs/CONFIGURATION.md +44 -0
  6. package/docs/JSON-OUTPUT.md +50 -0
  7. package/docs/RELEASING.md +65 -0
  8. package/docs/adr/0001-data-source-policy.md +36 -0
  9. package/docs/adr/0002-rule-severity-model.md +42 -0
  10. package/docs/adr/0003-release-pipeline.md +51 -0
  11. package/docs/brand/favicon.svg +8 -0
  12. package/docs/brand/guidelines.md +70 -0
  13. package/docs/brand/logo-dark.svg +11 -0
  14. package/docs/brand/logo-mono.svg +11 -0
  15. package/docs/brand/logo.svg +11 -0
  16. package/docs/brand/mark.svg +8 -0
  17. package/docs/brand/tokens.json +74 -0
  18. package/docs/demo.md +37 -0
  19. package/package.json +71 -0
  20. package/src/cli/args.ts +177 -0
  21. package/src/cli/copy.ts +76 -0
  22. package/src/cli/index.ts +5 -0
  23. package/src/cli/io.ts +20 -0
  24. package/src/cli/run.ts +98 -0
  25. package/src/cli/theme.ts +59 -0
  26. package/src/config/baseline.ts +116 -0
  27. package/src/config/config.ts +113 -0
  28. package/src/core/errors.ts +59 -0
  29. package/src/core/fs.ts +72 -0
  30. package/src/core/version.ts +9 -0
  31. package/src/report/human.ts +100 -0
  32. package/src/report/json.ts +11 -0
  33. package/src/report/sarif.ts +73 -0
  34. package/src/report/types.ts +114 -0
  35. package/src/rules/data/native-packages.json +81 -0
  36. package/src/rules/data/node-runtime.json +6 -0
  37. package/src/rules/install/engines.ts +74 -0
  38. package/src/rules/install/index.ts +27 -0
  39. package/src/rules/install/lifecycle-scripts.ts +70 -0
  40. package/src/rules/install/lockfile-presence.ts +68 -0
  41. package/src/rules/install/native-addon.ts +126 -0
  42. package/src/rules/run/index.ts +114 -0
  43. package/src/rules/runtime/builtins.ts +148 -0
  44. package/src/rules/runtime/index.ts +18 -0
  45. package/src/rules/severity.ts +46 -0
  46. package/src/scanner/execute.ts +301 -0
  47. package/src/scanner/graph.ts +77 -0
  48. package/src/scanner/lockfile.ts +545 -0
  49. package/src/scanner/manifest.ts +109 -0
  50. package/src/scanner/scan.ts +322 -0
  51. package/src/scanner/semver.ts +227 -0
  52. package/src/scanner/sources.ts +355 -0
  53. package/src/scanner/target.ts +224 -0
  54. package/src/scanner/workspaces.ts +170 -0
package/src/cli/run.ts ADDED
@@ -0,0 +1,98 @@
1
+ import { newFindings, serializeBaseline } from "../config/baseline";
2
+ import { formatError } from "../core/errors";
3
+ import { type FileSystem, nodeFileSystem } from "../core/fs";
4
+ import { TOOL_VERSION } from "../core/version";
5
+ import { renderHumanReport } from "../report/human";
6
+ import { renderJsonReport } from "../report/json";
7
+ import { renderSarifReport } from "../report/sarif";
8
+ import { exitCodeForFindings } from "../rules/severity";
9
+ import { scanTarget } from "../scanner/scan";
10
+ import { parseArgs } from "./args";
11
+ import { helpText, RUN_WARNING, TOOL } from "./copy";
12
+ import { type Io, systemIo } from "./io";
13
+ import { colorEnabled, createTheme } from "./theme";
14
+
15
+ export const EXIT_OK = 0;
16
+ export const EXIT_BLOCKERS = 1;
17
+ export const EXIT_USAGE = 2;
18
+
19
+ export function version(): string {
20
+ return TOOL_VERSION;
21
+ }
22
+
23
+ /**
24
+ * The whole CLI, minus the process boundary.
25
+ *
26
+ * `run` takes argv and an Io seam and returns an exit code, which keeps the
27
+ * entry point at index.ts tiny and every behaviour reachable from tests.
28
+ */
29
+ export async function run(
30
+ argv: readonly string[],
31
+ io: Io = systemIo(),
32
+ fs: FileSystem = nodeFileSystem(),
33
+ ): Promise<number> {
34
+ const theme = createTheme(colorEnabled(io.env, io.isTty));
35
+ const parsed = parseArgs(argv);
36
+
37
+ if (!parsed.ok) {
38
+ io.err(`${theme.red("error")} ${formatError(parsed.error)}`);
39
+ return EXIT_USAGE;
40
+ }
41
+
42
+ const options = parsed.value;
43
+
44
+ if (options.help) {
45
+ io.out(helpText(version()));
46
+ return EXIT_OK;
47
+ }
48
+
49
+ if (options.version) {
50
+ io.out(`${TOOL} ${version()}`);
51
+ return EXIT_OK;
52
+ }
53
+
54
+ if (options.run) {
55
+ io.err(`${theme.dim("!")} ${RUN_WARNING}`);
56
+ }
57
+
58
+ const scan = await scanTarget(options.target, {
59
+ fs,
60
+ run: options.run,
61
+ ...(options.runScript === undefined ? {} : { runScript: options.runScript }),
62
+ ...(options.config === undefined ? {} : { configPath: options.config }),
63
+ ...(options.scope === undefined ? {} : { scope: options.scope }),
64
+ ...(options.baseline === undefined ? {} : { baselinePath: options.baseline }),
65
+ });
66
+
67
+ if (!scan.ok) {
68
+ io.err(`${theme.red("error")} ${formatError(scan.error)}`);
69
+ return EXIT_USAGE;
70
+ }
71
+
72
+ const report = scan.value;
73
+
74
+ if (options.writeBaseline !== undefined) {
75
+ try {
76
+ await fs.writeTextFile(options.writeBaseline, serializeBaseline(report.findings));
77
+ } catch (error) {
78
+ io.err(
79
+ `${theme.red("error")} could not write ${options.writeBaseline}: ${error instanceof Error ? error.message : String(error)}`,
80
+ );
81
+ return EXIT_USAGE;
82
+ }
83
+ io.err(
84
+ `${theme.dim("!")} wrote ${report.findings.length} finding(s) to ${options.writeBaseline}`,
85
+ );
86
+ }
87
+
88
+ if (options.json) {
89
+ io.out(renderJsonReport(report));
90
+ } else if (options.sarif) {
91
+ io.out(renderSarifReport(report));
92
+ } else {
93
+ io.out(renderHumanReport(report, theme));
94
+ }
95
+
96
+ const failing = newFindings(report.findings, report.baseline !== undefined);
97
+ return exitCodeForFindings(failing, report.failOn) === 0 ? EXIT_OK : EXIT_BLOCKERS;
98
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Colour handling.
3
+ *
4
+ * Two rules, both boring on purpose: `NO_COLOR` always wins when present (the
5
+ * no-color.org convention: presence, not value, disables colour), and colour is
6
+ * never emitted to something that is not a terminal.
7
+ */
8
+ export interface Theme {
9
+ readonly enabled: boolean;
10
+ readonly bold: (text: string) => string;
11
+ readonly dim: (text: string) => string;
12
+ readonly red: (text: string) => string;
13
+ readonly green: (text: string) => string;
14
+ readonly yellow: (text: string) => string;
15
+ readonly cyan: (text: string) => string;
16
+ }
17
+
18
+ const identity = (text: string): string => text;
19
+
20
+ export function colorEnabled(
21
+ env: Readonly<Record<string, string | undefined>>,
22
+ isTty: boolean,
23
+ ): boolean {
24
+ if (env.NO_COLOR !== undefined) {
25
+ return false;
26
+ }
27
+ if (env.FORCE_COLOR !== undefined && env.FORCE_COLOR !== "0") {
28
+ return true;
29
+ }
30
+ return isTty;
31
+ }
32
+
33
+ export function createTheme(enabled: boolean): Theme {
34
+ if (!enabled) {
35
+ return {
36
+ enabled: false,
37
+ bold: identity,
38
+ dim: identity,
39
+ red: identity,
40
+ green: identity,
41
+ yellow: identity,
42
+ cyan: identity,
43
+ };
44
+ }
45
+
46
+ const wrap = (code: string) => {
47
+ return (text: string): string => `\u001B[${code}m${text}\u001B[0m`;
48
+ };
49
+
50
+ return {
51
+ enabled: true,
52
+ bold: wrap("1"),
53
+ dim: wrap("2"),
54
+ red: wrap("31"),
55
+ green: wrap("32"),
56
+ yellow: wrap("33"),
57
+ cyan: wrap("36"),
58
+ };
59
+ }
@@ -0,0 +1,116 @@
1
+ import { defineError, type Result } from "../core/errors";
2
+ import type { Finding } from "../report/types";
3
+
4
+ /**
5
+ * Baseline / regression detection.
6
+ *
7
+ * A baseline records the findings you have already accepted, so CI can fail on
8
+ * what is *new* instead of on a repository's whole history. The fingerprint is
9
+ * deliberately coarse - rule, package and path, not the message - so rewriting
10
+ * a detail string does not resurrect a finding you have already triaged.
11
+ */
12
+ export const BASELINE_SCHEMA_VERSION = 1;
13
+
14
+ export interface Baseline {
15
+ readonly schemaVersion: number;
16
+ readonly findings: readonly string[];
17
+ }
18
+
19
+ export interface BaselineSummary {
20
+ readonly path: string;
21
+ readonly known: number;
22
+ readonly new: number;
23
+ }
24
+
25
+ export function fingerprint(finding: Finding): string {
26
+ return [finding.id, finding.package ?? "", finding.path ?? ""].join("|");
27
+ }
28
+
29
+ export function serializeBaseline(findings: readonly Finding[]): string {
30
+ const fingerprints = [...new Set(findings.map(fingerprint))].sort();
31
+ const baseline: Baseline = { schemaVersion: BASELINE_SCHEMA_VERSION, findings: fingerprints };
32
+ return `${JSON.stringify(baseline, null, 2)}\n`;
33
+ }
34
+
35
+ export function parseBaseline(text: string, source: string): Result<Baseline> {
36
+ let raw: unknown;
37
+ try {
38
+ raw = JSON.parse(text);
39
+ } catch (error) {
40
+ return {
41
+ ok: false,
42
+ error: defineError("E_PARSE", `${source} is not a valid baseline file`, {
43
+ hint: "regenerate it with `bunready <path> --write-baseline <file>`.",
44
+ cause: error,
45
+ }),
46
+ };
47
+ }
48
+
49
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
50
+ return {
51
+ ok: false,
52
+ error: defineError("E_PARSE", `${source} must contain a JSON object`, {
53
+ hint: "regenerate it with `--write-baseline`.",
54
+ }),
55
+ };
56
+ }
57
+
58
+ const findings = (raw as { findings?: unknown }).findings;
59
+ if (!Array.isArray(findings)) {
60
+ return {
61
+ ok: false,
62
+ error: defineError("E_PARSE", `${source} has no findings array`, {
63
+ hint: "regenerate it with `--write-baseline`.",
64
+ }),
65
+ };
66
+ }
67
+
68
+ const schemaVersion = (raw as { schemaVersion?: unknown }).schemaVersion;
69
+ if (schemaVersion !== undefined && typeof schemaVersion !== "number") {
70
+ return {
71
+ ok: false,
72
+ error: defineError("E_PARSE", `${source} has a non-numeric schemaVersion`, {
73
+ hint: "regenerate it with `--write-baseline`.",
74
+ }),
75
+ };
76
+ }
77
+
78
+ return {
79
+ ok: true,
80
+ value: {
81
+ schemaVersion: typeof schemaVersion === "number" ? schemaVersion : BASELINE_SCHEMA_VERSION,
82
+ findings: findings.filter((entry): entry is string => typeof entry === "string"),
83
+ },
84
+ };
85
+ }
86
+
87
+ /** Mark each finding as new or already known, and count both. */
88
+ export function applyBaseline(
89
+ findings: readonly Finding[],
90
+ baseline: Baseline,
91
+ baselinePath: string,
92
+ ): { readonly findings: readonly Finding[]; readonly summary: BaselineSummary } {
93
+ const known = new Set(baseline.findings);
94
+ let knownCount = 0;
95
+
96
+ const marked = findings.map((finding) => {
97
+ const isNew = !known.has(fingerprint(finding));
98
+ if (!isNew) {
99
+ knownCount += 1;
100
+ }
101
+ return { ...finding, isNew };
102
+ });
103
+
104
+ return {
105
+ findings: marked,
106
+ summary: { path: baselinePath, known: knownCount, new: marked.length - knownCount },
107
+ };
108
+ }
109
+
110
+ /** Only the findings a baseline should fail a build on. */
111
+ export function newFindings(
112
+ findings: readonly Finding[],
113
+ hasBaseline: boolean,
114
+ ): readonly Finding[] {
115
+ return hasBaseline ? findings.filter((finding) => finding.isNew === true) : findings;
116
+ }
@@ -0,0 +1,113 @@
1
+ import { defineError, type Result } from "../core/errors";
2
+ import { SEVERITIES, type Severity } from "../rules/severity";
3
+
4
+ /**
5
+ * `bunready.config.json`. Every knob here exists because a real repository
6
+ * needed to say "I know, and it is fine" without editing the tool's rules.
7
+ */
8
+ export const CONFIG_FILENAME = "bunready.config.json";
9
+
10
+ export interface RunConfig {
11
+ readonly script: string | undefined;
12
+ readonly maxCopyMegabytes: number;
13
+ }
14
+
15
+ export interface BunreadyConfig {
16
+ /** Finding ids to drop, e.g. `install/no-lockfile`. */
17
+ readonly ignore: readonly string[];
18
+ /** Package names to drop findings for, matched on the finding's package. */
19
+ readonly ignorePackages: readonly string[];
20
+ /** Packages never reported by the native-addon rule. */
21
+ readonly nativeAllowlist: readonly string[];
22
+ /** Substrings matched against source paths during the import scan. */
23
+ readonly excludePaths: readonly string[];
24
+ /** Lowest severity that makes the process exit non-zero. */
25
+ readonly failOn: Severity;
26
+ readonly run: RunConfig;
27
+ }
28
+
29
+ export const DEFAULT_CONFIG: BunreadyConfig = {
30
+ ignore: [],
31
+ ignorePackages: [],
32
+ nativeAllowlist: [],
33
+ excludePaths: [],
34
+ failOn: "blocker",
35
+ run: { script: undefined, maxCopyMegabytes: 250 },
36
+ };
37
+
38
+ export const DEFAULT_MAX_COPY_MEGABYTES = 250;
39
+
40
+ function isRecord(value: unknown): value is Record<string, unknown> {
41
+ return typeof value === "object" && value !== null && !Array.isArray(value);
42
+ }
43
+
44
+ function readStrings(value: unknown): string[] {
45
+ return Array.isArray(value)
46
+ ? value.filter((entry): entry is string => typeof entry === "string")
47
+ : [];
48
+ }
49
+
50
+ function configError(source: string, detail: string, hint: string): Result<BunreadyConfig> {
51
+ return { ok: false, error: defineError("E_PARSE", `${source} ${detail}`, { hint }) };
52
+ }
53
+
54
+ export function parseConfig(text: string, source = CONFIG_FILENAME): Result<BunreadyConfig> {
55
+ let raw: unknown;
56
+ try {
57
+ raw = JSON.parse(text);
58
+ } catch (error) {
59
+ return {
60
+ ok: false,
61
+ error: defineError("E_PARSE", `${source} is not valid JSON`, {
62
+ hint: "fix the JSON syntax, or delete the file to use the defaults.",
63
+ cause: error,
64
+ }),
65
+ };
66
+ }
67
+
68
+ if (!isRecord(raw)) {
69
+ return configError(
70
+ source,
71
+ "must contain a JSON object",
72
+ "see docs/CONFIGURATION.md for the accepted keys.",
73
+ );
74
+ }
75
+
76
+ const failOnRaw = raw.failOn;
77
+ if (failOnRaw !== undefined && !(SEVERITIES as readonly unknown[]).includes(failOnRaw)) {
78
+ return configError(
79
+ source,
80
+ `has an unknown failOn value "${String(failOnRaw)}"`,
81
+ `use one of: ${SEVERITIES.join(", ")}.`,
82
+ );
83
+ }
84
+
85
+ const runRaw = isRecord(raw.run) ? raw.run : {};
86
+ const maxRaw = runRaw.maxCopyMegabytes;
87
+ if (
88
+ maxRaw !== undefined &&
89
+ (typeof maxRaw !== "number" || !Number.isFinite(maxRaw) || maxRaw <= 0)
90
+ ) {
91
+ return configError(
92
+ source,
93
+ "has an invalid run.maxCopyMegabytes",
94
+ "use a positive number of megabytes.",
95
+ );
96
+ }
97
+
98
+ return {
99
+ ok: true,
100
+ value: {
101
+ ignore: readStrings(raw.ignore),
102
+ ignorePackages: readStrings(raw.ignorePackages),
103
+ nativeAllowlist: readStrings(raw.nativeAllowlist),
104
+ excludePaths: readStrings(raw.excludePaths),
105
+ failOn: failOnRaw === undefined ? DEFAULT_CONFIG.failOn : (failOnRaw as Severity),
106
+ run: {
107
+ script:
108
+ typeof runRaw.script === "string" && runRaw.script !== "" ? runRaw.script : undefined,
109
+ maxCopyMegabytes: maxRaw === undefined ? DEFAULT_MAX_COPY_MEGABYTES : maxRaw,
110
+ },
111
+ },
112
+ };
113
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Typed error model.
3
+ *
4
+ * bunready never throws across module boundaries. Every fallible operation
5
+ * returns a `Result`, and every failure carries enough context for the CLI to
6
+ * say what happened *and* what the user can do about it.
7
+ */
8
+
9
+ /** Stable, machine-readable failure codes. Never reuse or renumber one. */
10
+ export type ErrorCode = "E_USAGE" | "E_IO" | "E_PARSE" | "E_UNSUPPORTED" | "E_INTERNAL";
11
+
12
+ /** A failure a caller can render, report, or recover from. */
13
+ export interface BunreadyError {
14
+ readonly code: ErrorCode;
15
+ readonly message: string;
16
+ /** Concrete next step for the user. Optional in the type, preferred in practice. */
17
+ readonly hint?: string;
18
+ /** Original failure, kept for debugging. Never printed by default. */
19
+ readonly cause?: unknown;
20
+ }
21
+
22
+ export type Ok<T> = { readonly ok: true; readonly value: T };
23
+ export type Err = { readonly ok: false; readonly error: BunreadyError };
24
+
25
+ /** Success or failure. Errors are values here, not control flow. */
26
+ export type Result<T> = Ok<T> | Err;
27
+
28
+ export function ok<T>(value: T): Ok<T> {
29
+ return { ok: true, value };
30
+ }
31
+
32
+ export function err<T = never>(error: BunreadyError): Result<T> {
33
+ return { ok: false, error };
34
+ }
35
+
36
+ export function isOk<T>(result: Result<T>): result is Ok<T> {
37
+ return result.ok;
38
+ }
39
+
40
+ export function isErr<T>(result: Result<T>): result is Err {
41
+ return !result.ok;
42
+ }
43
+
44
+ /** Build an error without ever assigning an explicit `undefined` optional field. */
45
+ export function defineError(
46
+ code: ErrorCode,
47
+ message: string,
48
+ options: { readonly hint?: string; readonly cause?: unknown } = {},
49
+ ): BunreadyError {
50
+ const base: BunreadyError = { code, message };
51
+ const withHint = options.hint === undefined ? base : { ...base, hint: options.hint };
52
+ return options.cause === undefined ? withHint : { ...withHint, cause: options.cause };
53
+ }
54
+
55
+ /** One-line-first rendering used by the CLI. */
56
+ export function formatError(error: BunreadyError): string {
57
+ const head = `${error.code}: ${error.message}`;
58
+ return error.hint === undefined ? head : `${head}\n hint: ${error.hint}`;
59
+ }
package/src/core/fs.ts ADDED
@@ -0,0 +1,72 @@
1
+ import { readdir, readFile, stat, writeFile } from "node:fs/promises";
2
+ import { type BunreadyError, defineError } from "./errors";
3
+
4
+ /**
5
+ * Result of one read attempt. "missing" and "error" are kept apart on purpose:
6
+ * a missing lockfile is normal and reportable, an unreadable one is a failure
7
+ * the user has to know about.
8
+ */
9
+ export type ReadOutcome =
10
+ | { readonly kind: "text"; readonly text: string }
11
+ | { readonly kind: "missing" }
12
+ | { readonly kind: "error"; readonly error: BunreadyError };
13
+
14
+ /** Minimal file seam, so scanners can be exercised without touching the disk. */
15
+ export interface DirectoryEntry {
16
+ readonly name: string;
17
+ readonly isDirectory: boolean;
18
+ }
19
+
20
+ export interface FileSystem {
21
+ readonly readTextFile: (path: string) => Promise<ReadOutcome>;
22
+ readonly writeTextFile: (path: string, text: string) => Promise<void>;
23
+ readonly pathExists: (path: string) => Promise<boolean>;
24
+ readonly listDirectory: (path: string) => Promise<readonly DirectoryEntry[]>;
25
+ }
26
+
27
+ function describe(error: unknown): string {
28
+ return error instanceof Error ? error.message : String(error);
29
+ }
30
+
31
+ export function nodeFileSystem(): FileSystem {
32
+ return {
33
+ readTextFile: async (path) => {
34
+ try {
35
+ return { kind: "text", text: await readFile(path, "utf8") };
36
+ } catch (error) {
37
+ const code = (error as { code?: string }).code;
38
+ if (code === "ENOENT" || code === "ENOTDIR") {
39
+ return { kind: "missing" };
40
+ }
41
+ return {
42
+ kind: "error",
43
+ error: defineError("E_IO", `cannot read ${path}: ${describe(error)}`, {
44
+ hint: "check the file permissions, then run bunready again.",
45
+ cause: error,
46
+ }),
47
+ };
48
+ }
49
+ },
50
+ pathExists: async (path) => {
51
+ try {
52
+ await stat(path);
53
+ return true;
54
+ } catch {
55
+ return false;
56
+ }
57
+ },
58
+ writeTextFile: async (path, text) => {
59
+ await writeFile(path, text, "utf8");
60
+ },
61
+ listDirectory: async (path) => {
62
+ try {
63
+ const entries = await readdir(path, { withFileTypes: true });
64
+ return entries
65
+ .map((entry) => ({ name: entry.name, isDirectory: entry.isDirectory() }))
66
+ .sort((a, b) => a.name.localeCompare(b.name));
67
+ } catch {
68
+ return [];
69
+ }
70
+ },
71
+ };
72
+ }
@@ -0,0 +1,9 @@
1
+ import packageJson from "../../package.json";
2
+
3
+ /**
4
+ * Tool identity, in a module with no dependencies, so anything (CLI, scanner,
5
+ * report) can read it without importing the CLI and creating a cycle.
6
+ */
7
+ export const TOOL_NAME = "bunready";
8
+
9
+ export const TOOL_VERSION: string = packageJson.version;
@@ -0,0 +1,100 @@
1
+ import { verdictLine } from "../cli/copy";
2
+ import type { Theme } from "../cli/theme";
3
+ import type { Finding, ScanReport } from "./types";
4
+
5
+ /**
6
+ * The human report.
7
+ *
8
+ * One finding per block, always the same shape: what, why, the evidence, the
9
+ * next step, the source. Colour is decoration - the severity word is always
10
+ * printed, so a monochrome log carries the same information.
11
+ */
12
+ const LABEL_WIDTH = 7;
13
+
14
+ function label(finding: Finding, theme: Theme): string {
15
+ switch (finding.severity) {
16
+ case "blocker":
17
+ return theme.red("blocker".padEnd(LABEL_WIDTH));
18
+ case "risk":
19
+ return theme.yellow("risk".padEnd(LABEL_WIDTH));
20
+ case "info":
21
+ return theme.cyan("info".padEnd(LABEL_WIDTH));
22
+ }
23
+ }
24
+
25
+ export function renderHumanReport(report: ScanReport, theme: Theme): string {
26
+ const lines: string[] = [];
27
+ const facts: string[] = [];
28
+
29
+ if (report.stats !== undefined) {
30
+ facts.push(`${report.stats.lockedPackages} locked packages`);
31
+ facts.push(`${report.stats.directDependencies + report.stats.devDependencies} direct`);
32
+ if (report.stats.duplicateVersions > 0) {
33
+ facts.push(`${report.stats.duplicateVersions} duplicated`);
34
+ }
35
+ if (report.stats.sourceFiles > 0) {
36
+ facts.push(`${report.stats.sourceFiles} source files`);
37
+ }
38
+ if (report.stats.lockfiles.length > 0) {
39
+ facts.push(
40
+ report.stats.lockfiles.map((path) => path.split(/[\\/]/).pop() ?? path).join(", "),
41
+ );
42
+ }
43
+ }
44
+
45
+ lines.push([theme.bold(`${report.tool} ${report.version}`), ...facts].join(theme.dim(" · ")));
46
+ lines.push(theme.dim(report.target));
47
+
48
+ if (report.targets !== undefined) {
49
+ lines.push(theme.dim(`${report.targets.length} scanned directories:`));
50
+ for (const target of report.targets) {
51
+ lines.push(
52
+ theme.dim(` ${target.kind === "root" ? "." : target.relative} ${target.verdict}`),
53
+ );
54
+ }
55
+ }
56
+
57
+ if (report.baseline !== undefined) {
58
+ lines.push(
59
+ theme.dim(
60
+ `baseline ${report.baseline.path}: ${report.baseline.known} known, ${report.baseline.new} new`,
61
+ ),
62
+ );
63
+ }
64
+
65
+ lines.push("");
66
+
67
+ if (report.findings.length === 0) {
68
+ lines.push("no findings");
69
+ lines.push("");
70
+ } else {
71
+ for (const finding of report.findings) {
72
+ lines.push(
73
+ `${label(finding, theme)} ${theme.bold(finding.title)} ${theme.dim(`(${finding.id})`)}${
74
+ finding.isNew === true ? theme.dim(" new") : ""
75
+ }`,
76
+ );
77
+ if (report.targets !== undefined && finding.path !== undefined) {
78
+ lines.push(` ${theme.dim("at:")} ${finding.path}`);
79
+ }
80
+ lines.push(` ${finding.detail}`);
81
+ if (finding.evidence !== undefined) {
82
+ lines.push(` ${theme.dim("evidence:")} ${finding.evidence}`);
83
+ }
84
+ if (finding.hint !== undefined) {
85
+ lines.push(` ${theme.dim("next:")} ${finding.hint}`);
86
+ }
87
+ if (finding.source !== undefined) {
88
+ lines.push(` ${theme.dim("source:")} ${finding.source}`);
89
+ }
90
+ lines.push("");
91
+ }
92
+ }
93
+
94
+ lines.push(
95
+ `${report.counts.blocker} blocker · ${report.counts.risk} risk · ${report.counts.info} info`,
96
+ );
97
+ lines.push(verdictLine(report.verdict, report.counts));
98
+
99
+ return lines.join("\n");
100
+ }
@@ -0,0 +1,11 @@
1
+ import type { ScanReport } from "./types";
2
+
3
+ /**
4
+ * The machine-readable report.
5
+ *
6
+ * Field names are the contract CI parses, so this stays a straight projection
7
+ * of `ScanReport`: nothing is added, renamed or reordered here.
8
+ */
9
+ export function renderJsonReport(report: ScanReport): string {
10
+ return JSON.stringify(report, null, 2);
11
+ }