Report format
Every check writes one file: $W/findings/<check>.json
where <check> is one of boundaries, circular-deps, caching, ci-coverage, prod-leakage.
{ "status": "ok | partial | skipped | not-run", "reason": "required unless status is ok", "findings": [ { "id": "caching/missing-outputs", "severity": "critical | high | medium | low", "title": "One line, specific: name the package/task", "location": { "file": "turbo.json", "line": 4, "package": "@acme/ui" }, "evidence": "The exact config/code/command output that proves it", "fix": "Concrete change, ideally a snippet" } ]}ok: check ran fully.partial: some sub-steps skipped (say which inreason).skipped: check could not run at all.not-run: user scoped it out. A missing findings file is rendered asskippedwith reasonno findings file; for user-scoped-out checks, write an explicitnot-runfile at$W/findings/<check>.json.location.fileis relative to the workspace root. Includepackagewhenever the finding belongs to one package.- Never emit a finding without
evidence. If you are unsure, lower severity and say so inevidence; do not guess. - Use only the ids defined in the check reference files.
Severity rubric
Section titled “Severity rubric”| Severity | Meaning |
|---|---|
| critical | Wrong code can ship or tests are silently skipped for real changes |
| high | Likely incorrect builds/caching or broken CI signal |
| medium | Correctness risk under specific conditions, or a boundary erosion |
| low | Hygiene, performance, or maintainability |
Severity may be lowered one level with a stated reason (e.g., package is private). Never raise it.
Render
Section titled “Render”After all checks, run this snippet. It validates every findings file and fails loudly (stderr, exit 1) if a status is outside the enum, reason is missing for a non-ok status, a finding id does not start with <check>/, or evidence is missing/empty. Arguments: output directory (default: $(node -p 'require("os").tmpdir()')/monorepo-doctor-reports). The default is outside $W because detection wipes $W. Write into the audited repo only when the user gives that path explicitly.
W="$(node -p 'require("os").tmpdir()')/monorepo-doctor"cat > "$W/render.mjs" <<'EOF'import fs from 'node:fs';import os from 'node:os';import path from 'node:path';const work = path.join(os.tmpdir(), 'monorepo-doctor');const ws = JSON.parse(fs.readFileSync(path.join(work, 'workspace.json'), 'utf8'));const outDir = path.resolve(process.argv[2] ?? path.join(os.tmpdir(), 'monorepo-doctor-reports'));fs.mkdirSync(outDir, { recursive: true });const CHECKS = ['boundaries', 'circular-deps', 'caching', 'ci-coverage', 'prod-leakage'];const STATUS = ['ok', 'partial', 'skipped', 'not-run'];const SEV = ['critical', 'high', 'medium', 'low'];const checks = [], findings = [], problems = [];for (const check of CHECKS) { const p = path.join(work, 'findings', `${check}.json`); let r; if (fs.existsSync(p)) { try { r = JSON.parse(fs.readFileSync(p, 'utf8')); } catch (e) { problems.push(`${check}: invalid JSON: ${e.message}`); r = { status: 'skipped', reason: 'invalid findings file', findings: [] }; } } else { r = { status: 'skipped', reason: 'no findings file', findings: [] }; } if (!STATUS.includes(r.status)) problems.push(`${check}: invalid status ${JSON.stringify(r.status)}`); if (r.status !== 'ok' && !String(r.reason ?? '').trim()) problems.push(`${check}: reason is required when status is ${r.status}`); const list = Array.isArray(r.findings) ? r.findings : (problems.push(`${check}: findings must be an array`), []); checks.push({ check, status: r.status, reason: r.reason, findingCount: list.length }); for (const f of list) { if (!SEV.includes(f.severity)) problems.push(`${check}: unknown severity ${JSON.stringify(f.severity)}${f.id ? ` (${f.id})` : ''}`); if (typeof f.id !== 'string' || !f.id.startsWith(`${check}/`)) problems.push(`${check}: finding id must start with ${check}/ (${f.id ?? 'missing'})`); if (!String(f.evidence ?? '').trim()) problems.push(`${check}: finding ${f.id ?? '?'} missing evidence`); findings.push({ ...f, check }); }}if (problems.length) { for (const problem of problems) console.error(`ERROR: ${problem}`); process.exit(1);}const safe = (s) => String(s ?? '');findings.sort((a, b) => SEV.indexOf(a.severity) - SEV.indexOf(b.severity) || safe(a.id).localeCompare(safe(b.id)) || safe(a.location?.file).localeCompare(safe(b.location?.file)) || ((a.location?.line ?? 0) - (b.location?.line ?? 0)));const summary = Object.fromEntries(SEV.map((s) => [s, findings.filter((f) => f.severity === s).length]));const report = { version: 1, generatedAt: new Date().toISOString(), workspace: { root: ws.root, packageManager: ws.packageManager, orchestrators: ws.orchestrators, packageCount: ws.packages.length }, summary, checks, findings,};fs.writeFileSync(path.join(outDir, 'monorepo-doctor-report.json'), JSON.stringify(report, null, 2) + '\n');const cell = (v) => safe(v).replace(/\|/g, '\\|').replace(/\r?\n/g, '<br>');const heading = (v) => safe(v).replace(/\s*\r?\n\s*/g, ' ');// Escape emphasis chars outside code spans so globs like **/*.spec.ts render verbatim.const inline = (v) => safe(v).split(/(`+[^`]*`+)/).map((p, i) => i % 2 ? p : p.replace(/[\\*_]/g, '\\$&')).join('');const loc = (l) => l?.file ? `\`${l.file}${l.line ? `:${l.line}` : ''}\`${l.package ? ` (${l.package})` : ''}` : '—';const fenceFor = (s) => '`'.repeat(Math.max(3, ...[...safe(s).matchAll(/`+/g)].map((m) => m[0].length + 1)));const md = [ '# Monorepo Doctor Report', '', `**Workspace:** ${ws.packageManager} · ${ws.orchestrators.map((o) => o.name).join(', ') || 'no orchestrator'} · ${ws.packages.length} packages`, '', `**Summary:** ${SEV.map((s) => `${summary[s]} ${s}`).join(' · ')}`, '', '| Check | Status | Findings | Note |', '|---|---|---|---|', ...checks.map((c) => `| ${cell(c.check)} | ${cell(c.status)} | ${c.findingCount} | ${cell(c.reason ?? '')} |`), '',];for (const s of SEV) { const fs_ = findings.filter((f) => f.severity === s); if (!fs_.length) continue; md.push(`## ${s[0].toUpperCase() + s.slice(1)}`, ''); for (const f of fs_) { const fence = fenceFor(f.evidence); const fix = safe(f.fix); const fixLines = /\r?\n/.test(fix) ? ['**Fix**', '', fenceFor(fix), fix, fenceFor(fix)] : [`**Fix:** ${inline(fix) || '—'}`]; md.push(`### ${heading(f.title)}`, '', `- **id:** ${f.id ? `\`${f.id}\`` : '—'}`, `- **where:** ${loc(f.location)}`, '', '**Evidence**', '', fence, safe(f.evidence), fence, '', ...fixLines, ''); }}if (!findings.length) md.push('No findings. 🎉', '');fs.writeFileSync(path.join(outDir, 'monorepo-doctor-report.md'), md.join('\n'));console.log(JSON.stringify({ outDir, summary }));EOFnode "$W/render.mjs" "<output-dir>" # replace <output-dir> with a literal path; use the user-given path, or omit it to write to <tmpdir>/monorepo-doctor-reportsTell the user the absolute report paths (the render step prints outDir).
CI mode
Section titled “CI mode”When the user asks for CI mode (or a fail threshold), write the reports first. In hosted CI, the agent step itself may not be able to fail the job reliably, so document/run this threshold check as a separate workflow step against the report file:
node -e 'const fs = require("node:fs");const path = require("node:path");const order = ["low", "medium", "high", "critical"];const reportPath = process.argv[1];const threshold = process.argv[2] || "high";const min = order.indexOf(threshold);if (!reportPath || min === -1) { console.error("usage: node -e <script> <report.json> <low|medium|high|critical>"); process.exit(2);}const r = JSON.parse(fs.readFileSync(path.resolve(reportPath), "utf8"));const bad = (r.findings ?? []).filter((f) => order.indexOf(f.severity) >= min);for (const f of bad) console.error(`${f.severity.toUpperCase()} ${f.id ?? "—"} ${f.location?.file ?? "—"}`);process.exit(bad.length ? 1 : 0);' "<output-dir>/monorepo-doctor-report.json" high