Skip to content

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 in reason). skipped: check could not run at all. not-run: user scoped it out. A missing findings file is rendered as skipped with reason no findings file; for user-scoped-out checks, write an explicit not-run file at $W/findings/<check>.json.
  • location.file is relative to the workspace root. Include package whenever the finding belongs to one package.
  • Never emit a finding without evidence. If you are unsure, lower severity and say so in evidence; do not guess.
  • Use only the ids defined in the check reference files.
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.

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.

Terminal window
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 }));
EOF
node "$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-reports

Tell the user the absolute report paths (the render step prints outDir).

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:

Terminal window
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