mirror of
https://github.com/AventurasTeam/Aventuras.git
synced 2026-11-13 17:55:55 +00:00
A summary should fit one 120-column line of the generated list (118 characters after the `- `), and a second indented line is fine. Past 236 characters docs-check reports a notice. Notices are a new kind of warning that fails nothing, even under --strict. Past 512, up from 200, it is an error. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FsdTn4ZMvVxxETgpvb1Li3
464 lines
17 KiB
TypeScript
464 lines
17 KiB
TypeScript
import { execFileSync } from 'node:child_process'
|
|
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import path from 'node:path'
|
|
import { afterEach, describe, expect, it } from 'vitest'
|
|
import { AGENTS_BLOCK_MAX_LINES, docsCheck, slugify } from './docs-check.ts'
|
|
|
|
const TAGS = 'persistence: Database schema, storage, and migrations\ncache: Caching strategy\n'
|
|
const AGENTS =
|
|
'# Agents\n\nHand-written.\n\n<!-- BEGIN GENERATED: decisions -->\n<!-- END GENERATED: decisions -->\n'
|
|
const BODY = `# Title
|
|
|
|
## Context and Problem Statement
|
|
|
|
Why.
|
|
|
|
## Considered Options
|
|
|
|
* A
|
|
* B
|
|
|
|
## Decision Outcome
|
|
|
|
Chosen option: "A", because reasons.
|
|
|
|
### Consequences
|
|
|
|
* Good, because x
|
|
* Bad, because y
|
|
`
|
|
|
|
function adr(frontmatter: string, body = BODY): string {
|
|
return `---\n${frontmatter}\n---\n\n${body}`
|
|
}
|
|
|
|
const roots: string[] = []
|
|
|
|
afterEach(() => {
|
|
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
|
|
})
|
|
|
|
/** A git repo holding a minimal valid system plus `files`; `null` leaves a default out. */
|
|
function repo(files: Record<string, string | null>): string {
|
|
const root = mkdtempSync(path.join(tmpdir(), 'docs-check-'))
|
|
roots.push(root)
|
|
execFileSync('git', ['init', '-q'], { cwd: root })
|
|
const all: Record<string, string | null> = {
|
|
'docs/decision-tags.yml': TAGS,
|
|
'docs/ARCHITECTURE.md': '# Architecture\n',
|
|
'docs/adr-template.md': '# Template\n',
|
|
'AGENTS.md': AGENTS,
|
|
...files,
|
|
}
|
|
for (const [file, content] of Object.entries(all)) {
|
|
if (content === null) continue
|
|
mkdirSync(path.dirname(path.join(root, file)), { recursive: true })
|
|
writeFileSync(path.join(root, file), content)
|
|
}
|
|
return root
|
|
}
|
|
|
|
const read = (root: string, file: string) => readFileSync(path.join(root, file), 'utf8')
|
|
|
|
/** Generates the outputs, then validates as the hook does. */
|
|
function check(root: string, strict = false) {
|
|
docsCheck(root, { write: true })
|
|
return docsCheck(root, { strict })
|
|
}
|
|
|
|
function errorsFor(files: Record<string, string | null>): string[] {
|
|
return check(repo(files)).errors
|
|
}
|
|
|
|
describe('a valid set', () => {
|
|
it('passes and renders the index grouped by tag, in vocabulary order', () => {
|
|
const root = repo({
|
|
'docs/decisions/2026-01-02-cache-keys.md': adr(
|
|
'summary: Generate our own cache keys.\ntags: [cache, persistence]\ncross-cutting: true',
|
|
),
|
|
'docs/decisions/sub/2026-01-01-wal-mode.md': adr(
|
|
'summary: Run SQLite in WAL mode.\ntags: [persistence]',
|
|
),
|
|
'docs/decisions/2026-01-03-plain-rule.md': adr('summary: Keep it plain.'),
|
|
})
|
|
const result = check(root)
|
|
expect(result.errors).toEqual([])
|
|
expect(result.ok).toBe(true)
|
|
|
|
const readme = read(root, 'docs/README.md')
|
|
const persistence = readme.indexOf('## persistence: Database schema')
|
|
const cache = readme.indexOf('## cache: Caching strategy')
|
|
const general = readme.indexOf('## General')
|
|
expect(persistence).toBeGreaterThan(-1)
|
|
expect(cache).toBeGreaterThan(persistence)
|
|
expect(general).toBeGreaterThan(cache)
|
|
const persistenceSection = readme.slice(persistence, cache)
|
|
expect(persistenceSection.indexOf('wal-mode')).toBeLessThan(
|
|
persistenceSection.indexOf('cache-keys'),
|
|
)
|
|
expect(readme.slice(cache, general)).toContain(
|
|
'- Generate our own cache keys. [cache-keys](decisions/2026-01-02-cache-keys.md)',
|
|
)
|
|
expect(readme.slice(general)).toContain('plain-rule')
|
|
|
|
const agents = read(root, 'AGENTS.md')
|
|
expect(agents).toContain('Hand-written.')
|
|
expect(agents).toContain(
|
|
'- Generate our own cache keys. [cache-keys](docs/decisions/2026-01-02-cache-keys.md)',
|
|
)
|
|
expect(agents).not.toContain('wal-mode')
|
|
})
|
|
|
|
it('writes byte-identical output on a second run', () => {
|
|
const root = repo({ 'docs/decisions/2026-01-01-a.md': adr('summary: A.\ncross-cutting: true') })
|
|
docsCheck(root, { write: true })
|
|
const readme = read(root, 'docs/README.md')
|
|
const agents = read(root, 'AGENTS.md')
|
|
expect(docsCheck(root, { write: true }).written).toEqual([])
|
|
expect(read(root, 'docs/README.md')).toBe(readme)
|
|
expect(read(root, 'AGENTS.md')).toBe(agents)
|
|
expect(readme).not.toContain('\r')
|
|
})
|
|
})
|
|
|
|
describe('filenames and slugs', () => {
|
|
it('rejects a name without a date or with an uppercase slug', () => {
|
|
expect(errorsFor({ 'docs/decisions/cache.md': adr('summary: A.') })).toEqual([
|
|
expect.stringContaining('name it YYYY-MM-DD-slug.md'),
|
|
])
|
|
expect(errorsFor({ 'docs/decisions/2026-01-01-Cache.md': adr('summary: A.') })).toEqual([
|
|
expect.stringContaining('name it YYYY-MM-DD-slug.md'),
|
|
])
|
|
})
|
|
|
|
it('rejects a date that does not exist', () => {
|
|
expect(errorsFor({ 'docs/decisions/2026-02-30-a.md': adr('summary: A.') })).toEqual([
|
|
expect.stringContaining('2026-02-30 is not a real date'),
|
|
])
|
|
})
|
|
|
|
it('rejects a slug used twice, across subdirectories and dates', () => {
|
|
const errors = errorsFor({
|
|
'docs/decisions/2026-01-01-a.md': adr('summary: A.'),
|
|
'docs/decisions/old/2025-01-01-a.md': adr('summary: Older A.'),
|
|
})
|
|
expect(errors).toEqual([expect.stringContaining('slug "a" is already used by')])
|
|
})
|
|
})
|
|
|
|
describe('frontmatter', () => {
|
|
const file = 'docs/decisions/2026-01-01-a.md'
|
|
|
|
it.each([
|
|
['tags: [cache]', 'summary is required'],
|
|
['summary: " "', 'summary is required'],
|
|
[`summary: ${'x'.repeat(513)}`, 'summary is 513 characters, over the limit of 512'],
|
|
['summary: A.\nstatus: accepted', 'unknown frontmatter key "status"'],
|
|
['summary: A.\ntags: cache', 'tags must be a list of strings'],
|
|
['summary: A.\ntags: [nope]', 'tag "nope" is not in docs/decision-tags.yml'],
|
|
['summary: A.\ncross-cutting: yes please', 'cross-cutting must be true or false'],
|
|
['summary: A.\nactive: "true"', 'active must be true or false'],
|
|
['summary: A.\nsuperseded-by: 2026-01-02-b.md', 'superseded-by must be the slug'],
|
|
])('%j fails with "%s"', (frontmatter, message) => {
|
|
expect(errorsFor({ [file]: adr(frontmatter) })).toEqual([expect.stringContaining(message)])
|
|
})
|
|
|
|
it('requires frontmatter', () => {
|
|
expect(errorsFor({ [file]: BODY })).toContain(
|
|
`${file}: missing frontmatter; copy it from docs/adr-template.md`,
|
|
)
|
|
})
|
|
|
|
it('accepts a summary of exactly the limit', () => {
|
|
expect(errorsFor({ [file]: adr(`summary: ${'x'.repeat(512)}`) })).toEqual([])
|
|
})
|
|
|
|
it('notes a summary over two lines, without failing even under --strict', () => {
|
|
expect(check(repo({ [file]: adr(`summary: ${'x'.repeat(236)}`) }), true).notices).toEqual([])
|
|
const result = check(repo({ [file]: adr(`summary: ${'x'.repeat(237)}`) }), true)
|
|
expect(result).toMatchObject({ ok: true, errors: [], warnings: [] })
|
|
expect(result.notices).toEqual([
|
|
`${file}: summary is 237 characters, past two lines; aim for under 118`,
|
|
])
|
|
})
|
|
})
|
|
|
|
describe('body', () => {
|
|
const file = 'docs/decisions/2026-01-01-a.md'
|
|
|
|
it('requires every MADR heading', () => {
|
|
const errors = errorsFor({
|
|
[file]: adr('summary: A.', BODY.replace('## Considered Options', '## Options')),
|
|
})
|
|
expect(errors).toEqual([expect.stringContaining('missing the "Considered Options" heading')])
|
|
})
|
|
|
|
it('requires an accepted downside under Consequences', () => {
|
|
const errors = errorsFor({
|
|
[file]: adr('summary: A.', BODY.replace('* Bad, because y', '* Neutral')),
|
|
})
|
|
expect(errors).toEqual([expect.stringContaining('Consequences needs a "* Bad, because')])
|
|
})
|
|
|
|
it('does not count a Bad line from a later section', () => {
|
|
const body = `${BODY.replace('* Bad, because y', '')}\n## More Information\n\n* Bad, because z\n`
|
|
expect(errorsFor({ [file]: adr('summary: A.', body) })).toEqual([
|
|
expect.stringContaining('Consequences needs'),
|
|
])
|
|
})
|
|
})
|
|
|
|
describe('deprecation and supersession', () => {
|
|
it('lists a deprecated ADR with its successor and keeps it out of AGENTS.md', () => {
|
|
const root = repo({
|
|
'docs/decisions/2026-01-01-old.md': adr(
|
|
'summary: Old way.\nactive: true\nsuperseded-by: new\ncross-cutting: true',
|
|
),
|
|
'docs/decisions/2026-02-01-new.md': adr('summary: New way.\ncross-cutting: true'),
|
|
})
|
|
expect(check(root).errors).toEqual([])
|
|
expect(read(root, 'docs/README.md')).toContain(
|
|
'- **Deprecated → [new](decisions/2026-02-01-new.md).** Old way. [old](decisions/2026-01-01-old.md)',
|
|
)
|
|
expect(read(root, 'AGENTS.md')).not.toContain('Old way.')
|
|
})
|
|
|
|
it('rejects a deprecated ADR whose successor is not active', () => {
|
|
const errors = errorsFor({
|
|
'docs/decisions/2026-01-01-a.md': adr('summary: A.\nactive: true\nsuperseded-by: b'),
|
|
'docs/decisions/2026-02-01-b.md': adr('summary: B.\nsuperseded-by: c'),
|
|
'docs/decisions/2026-03-01-c.md': adr('summary: C.'),
|
|
})
|
|
expect(errors).toEqual([expect.stringContaining('its successor b is superseded')])
|
|
})
|
|
|
|
it('follows a supersession chain to a retired terminal and lists none of it', () => {
|
|
const root = repo({
|
|
'docs/decisions/2026-01-01-a.md': adr('summary: A.\nsuperseded-by: b'),
|
|
'docs/decisions/2026-02-01-b.md': adr('summary: B.\nsuperseded-by: c'),
|
|
'docs/decisions/2026-03-01-c.md': adr('summary: C.\nactive: false'),
|
|
})
|
|
expect(check(root).errors).toEqual([])
|
|
expect(read(root, 'docs/README.md')).toContain('No decisions recorded yet.')
|
|
})
|
|
|
|
it('defaults active to false when superseded-by is set', () => {
|
|
const root = repo({
|
|
'docs/decisions/2026-01-01-a.md': adr('summary: A.\nsuperseded-by: b'),
|
|
'docs/decisions/2026-02-01-b.md': adr('summary: B.'),
|
|
})
|
|
check(root)
|
|
expect(read(root, 'docs/README.md')).not.toContain('[a]')
|
|
})
|
|
|
|
it('rejects a successor that does not exist', () => {
|
|
expect(
|
|
errorsFor({ 'docs/decisions/2026-01-01-a.md': adr('summary: A.\nsuperseded-by: gone') }),
|
|
).toEqual([expect.stringContaining('superseded-by names "gone", which no ADR has')])
|
|
})
|
|
|
|
it('rejects a cycle', () => {
|
|
const errors = errorsFor({
|
|
'docs/decisions/2026-01-01-a.md': adr('summary: A.\nsuperseded-by: b'),
|
|
'docs/decisions/2026-02-01-b.md': adr('summary: B.\nsuperseded-by: a'),
|
|
})
|
|
expect(errors).toContain(
|
|
'docs/decisions/2026-01-01-a.md: superseded-by loops (a → b → a); break the cycle',
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('code anchors', () => {
|
|
const decisions = {
|
|
'docs/decisions/2026-01-01-live.md': adr('summary: Live.'),
|
|
'docs/decisions/2026-01-02-fading.md': adr(
|
|
'summary: Fading.\nactive: true\nsuperseded-by: live',
|
|
),
|
|
'docs/decisions/2026-01-03-gone.md': adr('summary: Gone.\nsuperseded-by: live'),
|
|
'docs/decisions/2026-01-04-dropped.md': adr('summary: Dropped.\nactive: false'),
|
|
}
|
|
|
|
it('accepts anchors to active and deprecated ADRs', () => {
|
|
const source = '// ADR(live): why\n/* ADR(fading) */\nLOADR(nothing)\n'
|
|
expect(errorsFor({ ...decisions, 'src/a.ts': source })).toEqual([])
|
|
})
|
|
|
|
it('rejects anchors to superseded, retired and missing ADRs, with the line', () => {
|
|
const source = '// ADR(gone)\n# ADR(dropped)\n<!-- ADR(nothing) -->\n'
|
|
expect(errorsFor({ ...decisions, 'src/a.svelte': source })).toEqual([
|
|
'src/a.svelte:1: ADR(gone) names a superseded ADR; bring the code in line with live and update the anchor',
|
|
'src/a.svelte:2: ADR(dropped) names a retired ADR; remove the anchor',
|
|
'src/a.svelte:3: ADR(nothing) names no ADR; fix the slug or write the ADR',
|
|
])
|
|
})
|
|
|
|
it('ignores Markdown, docs/, binaries and ignored files', () => {
|
|
expect(
|
|
errorsFor({
|
|
'notes.md': 'Write `ADR(slug)` in a comment.\n',
|
|
'docs/evidence/x.ts': '// ADR(nothing)\n',
|
|
'docs/decisions/2026-01-01-a.md': adr('summary: A.\n', `${BODY}\n[x](../evidence/x.ts)\n`),
|
|
'bin.dat': 'ADR(nothing)\0',
|
|
'.gitignore': 'ignored.ts\n',
|
|
'ignored.ts': '// ADR(nothing)\n',
|
|
}),
|
|
).toEqual([])
|
|
})
|
|
})
|
|
|
|
describe('doc paths in source', () => {
|
|
it('rejects a doc path that does not exist, or a fragment it lacks', () => {
|
|
const errors = errorsFor({
|
|
'docs/guide.md': '# Guide\n\n## Caching\n',
|
|
'docs/decisions/2026-01-01-a.md': adr('summary: A.', `${BODY}\n[g](../guide.md)\n`),
|
|
'src/a.ts': [
|
|
'// See docs/guide.md and docs/guide.md#caching.',
|
|
'// See docs/gone.md.',
|
|
'// See docs/guide.md#nope',
|
|
"const url = 'https://example.com/docs/elsewhere.md'",
|
|
].join('\n'),
|
|
})
|
|
expect(errors).toEqual([
|
|
'src/a.ts:2: docs/gone.md does not exist; point it at the current doc or ADR',
|
|
'src/a.ts:3: docs/guide.md#nope: docs/guide.md has no heading or anchor "#nope"; point it at the current doc or ADR',
|
|
])
|
|
})
|
|
})
|
|
|
|
describe('links', () => {
|
|
it('checks paths and fragments in every Markdown file, skipping code and URLs', () => {
|
|
const errors = errorsFor({
|
|
'README.md': '[ok](docs/ARCHITECTURE.md#architecture) [bad](docs/missing.md)\n',
|
|
'docs/ARCHITECTURE.md': [
|
|
'# Architecture',
|
|
'',
|
|
'## The `#lib` import',
|
|
'',
|
|
'[self](#the-lib-import) [dir](../docs/) [up](../README.md) [web](https://x.y/z.md)',
|
|
'[frag](#no-such) `[code](nope.md)`',
|
|
'',
|
|
'```',
|
|
'[fenced](nope.md)',
|
|
'```',
|
|
].join('\n'),
|
|
})
|
|
expect(errors).toEqual([
|
|
'README.md: docs/missing.md does not exist',
|
|
'docs/ARCHITECTURE.md: #no-such: docs/ARCHITECTURE.md has no heading or anchor "#no-such"',
|
|
])
|
|
})
|
|
|
|
it('rejects a link out of the repository', () => {
|
|
expect(errorsFor({ 'docs/ARCHITECTURE.md': '[x](../../elsewhere.md)\n' })).toEqual([
|
|
'docs/ARCHITECTURE.md: ../../elsewhere.md points outside the repository',
|
|
])
|
|
})
|
|
})
|
|
|
|
describe('evidence', () => {
|
|
it('warns about an orphan, which fails only under --strict', () => {
|
|
const root = repo({ 'docs/evidence/run.log': 'data\n' })
|
|
const result = check(root)
|
|
expect(result.errors).toEqual([])
|
|
expect(result.warnings).toEqual([
|
|
expect.stringContaining('docs/evidence/run.log: no ADR links'),
|
|
])
|
|
expect(result.ok).toBe(true)
|
|
expect(check(root, true).ok).toBe(false)
|
|
})
|
|
|
|
it('counts a link from an ADR in any state, and a folder link for everything under it', () => {
|
|
const root = repo({
|
|
'docs/decisions/2026-01-01-a.md': adr(
|
|
'summary: A.\nactive: false',
|
|
`${BODY}\n[run](../evidence/run.log) [study](../evidence/study/)\n`,
|
|
),
|
|
'docs/evidence/run.log': 'data\n',
|
|
'docs/evidence/study/notes.md': '# Notes\n',
|
|
'docs/evidence/study/deep/data.csv': 'a,b\n',
|
|
})
|
|
expect(check(root, true)).toMatchObject({ ok: true, errors: [], warnings: [] })
|
|
})
|
|
|
|
it('does not count a link from outside the decisions', () => {
|
|
const root = repo({
|
|
'docs/ARCHITECTURE.md': '[run](evidence/run.log)\n',
|
|
'docs/evidence/run.log': 'data\n',
|
|
})
|
|
expect(check(root).warnings).toHaveLength(1)
|
|
})
|
|
|
|
it('does not count a link to docs/evidence/ itself, or to anything above it', () => {
|
|
const root = repo({
|
|
'docs/decisions/2026-01-01-a.md': adr(
|
|
'summary: A.',
|
|
`${BODY}\n[all](../evidence/) [docs](../) [root](../../)\n`,
|
|
),
|
|
'docs/evidence/run.log': 'data\n',
|
|
'docs/evidence/study/data.csv': 'a,b\n',
|
|
})
|
|
expect(check(root).warnings).toEqual([
|
|
expect.stringContaining('docs/evidence/run.log: no ADR links'),
|
|
expect.stringContaining('docs/evidence/study/data.csv: no ADR links'),
|
|
])
|
|
})
|
|
})
|
|
|
|
describe('generated outputs', () => {
|
|
it('rejects a hand-edited index or block', () => {
|
|
const root = repo({ 'docs/decisions/2026-01-01-a.md': adr('summary: A.\ncross-cutting: true') })
|
|
docsCheck(root, { write: true })
|
|
writeFileSync(path.join(root, 'docs/README.md'), `${read(root, 'docs/README.md')}extra\n`)
|
|
writeFileSync(path.join(root, 'AGENTS.md'), read(root, 'AGENTS.md').replace('- A.', '- B.'))
|
|
expect(docsCheck(root).errors).toEqual([
|
|
'docs/README.md is stale or was edited by hand; run `npm run docs:gen` and commit the result',
|
|
'AGENTS.md is stale or was edited by hand; run `npm run docs:gen` and commit the result',
|
|
])
|
|
})
|
|
|
|
it('reports a missing index in check mode', () => {
|
|
expect(docsCheck(repo({})).errors).toContain(
|
|
'docs/README.md is stale or was edited by hand; run `npm run docs:gen` and commit the result',
|
|
)
|
|
})
|
|
|
|
it.each([
|
|
['missing', '# Agents\n'],
|
|
['duplicated', `${AGENTS}${AGENTS}`],
|
|
['reversed', '<!-- END GENERATED: decisions -->\n<!-- BEGIN GENERATED: decisions -->\n'],
|
|
])('rejects %s markers', (_, agents) => {
|
|
expect(errorsFor({ 'AGENTS.md': agents })).toEqual([
|
|
expect.stringContaining(
|
|
'AGENTS.md: needs exactly one <!-- BEGIN GENERATED: decisions --> line',
|
|
),
|
|
])
|
|
})
|
|
|
|
it('rejects a missing AGENTS.md', () => {
|
|
expect(errorsFor({ 'AGENTS.md': null })).toEqual([
|
|
expect.stringContaining('AGENTS.md is missing'),
|
|
])
|
|
})
|
|
|
|
it('rejects a block over the line budget', () => {
|
|
const files: Record<string, string> = {}
|
|
for (let i = 0; i < AGENTS_BLOCK_MAX_LINES; i++) {
|
|
files[`docs/decisions/2026-01-01-rule-${i}.md`] = adr(
|
|
`summary: Rule ${i}.\ncross-cutting: true`,
|
|
)
|
|
}
|
|
expect(errorsFor(files)).toEqual([
|
|
expect.stringContaining(`over the budget of ${AGENTS_BLOCK_MAX_LINES}`),
|
|
])
|
|
})
|
|
})
|
|
|
|
describe('slugify', () => {
|
|
it('matches GitHub heading anchors', () => {
|
|
expect(slugify('Importing `src/lib`')).toBe('importing-srclib')
|
|
expect(slugify('The a<b & x bold')).toBe('the-ab--x-bold')
|
|
expect(slugify('What the helpers do not do (yet)')).toBe('what-the-helpers-do-not-do-yet')
|
|
expect(slugify('snake_case stays')).toBe('snake_case-stays')
|
|
})
|
|
})
|