@mcptoolshop/mcpt-publishing 1.0.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.
@@ -0,0 +1,102 @@
1
+ /**
2
+ * PyPI provider — audits Python packages on pypi.org.
3
+ *
4
+ * Uses the PyPI JSON API: https://pypi.org/pypi/<pkg>/json
5
+ */
6
+
7
+ import { execSync } from "node:child_process";
8
+ import { Provider } from "../provider.mjs";
9
+
10
+ export default class PyPIProvider extends Provider {
11
+ get name() { return "pypi"; }
12
+
13
+ detect(entry) {
14
+ return entry.ecosystem === "pypi";
15
+ }
16
+
17
+ async audit(entry, ctx) {
18
+ const meta = this.#fetchMeta(entry.name);
19
+ const tags = ctx.tags.get(entry.repo) ?? [];
20
+ const releases = ctx.releases.get(entry.repo) ?? [];
21
+
22
+ if (!meta) {
23
+ return {
24
+ version: "?",
25
+ findings: [{ severity: "RED", code: "pypi-unreachable", msg: `Cannot reach ${entry.name} on PyPI` }],
26
+ };
27
+ }
28
+
29
+ const version = meta.info?.version ?? "?";
30
+ const findings = this.#classify(entry, meta, version, tags, releases);
31
+ return { version, findings };
32
+ }
33
+
34
+ // ─── Private ───────────────────────────────────────────────────────────────
35
+
36
+ #fetchMeta(pkg) {
37
+ try {
38
+ const url = `https://pypi.org/pypi/${pkg}/json`;
39
+ const raw = execSync(`curl -sf "${url}"`, { encoding: "utf8", timeout: 15_000 });
40
+ return JSON.parse(raw);
41
+ } catch { return null; }
42
+ }
43
+
44
+ #classify(entry, meta, version, tags, releases) {
45
+ const findings = [];
46
+ const tagName = `v${version}`;
47
+
48
+ // Published-but-not-tagged (RED)
49
+ if (!tags.includes(tagName)) {
50
+ findings.push({
51
+ severity: "RED",
52
+ code: "published-not-tagged",
53
+ msg: `${entry.name}@${version} — no git tag ${tagName}`,
54
+ });
55
+ }
56
+
57
+ // Tagged-but-not-released (YELLOW for front-door)
58
+ if (tags.includes(tagName) && !releases.includes(tagName) && entry.audience === "front-door") {
59
+ findings.push({
60
+ severity: "YELLOW",
61
+ code: "tagged-not-released",
62
+ msg: `${entry.name} tag ${tagName} has no GitHub Release`,
63
+ });
64
+ }
65
+
66
+ // Description / summary
67
+ const summary = meta.info?.summary ?? "";
68
+ if (!summary) {
69
+ findings.push({
70
+ severity: entry.audience === "front-door" ? "YELLOW" : "GRAY",
71
+ code: "missing-description",
72
+ msg: `${entry.name} has no summary on PyPI`,
73
+ });
74
+ }
75
+
76
+ // Homepage / project URL
77
+ const projectUrl = meta.info?.home_page || meta.info?.project_urls?.Homepage || "";
78
+ if (!projectUrl) {
79
+ findings.push({
80
+ severity: "GRAY",
81
+ code: "missing-homepage",
82
+ msg: `${entry.name} has no homepage on PyPI`,
83
+ });
84
+ }
85
+
86
+ return findings;
87
+ }
88
+
89
+ receipt(result) {
90
+ const [owner, name] = result.repo.split("/");
91
+ return {
92
+ schemaVersion: "1.0.0",
93
+ repo: { owner, name },
94
+ target: "pypi",
95
+ version: result.version,
96
+ packageName: result.name,
97
+ commitSha: result.commitSha,
98
+ timestamp: new Date().toISOString(),
99
+ artifacts: result.artifacts ?? [],
100
+ };
101
+ }
102
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Receipt writer — validates and persists publish receipts.
3
+ *
4
+ * Receipts are immutable once written (append-only directory).
5
+ * Path: receipts/publish/<owner>--<name>/<target>/<version>.json
6
+ */
7
+
8
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
9
+ import { join, dirname } from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+
12
+ const __dirname = dirname(fileURLToPath(import.meta.url));
13
+ const ROOT = join(__dirname, "..", "..");
14
+
15
+ const VALID_TARGETS = ["npm", "nuget", "pypi", "ghcr"];
16
+
17
+ /**
18
+ * Validate receipt data against the receipt schema (lightweight, no Ajv).
19
+ * @param {object} receipt
20
+ * @throws {Error} on validation failure
21
+ */
22
+ export function validate(receipt) {
23
+ if (!receipt || typeof receipt !== "object") throw new Error("Receipt must be an object");
24
+
25
+ // Required top-level fields
26
+ const required = ["schemaVersion", "repo", "target", "version", "packageName", "commitSha", "timestamp", "artifacts"];
27
+ for (const field of required) {
28
+ if (!(field in receipt)) throw new Error(`Receipt missing required field: ${field}`);
29
+ }
30
+
31
+ // schemaVersion
32
+ if (receipt.schemaVersion !== "1.0.0") {
33
+ throw new Error(`Unknown schemaVersion: ${receipt.schemaVersion}`);
34
+ }
35
+
36
+ // repo
37
+ if (typeof receipt.repo !== "object" || !receipt.repo.owner || !receipt.repo.name) {
38
+ throw new Error("repo must have owner and name");
39
+ }
40
+
41
+ // target
42
+ if (!VALID_TARGETS.includes(receipt.target)) {
43
+ throw new Error(`Invalid target: ${receipt.target} (expected one of ${VALID_TARGETS.join(", ")})`);
44
+ }
45
+
46
+ // version
47
+ if (typeof receipt.version !== "string" || !receipt.version) {
48
+ throw new Error("version must be a non-empty string");
49
+ }
50
+
51
+ // packageName
52
+ if (typeof receipt.packageName !== "string" || !receipt.packageName) {
53
+ throw new Error("packageName must be a non-empty string");
54
+ }
55
+
56
+ // commitSha — 40 hex chars
57
+ if (typeof receipt.commitSha !== "string" || !/^[0-9a-f]{40}$/.test(receipt.commitSha)) {
58
+ throw new Error("commitSha must be a 40-character lowercase hex string");
59
+ }
60
+
61
+ // timestamp — ISO 8601
62
+ if (typeof receipt.timestamp !== "string" || !receipt.timestamp) {
63
+ throw new Error("timestamp must be a non-empty string");
64
+ }
65
+
66
+ // artifacts
67
+ if (!Array.isArray(receipt.artifacts)) {
68
+ throw new Error("artifacts must be an array");
69
+ }
70
+ for (const art of receipt.artifacts) {
71
+ if (!art.name || typeof art.name !== "string") throw new Error("artifact.name required");
72
+ if (typeof art.sha256 !== "string" || !/^[0-9a-f]{64}$/.test(art.sha256)) {
73
+ throw new Error(`Invalid artifact sha256: ${art.sha256}`);
74
+ }
75
+ if (typeof art.size !== "number" || art.size < 0 || !Number.isInteger(art.size)) {
76
+ throw new Error(`Invalid artifact size: ${art.size}`);
77
+ }
78
+ if (!art.url || typeof art.url !== "string") throw new Error("artifact.url required");
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Build the filesystem path for a receipt.
84
+ * @param {object} receipt
85
+ * @returns {string} Absolute path
86
+ */
87
+ function receiptPath(receipt) {
88
+ const slug = `${receipt.repo.owner}--${receipt.repo.name}`;
89
+ return join(ROOT, "receipts", "publish", slug, receipt.target, `${receipt.version}.json`);
90
+ }
91
+
92
+ /**
93
+ * Write a receipt to the immutable store.
94
+ * @param {object} receipt - Validated receipt data
95
+ * @returns {string} Absolute path of written receipt
96
+ * @throws {Error} if receipt already exists (immutability) or validation fails
97
+ */
98
+ export function write(receipt) {
99
+ validate(receipt);
100
+
101
+ const filePath = receiptPath(receipt);
102
+
103
+ if (existsSync(filePath)) {
104
+ throw new Error(`Receipt already exists (immutable): ${filePath}`);
105
+ }
106
+
107
+ mkdirSync(dirname(filePath), { recursive: true });
108
+ writeFileSync(filePath, JSON.stringify(receipt, null, 2) + "\n");
109
+ return filePath;
110
+ }
111
+
112
+ /**
113
+ * Read an existing receipt.
114
+ * @param {string} repoSlug - "owner--name"
115
+ * @param {string} target - "npm" | "nuget" | "pypi" | "ghcr"
116
+ * @param {string} version
117
+ * @returns {object|null}
118
+ */
119
+ export function read(repoSlug, target, version) {
120
+ const filePath = join(ROOT, "receipts", "publish", repoSlug, target, `${version}.json`);
121
+ if (!existsSync(filePath)) return null;
122
+ return JSON.parse(readFileSync(filePath, "utf8"));
123
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Provider registry — auto-discovers and validates providers.
3
+ *
4
+ * Scans scripts/lib/providers/*.mjs, imports each default export,
5
+ * and validates it extends Provider with required method overrides.
6
+ */
7
+
8
+ import { readdirSync } from "node:fs";
9
+ import { join, dirname } from "node:path";
10
+ import { fileURLToPath, pathToFileURL } from "node:url";
11
+ import { Provider } from "./provider.mjs";
12
+
13
+ const __dirname = dirname(fileURLToPath(import.meta.url));
14
+ const PROVIDERS_DIR = join(__dirname, "providers");
15
+
16
+ const REQUIRED_METHODS = ["detect", "audit"];
17
+
18
+ /**
19
+ * Dynamically import all .mjs files from providers/ directory.
20
+ * Each must export default a class extending Provider.
21
+ * @returns {Promise<Provider[]>}
22
+ */
23
+ export async function loadProviders() {
24
+ const files = readdirSync(PROVIDERS_DIR).filter(f => f.endsWith(".mjs"));
25
+ const providers = [];
26
+
27
+ for (const file of files) {
28
+ const filePath = join(PROVIDERS_DIR, file);
29
+ const fileUrl = pathToFileURL(filePath).href;
30
+ const mod = await import(fileUrl);
31
+ const Ctor = mod.default;
32
+
33
+ if (!Ctor || typeof Ctor !== "function") {
34
+ throw new Error(`${file}: default export must be a class`);
35
+ }
36
+
37
+ // Verify it extends Provider
38
+ if (!(Ctor.prototype instanceof Provider)) {
39
+ throw new Error(`${file}: default export must extend Provider`);
40
+ }
41
+
42
+ const instance = new Ctor();
43
+
44
+ // Validate required methods are overridden (not the base class stubs)
45
+ for (const method of REQUIRED_METHODS) {
46
+ if (instance[method] === Provider.prototype[method]) {
47
+ throw new Error(`${file}: must override ${method}()`);
48
+ }
49
+ }
50
+
51
+ providers.push(instance);
52
+ }
53
+
54
+ return providers;
55
+ }
56
+
57
+ /**
58
+ * Given an entry, return the providers that claim it via detect().
59
+ * @param {Provider[]} providers
60
+ * @param {object} entry
61
+ * @returns {Provider[]}
62
+ */
63
+ export function matchProviders(providers, entry) {
64
+ return providers.filter(p => p.detect(entry));
65
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Named exit codes for CI-friendly CLI behavior.
3
+ *
4
+ * 0 — success
5
+ * 1 — reserved for uncaught exceptions (Node default)
6
+ * 2 — drift found (audit found RED-severity issues)
7
+ * 3 — config or schema error
8
+ * 4 — missing credentials for a requested operation
9
+ */
10
+ export const EXIT = {
11
+ SUCCESS: 0,
12
+ DRIFT_FOUND: 2,
13
+ CONFIG_ERROR: 3,
14
+ MISSING_CREDENTIALS: 4,
15
+ };
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Help text for the mcpt-publishing CLI.
3
+ */
4
+
5
+ export const GLOBAL_HELP = `
6
+ mcpt-publishing — Publishing health auditor and receipt factory plugin.
7
+
8
+ Usage:
9
+ mcpt-publishing <command> [flags]
10
+
11
+ Commands:
12
+ audit Run publishing health audit across all registries
13
+ init Scaffold publishing.config.json and starter manifest
14
+ plan Dry-run publish plan (shows what would happen)
15
+ publish Execute publish and generate receipts
16
+ providers List registered providers and their status
17
+
18
+ Global flags:
19
+ --help Show help for a command
20
+ --version Show version
21
+ --json Machine-readable output (supported by all commands)
22
+
23
+ Examples:
24
+ mcpt-publishing audit # audit all packages, write reports
25
+ mcpt-publishing audit --json # JSON output to stdout
26
+ mcpt-publishing init # scaffold config in current directory
27
+ mcpt-publishing providers # list available providers
28
+
29
+ Environment:
30
+ PUBLISHING_CONFIG Path to publishing.config.json (overrides walk-up discovery)
31
+ GH_TOKEN GitHub token for API access (tags, releases, issues)
32
+
33
+ Docs: https://github.com/mcp-tool-shop/mcpt-publishing
34
+ `.trim();
@@ -0,0 +1,120 @@
1
+ /**
2
+ * CLI router — zero-dependency subcommand parser and dispatcher.
3
+ */
4
+
5
+ import { readFileSync } from "node:fs";
6
+ import { join, dirname } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import { GLOBAL_HELP } from "./help.mjs";
9
+
10
+ const __dirname = dirname(fileURLToPath(import.meta.url));
11
+
12
+ /** Lazy-loaded command map. Keys are subcommand names. */
13
+ const COMMANDS = {
14
+ audit: () => import("../commands/audit.mjs"),
15
+ init: () => import("../commands/init.mjs"),
16
+ plan: () => import("../commands/plan.mjs"),
17
+ publish: () => import("../commands/publish.mjs"),
18
+ providers: () => import("../commands/providers.mjs"),
19
+ };
20
+
21
+ /**
22
+ * Parse CLI flags from an argv slice (after the subcommand).
23
+ *
24
+ * --flag → { flag: true }
25
+ * --key value → { key: "value" }
26
+ * bare → pushed to _positionals
27
+ *
28
+ * @param {string[]} args
29
+ * @returns {object}
30
+ */
31
+ export function parseFlags(args) {
32
+ const flags = { _positionals: [] };
33
+ for (let i = 0; i < args.length; i++) {
34
+ const arg = args[i];
35
+ if (arg === "--") {
36
+ flags._positionals.push(...args.slice(i + 1));
37
+ break;
38
+ }
39
+ if (arg.startsWith("--")) {
40
+ const key = arg.slice(2);
41
+ const next = args[i + 1];
42
+ if (!next || next.startsWith("--")) {
43
+ flags[key] = true;
44
+ } else {
45
+ flags[key] = next;
46
+ i++;
47
+ }
48
+ } else if (arg.startsWith("-") && arg.length === 2) {
49
+ // Short flag aliases
50
+ const SHORT = { h: "help", v: "version", j: "json" };
51
+ const expanded = SHORT[arg[1]];
52
+ if (expanded) flags[expanded] = true;
53
+ else flags._positionals.push(arg);
54
+ } else {
55
+ flags._positionals.push(arg);
56
+ }
57
+ }
58
+ return flags;
59
+ }
60
+
61
+ /**
62
+ * Main entry point — parse argv and dispatch to the matching command.
63
+ * @param {string[]} argv - process.argv
64
+ */
65
+ export async function run(argv) {
66
+ const args = argv.slice(2);
67
+ const subcommand = args[0];
68
+
69
+ // --help / -h at global level (before subcommand lookup)
70
+ if (args.includes("--help") || args.includes("-h")) {
71
+ // If a valid subcommand precedes --help, show per-command help
72
+ if (subcommand && COMMANDS[subcommand]) {
73
+ // handled below after flag parsing
74
+ } else {
75
+ process.stdout.write(GLOBAL_HELP + "\n");
76
+ process.exit(0);
77
+ }
78
+ }
79
+
80
+ // --version at global level
81
+ if (args.includes("--version") || args.includes("-v")) {
82
+ const pkgPath = join(__dirname, "..", "..", "package.json");
83
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
84
+ process.stdout.write(pkg.version + "\n");
85
+ process.exit(0);
86
+ }
87
+
88
+ // No subcommand or unknown → global help
89
+ if (!subcommand || !COMMANDS[subcommand]) {
90
+ if (subcommand && !subcommand.startsWith("-") && !COMMANDS[subcommand]) {
91
+ process.stderr.write(`Unknown command: ${subcommand}\n\n`);
92
+ }
93
+ process.stdout.write(GLOBAL_HELP + "\n");
94
+ process.exit(!subcommand || subcommand.startsWith("-") ? 0 : 3);
95
+ }
96
+
97
+ // Parse flags after the subcommand
98
+ const flags = parseFlags(args.slice(1));
99
+
100
+ // Per-command help
101
+ if (flags.help) {
102
+ const mod = await COMMANDS[subcommand]();
103
+ if (mod.helpText) {
104
+ process.stdout.write(mod.helpText + "\n");
105
+ } else {
106
+ process.stdout.write(GLOBAL_HELP + "\n");
107
+ }
108
+ process.exit(0);
109
+ }
110
+
111
+ // Dispatch
112
+ try {
113
+ const mod = await COMMANDS[subcommand]();
114
+ const exitCode = await mod.execute(flags);
115
+ process.exit(exitCode ?? 0);
116
+ } catch (e) {
117
+ process.stderr.write(`Error: ${e.message}\n`);
118
+ process.exit(3);
119
+ }
120
+ }
@@ -0,0 +1,238 @@
1
+ /**
2
+ * `mcpt-publishing audit` — run publishing health audit across all registries.
3
+ *
4
+ * Loads config → reads manifest → imports providers from scripts/lib/registry.mjs
5
+ * → runs orchestration loop → writes reports → emits audit receipt.
6
+ *
7
+ * Exit codes:
8
+ * 0 — all clean
9
+ * 2 — RED-severity drift found
10
+ * 3 — config or file error
11
+ */
12
+
13
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
14
+ import { join, dirname } from "node:path";
15
+ import { fileURLToPath, pathToFileURL } from "node:url";
16
+ import { loadConfig } from "../config/loader.mjs";
17
+ import { emitAuditReceipt } from "../receipts/audit-receipt.mjs";
18
+ import { EXIT } from "../cli/exit-codes.mjs";
19
+
20
+ const __dirname = dirname(fileURLToPath(import.meta.url));
21
+
22
+ // Ecosystem labels for markdown headings
23
+ const ECOSYSTEM_LABELS = {
24
+ npm: "npm",
25
+ nuget: "NuGet",
26
+ pypi: "PyPI",
27
+ ghcr: "GHCR",
28
+ };
29
+
30
+ export const helpText = `
31
+ mcpt-publishing audit — Run publishing health audit.
32
+
33
+ Usage:
34
+ mcpt-publishing audit [flags]
35
+
36
+ Flags:
37
+ --json Output JSON to stdout (skip markdown reports)
38
+ --config Explicit path to publishing.config.json
39
+ --help Show this help
40
+
41
+ Exit codes:
42
+ 0 All packages clean
43
+ 2 RED-severity drift detected (CI-friendly non-zero)
44
+
45
+ Examples:
46
+ mcpt-publishing audit # writes reports/latest.md + .json
47
+ mcpt-publishing audit --json # JSON to stdout only
48
+ `.trim();
49
+
50
+ /**
51
+ * Execute the audit command.
52
+ * @param {object} flags - Parsed CLI flags
53
+ * @returns {number} Exit code
54
+ */
55
+ export async function execute(flags) {
56
+ // Load config
57
+ const config = flags.config
58
+ ? loadConfig(dirname(flags.config))
59
+ : loadConfig();
60
+
61
+ // Read manifest
62
+ const manifestPath = join(config.profilesDir, "manifest.json");
63
+ if (!existsSync(manifestPath)) {
64
+ process.stderr.write(`Error: Manifest not found at ${manifestPath}\n`);
65
+ process.stderr.write(`Run 'mcpt-publishing init' to scaffold the project.\n`);
66
+ return EXIT.CONFIG_ERROR;
67
+ }
68
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
69
+
70
+ // Import provider registry from scripts/lib (reuse existing working code)
71
+ const registryPath = join(__dirname, "..", "..", "scripts", "lib", "registry.mjs");
72
+ const registryUrl = pathToFileURL(registryPath).href;
73
+ const { loadProviders, matchProviders } = await import(registryUrl);
74
+
75
+ const providers = await loadProviders();
76
+
77
+ // Optional: filter by enabledProviders config
78
+ const enabled = config.enabledProviders ?? [];
79
+ const activeProviders = enabled.length > 0
80
+ ? providers.filter(p => enabled.includes(p.name))
81
+ : providers;
82
+
83
+ // Shared context for tag/release caching
84
+ const ctx = {
85
+ tags: new Map(),
86
+ releases: new Map(),
87
+ };
88
+
89
+ // Find the GitHub provider (context loader) — must run before ecosystem providers
90
+ const ghProvider = activeProviders.find(p => p.name === "github");
91
+
92
+ // Build results object with an array per ecosystem key present in the manifest
93
+ const results = {};
94
+ for (const key of Object.keys(manifest)) {
95
+ if (Array.isArray(manifest[key])) results[key] = [];
96
+ }
97
+ results.generated = new Date().toISOString();
98
+
99
+ const allFindings = [];
100
+
101
+ // Process each ecosystem section from the manifest
102
+ for (const [ecosystem, packages] of Object.entries(manifest)) {
103
+ if (!Array.isArray(packages)) continue;
104
+ process.stderr.write(`Auditing ${packages.length} ${ECOSYSTEM_LABELS[ecosystem] ?? ecosystem} packages...\n`);
105
+
106
+ for (const pkg of packages) {
107
+ const entry = { ...pkg, ecosystem };
108
+
109
+ // Ensure GitHub context (tags + releases) is loaded for this repo
110
+ if (ghProvider && ghProvider.detect(entry)) {
111
+ await ghProvider.audit(entry, ctx);
112
+ }
113
+
114
+ // Find the ecosystem-specific provider(s)
115
+ const ecosystemProviders = matchProviders(activeProviders, entry).filter(p => p.name !== "github");
116
+
117
+ let version = "?";
118
+ const findings = [];
119
+
120
+ for (const provider of ecosystemProviders) {
121
+ const result = await provider.audit(entry, ctx);
122
+ if (result.version && result.version !== "?") version = result.version;
123
+ findings.push(...result.findings);
124
+ }
125
+
126
+ const resultEntry = {
127
+ name: pkg.name,
128
+ version,
129
+ repo: pkg.repo,
130
+ audience: pkg.audience,
131
+ findings,
132
+ };
133
+
134
+ if (results[ecosystem]) {
135
+ results[ecosystem].push(resultEntry);
136
+ }
137
+ allFindings.push(...findings.map(f => ({ ...f, pkg: pkg.name, ecosystem })));
138
+ }
139
+ }
140
+
141
+ // Counts
142
+ const red = allFindings.filter(f => f.severity === "RED");
143
+ const yellow = allFindings.filter(f => f.severity === "YELLOW");
144
+ const gray = allFindings.filter(f => f.severity === "GRAY");
145
+ const info = allFindings.filter(f => f.severity === "INFO");
146
+ results.counts = { RED: red.length, YELLOW: yellow.length, GRAY: gray.length, INFO: info.length };
147
+
148
+ // Count total packages
149
+ let totalPackages = 0;
150
+ for (const [, val] of Object.entries(results)) {
151
+ if (Array.isArray(val)) totalPackages += val.length;
152
+ }
153
+ results.totalPackages = totalPackages;
154
+
155
+ // JSON-only mode
156
+ if (flags.json) {
157
+ process.stdout.write(JSON.stringify(results, null, 2) + "\n");
158
+ emitAuditReceipt(config, results);
159
+ return red.length > 0 ? EXIT.DRIFT_FOUND : EXIT.SUCCESS;
160
+ }
161
+
162
+ // Generate markdown report
163
+ const md = buildMarkdownReport(results, manifest, allFindings, red, yellow, gray, info);
164
+
165
+ // Write reports
166
+ mkdirSync(config.reportsDir, { recursive: true });
167
+ writeFileSync(join(config.reportsDir, "latest.md"), md);
168
+ writeFileSync(join(config.reportsDir, "latest.json"), JSON.stringify(results, null, 2));
169
+
170
+ const infoSuffix = info.length > 0 ? ` INFO=${info.length} (indexing — retry later)` : "";
171
+ process.stderr.write(`\nDone. RED=${red.length} YELLOW=${yellow.length} GRAY=${gray.length}${infoSuffix}\n`);
172
+ process.stderr.write(`Reports written to ${config.reportsDir}/latest.md and latest.json\n`);
173
+
174
+ // Emit audit receipt
175
+ emitAuditReceipt(config, results);
176
+
177
+ return red.length > 0 ? EXIT.DRIFT_FOUND : EXIT.SUCCESS;
178
+ }
179
+
180
+ // ─── Internal ────────────────────────────────────────────────────────────────
181
+
182
+ function buildMarkdownReport(results, manifest, allFindings, red, yellow, gray, info) {
183
+ const lines = [];
184
+ lines.push("# Publishing Health Report");
185
+ lines.push("");
186
+ lines.push(`> Generated: ${results.generated}`);
187
+ lines.push("");
188
+ const infoLabel = info.length > 0 ? ` | **INFO: ${info.length}** (indexing)` : "";
189
+ lines.push(`**RED: ${red.length}** | **YELLOW: ${yellow.length}** | **GRAY: ${gray.length}**${infoLabel}`);
190
+ lines.push("");
191
+
192
+ if (red.length + yellow.length + info.length > 0) {
193
+ lines.push("## Top Actions");
194
+ lines.push("");
195
+ for (const f of [...red, ...yellow, ...info].slice(0, 10)) {
196
+ lines.push(`- **${f.severity}** ${f.msg}`);
197
+ }
198
+ lines.push("");
199
+ }
200
+
201
+ // Group by package
202
+ const byRepo = {};
203
+ for (const f of allFindings) {
204
+ const key = f.pkg;
205
+ if (!byRepo[key]) byRepo[key] = [];
206
+ byRepo[key].push(f);
207
+ }
208
+
209
+ if (Object.keys(byRepo).length > 0) {
210
+ lines.push("## Findings by Package");
211
+ lines.push("");
212
+ for (const [pkg, findings] of Object.entries(byRepo)) {
213
+ lines.push(`### ${pkg}`);
214
+ for (const f of findings) {
215
+ lines.push(`- **${f.severity}** [${f.code}] ${f.msg}`);
216
+ }
217
+ lines.push("");
218
+ }
219
+ }
220
+
221
+ // Summary tables per ecosystem
222
+ for (const [ecosystem, packages] of Object.entries(manifest)) {
223
+ if (!Array.isArray(packages) || packages.length === 0) continue;
224
+ const label = ECOSYSTEM_LABELS[ecosystem] ?? ecosystem;
225
+
226
+ lines.push(`## ${label} Packages`);
227
+ lines.push("");
228
+ lines.push("| Package | Version | Audience | Issues |");
229
+ lines.push("|---------|---------|----------|--------|");
230
+ for (const e of results[ecosystem] ?? []) {
231
+ const issues = e.findings.length === 0 ? "clean" : e.findings.map(f => f.severity).join(", ");
232
+ lines.push(`| ${e.name} | ${e.version} | ${e.audience} | ${issues} |`);
233
+ }
234
+ lines.push("");
235
+ }
236
+
237
+ return lines.join("\n");
238
+ }