From e0a754ba5de384ff5d29850b2a7029a45db250e3 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sat, 22 Aug 2026 08:47:07 +0200 Subject: [PATCH 01/25] test(docs): check the evidence anchors against source Every factual claim the docs site makes about a source repository is anchored in a {/* Evidence: ... */} comment naming the code, test, or contract that backs it, and nothing verified those anchors. Across three recent pull requests, 24 stale path:line-range citations and one citation naming a test that does not exist were found only by reading them. The checker resolves the paths an anchor cites, including the continuation forms (src/foo.rs and a bare sibling filename read against the previous path, and a bare :N-M read against the file before it), then requires that every cited path exists, that every line reference is inside its file, and that every symbol-shaped token the anchor names occurs in at least one path that anchor cites. Line-range citations are counted but not yet refused. 81 remain in the tree; --strict-line-refs turns them into failures once they are converted to symbol citations. Signed-off-by: Jeremi Joslin --- docs/site/AGENTS.md | 16 + docs/site/package.json | 3 +- docs/site/scripts/check-evidence-anchors.mjs | 409 ++++++++++++++++++ .../scripts/check-evidence-anchors.test.mjs | 226 ++++++++++ 4 files changed, 653 insertions(+), 1 deletion(-) create mode 100644 docs/site/scripts/check-evidence-anchors.mjs create mode 100644 docs/site/scripts/check-evidence-anchors.test.mjs diff --git a/docs/site/AGENTS.md b/docs/site/AGENTS.md index 349f26de9..46c5c014c 100644 --- a/docs/site/AGENTS.md +++ b/docs/site/AGENTS.md @@ -28,6 +28,22 @@ fixtures, OpenAPI, or an upstream standard. When evidence is missing, mark the claim inline with a `TODO[evidence]` MDX comment and propose a weaker claim level, rather than deleting the claim or asserting it. +`npm run check` resolves those anchors and fails when one does not. A cited +path must exist, a cited line reference must fall inside its file, and a cited +symbol must occur in at least one path the same anchor cites. A citation that +has drifted is a merge blocker, not a wart, so check an anchor when you move +the code it points at. Run `npm run check:evidence-anchors` alone for the fast +version. A token that resolves to no repository path is read as prose and +skipped, so naming a file the repo does not own is still fine. + +Two things the check deliberately allows. Bare `path:start-end` citations still +pass: `--strict-line-refs` rejects them, but it stays off while a backlog of +them remains, and the check prints how many are left. Prefer citing a symbol +over a line range in new writing, because a symbol survives the next edit above +it. Prescriptive guidance that tells an operator to set a value is also +untouched, since the check reasons about claims describing what code does, not +about advice. + A procedure carries more than its commands: the reason for a step whose reason is not visible in the command, what an irreversible step forecloses, what failure looks like and the next move, and a `caution` or `danger` at every action that diff --git a/docs/site/package.json b/docs/site/package.json index e27eb769c..1b7448aa4 100644 --- a/docs/site/package.json +++ b/docs/site/package.json @@ -27,6 +27,7 @@ "check:docset": "node scripts/check-docset.mjs", "check:release-manifests": "python3 ../../release/scripts/registry-release validate-docsets", "check:evidence-links": "node scripts/check-evidence-links.mjs", + "check:evidence-anchors": "node scripts/check-evidence-anchors.mjs", "check:content": "node scripts/check-doc-frontmatter.mjs", "check:cli-reference": "node scripts/generate-cli-reference.mjs --check", "check:cutover": "node scripts/check-current-doc-cutover.mjs", @@ -46,7 +47,7 @@ "check:tutorial:evidence:dry-run": "bash scripts/check-evidence-tutorials.sh --dry-run", "check:links": "npm run build && npm run check:links:built", "check": "npm run check:source && npm run build && npm run check:built:current", - "check:source": "npm run check:cli-reference && npm run generate && npm run check:evidence-links && npm run check:docset && npm run check:release-manifests && npm run check:archive-lock && npm run check:content && npm run check:cutover && npm run check:notary-surface && npm run check:markdown && npm run check:style && npm run check:style:fixtures && npm run check:openapi && npm run check:config-vocabulary && npm run check:tutorial:dry-run && npm run check:tutorial:evidence:dry-run && npm run check:tutorial:discovery:dry-run && npm run check:svg", + "check:source": "npm run check:cli-reference && npm run generate && npm run check:evidence-links && npm run check:evidence-anchors && npm run check:docset && npm run check:release-manifests && npm run check:archive-lock && npm run check:content && npm run check:cutover && npm run check:notary-surface && npm run check:markdown && npm run check:style && npm run check:style:fixtures && npm run check:openapi && npm run check:config-vocabulary && npm run check:tutorial:dry-run && npm run check:tutorial:evidence:dry-run && npm run check:tutorial:discovery:dry-run && npm run check:svg", "check:built:current": "npm run check:accessibility:built && npm run check:llms:built && npm run check:seo:current && npm run check:links:current", "check:production": "npm run check:source && npm run build:dev && npm run check:production:built", "check:production:built": "DOCS_DIST_DIR=$PWD/dist/dev DOCS_PUBLIC_BASE=/dev/ npm run check:accessibility:built && DOCS_DIST_DIR=$PWD/dist/dev DOCS_PUBLIC_BASE=/dev/ npm run check:llms:built && DOCS_DIST_DIR=$PWD/dist/dev DOCS_PUBLIC_BASE=/dev/ npm run check:seo:current && DOCS_DIST_DIR=$PWD/dist/dev DOCS_PUBLIC_BASE=/dev/ npm run check:links:current", diff --git a/docs/site/scripts/check-evidence-anchors.mjs b/docs/site/scripts/check-evidence-anchors.mjs new file mode 100644 index 000000000..5a42a1f93 --- /dev/null +++ b/docs/site/scripts/check-evidence-anchors.mjs @@ -0,0 +1,409 @@ +#!/usr/bin/env node + +// Validates the {/* Evidence: ... */} anchors that carry every factual claim the +// documentation makes about the source repository: the paths they cite exist, the +// line references they carry are inside those files, and the symbols they name are +// present in at least one path the same anchor cites. + +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { dirname, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const scriptPath = fileURLToPath(import.meta.url); +const scriptDir = dirname(scriptPath); + +// Prose words that carry a symbol shape but name a language, never an item in the +// repository. Keep this list minimal: an entry here is a symbol the checker can no +// longer catch when it goes stale. +export const PROSE_SYMBOL_ALLOWLIST = new Set([ + // "the JavaScript example", "the TypeScript declarations": language names. + 'JavaScript', + 'TypeScript', +]); + +// Directories a repository-relative citation may start from. +const REPOSITORY_ROOTS = ['crates', 'products', 'release', 'docs', 'external', '\\.github']; +// Directories a continuation citation may start from, resolved against the crate or +// product root of the most recent full path in the same anchor. +const CONTINUATION_ROOTS = ['src', 'tests', 'examples', 'benches', 'schemas', 'scripts']; +// Extensions that make a bare token a sibling filename rather than ordinary prose. +const SOURCE_EXTENSIONS = ['rs', 'mjs', 'md', 'py', 'sh', 'toml', 'yaml', 'yml', 'jsonld', 'json']; +// Extensions read when a symbol has to be looked for inside a cited directory. +const TEXT_EXTENSIONS = new Set([ + ...SOURCE_EXTENSIONS, + 'js', + 'ts', + 'txt', + 'sql', + 'snap', + 'html', + 'css', + 'lock', +]); +const SKIPPED_DIRECTORIES = new Set(['target', 'node_modules', '.git', 'dist', '.astro']); +// Where a continuation with no full path before it is read from: the site the anchor +// itself lives in, whose own src/ tree the docs pages cite. +const DOCS_SITE_ROOT = 'docs/site'; + +const ANCHOR_PATTERN = /\{\/\*\s*Evidence:([\s\S]*?)\*\/\}/g; +const CITATION_PATTERN = new RegExp( + [ + `(?(?(?(?(?<=[\\s(]):\\d+(?:-\\d+)?(?![\\w-]))`, + ].join('|'), + 'g', +); +const LINE_SUFFIX = /^(?.*?)(?::(?\d+)(?:-(?\d+))?)?$/; +const WORD_PATTERN = /[A-Za-z_][A-Za-z0-9_]*(?:::[A-Za-z_][A-Za-z0-9_]*)+|[A-Za-z_][A-Za-z0-9_]*/g; +const SCREAMING_SNAKE_CASE = /^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+$/; +const SNAKE_CASE = /^[a-z][a-z0-9]*(?:_[a-z0-9]+)+$/; +const UPPER_CAMEL_CASE = /^(?:[A-Z][a-z0-9]+){2,}$/; + +export function extractAnchors(text) { + const anchors = []; + for (const match of text.matchAll(ANCHOR_PATTERN)) { + const line = text.slice(0, match.index).split('\n').length; + anchors.push({ line, body: match[1] }); + } + return anchors; +} + +function splitLineReference(token) { + const groups = LINE_SUFFIX.exec(token)?.groups; + if (!groups) { + return { path: token }; + } + const start = groups.start === undefined ? undefined : Number(groups.start); + const end = groups.end === undefined ? start : Number(groups.end); + // A sentence that ends on a path leaves its full stop inside the token. + return { path: groups.path.replace(/\.+$/, ''), start, end }; +} + +// The crate, product, or top-level unit a continuation citation is resolved against. +function citationRoot(path) { + const segments = path.split('/'); + return ['crates', 'products', 'docs', 'external'].includes(segments[0]) && segments.length > 1 + ? `${segments[0]}/${segments[1]}` + : segments[0]; +} + +function joinPath(base, tail) { + return base === '' ? tail : `${base}/${tail}`; +} + +// Every citation carries the ordered candidate paths it may resolve to, and whether a +// candidate is a claim or a guess. A full repository path, and a continuation anchored +// to one, plainly names a repository path, so a miss is drift and is reported. A bare +// sibling filename, or a continuation with no full path before it, is only a reading of +// the prose: when nothing resolves, it names a file the repository does not own (an +// adopter's configuration file, or a path inside a generated package) and is left alone. +export function parseAnchor(body, { siteRoot = DOCS_SITE_ROOT } = {}) { + const citations = []; + const strippedParts = []; + let cursor = 0; + let lastFullPath; + let previous; + + for (const match of body.matchAll(CITATION_PATTERN)) { + const { full, relative: continuation, sibling, lines } = match.groups; + strippedParts.push(body.slice(cursor, match.index), ' '); + cursor = match.index + match[0].length; + + if (lines !== undefined) { + if (!previous) { + continue; + } + const { start, end } = splitLineReference(`_${lines}`); + citations.push({ ...previous, form: 'lines', raw: lines, start, end }); + continue; + } + + const token = full ?? continuation ?? sibling; + const { path, start, end } = splitLineReference(token); + const trimmed = path.replace(/\/$/, ''); + const parentOfLastFullPath = + lastFullPath === undefined || dirname(lastFullPath) === '.' ? '' : dirname(lastFullPath); + let citation; + if (full !== undefined) { + lastFullPath = trimmed; + citation = { form: 'full', candidates: [trimmed], reportMissing: true }; + } else if (continuation !== undefined && lastFullPath === undefined) { + citation = { + form: 'continuation', + candidates: [joinPath(siteRoot, trimmed)], + reportMissing: false, + }; + } else if (continuation !== undefined) { + citation = { + form: 'continuation', + candidates: [ + joinPath(citationRoot(lastFullPath), trimmed), + joinPath(parentOfLastFullPath, trimmed), + ], + reportMissing: true, + }; + } else if (lastFullPath === undefined) { + // A sibling filename with no path before it has nothing to sit beside. + continue; + } else { + citation = { + form: 'sibling', + candidates: [ + joinPath(parentOfLastFullPath, trimmed), + joinPath(citationRoot(lastFullPath), trimmed), + joinPath(lastFullPath, trimmed), + ], + reportMissing: false, + basename: trimmed, + searchRoot: citationRoot(lastFullPath), + }; + } + + citation.candidates = [...new Set(citation.candidates)]; + previous = citation; + citations.push({ ...citation, raw: token, start, end }); + } + + strippedParts.push(body.slice(cursor)); + return { citations, symbols: extractSymbols(strippedParts.join('')) }; +} + +export function extractSymbols(prose) { + const symbols = []; + for (const match of prose.matchAll(WORD_PATTERN)) { + const token = match[0]; + const candidate = token.includes('::') ? token.split('::').at(-1) : token; + const qualified = token.includes('::'); + if (PROSE_SYMBOL_ALLOWLIST.has(candidate)) { + continue; + } + if ( + !qualified && + !SCREAMING_SNAKE_CASE.test(candidate) && + !SNAKE_CASE.test(candidate) && + !UPPER_CAMEL_CASE.test(candidate) + ) { + continue; + } + if (!symbols.includes(candidate)) { + symbols.push(candidate); + } + } + return symbols; +} + +function entryKind(absolute) { + try { + return statSync(absolute).isDirectory() ? 'directory' : 'file'; + } catch (error) { + if (error.code === 'ENOENT' || error.code === 'ENOTDIR') { + return 'missing'; + } + throw error; + } +} + +function lineCount(text) { + const lines = text.split('\n'); + return lines.at(-1) === '' ? lines.length - 1 : lines.length; +} + +function filesUnder(directory) { + const files = []; + for (const entry of readdirSync(directory, { withFileTypes: true })) { + if (entry.isDirectory()) { + if (!SKIPPED_DIRECTORIES.has(entry.name)) { + files.push(...filesUnder(resolve(directory, entry.name))); + } + continue; + } + if (entry.isFile()) { + files.push(resolve(directory, entry.name)); + } + } + return files; +} + +function isTextFile(path) { + return TEXT_EXTENSIONS.has(path.split('.').at(-1)); +} + +function wholeWordPattern(symbol) { + const escaped = symbol.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return new RegExp(`(? { + if (!fileTexts.has(path)) { + fileTexts.set(path, readFileSync(resolve(repoRoot, path), 'utf8')); + } + return fileTexts.get(path); + }; + + const listFiles = (path) => { + if (!directoryFiles.has(path)) { + const absolute = resolve(repoRoot, path); + directoryFiles.set( + path, + entryKind(absolute) === 'directory' + ? filesUnder(absolute).map((file) => relative(repoRoot, file).replaceAll('\\', '/')) + : [], + ); + } + return directoryFiles.get(path); + }; + + // A bare sibling filename may name a file that sits elsewhere in the crate or product + // the anchor already named, so fall back to a single unambiguous match under it. + const uniqueFileNamed = (root, basename) => { + const matches = listFiles(root).filter((path) => path.endsWith(`/${basename}`)); + return matches.length === 1 ? matches[0] : undefined; + }; + + for (const page of mdxPages(contentRoot)) { + const location = relative(contentRoot, page).replaceAll('\\', '/'); + for (const anchor of extractAnchors(readFileSync(page, 'utf8'))) { + anchors += 1; + const { citations, symbols: cited } = parseAnchor(anchor.body); + const at = `${location}:${anchor.line}`; + const citedFiles = []; + const citedDirectories = []; + let lastResolvedFile; + + for (const citation of citations) { + const range = + citation.start === undefined + ? '' + : `:${citation.start}${citation.end === citation.start ? '' : `-${citation.end}`}`; + const resolved = + citation.candidates.find( + (candidate) => entryKind(resolve(repoRoot, candidate)) !== 'missing', + ) ?? + (citation.basename === undefined + ? undefined + : uniqueFileNamed(citation.searchRoot, citation.basename)) ?? + // A bare line reference that follows a filename the repository does not own + // still belongs to the last file the anchor resolved. + (citation.form === 'lines' ? lastResolvedFile : undefined); + if (resolved === undefined && !citation.reportMissing) { + continue; + } + paths += 1; + if (range !== '') { + lineRefs += 1; + } + if (resolved === undefined) { + errors.push(`${at} cites ${citation.candidates[0]}${range}, which does not exist`); + continue; + } + if (strictLineRefs && range !== '') { + errors.push( + `${at} cites ${resolved}${range}; line numbers drift silently, so name the symbol, test, constant, or key instead`, + ); + } + if (entryKind(resolve(repoRoot, resolved)) === 'directory') { + if (range !== '') { + errors.push(`${at} cites ${resolved}${range}, but that path is a directory`); + } + citedDirectories.push(resolved); + continue; + } + citedFiles.push(resolved); + lastResolvedFile = resolved; + if (range === '') { + continue; + } + const count = lineCount(readText(resolved)); + if (citation.end > count) { + errors.push( + `${at} cites ${resolved}${range}, but the file has ${pluralLines(count)}`, + ); + } + } + + if (citedFiles.length === 0 && citedDirectories.length === 0) { + continue; + } + + for (const symbol of cited) { + symbols += 1; + const pattern = wholeWordPattern(symbol); + const found = + citedFiles.some((path) => pattern.test(readText(path))) || + citedDirectories.some((directory) => + listFiles(directory) + .filter((path) => isTextFile(path)) + .some((path) => pattern.test(readText(path))), + ); + if (!found) { + errors.push(`${at} cites ${symbol}, which no cited path contains`); + } + } + } + } + + return { anchors, paths, symbols, lineRefs, errors }; +} + +export function parseArguments(args) { + if (args.length === 0) { + return { strictLineRefs: false }; + } + if (args.length === 1 && args[0] === '--strict-line-refs') { + return { strictLineRefs: true }; + } + throw new Error('usage: check-evidence-anchors.mjs [--strict-line-refs]'); +} + +if (process.argv[1] && resolve(process.argv[1]) === scriptPath) { + try { + const options = parseArguments(process.argv.slice(2)); + const result = checkEvidenceAnchors(options); + const counts = + `${result.anchors} anchors, ${result.paths} cited paths, and ${result.symbols} cited symbols checked; ` + + `${result.lineRefs} line-range citations found`; + if (result.errors.length > 0) { + console.error(result.errors.join('\n')); + console.error(`Evidence anchor check failed: ${counts}.`); + process.exitCode = 1; + } else { + console.log(`Evidence anchor check passed: ${counts}.`); + } + } catch (error) { + console.error(error.message); + process.exitCode = 1; + } +} diff --git a/docs/site/scripts/check-evidence-anchors.test.mjs b/docs/site/scripts/check-evidence-anchors.test.mjs new file mode 100644 index 000000000..2f70ad7a4 --- /dev/null +++ b/docs/site/scripts/check-evidence-anchors.test.mjs @@ -0,0 +1,226 @@ +import assert from 'node:assert/strict'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, resolve } from 'node:path'; +import { test } from 'node:test'; + +import { + checkEvidenceAnchors, + extractAnchors, + parseAnchor, + parseArguments, +} from './check-evidence-anchors.mjs'; + +function write(root, path, contents) { + const target = resolve(root, path); + mkdirSync(dirname(target), { recursive: true }); + writeFileSync(target, contents); +} + +function repository(t) { + const root = mkdtempSync(resolve(tmpdir(), 'registry-evidence-anchors-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + write(root, 'crates/demo/src/lib.rs', 'pub fn verify_source_shape() {}\n'); + write(root, 'crates/demo/src/other.rs', 'pub const SOURCE_LIMIT: usize = 4;\n'); + write(root, 'crates/demo/tests/cli_contract.rs', 'fn covers_the_binary() {}\n'); + write(root, 'crates/demo/tests/language_server.rs', 'fn reports_editor_diagnostics() {}\n'); + return root; +} + +function check(root, body, options = {}) { + write(root, 'docs/site/src/content/docs/page.mdx', `---\ntitle: Page\n---\n\n${body}\n`); + return checkEvidenceAnchors({ repoRoot: root, ...options }); +} + +test('extracts multi-line anchors with the line they start on', () => { + const anchors = extractAnchors('one\ntwo\n{/* Evidence: crates/demo/src/lib.rs\n holds it. */}\n'); + assert.equal(anchors.length, 1); + assert.equal(anchors[0].line, 3); + assert.match(anchors[0].body, /holds it\./); +}); + +test('accepts an anchor whose path exists and whose symbol resolves', (t) => { + const root = repository(t); + const result = check(root, '{/* Evidence: crates/demo/src/lib.rs, verify_source_shape(). */}'); + assert.deepEqual(result.errors, []); + assert.equal(result.anchors, 1); + assert.equal(result.paths, 1); + assert.equal(result.symbols, 1); +}); + +test('reports a cited path that does not exist', (t) => { + const root = repository(t); + const result = check(root, '{/* Evidence: crates/demo/src/absent.rs holds the check. */}'); + assert.equal(result.errors.length, 1); + assert.match(result.errors[0], /^page\.mdx:5 /); + assert.match(result.errors[0], /crates\/demo\/src\/absent\.rs/); +}); + +test('reports a line reference past the end of the file with the real line count', (t) => { + const root = repository(t); + const result = check(root, '{/* Evidence: crates/demo/src/lib.rs:40-42 holds it. */}'); + assert.equal(result.errors.length, 1); + assert.match(result.errors[0], /crates\/demo\/src\/lib\.rs:40-42/); + assert.match(result.errors[0], /has 1 line\b/); +}); + +test('reports a symbol that appears in no cited path', (t) => { + const root = repository(t); + const result = check(root, '{/* Evidence: crates/demo/src/lib.rs, absent_test_name. */}'); + assert.equal(result.errors.length, 1); + assert.match(result.errors[0], /absent_test_name/); +}); + +test('accepts a symbol that appears in the second of two cited paths', (t) => { + const root = repository(t); + const result = check( + root, + '{/* Evidence: crates/demo/src/lib.rs and crates/demo/src/other.rs define SOURCE_LIMIT. */}', + ); + assert.deepEqual(result.errors, []); + assert.equal(result.paths, 2); +}); + +test('resolves a relative continuation against the crate root of the last full path', (t) => { + const root = repository(t); + const passing = check( + root, + '{/* Evidence: crates/demo/src/lib.rs, verify_source_shape(); src/other.rs, SOURCE_LIMIT. */}', + ); + assert.deepEqual(passing.errors, []); + assert.equal(passing.paths, 2); + + const failing = check(root, '{/* Evidence: crates/demo/src/lib.rs and src/absent.rs. */}'); + assert.equal(failing.errors.length, 1); + assert.match(failing.errors[0], /crates\/demo\/src\/absent\.rs/); +}); + +test('resolves a bare sibling filename against the directory of the last full path', (t) => { + const root = repository(t); + const passing = check( + root, + '{/* Evidence: crates/demo/tests/cli_contract.rs and language_server.rs pin the surfaces. */}', + ); + assert.deepEqual(passing.errors, []); + assert.equal(passing.paths, 2); + + const elsewhere = check( + root, + '{/* Evidence: crates/demo/tests/cli_contract.rs and other.rs, SOURCE_LIMIT. */}', + ); + assert.deepEqual(elsewhere.errors, []); + assert.equal(elsewhere.paths, 2); +}); + +test('leaves a bare filename the repository does not own out of the path check', (t) => { + const root = repository(t); + const result = check( + root, + '{/* Evidence: crates/demo/src/lib.rs accepts the values an adopter writes in origins.yaml. */}', + ); + assert.deepEqual(result.errors, []); + assert.equal(result.paths, 1); +}); + +test('resolves a bare line range against the most recently cited path', (t) => { + const root = repository(t); + const result = check(root, '{/* Evidence: crates/demo/src/other.rs:1, and :305-309. */}'); + assert.equal(result.errors.length, 1); + assert.match(result.errors[0], /crates\/demo\/src\/other\.rs:305-309/); + assert.match(result.errors[0], /has 1 line\b/); +}); + +test('carries a bare line range past a filename the repository does not own', (t) => { + const root = repository(t); + const result = check( + root, + '{/* Evidence: crates/demo/src/other.rs:1 writes origins.yaml, then :305-309. */}', + ); + assert.equal(result.errors.length, 1); + assert.match(result.errors[0], /crates\/demo\/src\/other\.rs:305-309/); +}); + +test('does not treat a cited filename stem as a symbol', (t) => { + const root = repository(t); + const result = check( + root, + '{/* Evidence: crates/demo/tests/cli_contract.rs pins the tooling inventory. */}', + ); + assert.deepEqual(result.errors, []); + assert.equal(result.symbols, 0); +}); + +test('skips prose words that carry a symbol shape but are on the allowlist', (t) => { + const root = repository(t); + const result = check( + root, + '{/* Evidence: crates/demo/src/lib.rs backs the JavaScript and TypeScript bindings. */}', + ); + assert.deepEqual(result.errors, []); + assert.equal(result.symbols, 0); +}); + +test('skips the symbol check when an anchor cites no path', (t) => { + const root = repository(t); + const result = check(root, '{/* Evidence: the operator contract states does_not_own. */}'); + assert.deepEqual(result.errors, []); + assert.equal(result.paths, 0); + assert.equal(result.symbols, 0); +}); + +test('reads a continuation with no full path before it against the docs site', (t) => { + const root = repository(t); + write(root, 'docs/site/src/data/projects.yaml', '- id: demo\n does_not_own: []\n'); + const resolved = check(root, '{/* Evidence: src/data/projects.yaml, does_not_own. */}'); + assert.deepEqual(resolved.errors, []); + assert.equal(resolved.paths, 1); + + const unresolved = check(root, '{/* Evidence: src/data/absent.yaml, does_not_own. */}'); + assert.deepEqual(unresolved.errors, []); + assert.equal(unresolved.paths, 0); +}); + +test('counts line-range citations and fails them only under strict line references', (t) => { + const root = repository(t); + const relaxed = check(root, '{/* Evidence: crates/demo/src/lib.rs:1, verify_source_shape(). */}'); + assert.deepEqual(relaxed.errors, []); + assert.equal(relaxed.lineRefs, 1); + + const strict = check( + root, + '{/* Evidence: crates/demo/src/lib.rs:1, verify_source_shape(). */}', + { strictLineRefs: true }, + ); + assert.equal(strict.errors.length, 1); + assert.match(strict.errors[0], /crates\/demo\/src\/lib\.rs:1/); + assert.match(strict.errors[0], /line numbers drift/); +}); + +test('takes the last segment of a qualified symbol path', (t) => { + const root = repository(t); + write(root, 'crates/demo/src/codes.rs', 'pub enum ProblemCode { AuditUnavailable }\n'); + const result = check( + root, + '{/* Evidence: crates/demo/src/codes.rs, ProblemCode::AuditUnavailable. */}', + ); + assert.deepEqual(result.errors, []); +}); + +test('parses the strict line reference flag', () => { + assert.deepEqual(parseArguments([]), { strictLineRefs: false }); + assert.deepEqual(parseArguments(['--strict-line-refs']), { strictLineRefs: true }); + assert.throws(() => parseArguments(['--unknown']), /usage: check-evidence-anchors\.mjs/); +}); + +test('parses citations and symbols without touching the filesystem', () => { + const parsed = parseAnchor( + 'crates/demo/src/lib.rs:12-14 defines verify_source_shape() and SOURCE_LIMIT.', + ); + assert.deepEqual( + parsed.citations.map((citation) => citation.candidates[0]), + ['crates/demo/src/lib.rs'], + ); + assert.deepEqual(parsed.citations[0].start, 12); + assert.deepEqual(parsed.citations[0].end, 14); + assert.deepEqual(parsed.symbols, ['verify_source_shape', 'SOURCE_LIMIT']); +}); From 503ef3bb52015a3abe5bb6360e9b9dda2f8e3c86 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sat, 22 Aug 2026 08:47:07 +0200 Subject: [PATCH 02/25] docs: correct the secret-file and problem-code claims What the anchor checker found, plus one claim the same reading exposed. The file secret provider accepts mode 0400 or 0600, not 0600 alone: validate_file_metadata() tests matches!(mode, 0o400 | 0o600), and evidencectl writes a 0400 runtime, so an operator reading "rejects anything else" would expect a working deployment to be refused. Three security pages claimed the stricter rule. The security landing page also cited file_secret_uses_open_file_owner_and_exact_mode_checks, which exists nowhere in the workspace; the real test is file_secret_accepts_only_owner_read_and_optional_owner_write_modes. The threat model carried both errors and was corrected in #806. The DPI safeguards page attributed Relay's problem catalog to crates/registry-relay-v2/src/problem.rs, which holds only the ProblemCodeResponseExt trait, and credited it with a PROBLEM_BASE constant that exists only in Evidence. The 26 entries live in registry-relay-http-contract and each carries its own literal type_uri. The count was right; the crate and the constant were not. The same anchor's contract.rs range named PurposeConstraint and AuthorityRowBinding but pointed at DatePrecision and DisclosureProfile, about 160 lines away, so that range and the stale auth.rs range beside it are dropped rather than repaired; the anchor already names both symbols. Security-sensitive review note: documentation only, no runtime or contract change. Every corrected claim was re-verified against the runtime source named in its anchor. Signed-off-by: Jeremi Joslin --- .../docs/explanation/dpi-safeguards-alignment.mdx | 10 +++++----- docs/site/src/content/docs/security/evidence.mdx | 4 ++-- .../content/docs/security/hardening-checklist.mdx | 5 +++-- docs/site/src/content/docs/security/index.mdx | 12 ++++++------ 4 files changed, 16 insertions(+), 15 deletions(-) diff --git a/docs/site/src/content/docs/explanation/dpi-safeguards-alignment.mdx b/docs/site/src/content/docs/explanation/dpi-safeguards-alignment.mdx index b8e0dd7d6..a8e59b3c6 100644 --- a/docs/site/src/content/docs/explanation/dpi-safeguards-alignment.mdx +++ b/docs/site/src/content/docs/explanation/dpi-safeguards-alignment.mdx @@ -8,7 +8,7 @@ source_repos: - registry-relay - registry-manifest - registry-evidence -last_reviewed: "2026-08-11" +last_reviewed: "2026-08-22" doc_type: explanation locale: en standards_referenced: @@ -90,10 +90,10 @@ framework. | Can other systems interoperate? | Manifest emits standards-shaped metadata; Evidence Gateway publishes a product-level OpenAPI document, and Relay serves an OpenAPI description generated from the deployment's own compiled contract at `GET /openapi.json`; Evidence Gateway can serialize one assertion as an SD-JWT VC under a frozen local profile. | These are scoped adoption claims, not blanket conformance to every named standard. Relay's OpenAPI describes one deployment, so two deployments do not share an API document. The SD-JWT VC profile is a second encoding of one response, and it excludes OpenID for Verifiable Credential Issuance (OID4VCI) in every part. | {/* Evidence: PurposeConstraint { claim, allowed } and AuthorityRowBinding over a token claim or the - token principal, crates/registry-relay-v2/src/contract.rs:966-976, enforced in - RelayAuthenticator::authorize(), crates/registry-relay-v2/src/auth.rs:204-257; the closed - 26-variant ProblemCode set and its PROBLEM_BASE constant, - crates/registry-relay-v2/src/problem.rs:14-45; the two fixed transforms are partial-string and + token principal, crates/registry-relay-v2/src/contract.rs, enforced in + RelayAuthenticator::authorize(), crates/registry-relay-v2/src/auth.rs; the closed 26-entry + ProblemCode catalog, where every entry carries its own literal type_uri and Relay has no + shared base constant, crates/registry-relay-http-contract/src/lib.rs; the two fixed transforms are partial-string and date-precision with year and year-month only; every audit failure maps to ProblemCode::AuditUnavailable (503) with no configuration to disable it, crates/registry-relay-v2/src/api.rs; the OpenAPI document is generated from the compiled diff --git a/docs/site/src/content/docs/security/evidence.mdx b/docs/site/src/content/docs/security/evidence.mdx index 958cfe2de..42fb618df 100644 --- a/docs/site/src/content/docs/security/evidence.mdx +++ b/docs/site/src/content/docs/security/evidence.mdx @@ -5,7 +5,7 @@ status: current owner: registry-docs source_repos: - registry-stack -last_reviewed: "2026-08-07" +last_reviewed: "2026-08-22" doc_type: explanation locale: en standards_referenced: [] @@ -223,7 +223,7 @@ does not otherwise touch. Owner-only key files. Each secret file below the configured `secretProviders.file.root` must be a regular, non-symlink file owned by the -service identity with mode `0600`; the file provider rejects anything else. +service identity with mode `0400` or `0600`; the file provider rejects anything else. Audit and subject-binding secret files must contain independently generated raw key bytes and be at least 32 bytes. The runtime derives separated audit-chain and identifier subkeys from each master, but Evidence Gateway also requires the two secret references and resolved diff --git a/docs/site/src/content/docs/security/hardening-checklist.mdx b/docs/site/src/content/docs/security/hardening-checklist.mdx index 29288190c..638820af7 100644 --- a/docs/site/src/content/docs/security/hardening-checklist.mdx +++ b/docs/site/src/content/docs/security/hardening-checklist.mdx @@ -8,7 +8,7 @@ source_repos: - registry-evidence - registry-mint - registry-platform -last_reviewed: "2026-08-11" +last_reviewed: "2026-08-22" doc_type: how-to locale: en standards_referenced: [] @@ -80,7 +80,8 @@ Relay package. It assumes you have already configured your services per filename with no directory component, so a nested or traversing path is rejected rather than resolved. No secret may exceed 64 KiB. - Relay: on Unix, a `secret:file/` target must be a regular file owned by the running user - with mode exactly `0600` and a link count of one; a hard-linked file is refused under every name. + with mode `0400` or `0600` and a link count of one; a hard-linked file is refused under every + name. Relay refuses to start at all on a non-Unix target, because the runtime file's own path-trust check fails closed where Unix ownership and sticky-directory semantics are unavailable, so do not treat a non-Unix host as a degraded-but-working option. diff --git a/docs/site/src/content/docs/security/index.mdx b/docs/site/src/content/docs/security/index.mdx index ed3530fdc..1cddb6379 100644 --- a/docs/site/src/content/docs/security/index.mdx +++ b/docs/site/src/content/docs/security/index.mdx @@ -9,7 +9,7 @@ source_repos: - registry-platform - registry-evidence - registry-mint -last_reviewed: "2026-08-11" +last_reviewed: "2026-08-22" doc_type: explanation locale: en standards_referenced: [] @@ -184,8 +184,8 @@ process does not serve. Those checks are the security surface worth reviewing: issuer, and a contract that declares any lookup requires quotas. Neither is a warning. - Secrets are referenced, never inlined. The runtime file accepts exactly two reference grammars, `secret:env/` and `secret:file/`, and nothing else. A file secret must be a single - flat filename, owned by the running user, with mode exactly `0600` and a link count of one, and - no secret may exceed 64 KiB. + flat filename, owned by the running user, with mode `0400` or `0600` and a link count of one, + and no secret may exceed 64 KiB. - The source is opened read-only and pinned. Relay opens SQLite with `SQLITE_OPEN_READ_ONLY | SQLITE_OPEN_URI | SQLITE_OPEN_NO_MUTEX`, refuses a symlink, refuses a `-wal` or `-journal` sidecar beside a snapshot, and requires a snapshot to sit on a read-only @@ -204,9 +204,9 @@ process does not serve. Those checks are the security surface worth reviewing: lookups, and #[cfg(not(unix))] validate_runtime_path returns StartupError::RuntimeInvalid, crates/registry-relay-v2/src/startup.rs:96-104 and :469-492; RelayRuntime is a closed deny_unknown_fields schema, crates/registry-relay-v2/src/contract.rs:1062-1079; the two secret - grammars, the 64 KiB bound, and the uid/0600/nlink==1 file checks with their tests - references_use_only_the_two_exact_contract_grammars and - file_secret_uses_open_file_owner_and_exact_mode_checks, + grammars, the 64 KiB bound, and the uid, 0400-or-0600 mode, and nlink==1 file checks with + their tests references_use_only_the_two_exact_contract_grammars and + file_secret_accepts_only_owner_read_and_optional_owner_write_modes, crates/registry-platform-config/src/secrets.rs; the open flags, crates/registry-platform-sqlite/src/schema.rs:131-133 and src/statement.rs:494-496; sidecar and symlink refusal and the read-only-or-non-writable requirement, From 6388243e4e432cdd873b885da78cad915070ba45 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sat, 22 Aug 2026 12:20:18 +0200 Subject: [PATCH 03/25] fix(docs): close four gaps in the evidence anchor check Review found four ways the check reported success where it should have reported drift, which is worse than no check because it manufactures confidence. A continuation path with nothing before it, such as src/data/projects.yaml, resolved under docs/site but was optional, so deleting the file removed the citation from validation instead of failing. It is a definite path and is now required. No real anchor relied on the leniency: the cited-path count is unchanged. Line references were only checked against the end of the file, so :0, a descending range such as :5-3, and a range starting past EOF all passed. A range now has to start at line 1 or later, end at or after its start, and lie inside the file. extractSymbols read SCREAMING_SNAKE_CASE, snake_case, and UpperCamelCase but not lowerCamelCase, so the wire and configuration keys the docs quote were never checked at all. Misspelling packageRevision left the gate green. Citations could leave the repository. The path pattern matches .. segments and the resolved path was never tested for containment, so an anchor citing crates/../../../etc/passwd counted as a valid existing path. Traversal is now an error rather than a skip, since skipping is how it would hide. Security-sensitive review note: the traversal fix is the reason this is not purely a correctness change. The check reads and stats every path it resolves, so before this it would follow a citation to an arbitrary file outside the tree. Signed-off-by: Jeremi Joslin --- docs/site/scripts/check-evidence-anchors.mjs | 45 +++++++++--- .../scripts/check-evidence-anchors.test.mjs | 70 ++++++++++++++++++- 2 files changed, 104 insertions(+), 11 deletions(-) diff --git a/docs/site/scripts/check-evidence-anchors.mjs b/docs/site/scripts/check-evidence-anchors.mjs index 5a42a1f93..74192d64c 100644 --- a/docs/site/scripts/check-evidence-anchors.mjs +++ b/docs/site/scripts/check-evidence-anchors.mjs @@ -6,7 +6,7 @@ // present in at least one path the same anchor cites. import { readFileSync, readdirSync, statSync } from 'node:fs'; -import { dirname, relative, resolve } from 'node:path'; +import { dirname, relative, resolve, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; const scriptPath = fileURLToPath(import.meta.url); @@ -60,6 +60,8 @@ const WORD_PATTERN = /[A-Za-z_][A-Za-z0-9_]*(?:::[A-Za-z_][A-Za-z0-9_]*)+|[A-Za- const SCREAMING_SNAKE_CASE = /^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+$/; const SNAKE_CASE = /^[a-z][a-z0-9]*(?:_[a-z0-9]+)+$/; const UPPER_CAMEL_CASE = /^(?:[A-Z][a-z0-9]+){2,}$/; +// A configuration or wire key: the internal capital is what holds it apart from prose. +const LOWER_CAMEL_CASE = /^[a-z][a-z0-9]*(?:[A-Z][a-z0-9]*)+$/; export function extractAnchors(text) { const anchors = []; @@ -94,10 +96,10 @@ function joinPath(base, tail) { } // Every citation carries the ordered candidate paths it may resolve to, and whether a -// candidate is a claim or a guess. A full repository path, and a continuation anchored -// to one, plainly names a repository path, so a miss is drift and is reported. A bare -// sibling filename, or a continuation with no full path before it, is only a reading of -// the prose: when nothing resolves, it names a file the repository does not own (an +// candidate is a claim or a guess. A full repository path, a continuation anchored to +// one, and a continuation read against the docs site plainly name a repository path, so +// a miss is drift and is reported. A bare sibling filename is only a reading of the +// prose: when nothing resolves, it names a file the repository does not own (an // adopter's configuration file, or a path inside a generated package) and is left alone. export function parseAnchor(body, { siteRoot = DOCS_SITE_ROOT } = {}) { const citations = []; @@ -133,7 +135,7 @@ export function parseAnchor(body, { siteRoot = DOCS_SITE_ROOT } = {}) { citation = { form: 'continuation', candidates: [joinPath(siteRoot, trimmed)], - reportMissing: false, + reportMissing: true, }; } else if (continuation !== undefined) { citation = { @@ -183,7 +185,8 @@ export function extractSymbols(prose) { !qualified && !SCREAMING_SNAKE_CASE.test(candidate) && !SNAKE_CASE.test(candidate) && - !UPPER_CAMEL_CASE.test(candidate) + !UPPER_CAMEL_CASE.test(candidate) && + !LOWER_CAMEL_CASE.test(candidate) ) { continue; } @@ -194,6 +197,17 @@ export function extractSymbols(prose) { return symbols; } +// A citation that climbs above the repository, by a `..` segment or by resolving outside +// the root, names nothing the documentation can cite, so it is refused before it is read. +function escapesRepository(repoRoot, path) { + if (path.split('/').includes('..')) { + return true; + } + const root = resolve(repoRoot); + const absolute = resolve(root, path); + return absolute !== root && !absolute.startsWith(`${root}${sep}`); +} + function entryKind(absolute) { try { return statSync(absolute).isDirectory() ? 'directory' : 'file'; @@ -308,6 +322,17 @@ export function checkEvidenceAnchors({ citation.start === undefined ? '' : `:${citation.start}${citation.end === citation.start ? '' : `-${citation.end}`}`; + const escaping = citation.candidates.find((candidate) => + escapesRepository(repoRoot, candidate), + ); + if (escaping !== undefined) { + paths += 1; + if (range !== '') { + lineRefs += 1; + } + errors.push(`${at} cites ${escaping}${range}, which leaves the repository`); + continue; + } const resolved = citation.candidates.find( (candidate) => entryKind(resolve(repoRoot, candidate)) !== 'missing', @@ -347,7 +372,11 @@ export function checkEvidenceAnchors({ continue; } const count = lineCount(readText(resolved)); - if (citation.end > count) { + if (citation.start < 1 || citation.end < citation.start) { + errors.push( + `${at} cites ${resolved}${range}, but a line range starts at line 1 and ends at or after its start`, + ); + } else if (citation.start > count || citation.end > count) { errors.push( `${at} cites ${resolved}${range}, but the file has ${pluralLines(count)}`, ); diff --git a/docs/site/scripts/check-evidence-anchors.test.mjs b/docs/site/scripts/check-evidence-anchors.test.mjs index 2f70ad7a4..c0f7767b0 100644 --- a/docs/site/scripts/check-evidence-anchors.test.mjs +++ b/docs/site/scripts/check-evidence-anchors.test.mjs @@ -7,6 +7,7 @@ import { test } from 'node:test'; import { checkEvidenceAnchors, extractAnchors, + extractSymbols, parseAnchor, parseArguments, } from './check-evidence-anchors.mjs'; @@ -56,6 +57,20 @@ test('reports a cited path that does not exist', (t) => { assert.match(result.errors[0], /crates\/demo\/src\/absent\.rs/); }); +test('reports a citation whose path climbs out of the repository', (t) => { + const root = repository(t); + const outside = resolve(root, '..', 'registry-evidence-anchors-outside.txt'); + writeFileSync(outside, 'held outside the repository\n'); + t.after(() => rmSync(outside, { force: true })); + const result = check( + root, + '{/* Evidence: crates/../../registry-evidence-anchors-outside.txt holds it. */}', + ); + assert.equal(result.errors.length, 1); + assert.match(result.errors[0], /crates\/\.\.\/\.\.\/registry-evidence-anchors-outside\.txt/); + assert.match(result.errors[0], /leaves the repository/); +}); + test('reports a line reference past the end of the file with the real line count', (t) => { const root = repository(t); const result = check(root, '{/* Evidence: crates/demo/src/lib.rs:40-42 holds it. */}'); @@ -64,6 +79,32 @@ test('reports a line reference past the end of the file with the real line count assert.match(result.errors[0], /has 1 line\b/); }); +test('reports a line reference that starts before the first line', (t) => { + const root = repository(t); + const result = check(root, '{/* Evidence: crates/demo/src/lib.rs:0 holds it. */}'); + assert.equal(result.errors.length, 1); + assert.match(result.errors[0], /crates\/demo\/src\/lib\.rs:0/); + assert.match(result.errors[0], /starts at line 1/); +}); + +test('reports a line range that ends before it starts', (t) => { + const root = repository(t); + write(root, 'crates/demo/src/wide.rs', 'one\ntwo\nthree\nfour\nfive\nsix\n'); + const result = check(root, '{/* Evidence: crates/demo/src/wide.rs:5-3 holds it. */}'); + assert.equal(result.errors.length, 1); + assert.match(result.errors[0], /crates\/demo\/src\/wide\.rs:5-3/); + assert.match(result.errors[0], /ends at or after its start/); +}); + +test('reports a bare line range that ends before it starts', (t) => { + const root = repository(t); + write(root, 'crates/demo/src/wide.rs', 'one\ntwo\nthree\nfour\nfive\nsix\n'); + const result = check(root, '{/* Evidence: crates/demo/src/wide.rs:1, and :5-3. */}'); + assert.equal(result.errors.length, 1); + assert.match(result.errors[0], /crates\/demo\/src\/wide\.rs:5-3/); + assert.match(result.errors[0], /ends at or after its start/); +}); + test('reports a symbol that appears in no cited path', (t) => { const root = repository(t); const result = check(root, '{/* Evidence: crates/demo/src/lib.rs, absent_test_name. */}'); @@ -71,6 +112,18 @@ test('reports a symbol that appears in no cited path', (t) => { assert.match(result.errors[0], /absent_test_name/); }); +test('checks a lower camel case symbol against the cited paths', (t) => { + const root = repository(t); + write(root, 'crates/demo/src/wire.rs', 'pub struct Body { packageRevision: u32 }\n'); + const passing = check(root, '{/* Evidence: crates/demo/src/wire.rs carries packageRevision. */}'); + assert.deepEqual(passing.errors, []); + assert.equal(passing.symbols, 1); + + const failing = check(root, '{/* Evidence: crates/demo/src/wire.rs carries packageRevison. */}'); + assert.equal(failing.errors.length, 1); + assert.match(failing.errors[0], /packageRevison/); +}); + test('accepts a symbol that appears in the second of two cited paths', (t) => { const root = repository(t); const result = check( @@ -174,10 +227,15 @@ test('reads a continuation with no full path before it against the docs site', ( const resolved = check(root, '{/* Evidence: src/data/projects.yaml, does_not_own. */}'); assert.deepEqual(resolved.errors, []); assert.equal(resolved.paths, 1); +}); - const unresolved = check(root, '{/* Evidence: src/data/absent.yaml, does_not_own. */}'); - assert.deepEqual(unresolved.errors, []); - assert.equal(unresolved.paths, 0); +test('reports a continuation the docs site does not hold', (t) => { + const root = repository(t); + const result = check(root, '{/* Evidence: src/data/absent.yaml, does_not_own. */}'); + assert.equal(result.errors.length, 1); + assert.match(result.errors[0], /docs\/site\/src\/data\/absent\.yaml/); + assert.match(result.errors[0], /does not exist/); + assert.equal(result.paths, 1); }); test('counts line-range citations and fails them only under strict line references', (t) => { @@ -206,6 +264,12 @@ test('takes the last segment of a qualified symbol path', (t) => { assert.deepEqual(result.errors, []); }); +test('keeps ordinary prose words out of the symbols a lower camel case name is read from', () => { + assert.deepEqual(extractSymbols('the source an evidence deployment reads is packageRevision.'), [ + 'packageRevision', + ]); +}); + test('parses the strict line reference flag', () => { assert.deepEqual(parseArguments([]), { strictLineRefs: false }); assert.deepEqual(parseArguments(['--strict-line-refs']), { strictLineRefs: true }); From 25f4b0a5cd7fdadfae5f9fbf859b5e0ec048b435 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sat, 22 Aug 2026 12:20:18 +0200 Subject: [PATCH 04/25] docs: cite where the wire keys the prose quotes are spelled The lower-camel-case check finds two anchors naming a wire key while citing only the Rust file, which carries the snake_case spelling. records-stay-home.mdx quotes authorityRowBinding and cites contract.rs, where the enum is AuthorityRowBinding and the field is authority_row_binding; the key itself is in the relayctl authoring schema. verify/index.mdx quotes recordsEquivalentTo and etagSameAs and cites fixtures.rs, which spells them records_equivalent_to and etag_same_as; both keys are in the business-registry acceptance expectations. recordsEquivalentTo passed only because a test literal in fixtures.rs happens to contain it, so the pair was inconsistent for an incidental reason. Both anchors gain the path carrying the key rather than losing the claim: the prose is describing what an adopter writes, so the spelling it quotes is the right one. The same records-stay-home anchor cited contract.rs:947-976 for AccessRule and AuthorityRowBinding, which are at 1107 and 1134; that range now holds PartialStringReveal, DateInputType, DatePrecision, and DisclosureProfile. The range is inside the file, so no line check can catch it. Dropped, since the anchor already names the symbols. Signed-off-by: Jeremi Joslin --- docs/site/src/content/docs/explanation/records-stay-home.mdx | 5 +++-- docs/site/src/content/docs/verify/index.mdx | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/site/src/content/docs/explanation/records-stay-home.mdx b/docs/site/src/content/docs/explanation/records-stay-home.mdx index 1a3ee681c..5245c4435 100644 --- a/docs/site/src/content/docs/explanation/records-stay-home.mdx +++ b/docs/site/src/content/docs/explanation/records-stay-home.mdx @@ -7,7 +7,7 @@ source_repos: - registry-stack - registry-evidence - registry-relay -last_reviewed: "2026-08-21" +last_reviewed: "2026-08-22" doc_type: explanation locale: en standards_referenced: @@ -172,7 +172,8 @@ partial string reveal (`***`) and a date reduced to year or year-month. Relay si responses carry no assertion. {/* Evidence: AccessRule is Public | Protected{scope, purpose, authorityRowBinding} and AuthorityRowBinding has a claim variant and a principal variant, - crates/registry-relay-v2/src/contract.rs; row authority is injected as a bound + crates/registry-relay-v2/src/contract.rs; the authorityRowBinding key is spelled that way in + crates/registry-relayctl/schemas/authoring/registry.schema.json; row authority is injected as a bound parameter with an exact-equality COLLATE BINARY predicate, never string concatenation, crates/registry-relay-v2/src/sqlite_runtime.rs add_row_authority(). */} Evidence Gateway returns the values a requirement declares rather than the source row; keeping that diff --git a/docs/site/src/content/docs/verify/index.mdx b/docs/site/src/content/docs/verify/index.mdx index 1756661d1..4716964af 100644 --- a/docs/site/src/content/docs/verify/index.mdx +++ b/docs/site/src/content/docs/verify/index.mdx @@ -5,7 +5,7 @@ status: current owner: registry-docs source_repos: - registry-stack -last_reviewed: "2026-08-11" +last_reviewed: "2026-08-22" doc_type: how-to locale: en standards_referenced: [] @@ -64,7 +64,8 @@ records across formats or the same entity tag, passes only in a full run. {/* Evidence: crates/registry-relay-v2/src/fixtures.rs compile_fixture_plan() skips every step whose id differs from the selection; assert_expectations() fails recordsEquivalentTo and etagSameAs when the referenced observation is - absent. */} + absent; both keys are spelled that way in + products/relay-v2/acceptance/business-registry/expected-http.yaml. */} ## Generate the review inputs From 45674f083b02f731db60a08578b4de371f9d62ad Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sat, 22 Aug 2026 13:14:50 +0200 Subject: [PATCH 05/25] fix(docs): close six more gaps in the evidence anchor check Six cases the checker claimed to check and silently did not: - a qualified symbol was read from its last segment only, so a typo in the module, type, or enum that qualified it stayed green; every segment that carries a symbol shape is now checked on its own. - an identifier spelled with empty parentheses, router() or prepare(), matched none of the four case shapes and was dropped; the anchors already spell a function reference that way, so it is read as a name whatever its case. - a bare filename the repository keeps at its root resolved against no candidate at all, so its symbols were never looked for. The root is tried after the search inside the cited unit rather than before it, so a crate's own README wins over the workspace one instead of losing to it. - a sibling filename read against the last full path rather than the last cited one, so a continuation between them was ignored. Only a citation that claims a repository path moves that anchor: a sibling is a reading of the prose and leaves it where it is. - a bare .rhai script was not a sibling filename, though the scripts are cited as bounded request preparation and source extraction. - a line suffix cut short, :5-, parsed as the single line 5 and is now reported as the malformed reference it is. The docs need no correction under any of them: 25 more symbols are checked, and every one resolves in a path its anchor already cites. The cited-path and line-range counts are unchanged, which is what holds the sibling-anchor change honest: moving that anchor too eagerly drops a path from the count in silence. Signed-off-by: Jeremi Joslin --- docs/site/scripts/check-evidence-anchors.mjs | 116 +++++++++++++----- .../scripts/check-evidence-anchors.test.mjs | 111 +++++++++++++++++ 2 files changed, 198 insertions(+), 29 deletions(-) diff --git a/docs/site/scripts/check-evidence-anchors.mjs b/docs/site/scripts/check-evidence-anchors.mjs index 74192d64c..da9d92975 100644 --- a/docs/site/scripts/check-evidence-anchors.mjs +++ b/docs/site/scripts/check-evidence-anchors.mjs @@ -24,10 +24,22 @@ export const PROSE_SYMBOL_ALLOWLIST = new Set([ // Directories a repository-relative citation may start from. const REPOSITORY_ROOTS = ['crates', 'products', 'release', 'docs', 'external', '\\.github']; // Directories a continuation citation may start from, resolved against the crate or -// product root of the most recent full path in the same anchor. +// product root of the most recently cited path in the same anchor. const CONTINUATION_ROOTS = ['src', 'tests', 'examples', 'benches', 'schemas', 'scripts']; // Extensions that make a bare token a sibling filename rather than ordinary prose. -const SOURCE_EXTENSIONS = ['rs', 'mjs', 'md', 'py', 'sh', 'toml', 'yaml', 'yml', 'jsonld', 'json']; +const SOURCE_EXTENSIONS = [ + 'rs', + 'mjs', + 'md', + 'py', + 'sh', + 'rhai', + 'toml', + 'yaml', + 'yml', + 'jsonld', + 'json', +]; // Extensions read when a symbol has to be looked for inside a cited directory. const TEXT_EXTENSIONS = new Set([ ...SOURCE_EXTENSIONS, @@ -105,7 +117,7 @@ export function parseAnchor(body, { siteRoot = DOCS_SITE_ROOT } = {}) { const citations = []; const strippedParts = []; let cursor = 0; - let lastFullPath; + let lastCitedPath; let previous; for (const match of body.matchAll(CITATION_PATTERN)) { @@ -125,13 +137,15 @@ export function parseAnchor(body, { siteRoot = DOCS_SITE_ROOT } = {}) { const token = full ?? continuation ?? sibling; const { path, start, end } = splitLineReference(token); const trimmed = path.replace(/\/$/, ''); - const parentOfLastFullPath = - lastFullPath === undefined || dirname(lastFullPath) === '.' ? '' : dirname(lastFullPath); + // A line suffix the anchor cut short, `:5-`, leaves its hyphen outside the token and + // would otherwise read as the single line 5 rather than the range it was meant to be. + const malformedLines = start !== undefined && body.startsWith('-', cursor); + const parentOfLastCitedPath = + lastCitedPath === undefined || dirname(lastCitedPath) === '.' ? '' : dirname(lastCitedPath); let citation; if (full !== undefined) { - lastFullPath = trimmed; citation = { form: 'full', candidates: [trimmed], reportMissing: true }; - } else if (continuation !== undefined && lastFullPath === undefined) { + } else if (continuation !== undefined && lastCitedPath === undefined) { citation = { form: 'continuation', candidates: [joinPath(siteRoot, trimmed)], @@ -141,58 +155,88 @@ export function parseAnchor(body, { siteRoot = DOCS_SITE_ROOT } = {}) { citation = { form: 'continuation', candidates: [ - joinPath(citationRoot(lastFullPath), trimmed), - joinPath(parentOfLastFullPath, trimmed), + joinPath(citationRoot(lastCitedPath), trimmed), + joinPath(parentOfLastCitedPath, trimmed), ], reportMissing: true, }; - } else if (lastFullPath === undefined) { + } else if (lastCitedPath === undefined) { // A sibling filename with no path before it has nothing to sit beside. continue; } else { citation = { form: 'sibling', candidates: [ - joinPath(parentOfLastFullPath, trimmed), - joinPath(citationRoot(lastFullPath), trimmed), - joinPath(lastFullPath, trimmed), + joinPath(parentOfLastCitedPath, trimmed), + joinPath(citationRoot(lastCitedPath), trimmed), + joinPath(lastCitedPath, trimmed), ], reportMissing: false, basename: trimmed, - searchRoot: citationRoot(lastFullPath), + // A bare filename may name a file the repository keeps at its root, Cargo.toml + // or deny.toml, which sits beside no cited path at all. It is tried only after + // the search inside the cited unit, so a nearer file always wins. + rootCandidate: trimmed, + searchRoot: citationRoot(lastCitedPath), }; } citation.candidates = [...new Set(citation.candidates)]; + // What follows reads against the path cited last, whichever form carried it: a + // continuation moves the anchor on just as a second full path does. A sibling is a + // reading of the prose rather than a path claim, so it leaves the anchor where it is. + if (citation.reportMissing) { + lastCitedPath = citation.candidates[0]; + } previous = citation; - citations.push({ ...citation, raw: token, start, end }); + citations.push({ ...citation, raw: token, start, end, malformedLines }); } strippedParts.push(body.slice(cursor)); return { citations, symbols: extractSymbols(strippedParts.join('')) }; } +// The four shapes that hold a name apart from the prose around it. +function carriesSymbolShape(candidate) { + return ( + SCREAMING_SNAKE_CASE.test(candidate) || + SNAKE_CASE.test(candidate) || + UPPER_CAMEL_CASE.test(candidate) || + LOWER_CAMEL_CASE.test(candidate) + ); +} + export function extractSymbols(prose) { const symbols = []; - for (const match of prose.matchAll(WORD_PATTERN)) { - const token = match[0]; - const candidate = token.includes('::') ? token.split('::').at(-1) : token; - const qualified = token.includes('::'); + const record = (candidate) => { if (PROSE_SYMBOL_ALLOWLIST.has(candidate)) { - continue; - } - if ( - !qualified && - !SCREAMING_SNAKE_CASE.test(candidate) && - !SNAKE_CASE.test(candidate) && - !UPPER_CAMEL_CASE.test(candidate) && - !LOWER_CAMEL_CASE.test(candidate) - ) { - continue; + return; } if (!symbols.includes(candidate)) { symbols.push(candidate); } + }; + + for (const match of prose.matchAll(WORD_PATTERN)) { + const token = match[0]; + const segments = token.split('::'); + const candidate = segments.at(-1); + // Every segment of a qualified path names something the repository holds, so a typo + // in the module, type, or enum that qualifies the name is drift too. Segments that + // carry no symbol shape, `std` and `fs` in std::fs::read, name nothing to look for. + for (const qualifier of segments.slice(0, -1)) { + if (carriesSymbolShape(qualifier)) { + record(qualifier); + } + } + // An anchor spells a function reference with empty parentheses, so an identifier + // written that way is a name whatever its case. Parentheses that carry anything, + // "the check (see below)", are prose. + const spelledAsCall = prose.startsWith('()', match.index + token.length); + if (segments.length === 1 && !spelledAsCall && !carriesSymbolShape(candidate)) { + continue; + } + record(candidate); } return symbols; } @@ -333,6 +377,16 @@ export function checkEvidenceAnchors({ errors.push(`${at} cites ${escaping}${range}, which leaves the repository`); continue; } + // A cut-short suffix parses as a line the anchor never meant, so it is reported + // as the malformed reference it is rather than checked against the file. + if (citation.malformedLines) { + paths += 1; + lineRefs += 1; + errors.push( + `${at} cites ${citation.raw}-, but a line reference names a line or a first and last line`, + ); + continue; + } const resolved = citation.candidates.find( (candidate) => entryKind(resolve(repoRoot, candidate)) !== 'missing', @@ -340,6 +394,10 @@ export function checkEvidenceAnchors({ (citation.basename === undefined ? undefined : uniqueFileNamed(citation.searchRoot, citation.basename)) ?? + (citation.rootCandidate !== undefined && + entryKind(resolve(repoRoot, citation.rootCandidate)) !== 'missing' + ? citation.rootCandidate + : undefined) ?? // A bare line reference that follows a filename the repository does not own // still belongs to the last file the anchor resolved. (citation.form === 'lines' ? lastResolvedFile : undefined); diff --git a/docs/site/scripts/check-evidence-anchors.test.mjs b/docs/site/scripts/check-evidence-anchors.test.mjs index c0f7767b0..813baa98d 100644 --- a/docs/site/scripts/check-evidence-anchors.test.mjs +++ b/docs/site/scripts/check-evidence-anchors.test.mjs @@ -105,6 +105,23 @@ test('reports a bare line range that ends before it starts', (t) => { assert.match(result.errors[0], /ends at or after its start/); }); +test('reports a line suffix the anchor cut short', (t) => { + const root = repository(t); + write(root, 'crates/demo/src/wide.rs', 'one\ntwo\nthree\nfour\nfive\nsix\n'); + const result = check(root, '{/* Evidence: crates/demo/src/wide.rs:5- holds it. */}'); + assert.equal(result.errors.length, 1); + assert.match(result.errors[0], /crates\/demo\/src\/wide\.rs:5-/); + assert.match(result.errors[0], /a line or a first and last line/); + assert.equal(result.lineRefs, 1); +}); + +test('leaves a hyphen the prose carries after a line reference alone', (t) => { + const root = repository(t); + write(root, 'crates/demo/src/wide.rs', 'one\ntwo\nthree\nfour\nfive\nsix\n'); + const result = check(root, '{/* Evidence: crates/demo/src/wide.rs:5 - the middle of it. */}'); + assert.deepEqual(result.errors, []); +}); + test('reports a symbol that appears in no cited path', (t) => { const root = repository(t); const result = check(root, '{/* Evidence: crates/demo/src/lib.rs, absent_test_name. */}'); @@ -165,6 +182,63 @@ test('resolves a bare sibling filename against the directory of the last full pa assert.equal(elsewhere.paths, 2); }); +test('resolves a bare sibling filename against the most recently cited path', (t) => { + const root = repository(t); + write(root, 'crates/demo/src/handler.rs', 'fn prepares_the_request() {}\n'); + write(root, 'crates/demo/tests/handler.rs', 'fn covers_the_handler() {}\n'); + const result = check( + root, + '{/* Evidence: crates/demo/src/lib.rs; tests/cli_contract.rs; handler.rs, covers_the_handler. */}', + ); + assert.deepEqual(result.errors, []); + assert.equal(result.paths, 3); +}); + +test('leaves the anchor where it is when one bare sibling filename follows another', (t) => { + const root = repository(t); + const result = check( + root, + '{/* Evidence: crates/demo holds cli_contract.rs and language_server.rs. */}', + ); + assert.deepEqual(result.errors, []); + assert.equal(result.paths, 3); +}); + +test('resolves a bare sibling filename against the repository root', (t) => { + const root = repository(t); + write(root, 'deny.toml', '[bans]\nmultiple_versions = "deny"\n'); + const result = check( + root, + '{/* Evidence: crates/demo/src/lib.rs, and deny.toml, multiple_versions. */}', + ); + assert.deepEqual(result.errors, []); + assert.equal(result.paths, 2); +}); + +test('prefers a file inside the cited unit over the one at the repository root', (t) => { + const root = repository(t); + write(root, 'README.md', 'The workspace README names workspace_wide_only.\n'); + write(root, 'crates/demo/reference/README.md', 'The crate README names crate_local_only.\n'); + const result = check( + root, + '{/* Evidence: crates/demo/src/lib.rs, and README.md, crate_local_only. */}', + ); + assert.deepEqual(result.errors, []); + assert.equal(result.paths, 2); +}); + +test('reads a bare script filename beside the path it sits with', (t) => { + const root = repository(t); + write(root, 'crates/demo/scripts/extract.rhai', 'let extracted = source_value;\n'); + write(root, 'crates/demo/scripts/prepare.rhai', 'let request_url = source_base;\n'); + const result = check( + root, + '{/* Evidence: crates/demo/scripts/extract.rhai and prepare.rhai, request_url. */}', + ); + assert.deepEqual(result.errors, []); + assert.equal(result.paths, 2); +}); + test('leaves a bare filename the repository does not own out of the path check', (t) => { const root = repository(t); const result = check( @@ -264,6 +338,43 @@ test('takes the last segment of a qualified symbol path', (t) => { assert.deepEqual(result.errors, []); }); +test('checks every segment of a qualified symbol path that carries a symbol shape', (t) => { + const root = repository(t); + write(root, 'crates/demo/src/codes.rs', 'pub enum ProblemCode { AuditUnavailable }\n'); + const result = check( + root, + '{/* Evidence: crates/demo/src/codes.rs, ProblmCode::AuditUnavailable. */}', + ); + assert.equal(result.errors.length, 1); + assert.match(result.errors[0], /ProblmCode/); + assert.equal(result.symbols, 2); +}); + +test('leaves the segments of a qualified path that name no symbol unchecked', () => { + assert.deepEqual(extractSymbols('std::fs::read_to_string reads it.'), ['read_to_string']); + assert.deepEqual(extractSymbols('ProblemCode::AuditUnavailable is returned.'), [ + 'ProblemCode', + 'AuditUnavailable', + ]); +}); + +test('reads an identifier spelled with empty parentheses as a symbol', (t) => { + const root = repository(t); + write(root, 'crates/demo/src/app.rs', 'pub fn router() -> Router {}\n'); + const passing = check(root, '{/* Evidence: crates/demo/src/app.rs builds router(). */}'); + assert.deepEqual(passing.errors, []); + assert.equal(passing.symbols, 1); + + const failing = check(root, '{/* Evidence: crates/demo/src/app.rs builds routes(). */}'); + assert.equal(failing.errors.length, 1); + assert.match(failing.errors[0], /routes/); +}); + +test('leaves a word the prose follows with a parenthesis out of the symbols', () => { + assert.deepEqual(extractSymbols('the check (see below) and the note(s) it carries'), []); + assert.deepEqual(extractSymbols('router(), prepare()'), ['router', 'prepare']); +}); + test('keeps ordinary prose words out of the symbols a lower camel case name is read from', () => { assert.deepEqual(extractSymbols('the source an evidence deployment reads is packageRevision.'), [ 'packageRevision', From 6b6bc47e6cd56b42b2b6317fd3b386664e49d9e9 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sat, 22 Aug 2026 13:22:41 +0200 Subject: [PATCH 06/25] fix(docs): refuse an anchor citation that a symlink leads out of The lexical containment check refused a `..` segment and resolved the citation against the repository root, but never consulted a real path. A tracked symlink pointing outside the checkout passed it, and both the stat and the read then followed the link, so content the repository does not hold could satisfy an anchor. Resolve both sides before deciding: the root as well, since a macOS temporary directory is itself reached through a symlink. A path with no real path stays the missing citation it already was. Signed-off-by: Jeremi Joslin --- docs/site/scripts/check-evidence-anchors.mjs | 29 +++++++++++++++++-- .../scripts/check-evidence-anchors.test.mjs | 17 ++++++++++- 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/docs/site/scripts/check-evidence-anchors.mjs b/docs/site/scripts/check-evidence-anchors.mjs index da9d92975..116223acb 100644 --- a/docs/site/scripts/check-evidence-anchors.mjs +++ b/docs/site/scripts/check-evidence-anchors.mjs @@ -5,7 +5,7 @@ // line references they carry are inside those files, and the symbols they name are // present in at least one path the same anchor cites. -import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { readFileSync, readdirSync, realpathSync, statSync } from 'node:fs'; import { dirname, relative, resolve, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -241,6 +241,19 @@ export function extractSymbols(prose) { return symbols; } +// The path a read would really open. A path the repository does not hold has none, and +// stays the missing citation it already was rather than becoming an escape. +function realPath(absolute) { + try { + return realpathSync(absolute); + } catch (error) { + if (error.code === 'ENOENT' || error.code === 'ENOTDIR') { + return undefined; + } + throw error; + } +} + // A citation that climbs above the repository, by a `..` segment or by resolving outside // the root, names nothing the documentation can cite, so it is refused before it is read. function escapesRepository(repoRoot, path) { @@ -249,7 +262,19 @@ function escapesRepository(repoRoot, path) { } const root = resolve(repoRoot); const absolute = resolve(root, path); - return absolute !== root && !absolute.startsWith(`${root}${sep}`); + if (absolute !== root && !absolute.startsWith(`${root}${sep}`)) { + return true; + } + // A symlink passes the check above and is then followed by both the stat and the read, + // so the real path is what decides. The root is resolved too: a macOS temporary + // directory is itself reached through a symlink, and an unresolved root would call + // every path beneath it an escape. + const realRoot = realPath(root); + const realAbsolute = realPath(absolute); + if (realRoot === undefined || realAbsolute === undefined) { + return false; + } + return realAbsolute !== realRoot && !realAbsolute.startsWith(`${realRoot}${sep}`); } function entryKind(absolute) { diff --git a/docs/site/scripts/check-evidence-anchors.test.mjs b/docs/site/scripts/check-evidence-anchors.test.mjs index 813baa98d..514c33825 100644 --- a/docs/site/scripts/check-evidence-anchors.test.mjs +++ b/docs/site/scripts/check-evidence-anchors.test.mjs @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, resolve } from 'node:path'; import { test } from 'node:test'; @@ -71,6 +71,21 @@ test('reports a citation whose path climbs out of the repository', (t) => { assert.match(result.errors[0], /leaves the repository/); }); +test('reports a citation whose real path leaves the repository through a symlink', (t) => { + const root = repository(t); + const outside = resolve(root, '..', 'registry-evidence-anchors-linked.rs'); + writeFileSync(outside, 'pub fn held_outside_the_repository() {}\n'); + t.after(() => rmSync(outside, { force: true })); + symlinkSync(outside, resolve(root, 'crates/demo/src/linked.rs')); + const result = check( + root, + '{/* Evidence: crates/demo/src/linked.rs, held_outside_the_repository(). */}', + ); + assert.equal(result.errors.length, 1); + assert.match(result.errors[0], /crates\/demo\/src\/linked\.rs/); + assert.match(result.errors[0], /leaves the repository/); +}); + test('reports a line reference past the end of the file with the real line count', (t) => { const root = repository(t); const result = check(root, '{/* Evidence: crates/demo/src/lib.rs:40-42 holds it. */}'); From ea8b12b5d112b0d9ce1c13a07652a08d05b364b1 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sat, 22 Aug 2026 13:28:25 +0200 Subject: [PATCH 07/25] feat(docs): check the wire values an anchor spells in capitals An exact wire value such as ES256 or RS256 matched none of the four case shapes, so an anchor could name one that no cited path holds and stay green. Add a fifth shape for it: uppercase letters and digits only, with at least two letters and at least one digit. Both exclusions are measured, not guessed. Requiring a digit keeps out the acronyms the prose spells in capitals, JSON, HTTP, SQL, and the second letter keeps out the product version words V1 and V2; without them the rule reports correct anchors today. Mixed-case values such as EdDSA stay uncaught: the only shape that admits them also admits OpenAPI, OpenID, OpenCRVS and 25 more prose names, one of which already resolves only through an unrelated file. Cited symbols checked go from 286 to 288, with no change to the cited paths or line ranges. Signed-off-by: Jeremi Joslin --- docs/site/scripts/check-evidence-anchors.mjs | 12 ++++++++++-- .../scripts/check-evidence-anchors.test.mjs | 17 +++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/docs/site/scripts/check-evidence-anchors.mjs b/docs/site/scripts/check-evidence-anchors.mjs index 116223acb..b6cf56cc6 100644 --- a/docs/site/scripts/check-evidence-anchors.mjs +++ b/docs/site/scripts/check-evidence-anchors.mjs @@ -74,6 +74,13 @@ const SNAKE_CASE = /^[a-z][a-z0-9]*(?:_[a-z0-9]+)+$/; const UPPER_CAMEL_CASE = /^(?:[A-Z][a-z0-9]+){2,}$/; // A configuration or wire key: the internal capital is what holds it apart from prose. const LOWER_CAMEL_CASE = /^[a-z][a-z0-9]*(?:[A-Z][a-z0-9]*)+$/; +// An exact wire value spelled in capitals: ES256, RS256, CRS84. Uppercase letters and +// digits only, with at least two letters and at least one digit. The digit is what holds +// it apart from an acronym the prose spells in capitals, JSON or HTTP, and the second +// letter is what holds it apart from a product version word, V1 or V2. Both exclusions +// are deliberate: those words carry no drift a cited file could disprove, and checking +// them would report correct anchors. +const UPPER_CASE_WIRE_VALUE = /^(?=[A-Z0-9]*[0-9])(?=(?:[0-9]*[A-Z]){2})[A-Z0-9]+$/; export function extractAnchors(text) { const anchors = []; @@ -196,13 +203,14 @@ export function parseAnchor(body, { siteRoot = DOCS_SITE_ROOT } = {}) { return { citations, symbols: extractSymbols(strippedParts.join('')) }; } -// The four shapes that hold a name apart from the prose around it. +// The five shapes that hold a name apart from the prose around it. function carriesSymbolShape(candidate) { return ( SCREAMING_SNAKE_CASE.test(candidate) || SNAKE_CASE.test(candidate) || UPPER_CAMEL_CASE.test(candidate) || - LOWER_CAMEL_CASE.test(candidate) + LOWER_CAMEL_CASE.test(candidate) || + UPPER_CASE_WIRE_VALUE.test(candidate) ); } diff --git a/docs/site/scripts/check-evidence-anchors.test.mjs b/docs/site/scripts/check-evidence-anchors.test.mjs index 514c33825..62dc4d77d 100644 --- a/docs/site/scripts/check-evidence-anchors.test.mjs +++ b/docs/site/scripts/check-evidence-anchors.test.mjs @@ -156,6 +156,23 @@ test('checks a lower camel case symbol against the cited paths', (t) => { assert.match(failing.errors[0], /packageRevison/); }); +test('checks an all-uppercase wire value against the cited paths', (t) => { + const root = repository(t); + write(root, 'crates/demo/src/algorithms.rs', 'const ALLOWED: [&str; 2] = ["ES256", "RS256"];\n'); + const passing = check(root, '{/* Evidence: crates/demo/src/algorithms.rs allows ES256. */}'); + assert.deepEqual(passing.errors, []); + assert.equal(passing.symbols, 1); + + const failing = check(root, '{/* Evidence: crates/demo/src/algorithms.rs allows ES265. */}'); + assert.equal(failing.errors.length, 1); + assert.match(failing.errors[0], /ES265/); +}); + +test('leaves a version word and an acronym the prose spells in capitals unchecked', () => { + assert.deepEqual(extractSymbols('the V2 registry contract serves JSON over HTTP'), []); + assert.deepEqual(extractSymbols('the profile allows ES256 and RS256'), ['ES256', 'RS256']); +}); + test('accepts a symbol that appears in the second of two cited paths', (t) => { const root = repository(t); const result = check( From 149ea5e1a5d3c4bc5f831306e6c805cc78abf55b Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sat, 22 Aug 2026 13:29:58 +0200 Subject: [PATCH 08/25] docs: state which symbol shapes the anchor check reads The guarantee the page advertises has to match what the check does, or it converts an unchecked claim into a checked-looking one. Name the shapes a symbol is read by, and name the one gap: an all-capital wire value with no digit is prose to the check. Reaching EdDSA needs a shape that also admits OpenAPI, OpenCRVS, and every acronym in the prose, so it stays uncaught by choice rather than by oversight. Signed-off-by: Jeremi Joslin --- docs/site/AGENTS.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/site/AGENTS.md b/docs/site/AGENTS.md index 46c5c014c..313cdf7a8 100644 --- a/docs/site/AGENTS.md +++ b/docs/site/AGENTS.md @@ -36,6 +36,16 @@ the code it points at. Run `npm run check:evidence-anchors` alone for the fast version. A token that resolves to no repository path is read as prose and skipped, so naming a file the repo does not own is still fine. +The check reads a symbol by its shape: `snake_case`, `SCREAMING_SNAKE_CASE`, +`UpperCamelCase`, `lowerCamelCase`, a name spelled with empty parentheses such as +`router()`, and an all-capital wire value carrying a digit such as `ES256`. A +qualified name is checked segment by segment, so a typo in the type that +qualifies it is caught too. Anything outside those shapes is prose, which leaves +one gap worth knowing: an all-capital wire value with no digit, `EdDSA`, is not +checked. The only shape that reaches it also pulls in `OpenAPI`, `OpenCRVS`, and +every acronym the prose spells, which would fire on correct anchors, so the gap +is deliberate. Spell such a value beside a symbol the check can see. + Two things the check deliberately allows. Bare `path:start-end` citations still pass: `--strict-line-refs` rejects them, but it stays off while a backlog of them remains, and the check prints how many are left. Prefer citing a symbol From 63b1627480b840a5193f9206f8a8a674f277e1ad Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sat, 22 Aug 2026 13:38:31 +0200 Subject: [PATCH 09/25] feat(docs): read a brace-list citation as the files it names An anchor names several files in one directory in a compact brace form, `src/{contract,compiler,api}.rs`. The citation pattern held no brace, so the match stopped at the directory before it and the check validated that directory alone: renaming or deleting any file the list names left the gate green. Expand the list instead of refusing it. A brace segment is read where a path segment can start, so every entry becomes its own full or continuation citation with the suffix the entries share, resolved and counted on its own. A brace group the prose itself writes, `{ claim, allowed }`, sits after no slash and stays prose. The one brace list in the content names six files, so the cited-path figure moves from 333 to 338: six citations in place of the single directory the match used to stop at. Anchors, symbols, and line ranges are unchanged. Signed-off-by: Jeremi Joslin --- docs/site/AGENTS.md | 5 +- docs/site/scripts/check-evidence-anchors.mjs | 124 +++++++++++------- .../scripts/check-evidence-anchors.test.mjs | 65 +++++++++ 3 files changed, 143 insertions(+), 51 deletions(-) diff --git a/docs/site/AGENTS.md b/docs/site/AGENTS.md index 313cdf7a8..34ebf4139 100644 --- a/docs/site/AGENTS.md +++ b/docs/site/AGENTS.md @@ -34,7 +34,10 @@ symbol must occur in at least one path the same anchor cites. A citation that has drifted is a merge blocker, not a wart, so check an anchor when you move the code it points at. Run `npm run check:evidence-anchors` alone for the fast version. A token that resolves to no repository path is read as prose and -skipped, so naming a file the repo does not own is still fine. +skipped, so naming a file the repo does not own is still fine. Several files in +one directory may be cited in the compact brace form, +`crates/registry-relay-v2/src/{api,startup}.rs`, which is read as one citation +per entry, so each file it names has to exist on its own. The check reads a symbol by its shape: `snake_case`, `SCREAMING_SNAKE_CASE`, `UpperCamelCase`, `lowerCamelCase`, a name spelled with empty parentheses such as diff --git a/docs/site/scripts/check-evidence-anchors.mjs b/docs/site/scripts/check-evidence-anchors.mjs index b6cf56cc6..2be50bd6b 100644 --- a/docs/site/scripts/check-evidence-anchors.mjs +++ b/docs/site/scripts/check-evidence-anchors.mjs @@ -58,10 +58,17 @@ const SKIPPED_DIRECTORIES = new Set(['target', 'node_modules', '.git', 'dist', ' const DOCS_SITE_ROOT = 'docs/site'; const ANCHOR_PATTERN = /\{\/\*\s*Evidence:([\s\S]*?)\*\/\}/g; +// A compact list of files that share a directory, `src/{api,startup}.rs`. It is read only +// where a path segment can start, so a brace group the prose itself writes, `{ claim, +// allowed }`, stays prose. +const BRACE_LIST = '\\{[A-Za-z0-9._-]+(?:,[A-Za-z0-9._-]+)+\\}'; +// What follows a citation root: path segments, any of which may be a brace list carrying the +// suffix its entries share, then an optional trailing slash and line reference. +const PATH_BODY = `(?:/(?:[A-Za-z0-9._-]+|${BRACE_LIST}[A-Za-z0-9._-]*))+/?(?::\\d+(?:-\\d+)?)?`; const CITATION_PATTERN = new RegExp( [ - `(?(?(?(?(?(?(?<=[\\s(]):\\d+(?:-\\d+)?(?![\\w-]))`, ].join('|'), @@ -102,6 +109,18 @@ function splitLineReference(token) { return { path: groups.path.replace(/\.+$/, ''), start, end }; } +// One path per brace-list entry: `src/{api,startup}.rs` names two files, so a rename of +// either is drift, and a path with no brace list stands alone as it always did. +function expandBraceLists(path) { + const match = /\{([A-Za-z0-9._-]+(?:,[A-Za-z0-9._-]+)+)\}/.exec(path); + if (!match) { + return [path]; + } + const before = path.slice(0, match.index); + const after = path.slice(match.index + match[0].length); + return match[1].split(',').flatMap((entry) => expandBraceLists(`${before}${entry}${after}`)); +} + // The crate, product, or top-level unit a continuation citation is resolved against. function citationRoot(path) { const segments = path.split('/'); @@ -147,56 +166,61 @@ export function parseAnchor(body, { siteRoot = DOCS_SITE_ROOT } = {}) { // A line suffix the anchor cut short, `:5-`, leaves its hyphen outside the token and // would otherwise read as the single line 5 rather than the range it was meant to be. const malformedLines = start !== undefined && body.startsWith('-', cursor); - const parentOfLastCitedPath = - lastCitedPath === undefined || dirname(lastCitedPath) === '.' ? '' : dirname(lastCitedPath); - let citation; - if (full !== undefined) { - citation = { form: 'full', candidates: [trimmed], reportMissing: true }; - } else if (continuation !== undefined && lastCitedPath === undefined) { - citation = { - form: 'continuation', - candidates: [joinPath(siteRoot, trimmed)], - reportMissing: true, - }; - } else if (continuation !== undefined) { - citation = { - form: 'continuation', - candidates: [ - joinPath(citationRoot(lastCitedPath), trimmed), - joinPath(parentOfLastCitedPath, trimmed), - ], - reportMissing: true, - }; - } else if (lastCitedPath === undefined) { - // A sibling filename with no path before it has nothing to sit beside. - continue; - } else { - citation = { - form: 'sibling', - candidates: [ - joinPath(parentOfLastCitedPath, trimmed), - joinPath(citationRoot(lastCitedPath), trimmed), - joinPath(lastCitedPath, trimmed), - ], - reportMissing: false, - basename: trimmed, - // A bare filename may name a file the repository keeps at its root, Cargo.toml - // or deny.toml, which sits beside no cited path at all. It is tried only after - // the search inside the cited unit, so a nearer file always wins. - rootCandidate: trimmed, - searchRoot: citationRoot(lastCitedPath), - }; - } - citation.candidates = [...new Set(citation.candidates)]; - // What follows reads against the path cited last, whichever form carried it: a - // continuation moves the anchor on just as a second full path does. A sibling is a - // reading of the prose rather than a path claim, so it leaves the anchor where it is. - if (citation.reportMissing) { - lastCitedPath = citation.candidates[0]; + // A brace list stands for one citation per entry, so each file it names is resolved and + // counted on its own, and each entry reads against the path the entry before it set. + for (const cited of expandBraceLists(trimmed)) { + const parentOfLastCitedPath = + lastCitedPath === undefined || dirname(lastCitedPath) === '.' ? '' : dirname(lastCitedPath); + let citation; + if (full !== undefined) { + citation = { form: 'full', candidates: [cited], reportMissing: true }; + } else if (continuation !== undefined && lastCitedPath === undefined) { + citation = { + form: 'continuation', + candidates: [joinPath(siteRoot, cited)], + reportMissing: true, + }; + } else if (continuation !== undefined) { + citation = { + form: 'continuation', + candidates: [ + joinPath(citationRoot(lastCitedPath), cited), + joinPath(parentOfLastCitedPath, cited), + ], + reportMissing: true, + }; + } else if (lastCitedPath === undefined) { + // A sibling filename with no path before it has nothing to sit beside. + continue; + } else { + citation = { + form: 'sibling', + candidates: [ + joinPath(parentOfLastCitedPath, cited), + joinPath(citationRoot(lastCitedPath), cited), + joinPath(lastCitedPath, cited), + ], + reportMissing: false, + basename: cited, + // A bare filename may name a file the repository keeps at its root, Cargo.toml + // or deny.toml, which sits beside no cited path at all. It is tried only after + // the search inside the cited unit, so a nearer file always wins. + rootCandidate: cited, + searchRoot: citationRoot(lastCitedPath), + }; + } + + citation.candidates = [...new Set(citation.candidates)]; + // What follows reads against the path cited last, whichever form carried it: a + // continuation moves the anchor on just as a second full path does. A sibling is a + // reading of the prose rather than a path claim, so it leaves the anchor where it is. + if (citation.reportMissing) { + lastCitedPath = citation.candidates[0]; + } + previous = citation; + citations.push({ ...citation, raw: token, start, end, malformedLines }); } - previous = citation; - citations.push({ ...citation, raw: token, start, end, malformedLines }); } strippedParts.push(body.slice(cursor)); diff --git a/docs/site/scripts/check-evidence-anchors.test.mjs b/docs/site/scripts/check-evidence-anchors.test.mjs index 62dc4d77d..78f5e659e 100644 --- a/docs/site/scripts/check-evidence-anchors.test.mjs +++ b/docs/site/scripts/check-evidence-anchors.test.mjs @@ -197,6 +197,71 @@ test('resolves a relative continuation against the crate root of the last full p assert.match(failing.errors[0], /crates\/demo\/src\/absent\.rs/); }); +test('expands a brace list into one citation per entry', () => { + const parsed = parseAnchor('crates/demo/src/{lib,other}.rs carry it.'); + assert.deepEqual( + parsed.citations.map((citation) => [citation.form, citation.candidates[0]]), + [ + ['full', 'crates/demo/src/lib.rs'], + ['full', 'crates/demo/src/other.rs'], + ], + ); + assert.ok(parsed.citations.every((citation) => citation.reportMissing)); +}); + +test('reports the entry of a brace list that does not exist', (t) => { + const root = repository(t); + const passing = check(root, '{/* Evidence: crates/demo/src/{lib,other}.rs carry it. */}'); + assert.deepEqual(passing.errors, []); + assert.equal(passing.paths, 2); + + const failing = check(root, '{/* Evidence: crates/demo/src/{lib,absent}.rs carry it. */}'); + assert.equal(failing.errors.length, 1); + assert.match(failing.errors[0], /crates\/demo\/src\/absent\.rs/); + assert.match(failing.errors[0], /does not exist/); + assert.equal(failing.paths, 2); +}); + +test('expands a brace list a sentence ends on', (t) => { + const root = repository(t); + const result = check( + root, + '{/* Evidence: the surfaces sit in crates/demo/src/{lib,other}.rs. */}', + ); + assert.deepEqual(result.errors, []); + assert.equal(result.paths, 2); +}); + +test('carries a line reference into every entry of a brace list', (t) => { + const root = repository(t); + const result = check(root, '{/* Evidence: crates/demo/src/{lib,other}.rs:40 carry it. */}'); + assert.equal(result.errors.length, 2); + assert.match(result.errors[0], /crates\/demo\/src\/lib\.rs:40/); + assert.match(result.errors[1], /crates\/demo\/src\/other\.rs:40/); + assert.equal(result.lineRefs, 2); +}); + +test('expands a brace list a continuation carries', (t) => { + const root = repository(t); + const result = check( + root, + '{/* Evidence: crates/demo/src/lib.rs, then src/{other,absent}.rs. */}', + ); + assert.equal(result.errors.length, 1); + assert.match(result.errors[0], /crates\/demo\/src\/absent\.rs/); + assert.equal(result.paths, 3); +}); + +test('leaves a brace group the prose writes out of the citations', (t) => { + const root = repository(t); + const result = check( + root, + '{/* Evidence: crates/demo/src/lib.rs returns { claim, allowed }. */}', + ); + assert.deepEqual(result.errors, []); + assert.equal(result.paths, 1); +}); + test('resolves a bare sibling filename against the directory of the last full path', (t) => { const root = repository(t); const passing = check( From 05676c6aee47e737cc2cd215851ea4ec6f6d52f9 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sat, 22 Aug 2026 13:45:58 +0200 Subject: [PATCH 10/25] feat(docs): check the leaf of a dotted key path an anchor cites An anchor names configuration and wire keys as dotted paths, such as evidence_data_request.transport_absences.credentials. The word pass splits on the dot and then drops any segment that carries no symbol shape, so the leaf of every such key went unchecked: misspelling it left the gate green. Read a dotted token as one key path and check every segment of it, once one segment already carries a symbol shape. That mark is what was measured, not guessed. Checking every segment of every dotted token adds 73 checks over 30 distinct tokens and reports five of them, all from the version string v0.9.0, and pulls in org, registrystack, and a bare digit besides. Requiring one shaped segment adds 15 checks over 11 distinct tokens with nothing reported, and none of the 15 passes by coincidence: each resolves in a file that also holds every shaped segment of the same token, which for request.fields_invalid and classification.context.selector_more_restrictive_than_disclosure is the file holding that exact literal. A `*` is skipped rather than looked up, since it stands for any key rather than naming one. One gap stays open by choice. A key path no segment of which carries a shape, sources.*.authentication.kind, is still unread. Treating the `*` itself as the mark would reach it and reports nothing today, but the three segments it would check are among the commonest words in the tree, with no shaped segment to tie the match to the file the sentence is about, so it would look checked while biting on almost nothing. Cited symbols move from 288 to 303, the 15 the measurement predicted. Anchors, paths, and line ranges are unchanged. Signed-off-by: Jeremi Joslin --- docs/site/AGENTS.md | 18 +++++--- docs/site/scripts/check-evidence-anchors.mjs | 22 ++++++++++ .../scripts/check-evidence-anchors.test.mjs | 44 +++++++++++++++++++ 3 files changed, 79 insertions(+), 5 deletions(-) diff --git a/docs/site/AGENTS.md b/docs/site/AGENTS.md index 34ebf4139..0926d086b 100644 --- a/docs/site/AGENTS.md +++ b/docs/site/AGENTS.md @@ -43,11 +43,19 @@ The check reads a symbol by its shape: `snake_case`, `SCREAMING_SNAKE_CASE`, `UpperCamelCase`, `lowerCamelCase`, a name spelled with empty parentheses such as `router()`, and an all-capital wire value carrying a digit such as `ES256`. A qualified name is checked segment by segment, so a typo in the type that -qualifies it is caught too. Anything outside those shapes is prose, which leaves -one gap worth knowing: an all-capital wire value with no digit, `EdDSA`, is not -checked. The only shape that reaches it also pulls in `OpenAPI`, `OpenCRVS`, and -every acronym the prose spells, which would fire on correct anchors, so the gap -is deliberate. Spell such a value beside a symbol the check can see. +qualifies it is caught too. A dotted configuration or wire key path is read the +same way, segment by segment, once one of its segments carries a shape: +`evidence_data_request.transport_absences.credentials` is checked down to its +leaf, and a `*` standing for any key is skipped rather than looked up. + +Anything outside those shapes is prose, which leaves two gaps worth knowing. An +all-capital wire value with no digit, `EdDSA`, is not checked, because the only +shape that reaches it also pulls in `OpenAPI`, `OpenCRVS`, and every acronym the +prose spells, which would fire on correct anchors. A key path no segment of +which carries a shape, `sources.*.authentication.kind`, is not read either: its +segments are among the commonest words in the tree, so a check on them would +pass on any file that happens to mention them. Both gaps are deliberate. Spell +such a value or key beside a symbol the check can see. Two things the check deliberately allows. Bare `path:start-end` citations still pass: `--strict-line-refs` rejects them, but it stays off while a backlog of diff --git a/docs/site/scripts/check-evidence-anchors.mjs b/docs/site/scripts/check-evidence-anchors.mjs index 2be50bd6b..207221484 100644 --- a/docs/site/scripts/check-evidence-anchors.mjs +++ b/docs/site/scripts/check-evidence-anchors.mjs @@ -76,6 +76,10 @@ const CITATION_PATTERN = new RegExp( ); const LINE_SUFFIX = /^(?.*?)(?::(?\d+)(?:-(?\d+))?)?$/; const WORD_PATTERN = /[A-Za-z_][A-Za-z0-9_]*(?:::[A-Za-z_][A-Za-z0-9_]*)+|[A-Za-z_][A-Za-z0-9_]*/g; +// A dotted configuration or wire key path, sources.*.authentication.kind. The word pass reads +// its segments one by one and keeps only the ones that carry a symbol shape, so this pattern is +// what puts the whole path back together before that decision is made. +const DOTTED_KEY_PATH = /(? { + const root = repository(t); + write(root, 'crates/demo/src/keys.rs', 'const KEY: &str = "transport_absences.credentials";\n'); + const passing = check( + root, + '{/* Evidence: crates/demo/src/keys.rs holds transport_absences.credentials. */}', + ); + assert.deepEqual(passing.errors, []); + assert.equal(passing.symbols, 2); + + const failing = check( + root, + '{/* Evidence: crates/demo/src/keys.rs holds transport_absences.credntials. */}', + ); + assert.equal(failing.errors.length, 1); + assert.match(failing.errors[0], /credntials/); +}); + +test('leaves a dotted token no segment gives a shape out of the symbols', (t) => { + const root = repository(t); + const result = check( + root, + '{/* Evidence: crates/demo/src/lib.rs is served from id.registrystack.org. */}', + ); + assert.deepEqual(result.errors, []); + assert.equal(result.symbols, 0); +}); + +test('reads a dotted key path segment by segment and a domain name not at all', () => { + assert.deepEqual(extractSymbols('the request sets transport_absences.credentials'), [ + 'transport_absences', + 'credentials', + ]); + assert.deepEqual(extractSymbols('published at id.registrystack.org since v0.9.0'), []); +}); + +test('skips the wildcard segment of a dotted key path', () => { + assert.deepEqual(extractSymbols('sources.*.authentication.source_kind names it'), [ + 'sources', + 'authentication', + 'source_kind', + ]); +}); + test('reads an identifier spelled with empty parentheses as a symbol', (t) => { const root = repository(t); write(root, 'crates/demo/src/app.rs', 'pub fn router() -> Router {}\n'); From 59d37562771df9170113714a035ed5a40ce2228a Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sat, 22 Aug 2026 14:05:03 +0200 Subject: [PATCH 11/25] feat(docs): read a bare filename that opens an anchor The sibling form needed a path before it, so a filename in first position produced no citation at all and the file it named never reached the symbol check. Read it against the repository instead: the file kept at the root, then a single unambiguous file of that name in the tree, the two fallbacks the sibling form already had. It stays a reading of the prose rather than a path claim, like every other bare filename: with no path before it, nothing says the repository owns the name, so an adopter's metadata.yaml is still left alone. The one anchor that opened on a filename, contracts.yaml in the boundaries map, names docs/site/src/data/contracts.yaml, whose registry-manifest.metadata-yaml entry carries the consumer note it cites. Spell that path out: an explicit path is reported when it goes missing, where a bare name would quietly stop resolving. Signed-off-by: Jeremi Joslin --- docs/site/scripts/check-evidence-anchors.mjs | 18 ++++++++++-- .../scripts/check-evidence-anchors.test.mjs | 29 +++++++++++++++++++ .../content/docs/map/boundaries-and-map.mdx | 4 +-- 3 files changed, 46 insertions(+), 5 deletions(-) diff --git a/docs/site/scripts/check-evidence-anchors.mjs b/docs/site/scripts/check-evidence-anchors.mjs index 207221484..8d9e7babf 100644 --- a/docs/site/scripts/check-evidence-anchors.mjs +++ b/docs/site/scripts/check-evidence-anchors.mjs @@ -195,8 +195,18 @@ export function parseAnchor(body, { siteRoot = DOCS_SITE_ROOT } = {}) { reportMissing: true, }; } else if (lastCitedPath === undefined) { - // A sibling filename with no path before it has nothing to sit beside. - continue; + // A bare filename that opens an anchor has no path to sit beside, so the + // repository itself is what it is read against: the file kept at the root, then a + // single unambiguous file of that name anywhere in the tree. It stays a reading of + // the prose for the same reason a sibling does, and the missing path before it is + // one more reason: nothing at all says the repository owns the name. + citation = { + form: 'sibling', + candidates: [cited], + reportMissing: false, + basename: cited, + searchRoot: '', + }; } else { citation = { form: 'sibling', @@ -424,7 +434,9 @@ export function checkEvidenceAnchors({ }; // A bare sibling filename may name a file that sits elsewhere in the crate or product - // the anchor already named, so fall back to a single unambiguous match under it. + // the anchor already named, so fall back to a single unambiguous match under it. The + // root is the repository itself when the filename opened the anchor and named no unit + // to search inside. const uniqueFileNamed = (root, basename) => { const matches = listFiles(root).filter((path) => path.endsWith(`/${basename}`)); return matches.length === 1 ? matches[0] : undefined; diff --git a/docs/site/scripts/check-evidence-anchors.test.mjs b/docs/site/scripts/check-evidence-anchors.test.mjs index bae7b28d9..6901d5cf5 100644 --- a/docs/site/scripts/check-evidence-anchors.test.mjs +++ b/docs/site/scripts/check-evidence-anchors.test.mjs @@ -346,6 +346,35 @@ test('leaves a bare filename the repository does not own out of the path check', assert.equal(result.paths, 1); }); +test('resolves a bare filename that opens an anchor against the repository root', (t) => { + const root = repository(t); + write(root, 'deny.toml', '[bans]\nmultiple_versions = "deny"\n'); + const result = check( + root, + '{/* Evidence: deny.toml, multiple_versions, and crates/demo/src/lib.rs. */}', + ); + assert.deepEqual(result.errors, []); + assert.equal(result.paths, 2); +}); + +test('searches the tree for a bare filename that opens an anchor', (t) => { + const root = repository(t); + write(root, 'products/demo/reference/CONFIG.md', 'The reference names bundle_signing_key.\n'); + const result = check(root, '{/* Evidence: CONFIG.md, bundle_signing_key. */}'); + assert.deepEqual(result.errors, []); + assert.equal(result.paths, 1); +}); + +test('leaves a bare filename that opens an anchor and names nothing out of the path check', (t) => { + const root = repository(t); + const result = check( + root, + '{/* Evidence: origins.yaml is the adopter file, crates/demo/src/lib.rs reads it. */}', + ); + assert.deepEqual(result.errors, []); + assert.equal(result.paths, 1); +}); + test('resolves a bare line range against the most recently cited path', (t) => { const root = repository(t); const result = check(root, '{/* Evidence: crates/demo/src/other.rs:1, and :305-309. */}'); diff --git a/docs/site/src/content/docs/map/boundaries-and-map.mdx b/docs/site/src/content/docs/map/boundaries-and-map.mdx index fe412841a..8ea128304 100644 --- a/docs/site/src/content/docs/map/boundaries-and-map.mdx +++ b/docs/site/src/content/docs/map/boundaries-and-map.mdx @@ -108,8 +108,8 @@ Registry Manifest is a pure library and CLI with no runtime data dependencies. - Production source configuration. Source file paths, source view and column names, scopes, and other deployment details belong in the Registry Relay registry contract and runtime file, not in a portable manifest. Relay does not read a manifest to find them. - {/* Evidence: contracts.yaml, registry-manifest.metadata-yaml consumer note; the V2 registry - contract and runtime schemas carry no manifest reference, + {/* Evidence: docs/site/src/data/contracts.yaml, registry-manifest.metadata-yaml consumer + note; the V2 registry contract and runtime schemas carry no manifest reference, crates/registry-relay-v2/src/contract.rs. */} - Evidence Gateway deployment configuration. An Evidence Gateway process loads one immutable governed bundle and one closed runtime file mounted read-only at startup, and reads no portable manifest. From 7f1491bf44d7bdf8a25e853e9eacd8cd2507e38f Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sat, 22 Aug 2026 14:07:01 +0200 Subject: [PATCH 12/25] feat(docs): read a bare child directory an anchor cites The sibling form asked for a filename extension, so a bare directory that continued a cited directory carried no citation and renaming it left the gate green. Read a trailing-slash name against the directory cited before it and report it when it is gone, which is what the one real case wanted: protected-read-evidence/ under the reference deployment projects. A trailing-slash name after a filename stays prose. governed/ and generated/ beside package.rs name directories relayctl writes into a package, not directories the repository holds, so checking them would report correct anchors. Signed-off-by: Jeremi Joslin --- docs/site/AGENTS.md | 6 ++- docs/site/scripts/check-evidence-anchors.mjs | 22 +++++++- .../scripts/check-evidence-anchors.test.mjs | 50 +++++++++++++++++++ 3 files changed, 75 insertions(+), 3 deletions(-) diff --git a/docs/site/AGENTS.md b/docs/site/AGENTS.md index 0926d086b..5602ab8a5 100644 --- a/docs/site/AGENTS.md +++ b/docs/site/AGENTS.md @@ -37,7 +37,11 @@ version. A token that resolves to no repository path is read as prose and skipped, so naming a file the repo does not own is still fine. Several files in one directory may be cited in the compact brace form, `crates/registry-relay-v2/src/{api,startup}.rs`, which is read as one citation -per entry, so each file it names has to exist on its own. +per entry, so each file it names has to exist on its own. A bare name ending in +a slash continues the directory cited before it, `deployment-projects/ then +protected-read-evidence/`, and has to exist under it. After a filename there is +no directory to continue, so `governed/` beside `package.rs` stays prose: it +names a directory the package writes, not one the repo holds. The check reads a symbol by its shape: `snake_case`, `SCREAMING_SNAKE_CASE`, `UpperCamelCase`, `lowerCamelCase`, a name spelled with empty parentheses such as diff --git a/docs/site/scripts/check-evidence-anchors.mjs b/docs/site/scripts/check-evidence-anchors.mjs index 8d9e7babf..91d691b5c 100644 --- a/docs/site/scripts/check-evidence-anchors.mjs +++ b/docs/site/scripts/check-evidence-anchors.mjs @@ -58,6 +58,10 @@ const SKIPPED_DIRECTORIES = new Set(['target', 'node_modules', '.git', 'dist', ' const DOCS_SITE_ROOT = 'docs/site'; const ANCHOR_PATTERN = /\{\/\*\s*Evidence:([\s\S]*?)\*\/\}/g; +// Whether a path named a directory, which is what a bare child citation continues. A dot +// in the last segment is what says a path named a file, so an extensionless script reads +// as a directory. The reading is a syntactic one because nothing here opens the repository. +const NAMES_A_DIRECTORY = /(?:^|\/)[^./]+$/; // A compact list of files that share a directory, `src/{api,startup}.rs`. It is read only // where a path segment can start, so a brace group the prose itself writes, `{ claim, // allowed }`, stays prose. @@ -70,6 +74,7 @@ const CITATION_PATTERN = new RegExp( `(?(?(?(?(?(?<=[\\s(]):\\d+(?:-\\d+)?(?![\\w-]))`, ].join('|'), 'g', @@ -151,7 +156,7 @@ export function parseAnchor(body, { siteRoot = DOCS_SITE_ROOT } = {}) { let previous; for (const match of body.matchAll(CITATION_PATTERN)) { - const { full, relative: continuation, sibling, lines } = match.groups; + const { full, relative: continuation, sibling, child, lines } = match.groups; strippedParts.push(body.slice(cursor, match.index), ' '); cursor = match.index + match[0].length; @@ -164,7 +169,7 @@ export function parseAnchor(body, { siteRoot = DOCS_SITE_ROOT } = {}) { continue; } - const token = full ?? continuation ?? sibling; + const token = full ?? continuation ?? sibling ?? child; const { path, start, end } = splitLineReference(token); const trimmed = path.replace(/\/$/, ''); // A line suffix the anchor cut short, `:5-`, leaves its hyphen outside the token and @@ -194,6 +199,19 @@ export function parseAnchor(body, { siteRoot = DOCS_SITE_ROOT } = {}) { ], reportMissing: true, }; + } else if (child !== undefined) { + // A bare child names a directory inside the one cited before it, so a rename of + // that directory is drift and is reported. After a filename there is no directory + // to continue and the name is prose: `governed/` beside package.rs names a + // directory the package writes, not one the repository holds. + if (lastCitedPath === undefined || !NAMES_A_DIRECTORY.test(lastCitedPath)) { + continue; + } + citation = { + form: 'child', + candidates: [joinPath(lastCitedPath, cited)], + reportMissing: true, + }; } else if (lastCitedPath === undefined) { // A bare filename that opens an anchor has no path to sit beside, so the // repository itself is what it is read against: the file kept at the root, then a diff --git a/docs/site/scripts/check-evidence-anchors.test.mjs b/docs/site/scripts/check-evidence-anchors.test.mjs index 6901d5cf5..47e77ee0b 100644 --- a/docs/site/scripts/check-evidence-anchors.test.mjs +++ b/docs/site/scripts/check-evidence-anchors.test.mjs @@ -346,6 +346,56 @@ test('leaves a bare filename the repository does not own out of the path check', assert.equal(result.paths, 1); }); +test('resolves a bare child directory against the directory cited before it', (t) => { + const root = repository(t); + write(root, 'products/demo/projects/protected-read/README.md', 'It names bounded_read_shape.\n'); + const result = check( + root, + '{/* Evidence: products/demo/projects/ then protected-read/, bounded_read_shape. */}', + ); + assert.deepEqual(result.errors, []); + assert.equal(result.paths, 2); +}); + +test('reports a bare child directory the cited directory does not hold', (t) => { + const root = repository(t); + write(root, 'products/demo/projects/protected-read/README.md', 'It names bounded_read_shape.\n'); + const result = check(root, '{/* Evidence: products/demo/projects/ then renamed-read/. */}'); + assert.equal(result.errors.length, 1); + assert.match(result.errors[0], /products\/demo\/projects\/renamed-read/); +}); + +test('reads one bare child directory against the last, so a chain resolves', (t) => { + const root = repository(t); + write(root, 'products/demo/projects/protected-read/governed/registry.yaml', 'id: demo\n'); + const result = check( + root, + '{/* Evidence: products/demo/projects/ protected-read/ governed/ holds it. */}', + ); + assert.deepEqual(result.errors, []); + assert.equal(result.paths, 3); +}); + +test('leaves a trailing-slash name that follows a cited file out of the citations', (t) => { + const root = repository(t); + const result = check( + root, + '{/* Evidence: crates/demo/src/lib.rs writes governed/ and generated/ into the package. */}', + ); + assert.deepEqual(result.errors, []); + assert.equal(result.paths, 1); +}); + +test('leaves a slash the prose writes out of the citations', (t) => { + const root = repository(t); + const result = check( + root, + '{/* Evidence: crates/demo/src/lib.rs decides read and/or write at https://example.org/. */}', + ); + assert.deepEqual(result.errors, []); + assert.equal(result.paths, 1); +}); + test('resolves a bare filename that opens an anchor against the repository root', (t) => { const root = repository(t); write(root, 'deny.toml', '[bans]\nmultiple_versions = "deny"\n'); From 632a46684fe597c2ad4d70b5570e009bb0b5de0c Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sat, 22 Aug 2026 14:12:02 +0200 Subject: [PATCH 13/25] feat(docs): read a name that runs an initialism into its capitals UpperCamelCase asked for an [A-Z][a-z]+ boundary at every word, so a name whose capitals run together went unchecked while the guidance said the shape was read. Seven declared types in crates/ carry it, all on the OAuth token surface, and the anchors that would cite them are the ones a rename there would drift. The discriminator is two lower-case runs and one capital run of two or more. Measured over all 134 anchors it adds no symbol check and no distinct token: OpenAPI, SQLite, OpenCRVS, and EdDSA carry one lower-case run, SDMX and JWKS carry none, so the acronyms the prose is full of stay prose. A name carrying one run, SDMXProfile, stays unchecked with them, and the guidance now names that boundary with the other gaps. Signed-off-by: Jeremi Joslin --- docs/site/AGENTS.md | 22 +++++++++++-------- docs/site/scripts/check-evidence-anchors.mjs | 10 ++++++++- .../scripts/check-evidence-anchors.test.mjs | 19 ++++++++++++++++ 3 files changed, 41 insertions(+), 10 deletions(-) diff --git a/docs/site/AGENTS.md b/docs/site/AGENTS.md index 5602ab8a5..2cae7de20 100644 --- a/docs/site/AGENTS.md +++ b/docs/site/AGENTS.md @@ -45,21 +45,25 @@ names a directory the package writes, not one the repo holds. The check reads a symbol by its shape: `snake_case`, `SCREAMING_SNAKE_CASE`, `UpperCamelCase`, `lowerCamelCase`, a name spelled with empty parentheses such as -`router()`, and an all-capital wire value carrying a digit such as `ES256`. A +`router()`, and an all-capital wire value carrying a digit such as `ES256`. +`UpperCamelCase` covers a name with an initialism run into it, `OAuthErrorCode`, +once that name carries two lower-case runs and one capital run of two or more. A qualified name is checked segment by segment, so a typo in the type that qualifies it is caught too. A dotted configuration or wire key path is read the same way, segment by segment, once one of its segments carries a shape: `evidence_data_request.transport_absences.credentials` is checked down to its leaf, and a `*` standing for any key is skipped rather than looked up. -Anything outside those shapes is prose, which leaves two gaps worth knowing. An -all-capital wire value with no digit, `EdDSA`, is not checked, because the only -shape that reaches it also pulls in `OpenAPI`, `OpenCRVS`, and every acronym the -prose spells, which would fire on correct anchors. A key path no segment of -which carries a shape, `sources.*.authentication.kind`, is not read either: its -segments are among the commonest words in the tree, so a check on them would -pass on any file that happens to mention them. Both gaps are deliberate. Spell -such a value or key beside a symbol the check can see. +Anything outside those shapes is prose, which leaves two gaps worth knowing. A +name nothing separates from an acronym the prose spells is not checked: an +all-capital wire value with no digit, `EdDSA`, and a capitalized name carrying +one lower-case run, `SDMXProfile`. The only shape that reaches either also pulls +in `OpenAPI`, `SQLite`, `OpenCRVS`, and every other acronym the prose spells, +which would fire on correct anchors. A key path no segment of which carries a +shape, `sources.*.authentication.kind`, is not read either: its segments are +among the commonest words in the tree, so a check on them would pass on any file +that happens to mention them. Both gaps are deliberate. Spell such a value or +key beside a symbol the check can see. Two things the check deliberately allows. Bare `path:start-end` citations still pass: `--strict-line-refs` rejects them, but it stays off while a backlog of diff --git a/docs/site/scripts/check-evidence-anchors.mjs b/docs/site/scripts/check-evidence-anchors.mjs index 91d691b5c..77527ff95 100644 --- a/docs/site/scripts/check-evidence-anchors.mjs +++ b/docs/site/scripts/check-evidence-anchors.mjs @@ -88,6 +88,13 @@ const DOTTED_KEY_PATH = /(? { + const root = repository(t); + write(root, 'crates/demo/src/token.rs', 'pub enum OAuthErrorCode {\n InvalidClient,\n}\n'); + const passing = check(root, '{/* Evidence: crates/demo/src/token.rs, OAuthErrorCode. */}'); + assert.deepEqual(passing.errors, []); + assert.equal(passing.symbols, 1); + + const failing = check(root, '{/* Evidence: crates/demo/src/token.rs, OAuthErrorKind. */}'); + assert.equal(failing.errors.length, 1); + assert.match(failing.errors[0], /OAuthErrorKind/); +}); + +test('leaves an acronym the prose spells with one lower-case run unchecked', () => { + assert.deepEqual(extractSymbols('the OpenAPI description of the SQLite source'), []); + assert.deepEqual(extractSymbols('the SDMX profile, the JWKS endpoint, and EdDSA'), []); + assert.deepEqual(extractSymbols('the OpenCRVS demo signs with SHA'), []); + assert.deepEqual(extractSymbols('it reads HTTPRedirectHandler'), ['HTTPRedirectHandler']); +}); + test('accepts a symbol that appears in the second of two cited paths', (t) => { const root = repository(t); const result = check( From 0c27764b6a199222d307a2e7640f72867d386632 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sat, 22 Aug 2026 14:17:29 +0200 Subject: [PATCH 14/25] docs: name the shape a citation carries The seven readings build object literals of different shapes, so the field a later reading looks for reads as absent on the ones that do not carry it. A typedef states which fields every citation has and which belong to one reading. Signed-off-by: Jeremi Joslin --- docs/site/scripts/check-evidence-anchors.mjs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/site/scripts/check-evidence-anchors.mjs b/docs/site/scripts/check-evidence-anchors.mjs index 77527ff95..f468208e2 100644 --- a/docs/site/scripts/check-evidence-anchors.mjs +++ b/docs/site/scripts/check-evidence-anchors.mjs @@ -155,7 +155,22 @@ function joinPath(base, tail) { // a miss is drift and is reported. A bare sibling filename is only a reading of the // prose: when nothing resolves, it names a file the repository does not own (an // adopter's configuration file, or a path inside a generated package) and is left alone. +/** + * @typedef {object} Citation + * @property {string} form which reading produced it, and so which fallbacks apply + * @property {string[]} candidates the paths it may resolve to, tried in order + * @property {boolean} reportMissing whether resolving nothing is drift or prose + * @property {string} [raw] the token as the anchor spelled it + * @property {number} [start] first line of a line reference + * @property {number} [end] last line of a line reference + * @property {boolean} [malformedLines] set when a line suffix was cut short + * @property {string} [basename] bare filename to search for when no candidate resolves + * @property {string} [searchRoot] where that search runs, the empty string for the whole tree + * @property {string} [rootCandidate] the same name as a file kept at the repository root + */ + export function parseAnchor(body, { siteRoot = DOCS_SITE_ROOT } = {}) { + /** @type {Citation[]} */ const citations = []; const strippedParts = []; let cursor = 0; @@ -188,6 +203,7 @@ export function parseAnchor(body, { siteRoot = DOCS_SITE_ROOT } = {}) { for (const cited of expandBraceLists(trimmed)) { const parentOfLastCitedPath = lastCitedPath === undefined || dirname(lastCitedPath) === '.' ? '' : dirname(lastCitedPath); + /** @type {Citation} */ let citation; if (full !== undefined) { citation = { form: 'full', candidates: [cited], reportMissing: true }; From 58fa8daca06cf0406f25f573dc1c094e39522ddf Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sat, 22 Aug 2026 14:25:36 +0200 Subject: [PATCH 15/25] ci: run the documentation anchor check on every pull request The anchors cite source across the whole workspace, but the only job that ran them sat behind the changed-path classifier's docs allow-list, which names five crate files. A rename of any other cited source left the docs job skipped and the drift merged green. Signed-off-by: Jeremi Joslin --- .github/workflows/ci.yml | 25 +++++++++++++++ docs/site/AGENTS.md | 4 ++- .../scripts/check-evidence-anchors.test.mjs | 31 ++++++++++++++++++- 3 files changed, 58 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e79b0f41e..497aec28f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -893,6 +893,30 @@ jobs: rust:1.95-trixie@sha256:f49565f188ee00bc2a18dd418183f2c5f23ef7d6e691890517ed341a598f67c3 \ bash /work/docs/site/scripts/check-evidence-tutorials.sh + evidence-anchors: + # The documentation anchors cite source across the whole workspace, so this check + # runs on every pull request rather than behind the changed-path classifier: a + # rename outside the docs job's allow-list is exactly the drift it exists to catch. + # The checker imports only node:fs, node:path, and node:url, so a checkout and a + # Node runtime are all it needs; the docs job runs it again inside check:source. + name: Evidence anchors + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + submodules: false + + - name: Setup Node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 + with: + node-version: 22.12.0 + + - name: Check documentation anchors against the source tree + run: node docs/site/scripts/check-evidence-anchors.mjs + docs: name: Docs checks needs: changes @@ -1186,6 +1210,7 @@ jobs: - release-tool - release-source-proof - evidence-tutorials + - evidence-anchors - docs - editor-extensions - client-bindings diff --git a/docs/site/AGENTS.md b/docs/site/AGENTS.md index 2cae7de20..ee85b9ac8 100644 --- a/docs/site/AGENTS.md +++ b/docs/site/AGENTS.md @@ -33,7 +33,9 @@ path must exist, a cited line reference must fall inside its file, and a cited symbol must occur in at least one path the same anchor cites. A citation that has drifted is a merge blocker, not a wart, so check an anchor when you move the code it points at. Run `npm run check:evidence-anchors` alone for the fast -version. A token that resolves to no repository path is read as prose and +version. Root CI runs it twice: once inside the docs job, and once in a job of +its own that runs on every pull request, because the anchors cite source all +over the workspace and the docs job only runs for a changed path it recognizes. A token that resolves to no repository path is read as prose and skipped, so naming a file the repo does not own is still fine. Several files in one directory may be cited in the compact brace form, `crates/registry-relay-v2/src/{api,startup}.rs`, which is read as one citation diff --git a/docs/site/scripts/check-evidence-anchors.test.mjs b/docs/site/scripts/check-evidence-anchors.test.mjs index 880832834..2243093a8 100644 --- a/docs/site/scripts/check-evidence-anchors.test.mjs +++ b/docs/site/scripts/check-evidence-anchors.test.mjs @@ -1,8 +1,11 @@ import assert from 'node:assert/strict'; -import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, resolve } from 'node:path'; import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +import YAML from 'yaml'; import { checkEvidenceAnchors, @@ -12,6 +15,8 @@ import { parseArguments, } from './check-evidence-anchors.mjs'; +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); + function write(root, path, contents) { const target = resolve(root, path); mkdirSync(dirname(target), { recursive: true }); @@ -638,3 +643,27 @@ test('parses citations and symbols without touching the filesystem', () => { assert.deepEqual(parsed.citations[0].end, 14); assert.deepEqual(parsed.symbols, ['verify_source_shape', 'SOURCE_LIMIT']); }); + +test('root CI runs the anchor check on every pull request and gates the branch on it', () => { + const workflow = YAML.parse( + readFileSync(resolve(repositoryRoot, '.github/workflows/ci.yml'), 'utf8'), + ); + const command = 'node docs/site/scripts/check-evidence-anchors.mjs'; + const running = Object.entries(workflow.jobs).filter(([, job]) => + (job.steps ?? []).some((step) => (step.run ?? '').includes(command)), + ); + assert.equal(running.length, 1); + const [jobId, job] = running[0]; + + // The anchors cite source across the whole workspace, so a job the changed-path + // classifier can skip is a gate that misses the renames it exists to catch. + assert.equal(job.if, undefined); + assert.deepEqual(job.needs ?? [], []); + // No install and no build: the checker imports only node:fs, node:path, and node:url. + assert.equal( + (job.steps ?? []).some((step) => (step.run ?? '').includes('npm ci')), + false, + ); + // A job the aggregate does not wait on can fail without blocking the branch. + assert.ok(workflow.jobs['ci-result'].needs.includes(jobId)); +}); From 8017dfdc4e5d544b05cb145702914e2aa2caf54e Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sat, 22 Aug 2026 14:32:57 +0200 Subject: [PATCH 16/25] docs: spell a one-word type or variant qualified in an anchor UpperCamelCase asks for two capitalized chunks, so a one-word name such as Public, Snapshot, or Visibility was read as prose and left unchecked. The qualified spelling is already checked segment by segment, so naming the owning type brings each variant under the existing rule. Signed-off-by: Jeremi Joslin --- docs/site/AGENTS.md | 24 ++++++++++++------- .../scripts/check-evidence-anchors.test.mjs | 18 ++++++++++++++ .../explanation/discovery-as-an-index.mdx | 4 ++-- .../docs/explanation/integration-patterns.mdx | 6 +++-- .../docs/explanation/records-stay-home.mdx | 5 ++-- .../content/docs/explanation/threat-model.mdx | 5 ++-- .../trusted-context-constraints.mdx | 3 ++- .../content/docs/map/boundaries-and-map.mdx | 4 ++-- .../docs/security/hardening-checklist.mdx | 2 +- 9 files changed, 51 insertions(+), 20 deletions(-) diff --git a/docs/site/AGENTS.md b/docs/site/AGENTS.md index ee85b9ac8..ba0315d37 100644 --- a/docs/site/AGENTS.md +++ b/docs/site/AGENTS.md @@ -50,22 +50,30 @@ The check reads a symbol by its shape: `snake_case`, `SCREAMING_SNAKE_CASE`, `router()`, and an all-capital wire value carrying a digit such as `ES256`. `UpperCamelCase` covers a name with an initialism run into it, `OAuthErrorCode`, once that name carries two lower-case runs and one capital run of two or more. A -qualified name is checked segment by segment, so a typo in the type that -qualifies it is caught too. A dotted configuration or wire key path is read the -same way, segment by segment, once one of its segments carries a shape: +qualified name is read segment by segment: the last segment is read whatever its +shape, and each segment that qualifies it is read once it carries a shape of its +own. A dotted configuration or wire key path is read the same way, segment by +segment, once one of its segments carries a shape: `evidence_data_request.transport_absences.credentials` is checked down to its leaf, and a `*` standing for any key is skipped rather than looked up. -Anything outside those shapes is prose, which leaves two gaps worth knowing. A -name nothing separates from an acronym the prose spells is not checked: an +Anything outside those shapes is prose, which leaves three gaps worth knowing. A +one-word name is not checked, because `UpperCamelCase` asks for two capitalized +chunks: the only shape that would reach `Visibility` also reaches every +sentence-initial word an anchor writes, `Evidence`, `Relay`, and `The` among +them. Spell a one-word type or variant qualified when you want it checked, +`AccessRule::Public` or `contract::Visibility`, since the last segment of a +qualified name is read whatever its shape. A qualifier is still read by shape, so +`Command::Check` puts `Check` under the check and leaves `Command` outside it. A +name nothing separates from an acronym the prose spells is not checked either: an all-capital wire value with no digit, `EdDSA`, and a capitalized name carrying one lower-case run, `SDMXProfile`. The only shape that reaches either also pulls in `OpenAPI`, `SQLite`, `OpenCRVS`, and every other acronym the prose spells, which would fire on correct anchors. A key path no segment of which carries a -shape, `sources.*.authentication.kind`, is not read either: its segments are +shape, `sources.*.authentication.kind`, is not read at all: its segments are among the commonest words in the tree, so a check on them would pass on any file -that happens to mention them. Both gaps are deliberate. Spell such a value or -key beside a symbol the check can see. +that happens to mention them. All three gaps are deliberate. Spell such a value +or key beside a symbol the check can see. Two things the check deliberately allows. Bare `path:start-end` citations still pass: `--strict-line-refs` rejects them, but it stays off while a backlog of diff --git a/docs/site/scripts/check-evidence-anchors.test.mjs b/docs/site/scripts/check-evidence-anchors.test.mjs index 2243093a8..d9e86a86b 100644 --- a/docs/site/scripts/check-evidence-anchors.test.mjs +++ b/docs/site/scripts/check-evidence-anchors.test.mjs @@ -667,3 +667,21 @@ test('root CI runs the anchor check on every pull request and gates the branch o // A job the aggregate does not wait on can fail without blocking the branch. assert.ok(workflow.jobs['ci-result'].needs.includes(jobId)); }); + +test('reads a one-word name only where the anchor spells it qualified', (t) => { + const root = repository(t); + write(root, 'crates/demo/src/rule.rs', 'pub enum AccessRule {\n Public(String),\n}\n'); + // A one-word name carries no shape holding it apart from a capitalized prose word, + // and every sentence an anchor opens starts with one. + assert.deepEqual(extractSymbols('AccessRule is Public or Protected'), ['AccessRule']); + // The last segment of a qualified name is read whatever its shape; a qualifier is + // read by shape, so a one-word type that only ever qualifies stays outside the check. + assert.deepEqual(extractSymbols('AccessRule::Public reads it'), ['AccessRule', 'Public']); + assert.deepEqual(extractSymbols('Command::run() reads it'), ['run']); + + const bare = check(root, '{/* Evidence: crates/demo/src/rule.rs, AccessRule is Protectd. */}'); + assert.deepEqual(bare.errors, []); + const qualified = check(root, '{/* Evidence: crates/demo/src/rule.rs, AccessRule::Protectd. */}'); + assert.equal(qualified.errors.length, 1); + assert.match(qualified.errors[0], /Protectd/); +}); diff --git a/docs/site/src/content/docs/explanation/discovery-as-an-index.mdx b/docs/site/src/content/docs/explanation/discovery-as-an-index.mdx index d5531e7a0..0e9b6216c 100644 --- a/docs/site/src/content/docs/explanation/discovery-as-an-index.mdx +++ b/docs/site/src/content/docs/explanation/discovery-as-an-index.mdx @@ -94,8 +94,8 @@ every required type. For Registry Relay, the application searches the public sem operation family as one correlated tuple, then explicitly selects one record. Discovery preserves the complete Evidence resolution context, Relay tuple, catalog revision, and origin provenance. -{/* Evidence: `crates/registry-discovery/src/query.rs`, `Directory::resolve_evidence_types()` and - `Directory::search_services()`; `crates/registry-discovery-client/src/selection.rs`, +{/* Evidence: `crates/registry-discovery/src/query.rs`, the `query::Directory` type with + `Directory::resolve_evidence_types()` and `Directory::search_services()`; `crates/registry-discovery-client/src/selection.rs`, `EvidenceTypeResolveSelectionExt`, `ServiceSearchSelectionExt::select_evidence()`, `ServiceSearchSelectionExt::select_relay()`, and the typed selection fields. */} diff --git a/docs/site/src/content/docs/explanation/integration-patterns.mdx b/docs/site/src/content/docs/explanation/integration-patterns.mdx index dd7158daf..b937e43ae 100644 --- a/docs/site/src/content/docs/explanation/integration-patterns.mdx +++ b/docs/site/src/content/docs/explanation/integration-patterns.mdx @@ -154,7 +154,8 @@ Deployment-local facts, including where the database file actually is, stay in ` never enter the reviewed contract, so the same contract can be reviewed once and deployed in staging and production without editing it. -{/* Evidence: the SourceProfile enum declares the two variants Snapshot and LiveReadOnly, and the +{/* Evidence: the SourceProfile enum declares the two variants SourceProfile::Snapshot and + SourceProfile::LiveReadOnly, and the RelayRuntime struct carries sources, package path, listener, issuer, audit sink, limits, and quotas, and nothing that can add or widen a resource, operation, access profile, or disclosure decision, both in crates/registry-relay-v2/src/contract.rs. */} @@ -239,7 +240,8 @@ If you are reading an integration guide that describes source scripts, same-orig API-key placement for Relay, it describes the retired runtime. See [Known limitations](../known-limitations/) for the full list of what went with it. -{/* Evidence: the SourceProfile enum declares exactly Snapshot and LiveReadOnly, and the +{/* Evidence: the SourceProfile enum declares exactly SourceProfile::Snapshot and + SourceProfile::LiveReadOnly, and the RelayRuntime struct's RuntimeSource accepts a source only as a path, both in crates/registry-relay-v2/src/contract.rs; the crate holds no HTTP source client. Read-only opening is enforced in crates/registry-platform-sqlite (SQLITE_OPEN_READ_ONLY | diff --git a/docs/site/src/content/docs/explanation/records-stay-home.mdx b/docs/site/src/content/docs/explanation/records-stay-home.mdx index 5245c4435..dbfef5972 100644 --- a/docs/site/src/content/docs/explanation/records-stay-home.mdx +++ b/docs/site/src/content/docs/explanation/records-stay-home.mdx @@ -170,8 +170,9 @@ the caller's own authority through a bound `:row_authority` parameter derived fr or from the token's principal identifier. Two fixed transforms can narrow a property further: a partial string reveal (`***`) and a date reduced to year or year-month. Relay signs nothing; its responses carry no assertion. -{/* Evidence: AccessRule is Public | Protected{scope, purpose, authorityRowBinding} and - AuthorityRowBinding has a claim variant and a principal variant, +{/* Evidence: an access profile is AccessRule::Public or AccessRule::Protected, whose + ProtectedAccess carries scope, purpose, and authorityRowBinding, and a row binding is + AuthorityRowBinding::Claim or AuthorityRowBinding::Principal, crates/registry-relay-v2/src/contract.rs; the authorityRowBinding key is spelled that way in crates/registry-relayctl/schemas/authoring/registry.schema.json; row authority is injected as a bound parameter with an exact-equality COLLATE BINARY predicate, never string concatenation, diff --git a/docs/site/src/content/docs/explanation/threat-model.mdx b/docs/site/src/content/docs/explanation/threat-model.mdx index 4f3db6785..5f6259dd7 100644 --- a/docs/site/src/content/docs/explanation/threat-model.mdx +++ b/docs/site/src/content/docs/explanation/threat-model.mdx @@ -135,8 +135,9 @@ static-credential mode and no API-key mode; the runtime reads no credential from {/* Evidence: AuthenticationRuntime carries only an optional issuer, crates/registry-relay-v2/src/contract.rs; validate_runtime_contract requires an issuer when the contract has protected access, crates/registry-relay-v2/src/startup.rs; - RelayAuthenticator::authorize() admits Public unconditionally and requires a scoped - principal for Protected, crates/registry-relay-v2/src/auth.rs. */} + RelayAuthenticator::authorize() admits CompiledAccess::Public unconditionally and + requires a scoped principal for CompiledAccess::Protected, + crates/registry-relay-v2/src/auth.rs. */} Evidence Gateway runs one reviewed OIDC bearer profile with exactly one trusted issuer and exact audience, token type, and algorithm allowlists, and one configured principal claim with no `client_id`, `azp`, header, or request fallback: missing data denies rather than diff --git a/docs/site/src/content/docs/explanation/trusted-context-constraints.mdx b/docs/site/src/content/docs/explanation/trusted-context-constraints.mdx index ca10ba209..7a6841057 100644 --- a/docs/site/src/content/docs/explanation/trusted-context-constraints.mdx +++ b/docs/site/src/content/docs/explanation/trusted-context-constraints.mdx @@ -54,7 +54,8 @@ is set by `metadataVisibility.processing`, which is public, operation-bound, or {/* Evidence: ProcessingDescription { id, operationRefs, purpose, recipientClass, legalBasisRef, dpvProfileRef, safeguards } with deny_unknown_fields, crates/registry-relay-v2/src/contract.rs; MetadataVisibility carries a processing - field over the closed Visibility enum Public | OperationBound | OperatorOnly, + field over the closed contract::Visibility enum, whose variants are Visibility::Public, + Visibility::OperationBound, and Visibility::OperatorOnly, crates/registry-relay-v2/src/contract.rs; the compiler requires every legalBasisRef and dpvProfileRef to resolve to a governed file it seals, crates/registry-relay-v2/src/compiler.rs. */} diff --git a/docs/site/src/content/docs/map/boundaries-and-map.mdx b/docs/site/src/content/docs/map/boundaries-and-map.mdx index 8ea128304..811e2c77f 100644 --- a/docs/site/src/content/docs/map/boundaries-and-map.mdx +++ b/docs/site/src/content/docs/map/boundaries-and-map.mdx @@ -142,8 +142,8 @@ It does not own: speaks no source protocol, holds no source credential, and makes no outbound call to a source, so a CSV, spreadsheet, Parquet, PostgreSQL, or HTTP registry has to be turned into SQLite by something else before Relay can serve it. - {/* Evidence: the SourceProfile enum has exactly two variants, Snapshot and LiveReadOnly, - crates/registry-relay-v2/src/contract.rs. */} + {/* Evidence: the SourceProfile enum has exactly two variants, SourceProfile::Snapshot and + SourceProfile::LiveReadOnly, crates/registry-relay-v2/src/contract.rs. */} - Portable metadata schema ownership. The `metadata.yaml` manifest format and its renderers are owned by Registry Manifest. Relay does not consume a manifest and does not emit one: its artifact generator produces no `registry-manifest.yaml`, and a test holds that. diff --git a/docs/site/src/content/docs/security/hardening-checklist.mdx b/docs/site/src/content/docs/security/hardening-checklist.mdx index 638820af7..69f16080c 100644 --- a/docs/site/src/content/docs/security/hardening-checklist.mdx +++ b/docs/site/src/content/docs/security/hardening-checklist.mdx @@ -310,7 +310,7 @@ serve, and a startup that fails rather than degrades. protected_contracts_require_issuer_lists_require_cursor_and_lookups_require_quota, crates/registry-relay-v2/src/startup.rs:469-492; QuotaLimiter is an in-memory per-operation token bucket, crates/registry-relay-v2/src/server.rs:359-401; MetadataVisibility over the - closed Visibility enum, crates/registry-relay-v2/src/contract.rs:1042-1060. */} + closed contract::Visibility enum, crates/registry-relay-v2/src/contract.rs:1042-1060. */} - Evidence Gateway serves one operator-controlled trust domain per process. Mutually distrustful issuers or customers, or one issuer whose clients carry the same authority under different claim names, From ff622f315ec5ed666cbd2091d8de598296e0433f Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sat, 22 Aug 2026 14:37:14 +0200 Subject: [PATCH 17/25] feat(docs): require a bare sibling that names a Rust source file A sibling stays optional so an anchor may name a file the repository does not own, but that also let a repository-owned file be deleted with the check still green. Only this repository writes Rust into the stack, so a bare .rs name is a claim: measured over every anchor, the rule requires six of the seven siblings that resolve today and fails none of the three that do not. Signed-off-by: Jeremi Joslin --- docs/site/AGENTS.md | 8 ++++++-- docs/site/scripts/check-evidence-anchors.mjs | 18 +++++++++++++----- .../scripts/check-evidence-anchors.test.mjs | 18 ++++++++++++++++++ 3 files changed, 37 insertions(+), 7 deletions(-) diff --git a/docs/site/AGENTS.md b/docs/site/AGENTS.md index ba0315d37..928fed791 100644 --- a/docs/site/AGENTS.md +++ b/docs/site/AGENTS.md @@ -35,8 +35,12 @@ has drifted is a merge blocker, not a wart, so check an anchor when you move the code it points at. Run `npm run check:evidence-anchors` alone for the fast version. Root CI runs it twice: once inside the docs job, and once in a job of its own that runs on every pull request, because the anchors cite source all -over the workspace and the docs job only runs for a changed path it recognizes. A token that resolves to no repository path is read as prose and -skipped, so naming a file the repo does not own is still fine. Several files in +over the workspace and the docs job only runs for a changed path it recognizes. +A bare filename beside a cited path is read as prose when nothing resolves, so +naming a file the repo does not own, an adopter's `origins.yaml` or a path a +generated package writes, is still fine. A bare `.rs` sibling is the exception: +only this repository writes Rust into the stack, so a Rust filename has to +resolve, and deleting the file one names fails the check. Several files in one directory may be cited in the compact brace form, `crates/registry-relay-v2/src/{api,startup}.rs`, which is read as one citation per entry, so each file it names has to exist on its own. A bare name ending in diff --git a/docs/site/scripts/check-evidence-anchors.mjs b/docs/site/scripts/check-evidence-anchors.mjs index f468208e2..0ea5a9888 100644 --- a/docs/site/scripts/check-evidence-anchors.mjs +++ b/docs/site/scripts/check-evidence-anchors.mjs @@ -62,6 +62,11 @@ const ANCHOR_PATTERN = /\{\/\*\s*Evidence:([\s\S]*?)\*\/\}/g; // in the last segment is what says a path named a file, so an extensionless script reads // as a directory. The reading is a syntactic one because nothing here opens the repository. const NAMES_A_DIRECTORY = /(?:^|\/)[^./]+$/; +// A bare sibling that names a Rust source file is one the repository owns: an adopter of +// this stack writes configuration and scripts, never Rust, and a package the runtime +// generates carries none either. Every other extension a sibling may carry names a file the +// repository need not hold, so only this one turns a miss into drift. +const NAMES_RUST_SOURCE = /\.rs$/; // A compact list of files that share a directory, `src/{api,startup}.rs`. It is read only // where a path segment can start, so a brace group the prose itself writes, `{ claim, // allowed }`, stays prose. @@ -155,6 +160,8 @@ function joinPath(base, tail) { // a miss is drift and is reported. A bare sibling filename is only a reading of the // prose: when nothing resolves, it names a file the repository does not own (an // adopter's configuration file, or a path inside a generated package) and is left alone. +// A bare Rust filename is the one sibling that is a claim, because only this repository +// writes Rust into the stack, so a miss there is drift like any other. /** * @typedef {object} Citation * @property {string} form which reading produced it, and so which fallbacks apply @@ -244,7 +251,7 @@ export function parseAnchor(body, { siteRoot = DOCS_SITE_ROOT } = {}) { citation = { form: 'sibling', candidates: [cited], - reportMissing: false, + reportMissing: NAMES_RUST_SOURCE.test(cited), basename: cited, searchRoot: '', }; @@ -256,7 +263,7 @@ export function parseAnchor(body, { siteRoot = DOCS_SITE_ROOT } = {}) { joinPath(citationRoot(lastCitedPath), cited), joinPath(lastCitedPath, cited), ], - reportMissing: false, + reportMissing: NAMES_RUST_SOURCE.test(cited), basename: cited, // A bare filename may name a file the repository keeps at its root, Cargo.toml // or deny.toml, which sits beside no cited path at all. It is tried only after @@ -268,9 +275,10 @@ export function parseAnchor(body, { siteRoot = DOCS_SITE_ROOT } = {}) { citation.candidates = [...new Set(citation.candidates)]; // What follows reads against the path cited last, whichever form carried it: a - // continuation moves the anchor on just as a second full path does. A sibling is a - // reading of the prose rather than a path claim, so it leaves the anchor where it is. - if (citation.reportMissing) { + // continuation moves the anchor on just as a second full path does. A sibling names + // no directory to read the next citation against, so it leaves the anchor where it + // is whether or not the repository has to hold the file it names. + if (citation.form !== 'sibling') { lastCitedPath = citation.candidates[0]; } previous = citation; diff --git a/docs/site/scripts/check-evidence-anchors.test.mjs b/docs/site/scripts/check-evidence-anchors.test.mjs index d9e86a86b..26d19e7d5 100644 --- a/docs/site/scripts/check-evidence-anchors.test.mjs +++ b/docs/site/scripts/check-evidence-anchors.test.mjs @@ -370,6 +370,24 @@ test('leaves a bare filename the repository does not own out of the path check', assert.equal(result.paths, 1); }); +test('reports a bare sibling Rust file no cited unit holds', (t) => { + const root = repository(t); + const result = check( + root, + '{/* Evidence: crates/demo/tests/cli_contract.rs and absent.rs pin the surfaces. */}', + ); + assert.equal(result.errors.length, 1); + assert.match(result.errors[0], /crates\/demo\/tests\/absent\.rs, which does not exist/); + assert.equal(result.paths, 2); +}); + +test('reports a bare Rust filename that opens an anchor and names nothing', (t) => { + const root = repository(t); + const result = check(root, '{/* Evidence: absent.rs, and crates/demo/src/lib.rs reads it. */}'); + assert.equal(result.errors.length, 1); + assert.match(result.errors[0], /absent\.rs, which does not exist/); +}); + test('resolves a bare child directory against the directory cited before it', (t) => { const root = repository(t); write(root, 'products/demo/projects/protected-read/README.md', 'It names bounded_read_shape.\n'); From 96ac3350287b4667a9bca858ff30a32d6010601b Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sat, 22 Aug 2026 15:04:09 +0200 Subject: [PATCH 18/25] fix(docs): refuse the repository-root file a symlink leads out of The escape check reads a citation's candidate list, and the file kept at the repository root is no member of it: a bare filename carries it separately as its last resort. A root file that is a symlink pointing outside the checkout therefore passed the check, resolved the citation, and had its outside contents read to satisfy the anchor's symbols. Unwind the resolution chain into explicit steps so the root candidate can be refused where it is consulted, and report the refusal rather than skipping the citation: it is the only thing that would have resolved, so falling through silently would leave the anchor unchecked. The search for a single unambiguous file of the same name needs no such check. It walks the tree with readdirSync(..., { withFileTypes: true }), whose Dirent.isFile() and isDirectory() read the entry rather than its target, so a symlink is neither and never enters the walk. Signed-off-by: Jeremi Joslin --- docs/site/scripts/check-evidence-anchors.mjs | 41 ++++++++++++++----- .../scripts/check-evidence-anchors.test.mjs | 31 ++++++++++++++ 2 files changed, 61 insertions(+), 11 deletions(-) diff --git a/docs/site/scripts/check-evidence-anchors.mjs b/docs/site/scripts/check-evidence-anchors.mjs index 0ea5a9888..e6f00dc06 100644 --- a/docs/site/scripts/check-evidence-anchors.mjs +++ b/docs/site/scripts/check-evidence-anchors.mjs @@ -528,20 +528,39 @@ export function checkEvidenceAnchors({ ); continue; } - const resolved = - citation.candidates.find( - (candidate) => entryKind(resolve(repoRoot, candidate)) !== 'missing', - ) ?? - (citation.basename === undefined - ? undefined - : uniqueFileNamed(citation.searchRoot, citation.basename)) ?? - (citation.rootCandidate !== undefined && + let resolved = citation.candidates.find( + (candidate) => entryKind(resolve(repoRoot, candidate)) !== 'missing', + ); + if (resolved === undefined && citation.basename !== undefined) { + resolved = uniqueFileNamed(citation.searchRoot, citation.basename); + } + // The file kept at the repository root is the last place a bare filename is looked + // for, and the one resolution the escape check above cannot have seen, because the + // root candidate is no member of the candidate list. A root file that a symlink + // leads out of the checkout is refused here rather than read, and refusing it is + // reported: it is the only thing that would have resolved. + if ( + resolved === undefined && + citation.rootCandidate !== undefined && entryKind(resolve(repoRoot, citation.rootCandidate)) !== 'missing' - ? citation.rootCandidate - : undefined) ?? + ) { + if (escapesRepository(repoRoot, citation.rootCandidate)) { + paths += 1; + if (range !== '') { + lineRefs += 1; + } + errors.push( + `${at} cites ${citation.rootCandidate}${range}, which leaves the repository`, + ); + continue; + } + resolved = citation.rootCandidate; + } + if (resolved === undefined && citation.form === 'lines') { // A bare line reference that follows a filename the repository does not own // still belongs to the last file the anchor resolved. - (citation.form === 'lines' ? lastResolvedFile : undefined); + resolved = lastResolvedFile; + } if (resolved === undefined && !citation.reportMissing) { continue; } diff --git a/docs/site/scripts/check-evidence-anchors.test.mjs b/docs/site/scripts/check-evidence-anchors.test.mjs index 26d19e7d5..6cbee0491 100644 --- a/docs/site/scripts/check-evidence-anchors.test.mjs +++ b/docs/site/scripts/check-evidence-anchors.test.mjs @@ -91,6 +91,37 @@ test('reports a citation whose real path leaves the repository through a symlink assert.match(result.errors[0], /leaves the repository/); }); +test('refuses a repository-root file a symlink leads out of', (t) => { + const root = repository(t); + const outside = resolve(root, '..', 'registry-evidence-anchors-root-linked.toml'); + writeFileSync(outside, '[bans]\nmultiple_versions = "deny"\n'); + t.after(() => rmSync(outside, { force: true })); + symlinkSync(outside, resolve(root, 'deny.toml')); + const result = check( + root, + '{/* Evidence: crates/demo/src/lib.rs, and deny.toml, multiple_versions. */}', + ); + assert.equal(result.errors.length, 2); + assert.match(result.errors[0], /deny\.toml/); + assert.match(result.errors[0], /leaves the repository/); + // The file outside the checkout is never read, so the symbol it holds stays unfound. + assert.match(result.errors[1], /multiple_versions/); +}); + +test('resolves a sibling beside its cited path though the root file of that name escapes', (t) => { + const root = repository(t); + const outside = resolve(root, '..', 'registry-evidence-anchors-root-shadow.rs'); + writeFileSync(outside, 'pub const SOURCE_LIMIT: usize = 9;\n'); + t.after(() => rmSync(outside, { force: true })); + symlinkSync(outside, resolve(root, 'other.rs')); + const result = check( + root, + '{/* Evidence: crates/demo/src/lib.rs, and other.rs, SOURCE_LIMIT. */}', + ); + assert.deepEqual(result.errors, []); + assert.equal(result.paths, 2); +}); + test('reports a line reference past the end of the file with the real line count', (t) => { const root = repository(t); const result = check(root, '{/* Evidence: crates/demo/src/lib.rs:40-42 holds it. */}'); From b56f0e268f695245ea0c1068cd7d05d561598c72 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sat, 22 Aug 2026 15:06:47 +0200 Subject: [PATCH 19/25] feat(docs): open a citation on every top-level directory The citation roots named six directories and the repository keeps ten, so a path into editors/, docker/, or .cargo/ matched no citation pattern, parsed as prose, and left its anchor checked against nothing. A rename under any of them was invisible to the gate. Name all ten, and add a test that reads the tracked directories out of the committed tree so the next one the repository grows fails the gate rather than slipping through it. Git is what says which directories are tracked: a listing of the checkout also carries build output and local tooling, and which of those are present differs between a clean CI checkout and a working machine. schemas/ is on both root lists, because a crate or product keeps one and so does the repository. Reading it as a repository path outright would take the continuation reading away from the two dozen crate and product schemas directories in exchange for the one file the repository keeps at the top, so a citation that starts there keeps the continuation candidates and gains the repository reading as its last one. The nearer directory still wins, as it does for a bare filename that may name a file kept at the root. Signed-off-by: Jeremi Joslin --- docs/site/scripts/check-evidence-anchors.mjs | 35 +++++++++-- .../scripts/check-evidence-anchors.test.mjs | 60 +++++++++++++++++++ 2 files changed, 89 insertions(+), 6 deletions(-) diff --git a/docs/site/scripts/check-evidence-anchors.mjs b/docs/site/scripts/check-evidence-anchors.mjs index e6f00dc06..1cac056ad 100644 --- a/docs/site/scripts/check-evidence-anchors.mjs +++ b/docs/site/scripts/check-evidence-anchors.mjs @@ -21,11 +21,30 @@ export const PROSE_SYMBOL_ALLOWLIST = new Set([ 'TypeScript', ]); -// Directories a repository-relative citation may start from. -const REPOSITORY_ROOTS = ['crates', 'products', 'release', 'docs', 'external', '\\.github']; +// Directories a repository-relative citation may start from: every top-level directory the +// repository keeps, because a citation into one this list omits parses as no citation at +// all and leaves its anchor checked against nothing. The entries are regular expression +// source, so a dot-directory carries its escape. +export const REPOSITORY_ROOTS = [ + 'crates', + 'products', + 'release', + 'docs', + 'docker', + 'editors', + 'external', + 'schemas', + '\\.cargo', + '\\.github', +]; // Directories a continuation citation may start from, resolved against the crate or // product root of the most recently cited path in the same anchor. const CONTINUATION_ROOTS = ['src', 'tests', 'examples', 'benches', 'schemas', 'scripts']; +// The roots both lists name: a crate or product keeps a schemas/ directory of its own and +// so does the repository. A citation that starts at one is read against the unit cited +// before it first and against the repository root last, so the nearer directory wins, the +// way it does for a bare filename that may name a file kept at the root. +const SHARED_ROOTS = new Set(REPOSITORY_ROOTS.filter((root) => CONTINUATION_ROOTS.includes(root))); // Extensions that make a bare token a sibling filename rather than ordinary prose. const SOURCE_EXTENSIONS = [ 'rs', @@ -210,22 +229,26 @@ export function parseAnchor(body, { siteRoot = DOCS_SITE_ROOT } = {}) { for (const cited of expandBraceLists(trimmed)) { const parentOfLastCitedPath = lastCitedPath === undefined || dirname(lastCitedPath) === '.' ? '' : dirname(lastCitedPath); + // A path that starts at a shared root is read as a continuation, and carries the + // repository reading of the same token as its last candidate. + const shared = full !== undefined && SHARED_ROOTS.has(cited.split('/')[0]); /** @type {Citation} */ let citation; - if (full !== undefined) { + if (full !== undefined && !shared) { citation = { form: 'full', candidates: [cited], reportMissing: true }; - } else if (continuation !== undefined && lastCitedPath === undefined) { + } else if ((continuation !== undefined || shared) && lastCitedPath === undefined) { citation = { form: 'continuation', - candidates: [joinPath(siteRoot, cited)], + candidates: [joinPath(siteRoot, cited), ...(shared ? [cited] : [])], reportMissing: true, }; - } else if (continuation !== undefined) { + } else if (continuation !== undefined || shared) { citation = { form: 'continuation', candidates: [ joinPath(citationRoot(lastCitedPath), cited), joinPath(parentOfLastCitedPath, cited), + ...(shared ? [cited] : []), ], reportMissing: true, }; diff --git a/docs/site/scripts/check-evidence-anchors.test.mjs b/docs/site/scripts/check-evidence-anchors.test.mjs index 6cbee0491..b27bae0ea 100644 --- a/docs/site/scripts/check-evidence-anchors.test.mjs +++ b/docs/site/scripts/check-evidence-anchors.test.mjs @@ -1,4 +1,5 @@ import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, resolve } from 'node:path'; @@ -8,6 +9,7 @@ import { fileURLToPath } from 'node:url'; import YAML from 'yaml'; import { + REPOSITORY_ROOTS, checkEvidenceAnchors, extractAnchors, extractSymbols, @@ -717,6 +719,64 @@ test('root CI runs the anchor check on every pull request and gates the branch o assert.ok(workflow.jobs['ci-result'].needs.includes(jobId)); }); +test('names every top-level directory the repository tracks as a citation root', () => { + // A citation root the list does not name parses as no citation at all, so the anchor + // carrying it is checked against nothing. Git is what says which directories the + // repository keeps: a listing of the checkout also carries build output and local + // tooling, and which of those are present differs between a clean CI checkout and a + // working machine, so a listing would fail for reasons that are not drift. + const tracked = execFileSync('git', ['ls-tree', '-d', '--name-only', 'HEAD'], { + cwd: repositoryRoot, + encoding: 'utf8', + }) + .split('\n') + .filter((name) => name !== ''); + assert.ok(tracked.length > 0); + // The roots are regular expression source, so a dot-directory carries its escape. + const named = new Set(REPOSITORY_ROOTS.map((root) => root.replaceAll('\\', ''))); + assert.deepEqual( + tracked.filter((name) => !named.has(name)), + [], + ); +}); + +test('reads a citation into a top-level directory beside the crates and products', (t) => { + const root = repository(t); + write(root, 'editors/vscode/package.json', '{ "contributes": { "packageRevision": 1 } }\n'); + write(root, 'docker/compose/docker-compose.yaml', 'services:\n evidence: {}\n'); + write(root, '.cargo/config.toml', '[build]\nrustflags = ["--cfg", "source_neutral"]\n'); + const passing = check( + root, + '{/* Evidence: editors/vscode/package.json, packageRevision; docker/compose/docker-compose.yaml; .cargo/config.toml, source_neutral. */}', + ); + assert.deepEqual(passing.errors, []); + assert.equal(passing.paths, 3); + + const failing = check(root, '{/* Evidence: editors/vscode/absent.json carries it. */}'); + assert.equal(failing.errors.length, 1); + assert.match(failing.errors[0], /editors\/vscode\/absent\.json/); + assert.match(failing.errors[0], /does not exist/); +}); + +test('reads a citation that starts at a shared root nearest first', (t) => { + const root = repository(t); + write(root, 'crates/demo/schemas/authoring/registry.schema.json', '{ "title": "authoring_form" }\n'); + write(root, 'schemas/registry-notary.config.schema.json', '{ "title": "notary_config" }\n'); + const nearer = check( + root, + '{/* Evidence: crates/demo/src/lib.rs, then schemas/authoring/registry.schema.json, authoring_form. */}', + ); + assert.deepEqual(nearer.errors, []); + assert.equal(nearer.paths, 2); + + const atTheRoot = check( + root, + '{/* Evidence: schemas/registry-notary.config.schema.json, notary_config. */}', + ); + assert.deepEqual(atTheRoot.errors, []); + assert.equal(atTheRoot.paths, 1); +}); + test('reads a one-word name only where the anchor spells it qualified', (t) => { const root = repository(t); write(root, 'crates/demo/src/rule.rs', 'pub enum AccessRule {\n Public(String),\n}\n'); From 5cbeaaf312bb259017b25751aaa770ead2849acd Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sat, 22 Aug 2026 15:08:26 +0200 Subject: [PATCH 20/25] feat(docs): report an anchor that resolves no path An anchor whose paths all failed to be read as citations, by a typo such as crate/ for crates/ or by naming a directory the roots do not cover, reached the symbol check with nothing to read against and was skipped. It counted as an anchor and contributed no checked path and no checked symbol, so the gate reported a guarantee it had not made for it. The two ways an anchor arrives there are different mistakes and are told apart. One that parsed no citation at all cites no path in this repository: the writer named nothing the check could open, and pairing an upstream standard with the file that implements it is the fix. One that parsed citations none of which resolved is reported as resolving none of them: every path it named was read as prose, which is what a bare filename the repository does not own is, and the anchor needs one path the repository does hold. An anchor whose citations were already reported keeps its own message, because it fails on those and a second line names no further drift. Signed-off-by: Jeremi Joslin --- docs/site/scripts/check-evidence-anchors.mjs | 12 ++++++++++ .../scripts/check-evidence-anchors.test.mjs | 23 +++++++++++++++++-- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/docs/site/scripts/check-evidence-anchors.mjs b/docs/site/scripts/check-evidence-anchors.mjs index 1cac056ad..eec9c9d26 100644 --- a/docs/site/scripts/check-evidence-anchors.mjs +++ b/docs/site/scripts/check-evidence-anchors.mjs @@ -521,6 +521,7 @@ export function checkEvidenceAnchors({ anchors += 1; const { citations, symbols: cited } = parseAnchor(anchor.body); const at = `${location}:${anchor.line}`; + const reportedBefore = errors.length; const citedFiles = []; const citedDirectories = []; let lastResolvedFile; @@ -625,6 +626,17 @@ export function checkEvidenceAnchors({ } if (citedFiles.length === 0 && citedDirectories.length === 0) { + // An anchor that resolves no path has nothing to read its symbols against, so + // letting it pass would state a guarantee the check never made for it. An anchor + // whose citations were reported already fails, and naming it twice names no + // further drift. + if (errors.length === reportedBefore) { + errors.push( + citations.length === 0 + ? `${at} cites no path in this repository, so nothing it claims was checked` + : `${at} resolves none of the paths it cites, so nothing it claims was checked`, + ); + } continue; } diff --git a/docs/site/scripts/check-evidence-anchors.test.mjs b/docs/site/scripts/check-evidence-anchors.test.mjs index b27bae0ea..f4c1076ec 100644 --- a/docs/site/scripts/check-evidence-anchors.test.mjs +++ b/docs/site/scripts/check-evidence-anchors.test.mjs @@ -538,14 +538,33 @@ test('skips prose words that carry a symbol shape but are on the allowlist', (t) assert.equal(result.symbols, 0); }); -test('skips the symbol check when an anchor cites no path', (t) => { +test('reports an anchor that cites no path at all', (t) => { const root = repository(t); const result = check(root, '{/* Evidence: the operator contract states does_not_own. */}'); - assert.deepEqual(result.errors, []); + assert.equal(result.errors.length, 1); + assert.match(result.errors[0], /^page\.mdx:5 /); + assert.match(result.errors[0], /cites no path in this repository/); assert.equal(result.paths, 0); assert.equal(result.symbols, 0); }); +test('reports an anchor none of whose citations resolve', (t) => { + const root = repository(t); + const result = check(root, '{/* Evidence: origins.yaml carries the source_kind an adopter sets. */}'); + assert.equal(result.errors.length, 1); + assert.match(result.errors[0], /resolves none of the paths it cites/); + assert.equal(result.paths, 0); + assert.equal(result.symbols, 0); +}); + +test('leaves an anchor whose citations were reported to report itself again', (t) => { + const root = repository(t); + const result = check(root, '{/* Evidence: crates/demo/src/absent.rs holds does_not_own. */}'); + assert.equal(result.errors.length, 1); + assert.match(result.errors[0], /crates\/demo\/src\/absent\.rs/); + assert.match(result.errors[0], /does not exist/); +}); + test('reads a continuation with no full path before it against the docs site', (t) => { const root = repository(t); write(root, 'docs/site/src/data/projects.yaml', '- id: demo\n does_not_own: []\n'); From 50b377d0210ae4be29ef78d5f502e0d150673cf1 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sat, 22 Aug 2026 15:10:53 +0200 Subject: [PATCH 21/25] fix(docs): report a line reference the anchor spelled wrong The citation pattern reads a valid line suffix and stops, so whatever the anchor wrote past it was thrown away. `:abc` left the citation checked as the bare file, and `:1foo` left it checked as line 1, neither of them a line the anchor meant, and neither reported. Only the range cut short, `:5-`, was caught, and only because a trailing hyphen was looked for by name. Read the leftover instead: word characters or a hyphen, which a well-formed reference would have carried itself, optionally behind the colon that opens one. That is what a mis-spelled reference leaves and what a correct one never does, since prose puts a space after a colon it writes and puts its punctuation after a reference it ends on. The existing report carries the leftover rather than a hardcoded hyphen, so the three shapes read alike. The rule fires on none of the 134 anchors in the tree, which keeps the counts and the zero errors exactly where they were. Signed-off-by: Jeremi Joslin --- docs/site/scripts/check-evidence-anchors.mjs | 20 +++++++--- .../scripts/check-evidence-anchors.test.mjs | 39 +++++++++++++++++++ 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/docs/site/scripts/check-evidence-anchors.mjs b/docs/site/scripts/check-evidence-anchors.mjs index eec9c9d26..5073d18e4 100644 --- a/docs/site/scripts/check-evidence-anchors.mjs +++ b/docs/site/scripts/check-evidence-anchors.mjs @@ -104,6 +104,12 @@ const CITATION_PATTERN = new RegExp( 'g', ); const LINE_SUFFIX = /^(?.*?)(?::(?\d+)(?:-(?\d+))?)?$/; +// What a line reference the citation pattern could not read leaves behind the token it +// follows: word characters or a hyphen, which a well-formed reference would have carried +// itself, optionally behind the colon that opens one. A colon the prose writes is followed +// by a space, and a reference the prose punctuates is followed by the punctuation, so +// neither leaves anything this reads. +const UNREAD_LINE_REFERENCE = /^:?[\w-][\w:-]*/; const WORD_PATTERN = /[A-Za-z_][A-Za-z0-9_]*(?:::[A-Za-z_][A-Za-z0-9_]*)+|[A-Za-z_][A-Za-z0-9_]*/g; // A dotted configuration or wire key path, sources.*.authentication.kind. The word pass reads // its segments one by one and keeps only the ones that carry a symbol shape, so this pattern is @@ -189,7 +195,7 @@ function joinPath(base, tail) { * @property {string} [raw] the token as the anchor spelled it * @property {number} [start] first line of a line reference * @property {number} [end] last line of a line reference - * @property {boolean} [malformedLines] set when a line suffix was cut short + * @property {string} [malformedLines] the part of a line reference the pattern could not read * @property {string} [basename] bare filename to search for when no candidate resolves * @property {string} [searchRoot] where that search runs, the empty string for the whole tree * @property {string} [rootCandidate] the same name as a file kept at the repository root @@ -220,9 +226,11 @@ export function parseAnchor(body, { siteRoot = DOCS_SITE_ROOT } = {}) { const token = full ?? continuation ?? sibling ?? child; const { path, start, end } = splitLineReference(token); const trimmed = path.replace(/\/$/, ''); - // A line suffix the anchor cut short, `:5-`, leaves its hyphen outside the token and - // would otherwise read as the single line 5 rather than the range it was meant to be. - const malformedLines = start !== undefined && body.startsWith('-', cursor); + // A line reference the anchor spelled wrong leaves the part the pattern could not read + // outside the token: the hyphen of a range cut short in `:5-`, the word run into the + // number in `:1foo`, the whole suffix in `:abc`. Each would otherwise be thrown away, + // leaving the citation checked as the bare file or as a line the anchor never meant. + const malformedLines = UNREAD_LINE_REFERENCE.exec(body.slice(cursor))?.[0]; // A brace list stands for one citation per entry, so each file it names is resolved and // counted on its own, and each entry reads against the path the entry before it set. @@ -544,11 +552,11 @@ export function checkEvidenceAnchors({ } // A cut-short suffix parses as a line the anchor never meant, so it is reported // as the malformed reference it is rather than checked against the file. - if (citation.malformedLines) { + if (citation.malformedLines !== undefined) { paths += 1; lineRefs += 1; errors.push( - `${at} cites ${citation.raw}-, but a line reference names a line or a first and last line`, + `${at} cites ${citation.raw}${citation.malformedLines}, but a line reference names a line or a first and last line`, ); continue; } diff --git a/docs/site/scripts/check-evidence-anchors.test.mjs b/docs/site/scripts/check-evidence-anchors.test.mjs index f4c1076ec..2c4c4752f 100644 --- a/docs/site/scripts/check-evidence-anchors.test.mjs +++ b/docs/site/scripts/check-evidence-anchors.test.mjs @@ -168,6 +168,45 @@ test('reports a line suffix the anchor cut short', (t) => { assert.equal(result.lineRefs, 1); }); +test('reports a line reference the anchor spelled with something other than a number', (t) => { + const root = repository(t); + const result = check(root, '{/* Evidence: crates/demo/src/lib.rs:abc holds it. */}'); + assert.equal(result.errors.length, 1); + assert.match(result.errors[0], /crates\/demo\/src\/lib\.rs:abc/); + assert.match(result.errors[0], /a line or a first and last line/); + assert.equal(result.lineRefs, 1); +}); + +test('reports a line reference the anchor ran into the word behind it', (t) => { + const root = repository(t); + const result = check(root, '{/* Evidence: crates/demo/src/lib.rs:1foo holds it. */}'); + assert.equal(result.errors.length, 1); + assert.match(result.errors[0], /crates\/demo\/src\/lib\.rs:1foo/); + assert.match(result.errors[0], /a line or a first and last line/); +}); + +test('leaves a colon the prose writes after a cited path alone', (t) => { + const root = repository(t); + const result = check( + root, + '{/* Evidence: crates/demo/src/lib.rs: it holds verify_source_shape(). */}', + ); + assert.deepEqual(result.errors, []); + assert.equal(result.paths, 1); + assert.equal(result.lineRefs, 0); +}); + +test('leaves a well-formed line reference the prose punctuates alone', (t) => { + const root = repository(t); + write(root, 'crates/demo/src/wide.rs', 'one\ntwo\nthree\nfour\nfive\nsix\n'); + const result = check( + root, + '{/* Evidence: crates/demo/src/wide.rs:1-2, crates/demo/src/wide.rs:3-4; crates/demo/src/wide.rs:5-6. */}', + ); + assert.deepEqual(result.errors, []); + assert.equal(result.lineRefs, 3); +}); + test('leaves a hyphen the prose carries after a line reference alone', (t) => { const root = repository(t); write(root, 'crates/demo/src/wide.rs', 'one\ntwo\nthree\nfour\nfive\nsix\n'); From b70ecfa84402eec1091ba7ab6c67b8b4ca4c8ebc Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sat, 22 Aug 2026 15:12:46 +0200 Subject: [PATCH 22/25] fix(docs): read a bare child against the kind its parent resolves to Whether a trailing-slash name continues a directory or is prose was decided by looking for a dot in the last segment of the path before it. An extensionless executable such as release/scripts/registry-release has no dot, so it read as a directory and the name after it was resolved beneath a file, which can never exist. A correct anchor for any of the repository's extensionless scripts was reported as drift. The parse stays free of the filesystem, which is what makes it directly testable: it records the path the child continues, and the resolution, which already opens the tree, reads that path's kind and drops the citation where it is not a directory. A name whose parent is a file, or is nothing the repository holds, is prose and is left alone rather than reported. This is the same discrimination that already made governed/ and generated/ correct beside package.rs in explanation/publishing-pipeline.mdx. The dot in that filename is what saved them; the parent's real kind saves them for the reason they are actually prose, and that anchor is the regression test in the tree. Signed-off-by: Jeremi Joslin --- docs/site/scripts/check-evidence-anchors.mjs | 23 ++++++++++++++----- .../scripts/check-evidence-anchors.test.mjs | 22 ++++++++++++++++++ 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/docs/site/scripts/check-evidence-anchors.mjs b/docs/site/scripts/check-evidence-anchors.mjs index 5073d18e4..eefaa2d70 100644 --- a/docs/site/scripts/check-evidence-anchors.mjs +++ b/docs/site/scripts/check-evidence-anchors.mjs @@ -77,10 +77,6 @@ const SKIPPED_DIRECTORIES = new Set(['target', 'node_modules', '.git', 'dist', ' const DOCS_SITE_ROOT = 'docs/site'; const ANCHOR_PATTERN = /\{\/\*\s*Evidence:([\s\S]*?)\*\/\}/g; -// Whether a path named a directory, which is what a bare child citation continues. A dot -// in the last segment is what says a path named a file, so an extensionless script reads -// as a directory. The reading is a syntactic one because nothing here opens the repository. -const NAMES_A_DIRECTORY = /(?:^|\/)[^./]+$/; // A bare sibling that names a Rust source file is one the repository owns: an adopter of // this stack writes configuration and scripts, never Rust, and a package the runtime // generates carries none either. Every other extension a sibling may carry names a file the @@ -199,6 +195,8 @@ function joinPath(base, tail) { * @property {string} [basename] bare filename to search for when no candidate resolves * @property {string} [searchRoot] where that search runs, the empty string for the whole tree * @property {string} [rootCandidate] the same name as a file kept at the repository root + * @property {string} [parentDirectory] the path a bare child continues, which the + * repository has to hold as a directory for the child to name one */ export function parseAnchor(body, { siteRoot = DOCS_SITE_ROOT } = {}) { @@ -264,14 +262,17 @@ export function parseAnchor(body, { siteRoot = DOCS_SITE_ROOT } = {}) { // A bare child names a directory inside the one cited before it, so a rename of // that directory is drift and is reported. After a filename there is no directory // to continue and the name is prose: `governed/` beside package.rs names a - // directory the package writes, not one the repository holds. - if (lastCitedPath === undefined || !NAMES_A_DIRECTORY.test(lastCitedPath)) { + // directory the package writes, not one the repository holds. Which of the two the + // path before it is, is a fact about the repository rather than about the token, so + // the parse records that path and the resolution reads its kind. + if (lastCitedPath === undefined) { continue; } citation = { form: 'child', candidates: [joinPath(lastCitedPath, cited)], reportMissing: true, + parentDirectory: lastCitedPath, }; } else if (lastCitedPath === undefined) { // A bare filename that opens an anchor has no path to sit beside, so the @@ -539,6 +540,16 @@ export function checkEvidenceAnchors({ citation.start === undefined ? '' : `:${citation.start}${citation.end === citation.start ? '' : `-${citation.end}`}`; + // A bare child continues a directory. Where the path before it is a file, or is + // nothing the repository holds at all, the name is prose rather than a citation: + // it names a directory that file writes at runtime, not a path the repository + // keeps, and an extensionless script is a file like any other. + if ( + citation.parentDirectory !== undefined && + entryKind(resolve(repoRoot, citation.parentDirectory)) !== 'directory' + ) { + continue; + } const escaping = citation.candidates.find((candidate) => escapesRepository(repoRoot, candidate), ); diff --git a/docs/site/scripts/check-evidence-anchors.test.mjs b/docs/site/scripts/check-evidence-anchors.test.mjs index 2c4c4752f..9f22dcd40 100644 --- a/docs/site/scripts/check-evidence-anchors.test.mjs +++ b/docs/site/scripts/check-evidence-anchors.test.mjs @@ -490,6 +490,28 @@ test('reads one bare child directory against the last, so a chain resolves', (t) assert.equal(result.paths, 3); }); +test('reads a trailing-slash name that follows an extensionless file as prose', (t) => { + const root = repository(t); + write(root, 'release/scripts/registry-release', '#!/usr/bin/env python3\nprint("pack")\n'); + const result = check( + root, + '{/* Evidence: release/scripts/registry-release writes output/ and manifests/. */}', + ); + assert.deepEqual(result.errors, []); + assert.equal(result.paths, 1); +}); + +test('continues a bare child under an extensionless directory the repository holds', (t) => { + const root = repository(t); + write(root, 'products/demo/projects/protected-read/README.md', 'It names bounded_read_shape.\n'); + const result = check( + root, + '{/* Evidence: products/demo/projects then protected-read/, bounded_read_shape. */}', + ); + assert.deepEqual(result.errors, []); + assert.equal(result.paths, 2); +}); + test('leaves a trailing-slash name that follows a cited file out of the citations', (t) => { const root = repository(t); const result = check( From 73f6c959a9313d9809f0d061470033c25d1ec94a Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sat, 22 Aug 2026 15:14:45 +0200 Subject: [PATCH 23/25] docs: state what the anchor check now guarantees The five fixes changed what an anchor may say and what the gate promises, so the guidance says it: an anchor has to cite at least one path the repository holds, a citation may start at any top-level directory, `schemas/` reads nearest first because a crate and the repository both keep one, a path a symlink leads out of the checkout is refused, a line reference is spelled `:12` or `:12-14` and nothing else, and a trailing-slash name continues the path before it only where the repository holds that path as a directory. The bare filename read as prose keeps its exception and gains its condition: it is fine wherever the anchor resolves some other path. Signed-off-by: Jeremi Joslin --- docs/site/AGENTS.md | 41 ++++++++++++++++++++++++++--------------- 1 file changed, 26 insertions(+), 15 deletions(-) diff --git a/docs/site/AGENTS.md b/docs/site/AGENTS.md index 928fed791..b43230203 100644 --- a/docs/site/AGENTS.md +++ b/docs/site/AGENTS.md @@ -30,24 +30,35 @@ level, rather than deleting the claim or asserting it. `npm run check` resolves those anchors and fails when one does not. A cited path must exist, a cited line reference must fall inside its file, and a cited -symbol must occur in at least one path the same anchor cites. A citation that -has drifted is a merge blocker, not a wart, so check an anchor when you move -the code it points at. Run `npm run check:evidence-anchors` alone for the fast -version. Root CI runs it twice: once inside the docs job, and once in a job of -its own that runs on every pull request, because the anchors cite source all -over the workspace and the docs job only runs for a changed path it recognizes. -A bare filename beside a cited path is read as prose when nothing resolves, so -naming a file the repo does not own, an adopter's `origins.yaml` or a path a -generated package writes, is still fine. A bare `.rs` sibling is the exception: -only this repository writes Rust into the stack, so a Rust filename has to -resolve, and deleting the file one names fails the check. Several files in -one directory may be cited in the compact brace form, +symbol must occur in at least one path the same anchor cites. Every anchor has +to cite at least one path this repository holds, since one that resolves none +has nothing to read its symbols against; pair an upstream standard with the +file that implements it rather than citing the standard alone. A citation may +start at any top-level directory the repository keeps, and `schemas/` is read +against the crate or product cited before it first and against the repository +root last, because both keep one. A path a symlink leads out of the checkout is +refused rather than read. A line reference is spelled `:12` or `:12-14` and +nothing else, so `:abc` and `:1foo` are reported rather than thrown away. A +citation that has drifted is a merge blocker, not a wart, so check an anchor +when you move the code it points at. Run `npm run check:evidence-anchors` alone +for the fast version. Root CI runs it twice: once inside the docs job, and once +in a job of its own that runs on every pull request, because the anchors cite +source all over the workspace and the docs job only runs for a changed path it +recognizes. A bare filename beside a cited path is read as prose when nothing +resolves, so naming a file the repo does not own, an adopter's `origins.yaml` +or a path a generated package writes, is still fine wherever the anchor +resolves some other path. A bare `.rs` sibling is the exception: only this +repository writes Rust into the stack, so a Rust filename has to resolve, and +deleting the file one names fails the check. Several files in one directory may +be cited in the compact brace form, `crates/registry-relay-v2/src/{api,startup}.rs`, which is read as one citation per entry, so each file it names has to exist on its own. A bare name ending in a slash continues the directory cited before it, `deployment-projects/ then -protected-read-evidence/`, and has to exist under it. After a filename there is -no directory to continue, so `governed/` beside `package.rs` stays prose: it -names a directory the package writes, not one the repo holds. +protected-read-evidence/`, and has to exist under it. It continues that path +only where the repo holds it as a directory, so beside a file the name stays +prose: `governed/` after `package.rs`, and `output/` after the extensionless +`release/scripts/registry-release`, name directories the program writes, not +ones the repo holds. The check reads a symbol by its shape: `snake_case`, `SCREAMING_SNAKE_CASE`, `UpperCamelCase`, `lowerCamelCase`, a name spelled with empty parentheses such as From 000323199b32ad83193f29f9f55a947cb2e12f18 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sat, 22 Aug 2026 16:22:46 +0200 Subject: [PATCH 24/25] fix(docs): read a bare child against the directory the anchor resolved A bare child continued the parse's first candidate rather than the path the citation actually resolved to. Where a shared root resolved through a fallback, the repository's own schemas/ rather than the cited crate's, that first candidate named nothing, so the guard read it as prose and dropped the child citation without checking or counting it: deleting the directory it named left the gate green. The parse now records the child's bare name and the resolution reads it against the directory the anchor reached, dropping that position wherever a citation resolves nothing so a child after a reported miss stays prose instead of being read against a stale directory. Signed-off-by: Jeremi Joslin --- docs/site/scripts/check-evidence-anchors.mjs | 45 +++++++++++++------ .../scripts/check-evidence-anchors.test.mjs | 21 +++++++++ 2 files changed, 52 insertions(+), 14 deletions(-) diff --git a/docs/site/scripts/check-evidence-anchors.mjs b/docs/site/scripts/check-evidence-anchors.mjs index eefaa2d70..ae7c1d9ee 100644 --- a/docs/site/scripts/check-evidence-anchors.mjs +++ b/docs/site/scripts/check-evidence-anchors.mjs @@ -195,8 +195,8 @@ function joinPath(base, tail) { * @property {string} [basename] bare filename to search for when no candidate resolves * @property {string} [searchRoot] where that search runs, the empty string for the whole tree * @property {string} [rootCandidate] the same name as a file kept at the repository root - * @property {string} [parentDirectory] the path a bare child continues, which the - * repository has to hold as a directory for the child to name one + * @property {string} [childName] the bare name a child continues the last resolved + * directory with, which the repository has to hold as a directory for it to name one */ export function parseAnchor(body, { siteRoot = DOCS_SITE_ROOT } = {}) { @@ -264,7 +264,8 @@ export function parseAnchor(body, { siteRoot = DOCS_SITE_ROOT } = {}) { // to continue and the name is prose: `governed/` beside package.rs names a // directory the package writes, not one the repository holds. Which of the two the // path before it is, is a fact about the repository rather than about the token, so - // the parse records that path and the resolution reads its kind. + // the parse records the name alone and the resolution reads the kind of the path + // the anchor reached. if (lastCitedPath === undefined) { continue; } @@ -272,7 +273,7 @@ export function parseAnchor(body, { siteRoot = DOCS_SITE_ROOT } = {}) { form: 'child', candidates: [joinPath(lastCitedPath, cited)], reportMissing: true, - parentDirectory: lastCitedPath, + childName: cited, }; } else if (lastCitedPath === undefined) { // A bare filename that opens an anchor has no path to sit beside, so the @@ -534,6 +535,10 @@ export function checkEvidenceAnchors({ const citedFiles = []; const citedDirectories = []; let lastResolvedFile; + // Where the anchor stands: the path the citation before this one resolved to, which + // a bare child continues. It is not the parse's first candidate, because a citation + // that resolved through a fallback left that guess naming nothing. + let lastResolvedPath; for (const citation of citations) { const range = @@ -544,20 +549,24 @@ export function checkEvidenceAnchors({ // nothing the repository holds at all, the name is prose rather than a citation: // it names a directory that file writes at runtime, not a path the repository // keeps, and an extensionless script is a file like any other. - if ( - citation.parentDirectory !== undefined && - entryKind(resolve(repoRoot, citation.parentDirectory)) !== 'directory' - ) { - continue; + let candidates = citation.candidates; + if (citation.form === 'child') { + if ( + lastResolvedPath === undefined || + entryKind(resolve(repoRoot, lastResolvedPath)) !== 'directory' + ) { + lastResolvedPath = undefined; + continue; + } + candidates = [joinPath(lastResolvedPath, citation.childName)]; } - const escaping = citation.candidates.find((candidate) => - escapesRepository(repoRoot, candidate), - ); + const escaping = candidates.find((candidate) => escapesRepository(repoRoot, candidate)); if (escaping !== undefined) { paths += 1; if (range !== '') { lineRefs += 1; } + lastResolvedPath = undefined; errors.push(`${at} cites ${escaping}${range}, which leaves the repository`); continue; } @@ -566,12 +575,13 @@ export function checkEvidenceAnchors({ if (citation.malformedLines !== undefined) { paths += 1; lineRefs += 1; + lastResolvedPath = undefined; errors.push( `${at} cites ${citation.raw}${citation.malformedLines}, but a line reference names a line or a first and last line`, ); continue; } - let resolved = citation.candidates.find( + let resolved = candidates.find( (candidate) => entryKind(resolve(repoRoot, candidate)) !== 'missing', ); if (resolved === undefined && citation.basename !== undefined) { @@ -592,6 +602,7 @@ export function checkEvidenceAnchors({ if (range !== '') { lineRefs += 1; } + lastResolvedPath = undefined; errors.push( `${at} cites ${citation.rootCandidate}${range}, which leaves the repository`, ); @@ -612,9 +623,15 @@ export function checkEvidenceAnchors({ lineRefs += 1; } if (resolved === undefined) { - errors.push(`${at} cites ${citation.candidates[0]}${range}, which does not exist`); + lastResolvedPath = undefined; + errors.push(`${at} cites ${candidates[0]}${range}, which does not exist`); continue; } + // A sibling names no directory to read the next citation against, so it leaves the + // anchor where it is, the same rule the parse applies to the candidate chain. + if (citation.form !== 'sibling') { + lastResolvedPath = resolved; + } if (strictLineRefs && range !== '') { errors.push( `${at} cites ${resolved}${range}; line numbers drift silently, so name the symbol, test, constant, or key instead`, diff --git a/docs/site/scripts/check-evidence-anchors.test.mjs b/docs/site/scripts/check-evidence-anchors.test.mjs index 9f22dcd40..9f8f89276 100644 --- a/docs/site/scripts/check-evidence-anchors.test.mjs +++ b/docs/site/scripts/check-evidence-anchors.test.mjs @@ -490,6 +490,27 @@ test('reads one bare child directory against the last, so a chain resolves', (t) assert.equal(result.paths, 3); }); +test('reads a bare child against the directory the anchor resolved, not the first guess', (t) => { + const root = repository(t); + write(root, 'schemas/registry/profile/registry.schema.json', '{ "title": "profile_form" }\n'); + // The shared root resolves at the repository, the third candidate, because the crate + // cited before it keeps no schemas/ of its own. A child read against the first candidate + // instead would find no directory there and drop the citation unchecked. + const held = check( + root, + '{/* Evidence: crates/demo/src/lib.rs, then schemas/registry/ profile/, profile_form. */}', + ); + assert.deepEqual(held.errors, []); + assert.equal(held.paths, 3); + + const renamed = check( + root, + '{/* Evidence: crates/demo/src/lib.rs, then schemas/registry/ absent/. */}', + ); + assert.equal(renamed.errors.length, 1); + assert.match(renamed.errors[0], /schemas\/registry\/absent/); +}); + test('reads a trailing-slash name that follows an extensionless file as prose', (t) => { const root = repository(t); write(root, 'release/scripts/registry-release', '#!/usr/bin/env python3\nprint("pack")\n'); From f20ace495f0a79c3ac24f29d4e055bdeaea70b1c Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sat, 22 Aug 2026 16:36:39 +0200 Subject: [PATCH 25/25] fix(docs): close three more gaps in the evidence anchor check A line reference the anchor carried on past the line the pattern read, the `.5` of `:1.5`, was thrown away and the citation checked against line 1. It is read now, and only where the token carried a reference, so the full stop that ends a sentence on a path stays punctuation. A bare JavaScript or TypeScript filename was no sibling the grammar knew, so a name that resolves was never counted and `projectRoot.ts` was read as a dotted key path instead, demanding a symbol spelled `ts`. Both are siblings now. Whether one has to resolve is unchanged: only a Rust name does, because only this repository writes Rust into the stack. A file with no extension is a script the repository keeps, so a symbol an anchor cites from `release/scripts/registry-release` was reported absent wherever the anchor cited the directory holding it. The directory search reads it now. Signed-off-by: Jeremi Joslin --- docs/site/AGENTS.md | 7 +-- docs/site/scripts/check-evidence-anchors.mjs | 21 ++++++-- .../scripts/check-evidence-anchors.test.mjs | 49 +++++++++++++++++++ 3 files changed, 70 insertions(+), 7 deletions(-) diff --git a/docs/site/AGENTS.md b/docs/site/AGENTS.md index b43230203..4e911fd5e 100644 --- a/docs/site/AGENTS.md +++ b/docs/site/AGENTS.md @@ -38,9 +38,10 @@ start at any top-level directory the repository keeps, and `schemas/` is read against the crate or product cited before it first and against the repository root last, because both keep one. A path a symlink leads out of the checkout is refused rather than read. A line reference is spelled `:12` or `:12-14` and -nothing else, so `:abc` and `:1foo` are reported rather than thrown away. A -citation that has drifted is a merge blocker, not a wart, so check an anchor -when you move the code it points at. Run `npm run check:evidence-anchors` alone +nothing else, so `:abc`, `:1foo`, and `:1.5` are reported rather than thrown +away; the full stop that ends a sentence on a path is punctuation and is left +alone. A citation that has drifted is a merge blocker, not a wart, so check an +anchor when you move the code it points at. Run `npm run check:evidence-anchors` alone for the fast version. Root CI runs it twice: once inside the docs job, and once in a job of its own that runs on every pull request, because the anchors cite source all over the workspace and the docs job only runs for a changed path it diff --git a/docs/site/scripts/check-evidence-anchors.mjs b/docs/site/scripts/check-evidence-anchors.mjs index ae7c1d9ee..56456513b 100644 --- a/docs/site/scripts/check-evidence-anchors.mjs +++ b/docs/site/scripts/check-evidence-anchors.mjs @@ -58,12 +58,12 @@ const SOURCE_EXTENSIONS = [ 'yml', 'jsonld', 'json', + 'js', + 'ts', ]; // Extensions read when a symbol has to be looked for inside a cited directory. const TEXT_EXTENSIONS = new Set([ ...SOURCE_EXTENSIONS, - 'js', - 'ts', 'txt', 'sql', 'snap', @@ -106,6 +106,12 @@ const LINE_SUFFIX = /^(?.*?)(?::(?\d+)(?:-(?\d+))?)?$/; // by a space, and a reference the prose punctuates is followed by the punctuation, so // neither leaves anything this reads. const UNREAD_LINE_REFERENCE = /^:?[\w-][\w:-]*/; +// The same reference carried on past the line the pattern did read, the `.5` of `:1.5`, +// which would otherwise be thrown away and leave the citation checked against line 1. It is +// read only where the token carried a reference, so the full stop that ends a sentence on a +// path stays punctuation, and it needs a character after the dot, so a sentence ending on a +// line reference leaves nothing this reads either. +const CONTINUED_LINE_REFERENCE = /^\.[\w:.-]+/; const WORD_PATTERN = /[A-Za-z_][A-Za-z0-9_]*(?:::[A-Za-z_][A-Za-z0-9_]*)+|[A-Za-z_][A-Za-z0-9_]*/g; // A dotted configuration or wire key path, sources.*.authentication.kind. The word pass reads // its segments one by one and keeps only the ones that carry a symbol shape, so this pattern is @@ -228,7 +234,10 @@ export function parseAnchor(body, { siteRoot = DOCS_SITE_ROOT } = {}) { // outside the token: the hyphen of a range cut short in `:5-`, the word run into the // number in `:1foo`, the whole suffix in `:abc`. Each would otherwise be thrown away, // leaving the citation checked as the bare file or as a line the anchor never meant. - const malformedLines = UNREAD_LINE_REFERENCE.exec(body.slice(cursor))?.[0]; + const unread = body.slice(cursor); + const malformedLines = + UNREAD_LINE_REFERENCE.exec(unread)?.[0] ?? + (start === undefined ? undefined : CONTINUED_LINE_REFERENCE.exec(unread)?.[0]); // A brace list stands for one citation per entry, so each file it names is resolved and // counted on its own, and each entry reads against the path the entry before it set. @@ -457,7 +466,11 @@ function filesUnder(directory) { } function isTextFile(path) { - return TEXT_EXTENSIONS.has(path.split('.').at(-1)); + // A name with no extension at all is a script the repository keeps, `registry-release` or + // `justfile`, and reading it is how a symbol an anchor cites from one is found. The + // directories that hold anything else are skipped before the walk reaches them. + const name = path.split('/').at(-1); + return name.includes('.') ? TEXT_EXTENSIONS.has(name.split('.').at(-1)) : true; } function wholeWordPattern(symbol) { diff --git a/docs/site/scripts/check-evidence-anchors.test.mjs b/docs/site/scripts/check-evidence-anchors.test.mjs index 9f8f89276..9c43b47c5 100644 --- a/docs/site/scripts/check-evidence-anchors.test.mjs +++ b/docs/site/scripts/check-evidence-anchors.test.mjs @@ -490,6 +490,55 @@ test('reads one bare child directory against the last, so a chain resolves', (t) assert.equal(result.paths, 3); }); +test('reports a line reference the anchor continued with a dot', (t) => { + const root = repository(t); + const continued = check(root, '{/* Evidence: crates/demo/src/lib.rs:1.5 holds it. */}'); + assert.equal(continued.errors.length, 1); + assert.match(continued.errors[0], /a line reference names a line or a first and last line/); + + // A full stop that ends the sentence is punctuation the prose wrote, not a reference the + // anchor carried on, so it leaves the line it does name alone. + const ended = check(root, '{/* Evidence: crates/demo/src/lib.rs:1. It holds the shape. */}'); + assert.deepEqual(ended.errors, []); + assert.equal(ended.lineRefs, 1); +}); + +test('reads a JavaScript or TypeScript sibling as a citation, not a dotted key path', (t) => { + const root = repository(t); + write(root, 'editors/vscode/src/extension.ts', 'export const activateEditor = 1;\n'); + write(root, 'editors/vscode/src/projectRoot.ts', 'export const rootOf = 2;\n'); + write(root, 'crates/demo/client.js', 'export const requestShape = 3;\n'); + write(root, 'crates/demo/index.js', 'export const entryShape = 4;\n'); + // Read as a dotted key path instead, the name would demand a symbol spelled `ts`. + assert.deepEqual( + parseAnchor('editors/vscode/src/extension.ts and projectRoot.ts hold it.').symbols, + [], + ); + const typescript = check( + root, + '{/* Evidence: editors/vscode/src/extension.ts and projectRoot.ts, rootOf. */}', + ); + assert.deepEqual(typescript.errors, []); + assert.equal(typescript.paths, 2); + + const javascript = check(root, '{/* Evidence: crates/demo/client.js and index.js, entryShape. */}'); + assert.deepEqual(javascript.errors, []); + assert.equal(javascript.paths, 2); +}); + +test('reads an extensionless script under a cited directory', (t) => { + const root = repository(t); + write( + root, + 'release/scripts/registry-release', + '#!/usr/bin/env python3\nartifact_inventory_errors = []\n', + ); + const result = check(root, '{/* Evidence: release/scripts/ reports artifact_inventory_errors. */}'); + assert.deepEqual(result.errors, []); + assert.equal(result.paths, 1); + assert.equal(result.symbols, 1); +}); + test('reads a bare child against the directory the anchor resolved, not the first guess', (t) => { const root = repository(t); write(root, 'schemas/registry/profile/registry.schema.json', '{ "title": "profile_form" }\n');