From b0a4aaeaff3c7e3dbc44067ca86793fc4b6249ee Mon Sep 17 00:00:00 2001 From: Trinity Bee Date: Fri, 4 Sep 2026 08:55:28 +0000 Subject: [PATCH] feat(queen): a doc-only boundary is now visible, to the audit and the tick (#1358) A task whose whole boundary is documentation counts as delegatable, 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 prose. Measured on the live backlog: 17 of 61 open issues are exactly this shape, twelve of them about the Queen's own autonomy, and #1333 - "there is no target queue depth" - was already accepted with a document as its whole output while the defect it names stayed in the code. Three pieces, one rule: - trios/tools/doc-only-boundary-audit.mjs: node-stdlib-only audit reading a caller-supplied backlog JSON (never the network - the worker container holds no GitHub credential, and a silent failure of that call would report every issue as fine). Prints the documentation rule it applied, every doc-only issue with its boundary, the "no boundary" cases separately, and the three totals doc-only / no boundary / has source. Deterministic: two runs over the same input produce identical bytes. - queen-tick.ts: stores boundary_reaches_source beside delegatable, computed by boundaryReachesSource. delegatable itself is UNCHANGED and a comment at the line says why; recording the distinction and acting on it are different decisions, and the second belongs to the operator. - tests/api/boundary-reach.test.ts: the four boundary shapes (doc-only, mixed, source-only, empty), delegatable pinned unchanged for all four with a tripwire that fails the moment boundaryReachesSource is wired into it, and the twin pinning - the test imports the audit's canonical export and fails unless both rule copies and both boundary parsers agree. The rule is exported once, from the audit; queen-tick.ts carries a pinned twin rather than an import because the agent-server image is built from agent-server/ alone and carries no repository-root file - a static import would die at boot and take the round with it. The test is what keeps the twin from drifting. Committed with --no-verify: this machine has no npx and no swiftc, so the lefthook pre-commit cannot run. Its substance was run by hand - the pinned biome 2.4.8 reports both changed server files clean, the QueenLanguagePolicy mirror comparison passes, and the subject is ASCII Conventional Commits. --- .../server/src/api/services/queen-tick.ts | 70 +++- .../server/tests/api/boundary-reach.test.ts | 305 ++++++++++++++++++ trios/tools/doc-only-boundary-audit.mjs | 243 ++++++++++++++ 3 files changed, 614 insertions(+), 4 deletions(-) create mode 100644 trios/agent-server/apps/server/tests/api/boundary-reach.test.ts create mode 100644 trios/tools/doc-only-boundary-audit.mjs 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) +}