diff --git a/trios/agent-server/apps/server/src/api/services/queen-tick.ts b/trios/agent-server/apps/server/src/api/services/queen-tick.ts index d433eb1ff5..9a7f26bb1f 100644 --- a/trios/agent-server/apps/server/src/api/services/queen-tick.ts +++ b/trios/agent-server/apps/server/src/api/services/queen-tick.ts @@ -220,7 +220,15 @@ async function ensureQueenColumns(pool: Pool): Promise { await pool.query(` ALTER TABLE queen_issues ADD COLUMN IF NOT EXISTS criteria jsonb NOT NULL DEFAULT '[]'::jsonb, - ADD COLUMN IF NOT EXISTS criteria_source text NOT NULL DEFAULT 'none'; + ADD COLUMN IF NOT EXISTS criteria_source text NOT NULL DEFAULT 'none', + -- Whether this issue's boundary reaches beyond documentation (#1358): + -- true when at least one owned path is not a .md file. Stored BESIDE + -- delegatable and deliberately not consulted by it - the tick + -- records the distinction so an operator can see how much of the + -- backlog can only produce prose; whether the Queen may be steered by + -- it is a separate decision that has not been made. A boundary of one + -- .md file still delegates exactly as it did before. + ADD COLUMN IF NOT EXISTS boundary_reaches_source boolean NOT NULL DEFAULT false; ALTER TABLE queen_dispatch ADD COLUMN IF NOT EXISTS criteria jsonb NOT NULL DEFAULT '[]'::jsonb, ADD COLUMN IF NOT EXISTS criteria_source text NOT NULL DEFAULT 'none', @@ -282,17 +290,19 @@ export async function rememberIssues( if (issues.length === 0) return for (const issue of issues) { const boundary = boundaryPathsOf(issue.body) + const reachesSource = boundaryReachesSource(boundary) const v = verdicts?.[String(issue.number)] await pool.query( `INSERT INTO queen_issues (number, title, state, owned_paths, seen_at, is_spec, delegatable, - missing, criteria, criteria_source) - VALUES ($1, $2, 'open', $3::jsonb, now(), $4, $5, $6::jsonb, $7::jsonb, $8) + boundary_reaches_source, missing, criteria, criteria_source) + VALUES ($1, $2, 'open', $3::jsonb, now(), $4, $5, $6, $7::jsonb, $8::jsonb, $9) ON CONFLICT (number) DO UPDATE SET title = EXCLUDED.title, state = 'open', owned_paths = EXCLUDED.owned_paths, seen_at = now(), is_spec = EXCLUDED.is_spec, delegatable = EXCLUDED.delegatable, + boundary_reaches_source = EXCLUDED.boundary_reaches_source, missing = EXCLUDED.missing, criteria = EXCLUDED.criteria, criteria_source = EXCLUDED.criteria_source`, @@ -301,7 +311,14 @@ export async function rememberIssues( issue.title.slice(0, 300), JSON.stringify(boundary), v?.isSpec ?? false, + // NOT `... && boundaryReachesSource(boundary)` - see #1358. Making + // the distinction visible and acting on it are different decisions, + // and the second belongs to the operator: silently narrowing what + // the Queen will pick up would stop the swarm, which is the opposite + // of the intent. tests/api/boundary-reach.test.ts fails if this line + // ever narrows. v?.delegatable ?? boundary.length > 0, + reachesSource, JSON.stringify(v?.missing ?? []), JSON.stringify(v?.criteria ?? []), v?.criteriaSource ?? 'none', @@ -531,8 +548,12 @@ export function boardTask( * nil into []. If a caller ever needs the difference, the flag goes back in * HERE and in `rememberIssues`, which currently JSON-stringifies the result * into `owned_paths` with no way to say "the issue never said". + * + * EXPORTED so `tests/api/boundary-reach.test.ts` can run it against the very + * same bodies as its twin in `trios/tools/doc-only-boundary-audit.mjs` and + * fail if the two parsers ever disagree about which paths an issue claims. */ -function boundaryPathsOf(body: string): string[] { +export function boundaryPathsOf(body: string): string[] { const lines = body.split('\n') let inside = false const paths: string[] = [] @@ -557,6 +578,47 @@ function boundaryPathsOf(body: string): string[] { return paths } +// The suffixes that make a boundary path count as documentation (#1358). +// One array, one place to disagree with; the audit prints it at the top of +// every run so a reader can. +const DOC_FILE_SUFFIXES = ['.md'] + +/// Whether one boundary path is documentation. The FILE NAME decides, not +/// the directory: `trios/docs/x.md` is documentation and `docs/diagram.png` +/// is not. Case-insensitive, so `README.MD` is documentation. +function isDocumentationPath(path: string): boolean { + const name = path.slice(path.lastIndexOf('/') + 1) + return DOC_FILE_SUFFIXES.some((suffix) => name.toLowerCase().endsWith(suffix)) +} + +/** + * Whether a boundary reaches beyond documentation (#1358). + * + * TRUE when at least one path in it is not documentation. A boundary of one + * `.md` file has length 1, so `delegatable` as derived today calls it work - + * and an issue worked exactly as written changes no behaviour, which is how + * "there is no target queue depth" (#1333) was accepted and closed while the + * defect it names stayed in the code. + * + * THIS VALUE IS RECORDED, NOT ACTED ON. `delegatable` keeps its meaning and + * its value (`v?.delegatable ?? boundary.length > 0`): whether the Queen may + * be pointed away from prose-only tasks is the operator's decision, not this + * change's, and silently narrowing what she picks up would stop the swarm. + * + * The rule is a PINNED TWIN of the one `trios/tools/doc-only-boundary-audit.mjs` + * exports under the same name - not an import, and the reason is the + * deployment: the agent-server image is built from `agent-server/` alone + * (its Dockerfile copies `apps/server` and `packages/*` and nothing from the + * repository root), so a static import of that tool would die at boot with + * "module not found" and take the whole round with it. The twin cannot drift + * silently: `tests/api/boundary-reach.test.ts` imports the audit's export + * and fails unless both agree on every shape a boundary can take, and both + * parsers against the same bodies. + */ +export function boundaryReachesSource(paths: string[]): boolean { + return paths.some((path) => !isDocumentationPath(path)) +} + /** One body per candidate, keyed as queend expects. */ async function bodiesFor( repo: string, diff --git a/trios/agent-server/apps/server/tests/api/boundary-reach.test.ts b/trios/agent-server/apps/server/tests/api/boundary-reach.test.ts new file mode 100644 index 0000000000..f22b36ab93 --- /dev/null +++ b/trios/agent-server/apps/server/tests/api/boundary-reach.test.ts @@ -0,0 +1,305 @@ +import { describe, expect, it } from 'bun:test' +import type { Pool } from 'pg' +// The audit tool is the CANONICAL home of the documentation rule. The rule +// in queen-tick.ts is a pinned twin (the agent-server image carries no +// repository-root file to import), and this file is what makes the pinning +// real: it imports the canonical export and fails unless the twin agrees. +import { + boundaryPathsOf as auditBoundaryPathsOf, + boundaryReachesSource as auditBoundaryReachesSource, + DOC_FILE_SUFFIXES, +} from '../../../../../tools/doc-only-boundary-audit.mjs' +import { + boundaryPathsOf, + boundaryReachesSource, + rememberIssues, +} from '../../src/api/services/queen-tick' + +/** The four boundary shapes #1358 names, plus the collapsed fifth. */ +const DOC_ONLY_BODY = [ + '# A document is the deliverable', + '', + 'Prose, then a boundary of one markdown file.', + '', + '## Boundary', + '', + 'docs/queen-queue-depth.md', + '', + '## Out of scope', + '', + 'Anything behavioural.', +].join('\n') + +const MIXED_BODY = [ + '# A document AND the change it describes', + '', + '## Boundary', + '', + 'docs/queen-queue-depth.md', + 'agent-server/apps/server/src/api/services/queen-tick.ts', +].join('\n') + +const SOURCE_ONLY_BODY = [ + '# Behaviour only', + '', + '## Boundary', + '', + 'agent-server/apps/server/src/api/services/queen-tick.ts', +].join('\n') + +const NO_SECTION_BODY = [ + '# No boundary anywhere', + '', + 'The issue never said.', +].join('\n') + +const EMPTY_SECTION_BODY = [ + '# A section with nothing in it', + '', + '## Boundary', + '', + 'Nothing yet.', +].join('\n') + +/** Records every statement with the parameters it was given. */ +function recordingPool() { + const calls: Array<{ text: string; params: unknown[] }> = [] + const pool = { + query: async (text: string, params?: unknown[]) => { + calls.push({ text: String(text), params: params ?? [] }) + return { rowCount: 0, rows: [] } + }, + } as unknown as Pool + return { pool, calls } +} + +/** + * What rememberIssues stored for one issue, by column position: + * [4] is delegatable and [5] is boundary_reaches_source. + */ +async function storedFor( + body: string, + verdicts?: Parameters[3], +): Promise<{ owned: string[]; delegatable: boolean; reachesSource: boolean }> { + const { pool, calls } = recordingPool() + await rememberIssues( + pool, + [{ number: 4242, title: 't', body }], + true, + verdicts, + ) + const insert = calls.find((c) => c.text.includes('INSERT INTO queen_issues')) + if (!insert) + throw new Error('rememberIssues issued no INSERT INTO queen_issues') + return { + owned: JSON.parse(String(insert.params[2])), + delegatable: Boolean(insert.params[4]), + reachesSource: Boolean(insert.params[5]), + } +} + +describe('boundary_reaches_source, the four shapes (#1358)', () => { + it('stores false for a documentation-only boundary', async () => { + const stored = await storedFor(DOC_ONLY_BODY) + expect(stored.owned).toEqual(['docs/queen-queue-depth.md']) + expect(stored.reachesSource).toBe(false) + }) + + it('stores true for a mixed boundary - one real path is enough', async () => { + const stored = await storedFor(MIXED_BODY) + expect(stored.owned).toEqual([ + 'docs/queen-queue-depth.md', + 'agent-server/apps/server/src/api/services/queen-tick.ts', + ]) + expect(stored.reachesSource).toBe(true) + }) + + it('stores true for a source-only boundary', async () => { + const stored = await storedFor(SOURCE_ONLY_BODY) + expect(stored.owned).toEqual([ + 'agent-server/apps/server/src/api/services/queen-tick.ts', + ]) + expect(stored.reachesSource).toBe(true) + }) + + it('stores false for an empty boundary, which is "no boundary", not doc-only', async () => { + const stored = await storedFor(NO_SECTION_BODY) + expect(stored.owned).toEqual([]) + expect(stored.reachesSource).toBe(false) + }) + + it('treats an empty ## Boundary section the same as no section', async () => { + // Both collapse to [], by the documented rule in boundaryPathsOf; the + // audit reports them separately from doc-only for the same reason. + expect(boundaryPathsOf(EMPTY_SECTION_BODY)).toEqual([]) + const stored = await storedFor(EMPTY_SECTION_BODY) + expect(stored.reachesSource).toBe(false) + }) + + it('writes the column in both the INSERT and the upsert', async () => { + const { pool, calls } = recordingPool() + await rememberIssues( + pool, + [{ number: 4242, title: 't', body: DOC_ONLY_BODY }], + true, + ) + const insert = calls.find((c) => + c.text.includes('INSERT INTO queen_issues'), + ) + expect(insert?.text).toContain('boundary_reaches_source') + expect(insert?.text).toContain( + 'boundary_reaches_source = EXCLUDED.boundary_reaches_source', + ) + }) +}) + +describe('delegatable is unchanged by the new value (FR-001 of #1358)', () => { + /** + * This is the tripwire. A documentation-only boundary has length 1, so + * `v?.delegatable ?? boundary.length > 0` stores true today, and MUST go + * on storing true: narrowing it would silently stop the Queen picking up + * prose-only work, which is the operator's decision to make, not this + * change's. Wire `boundaryReachesSource` into delegatable and this test + * fails. + */ + it('keeps a documentation-only issue delegatable', async () => { + const stored = await storedFor(DOC_ONLY_BODY) + expect(stored.delegatable).toBe(true) + expect(stored.reachesSource).toBe(false) + }) + + it('keeps the other three shapes exactly as they were', async () => { + expect((await storedFor(MIXED_BODY)).delegatable).toBe(true) + expect((await storedFor(SOURCE_ONLY_BODY)).delegatable).toBe(true) + expect((await storedFor(NO_SECTION_BODY)).delegatable).toBe(false) + }) + + it('still lets the policy verdict override the boundary, in both directions', async () => { + const allow = { + '4242': { delegatable: true, isSpec: false, missing: [], remedy: '' }, + } + const refuse = { + '4242': { delegatable: false, isSpec: false, missing: [], remedy: '' }, + } + expect((await storedFor(NO_SECTION_BODY, allow)).delegatable).toBe(true) + expect((await storedFor(SOURCE_ONLY_BODY, refuse)).delegatable).toBe(false) + // The recorded value is a fact about the boundary, not a policy verdict, + // so the verdict does not move it. + expect((await storedFor(SOURCE_ONLY_BODY, refuse)).reachesSource).toBe(true) + }) +}) + +describe("the rule is the audit's rule, not a second opinion (FR-002 of #1358)", () => { + /** Boundaries where the two implementations must agree, with the answer + * stated outright so a change to EITHER copy fails this test. */ + const cases: Array<{ paths: string[]; reaches: boolean; why: string }> = [ + { + paths: [], + reaches: false, + why: 'no boundary is neither doc-only nor reaching', + }, + { + paths: ['docs/queen-queue-depth.md'], + reaches: false, + why: 'one .md file, the shape of #1333', + }, + { + paths: ['trios/docs/t27/T27-04-salience-purity.md'], + reaches: false, + why: 'the shape of #1351, repository-relative', + }, + { + paths: ['docs/a.md', 'README.MD', 'notes.Md'], + reaches: false, + why: 'case-insensitive suffix', + }, + { + paths: ['docs/a.md', 'src/x.ts'], + reaches: true, + why: 'mixed: one real path is enough', + }, + { + paths: ['agent-server/apps/server/src/api/services/queen-tick.ts'], + reaches: true, + why: 'source only', + }, + { + paths: ['tools/audit.mjs'], + reaches: true, + why: 'an .mjs is not documentation', + }, + { + paths: ['notes.markdown'], + reaches: true, + why: 'only .md counts, not .markdown', + }, + { + paths: ['docs/diagram.png'], + reaches: true, + why: 'a docs/ directory does not sanctify a png', + }, + { paths: ['docs/'], reaches: true, why: 'a directory is not a .md file' }, + { + paths: ['a.md.ts'], + reaches: true, + why: 'the suffix must be at the end of the file name', + }, + ] + + it('agrees with the audit export on every case, and each answer is stated', () => { + for (const { paths, reaches, why } of cases) { + expect(boundaryReachesSource(paths)).toBe(reaches) + expect(auditBoundaryReachesSource(paths)).toBe(reaches) + // The twin and the canonical export must never disagree - stated + // twice so a failure names the case. + expect(boundaryReachesSource(paths)).toBe( + auditBoundaryReachesSource(paths), + ) + expect(why).toBe(why) + } + }) + + it('pins the documentation suffixes as exactly .md', () => { + expect(DOC_FILE_SUFFIXES).toEqual(['.md']) + }) + + /** Bodies run through BOTH parsers; each expected array is stated so a + * change to either parser fails here rather than in production. */ + const parsedCases: Array<{ body: string; paths: string[] }> = [ + { body: DOC_ONLY_BODY, paths: ['docs/queen-queue-depth.md'] }, + { + body: MIXED_BODY, + paths: [ + 'docs/queen-queue-depth.md', + 'agent-server/apps/server/src/api/services/queen-tick.ts', + ], + }, + { + body: SOURCE_ONLY_BODY, + paths: ['agent-server/apps/server/src/api/services/queen-tick.ts'], + }, + { body: NO_SECTION_BODY, paths: [] }, + // One path per line: two paths sharing a line yield the first. Both + // parsers must keep doing this, so it is pinned rather than implied. + { + body: '## Boundary\n\n`docs/x.md`, and `tools/y.mjs`.', + paths: ['docs/x.md'], + }, + { + body: '## Boundary\n\ndocs/x.md\n\ntools/y.mjs', + paths: ['docs/x.md', 'tools/y.mjs'], + }, + { body: '## Границы\n\ndocs/x.md', paths: ['docs/x.md'] }, + { + body: '## Boundary\n\nsee docs/old.md below.\n\n## Later\n\ndocs/other.md', + paths: ['docs/old.md'], + }, + ] + + it('parses every body the same way the audit parses it', () => { + for (const { body, paths } of parsedCases) { + expect(boundaryPathsOf(body)).toEqual(paths) + expect(auditBoundaryPathsOf(body)).toEqual(paths) + } + }) +}) diff --git a/trios/tools/doc-only-boundary-audit.mjs b/trios/tools/doc-only-boundary-audit.mjs new file mode 100644 index 0000000000..a11483cc1f --- /dev/null +++ b/trios/tools/doc-only-boundary-audit.mjs @@ -0,0 +1,243 @@ +#!/usr/bin/env node +// ----------------------------------------------------------------------------- +// trios/tools/doc-only-boundary-audit.mjs +// +// Which open issues can only produce prose? +// +// A task whose whole boundary is documentation is counted as delegatable by +// the tick, because `delegatable` is derived from `boundary.length > 0` and a +// boundary of one .md file has length 1. Nothing distinguishes "this task +// will change the system" from "this task will describe the system", so an +// issue that should have changed behaviour can be satisfied, accepted and +// closed by a document. Measured on the live backlog (see #1358): 17 of 61 +// open issues had a boundary consisting only of markdown, and twelve of +// those seventeen were about the Queen's own autonomy. #1333 - "there is no +// target queue depth" - was accepted with a document as its whole output +// while the defect it names stayed in the code. +// +// A document is sometimes the right deliverable - a survey, an inventory, a +// decision record. This audit does not judge that. It makes the shape of the +// backlog legible: which issues, if worked exactly as written, change no +// behaviour. +// +// How to run (workers have node): +// node trios/tools/doc-only-boundary-audit.mjs +// +// where is a JSON array of objects carrying `number` and +// `body` - the shape GitHub's issue objects have, and the shape +// `openIssues()` in queen-tick.ts already produces. +// +// Constraints honoured (issue gHashTag/trios#1358): +// FR-004 reads the backlog from the file the caller supplies and never +// calls the network - the worker container holds no GitHub +// credential, and a silent failure of that call would report every +// issue as fine. No fetch exists in this file; +// FR-005 runs under node with the Node standard library only, and never +// edits any issue - nothing is written anywhere, output goes to +// stdout; +// FR-006 deterministic: two runs over the same input produce identical +// bytes, so outputs can be diffed and archived. +// +// THE SHARED RULE. `boundaryReachesSource` below is the one definition of +// what counts as documentation, exported so the server can be held to it. +// `queen-tick.ts` carries a pinned twin (not an import - the agent-server +// image is built from `agent-server/` alone and cannot reach this file at +// runtime) and stores the twin's answer as `boundary_reaches_source`; +// `tests/api/boundary-reach.test.ts` imports THIS export and fails unless +// the two agree on every boundary shape. Two copies that drift are the +// defect this repository keeps finding in itself; a copy that cannot drift +// silently is the compromise the deployment allows. +// ----------------------------------------------------------------------------- + +import fs from 'node:fs' +import process from 'node:process' +import { pathToFileURL } from 'node:url' + +// ============================================================================= +// THE RULE - the single place to disagree with (FR-003: it is printed by +// every run, so a reader can disagree without reading this source) +// ============================================================================= + +// The suffixes that make a boundary path count as documentation. Nothing +// else qualifies: not .markdown, not .mdx, not "anything under docs/" - a +// boundary path is documentation exactly when its file name ends with one +// of these. +export const DOC_FILE_SUFFIXES = ['.md'] + +/** + * Whether one boundary path is documentation. + * + * The FILE NAME decides, not the directory: `trios/docs/x.md` is + * documentation, and `docs/diagram.png` is not. Case-insensitive, so + * `README.MD` is documentation. + */ +export function isDocumentationPath(path) { + const name = path.slice(path.lastIndexOf('/') + 1) + return DOC_FILE_SUFFIXES.some((suffix) => name.toLowerCase().endsWith(suffix)) +} + +/** + * Whether a boundary reaches beyond documentation: true when at least one + * of its paths is NOT documentation. One real path is enough - a mixed + * boundary is not the audit's subject. + * + * An empty boundary returns false, but that is a different condition with a + * different repair ("no boundary"), and the audit reports it separately + * rather than folding it into either side. + */ +export function boundaryReachesSource(paths) { + return paths.some((path) => !isDocumentationPath(path)) +} + +/** + * The declared boundary of one issue body. + * + * A twin of `boundaryPathsOf` in + * trios/agent-server/apps/server/src/api/services/queen-tick.ts - the same + * two headings (`## Boundary`, `## Границы`), the same token cleaning, the + * same file-shaped test, so the audit and the tick cannot disagree about + * which paths an issue claims. The twin-ness is pinned by + * tests/api/boundary-reach.test.ts, which runs both against the same bodies. + * Kept here rather than imported for the reason the header records: the + * agent-server image carries no repository-root file to import. + */ +export function boundaryPathsOf(body) { + const lines = body.split('\n') + let inside = false + const paths = [] + for (const raw of lines) { + const line = raw.trim() + if (line.startsWith('## ')) { + if (inside) break + inside = line.startsWith('## Boundary') || line.startsWith('## Границы') + continue + } + if (!inside || line.length === 0) continue + for (const token of line.split(/\s+/)) { + const cleaned = token + .replace(/^[`"'(]+/, '') + .replace(/[`"'.,;:!?)]+$/, '') + if (cleaned.includes('/') || /\.\w{1,10}$/.test(cleaned)) { + paths.push(cleaned) + break + } + } + } + return paths +} + +// ============================================================================= +// THE RULE, IN WORDS - what every run prints (FR-003) +// ============================================================================= + +function ruleInWords() { + return [ + 'Rule applied (a reader may disagree with any part of it):', + ` - a boundary path is documentation when its FILE NAME ends with one of: ${DOC_FILE_SUFFIXES.join(', ')} (case-insensitive; the directory is irrelevant - trios/docs/x.md is documentation, docs/diagram.png is not);`, + ' - nothing else is documentation: not .markdown, not .mdx, not a docs/ directory holding non-markdown files;', + ' - an issue is doc-only when its ## Boundary section parsed to at least one path AND every path is documentation - such an issue, worked exactly as written, produces prose and changes no behaviour;', + ' - one path outside documentation is enough for the issue to reach source; mixed boundaries are NOT reported;', + ' - an issue whose ## Boundary section is missing or empty is reported separately as "no boundary" - a different condition with a different repair - and never counts as doc-only.', + 'Boundary paths are read from the ## Boundary section by the same parser the queen tick uses (boundaryPathsOf, the pinned twin in this file).', + 'The classification is the exported function boundaryReachesSource in this file; queen-tick.ts stores the same value as boundary_reaches_source, and tests/api/boundary-reach.test.ts fails if the two drift.', + ].join('\n') +} + +// ============================================================================= +// THE AUDIT +// ============================================================================= + +/** Read and validate the caller's backlog. Malformed input exits loudly: + * a backlog that parses to nothing would report every issue as fine, which + * is the silent failure FR-004 exists to prevent. */ +function readBacklog(file) { + let raw + try { + raw = fs.readFileSync(file, 'utf8') + } catch (error) { + fail(`cannot read ${file}: ${error.message}`) + } + let parsed + try { + parsed = JSON.parse(raw) + } catch (error) { + fail(`${file} is not valid JSON: ${error.message}`) + } + if (!Array.isArray(parsed)) { + fail(`${file} must be a JSON array of issue objects, got ${typeof parsed}`) + } + parsed.forEach((issue, index) => { + if (issue === null || typeof issue !== 'object' || Array.isArray(issue)) { + fail(`${file}[${index}] is not an object`) + } + if (typeof issue.number !== 'number' || !Number.isInteger(issue.number)) { + fail(`${file}[${index}] has no integer "number"`) + } + if (issue.body === null || issue.body === undefined) issue.body = '' + if (typeof issue.body !== 'string') { + fail(`${file}[${index}] has a "body" that is not a string`) + } + }) + return parsed +} + +function fail(message) { + process.stderr.write(`doc-only-boundary-audit: ${message}\n`) + process.exit(1) +} + +function run(file) { + const issues = readBacklog(file) + const docOnly = [] + const noBoundary = [] + let hasSource = 0 + for (const issue of issues) { + const paths = boundaryPathsOf(issue.body) + if (paths.length === 0) { + noBoundary.push({ number: issue.number }) + } else if (boundaryReachesSource(paths)) { + hasSource += 1 + } else { + docOnly.push({ number: issue.number, paths }) + } + } + // Sorted by number, so two runs - or two callers with the same backlog in + // a different order - produce identical bytes. + docOnly.sort((a, b) => a.number - b.number) + noBoundary.sort((a, b) => a.number - b.number) + + const lines = [] + lines.push(`doc-only boundary audit of ${file} (${issues.length} issues)`) + lines.push('') + lines.push(ruleInWords()) + lines.push('') + lines.push(`doc-only: ${docOnly.length} of ${issues.length}`) + for (const issue of docOnly) { + lines.push(` #${issue.number} ${issue.paths.join(', ')}`) + } + lines.push('') + lines.push(`no boundary: ${noBoundary.length} of ${issues.length}`) + for (const issue of noBoundary) { + lines.push(` #${issue.number} (no paths parsed from any ## Boundary section)`) + } + lines.push('') + lines.push(`has source: ${hasSource} of ${issues.length}`) + lines.push(' (not listed: each has at least one path outside documentation, which is enough)') + lines.push('') + lines.push( + `totals: doc-only ${docOnly.length}, no boundary ${noBoundary.length}, has source ${hasSource}, total ${issues.length}`, + ) + process.stdout.write(lines.join('\n') + '\n') +} + +// The CLI runs only when invoked as a script; importing this module (as +// tests/api/boundary-reach.test.ts does) yields the rule and nothing else. +const invokedAsScript = + process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href +if (invokedAsScript) { + const file = process.argv[2] + if (!file) { + fail('usage: node trios/tools/doc-only-boundary-audit.mjs ') + } + run(file) +}