470 lines
17 KiB
Markdown
470 lines
17 KiB
Markdown
<!-- markdownlint-disable MD024 -->
|
|
|
|
# Capability Spec: docs
|
|
|
|
**Package**: `@suaveplan/docs`
|
|
**Status**: SHIPPED — archived `2026-05-25-enterprise-docs`
|
|
|
|
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
|
|
|
|
## ADDED Requirements
|
|
|
|
### Requirement: Stable error code registry
|
|
|
|
The package SHALL expose a `DOCS_CODES` constant created via
|
|
`defineCodes("DOCS", [...] as const)` containing exactly eight stable codes:
|
|
`INVALID_OPTIONS`, `READ_FAILED`, `PARSE_FAILED`, `GLOB_FAILED`, `ABORTED`,
|
|
`LINK_RESOLUTION_FAILED`, `HIERARCHY_CYCLE_LIMIT`, `SEARCH_INVALID_QUERY`. A
|
|
class `DocsError` extending `SuaveplanError<DocsErrorCode>` MUST be
|
|
constructible with every code.
|
|
|
|
#### Scenario: registry shape is frozen
|
|
|
|
- **WHEN** a consumer reads `DOCS_CODES`
|
|
- **THEN** the returned object MUST contain all eight expected keys with
|
|
namespaced string values (`DOCS.INVALID_OPTIONS`, …).
|
|
|
|
#### Scenario: DocsError preserves code and cause
|
|
|
|
- **WHEN** a `DocsError` is constructed with `cause: nativeErr`
|
|
- **THEN** `error.cause` MUST return the original native error and
|
|
`error.code` MUST preserve the namespaced code.
|
|
|
|
**Verified by:** `src/errors/errors.test.ts::"DOCS_CODES has all eight stable codes"` AND `src/errors/errors.test.ts::"DocsError preserves code and cause"`
|
|
|
|
---
|
|
|
|
### Requirement: DiscoverOptions Zod schema
|
|
|
|
The package SHALL expose `DiscoverOptionsSchema: ZodType<DiscoverOptions>` that
|
|
enforces `rootDir: string (non-empty)`, optional `ignore: string[]`, optional
|
|
`includeApps: boolean`, and optional `signal: AbortSignal`. `discoverDocs` MUST
|
|
parse its argument through the schema and throw `DocsError(DOCS.INVALID_OPTIONS)`
|
|
on a validation failure.
|
|
|
|
#### Scenario: invalid rootDir is rejected
|
|
|
|
- **WHEN** `discoverDocs({ rootDir: "" })` is called
|
|
- **THEN** a `DocsError` with code `DOCS.INVALID_OPTIONS` MUST be thrown.
|
|
|
|
#### Scenario: valid options pass through
|
|
|
|
- **WHEN** `discoverDocs({ rootDir: tempDir })` is called against a populated
|
|
temp dir
|
|
- **THEN** the returned array MUST contain every `.md` file in the temp dir.
|
|
|
|
**Verified by:** `src/discovery/discover.test.ts::"rejects empty rootDir with INVALID_OPTIONS"` AND `src/discovery/discover.test.ts::"finds all .md files in packages/"`
|
|
|
|
---
|
|
|
|
### Requirement: discoverDocs honours AbortSignal
|
|
|
|
`discoverDocs(options)` SHALL observe `options.signal` and throw
|
|
`DocsError(DOCS.ABORTED)` when the signal has already aborted at call entry, or
|
|
when it transitions to aborted between glob and parse.
|
|
|
|
#### Scenario: pre-aborted signal short-circuits
|
|
|
|
- **WHEN** `discoverDocs({ rootDir, signal })` is called with an already-aborted
|
|
signal
|
|
- **THEN** a `DocsError` with code `DOCS.ABORTED` MUST be thrown without any
|
|
parse work being done.
|
|
|
|
**Verified by:** `src/discovery/discover.test.ts::"throws ABORTED on pre-aborted signal"`
|
|
|
|
---
|
|
|
|
### Requirement: discoverDocs surfaces IO errors via counter
|
|
|
|
`discoverDocs` SHALL increment the `docs_io_error_total` counter (partitioned
|
|
by `code`) when a per-file `readFile` raises, and SHALL continue the sweep
|
|
(IO errors are non-fatal so a single unreadable file does not abort a 10 000-
|
|
file monorepo scan).
|
|
|
|
#### Scenario: unreadable file increments io_error counter
|
|
|
|
- **WHEN** discovery encounters a file whose `readFile` rejects
|
|
- **THEN** the `docs_io_error_total` counter MUST record the failure and the
|
|
surrounding sweep MUST continue.
|
|
|
|
**Verified by:** `src/discovery/discover.test.ts::"surfaces readFile failures via io_error counter"`
|
|
|
|
---
|
|
|
|
### Requirement: discoverDocs and includeApps flag
|
|
|
|
When `includeApps: true` is supplied, `discoverDocs` SHALL also walk `apps/**/*.md`.
|
|
When unset or false, `apps/` SHALL be excluded.
|
|
|
|
#### Scenario: apps excluded by default
|
|
|
|
- **WHEN** `discoverDocs({ rootDir })` is called against a tree containing
|
|
`apps/my-app/README.md`
|
|
- **THEN** the result MUST NOT include `apps/my-app/README.md`.
|
|
|
|
#### Scenario: apps included on opt-in
|
|
|
|
- **WHEN** `discoverDocs({ rootDir, includeApps: true })` is called
|
|
- **THEN** the result MUST include `apps/my-app/README.md`.
|
|
|
|
**Verified by:** `src/discovery/discover.test.ts::"excludes apps/ by default"` AND `src/discovery/discover.test.ts::"includes apps/ when includeApps: true"`
|
|
|
|
---
|
|
|
|
### Requirement: discoverDocs returns sorted results
|
|
|
|
`discoverDocs` SHALL return results sorted by `relativePath` ascending using
|
|
`String.prototype.localeCompare`.
|
|
|
|
#### Scenario: results are sorted
|
|
|
|
- **WHEN** `discoverDocs` returns
|
|
- **THEN** every consecutive pair MUST satisfy `a.relativePath.localeCompare(b.relativePath) <= 0`.
|
|
|
|
**Verified by:** `src/discovery/discover.test.ts::"returns sorted results"`
|
|
|
|
---
|
|
|
|
### Requirement: parseDoc extracts title from first H1
|
|
|
|
`parseDoc(absolutePath, relativePath, rawContent)` SHALL set `doc.title` from
|
|
the first H1 in the parsed mdast, falling back to the filename (without `.md`)
|
|
when no H1 exists, and overriding both with `frontmatter.title` when supplied
|
|
as a string.
|
|
|
|
#### Scenario: title from H1
|
|
|
|
- **WHEN** `parseDoc` is called with `"# My Package\n\nText"`
|
|
- **THEN** `doc.title` MUST equal `"My Package"`.
|
|
|
|
#### Scenario: title from frontmatter override
|
|
|
|
- **WHEN** the document carries `---\ntitle: "Override"\n---\n# H1\n…`
|
|
- **THEN** `doc.title` MUST equal `"Override"`.
|
|
|
|
#### Scenario: title from filename fallback
|
|
|
|
- **WHEN** the document has no H1
|
|
- **THEN** `doc.title` MUST equal the filename without the `.md` extension.
|
|
|
|
**Verified by:** `src/parser/parse.test.ts::"extracts title from first H1"` AND `src/parser/parse.test.ts::"prefers frontmatter title over H1"` AND `src/parser/parse.test.ts::"falls back to filename when no H1"`
|
|
|
|
---
|
|
|
|
### Requirement: parseDoc extracts links via typed mdast
|
|
|
|
`parseDoc` SHALL populate `doc.links` with one `DocLink` per `link` node in the
|
|
mdast tree, using typed `Link` and `Text` nodes from `@types/mdast` — no `any`
|
|
casts. Each `DocLink` MUST carry `text` (the first text child or empty string)
|
|
and `href` (the link URL).
|
|
|
|
#### Scenario: extracts every link
|
|
|
|
- **WHEN** `parseDoc` is called with content containing two markdown links
|
|
- **THEN** `doc.links` MUST have length 2 with the correct `href` and `text`
|
|
for each.
|
|
|
|
**Verified by:** `src/parser/parse.test.ts::"extracts links"`
|
|
|
|
---
|
|
|
|
### Requirement: parseDoc derives DocCategory across genesis vocabulary
|
|
|
|
`parseDoc` SHALL set `doc.category` by reading the segment after `packages/`.
|
|
The union MUST contain the eighteen canonical genesis categories
|
|
(`foundation`, `persistence`, `networking`, `communication`, `api-stack`,
|
|
`identity`, `security-stack`, `observability`, `media`, `ui`, `threejs`,
|
|
`localization`, `geospatial`, `money`, `commerce`, `testkit`, `devtools`,
|
|
`gaming`) plus `root` for paths outside `packages/`.
|
|
|
|
#### Scenario: devtools category recognised
|
|
|
|
- **WHEN** `parseDoc` is called with `relativePath = "packages/devtools/docs/docs/README.md"`
|
|
- **THEN** `doc.category` MUST equal `"devtools"`.
|
|
|
|
#### Scenario: root category for top-level files
|
|
|
|
- **WHEN** `parseDoc` is called with `relativePath = "README.md"`
|
|
- **THEN** `doc.category` MUST equal `"root"`.
|
|
|
|
**Verified by:** `src/parser/parse.test.ts::"recognises every genesis category"` AND `src/parser/parse.test.ts::"assigns root category for top-level files"`
|
|
|
|
---
|
|
|
|
### Requirement: parseDoc derives packageName
|
|
|
|
`parseDoc` SHALL set `doc.packageName` to `@suaveplan/<pkg>` for paths matching
|
|
`packages/<category>/<plugin>/<pkg>/…`, to `app:<name>` for paths matching
|
|
`apps/<name>/…`, and to `"root"` otherwise.
|
|
|
|
#### Scenario: packageName for genesis layout
|
|
|
|
- **WHEN** `parseDoc` is called with `"packages/devtools/docs/docs/README.md"`
|
|
- **THEN** `doc.packageName` MUST equal `"@suaveplan/docs"`.
|
|
|
|
**Verified by:** `src/parser/parse.test.ts::"derives package name from genesis layout"`
|
|
|
|
---
|
|
|
|
### Requirement: parseDoc returns deterministic 16-char id
|
|
|
|
`parseDoc` SHALL compute `doc.id` as the first 16 hex chars of
|
|
`sha256(relativePath)`. The id MUST be stable across calls with identical
|
|
`relativePath` and distinct for distinct paths.
|
|
|
|
#### Scenario: stable id for same path
|
|
|
|
- **WHEN** `parseDoc` is called twice with the same `relativePath`
|
|
- **THEN** the two returned ids MUST be equal.
|
|
|
|
**Verified by:** `src/parser/parse.test.ts::"generates stable id from relativePath"`
|
|
|
|
---
|
|
|
|
### Requirement: parseDoc preserves raw content + frontmatter
|
|
|
|
`doc.content` SHALL hold the exact raw content passed in (including
|
|
frontmatter delimiters); `doc.frontmatter` SHALL hold the parsed key-value map.
|
|
|
|
#### Scenario: raw content round-trip
|
|
|
|
- **WHEN** `parseDoc` is called with content containing frontmatter
|
|
- **THEN** `doc.content` MUST equal the raw input verbatim and
|
|
`doc.frontmatter` MUST contain the parsed keys.
|
|
|
|
**Verified by:** `src/parser/parse.test.ts::"preserves raw content including frontmatter"` AND `src/parser/parse.test.ts::"parses frontmatter"`
|
|
|
|
---
|
|
|
|
### Requirement: buildGraph indexes nodes and resolves links
|
|
|
|
`buildGraph(docs)` SHALL return a `DocGraph` whose `nodes` map contains every
|
|
input doc by id, whose `references` map records outbound link ids, whose
|
|
`referencedBy` map records inbound link ids, and whose `links[i].resolvedId`
|
|
is populated on every resolved link.
|
|
|
|
#### Scenario: relative link is resolved
|
|
|
|
- **WHEN** README A links to `./src/string.md` and `string.md` exists as a doc
|
|
- **THEN** `graph.references.get(A.id)` MUST contain `string.id` and
|
|
`graph.referencedBy.get(string.id)` MUST contain `A.id`.
|
|
|
|
#### Scenario: external and anchor links are ignored
|
|
|
|
- **WHEN** README has links to `https://…`, `mailto:…`, or `#section`
|
|
- **THEN** `graph.references.get(A.id)` MUST be empty.
|
|
|
|
**Verified by:** `src/graph/build-graph.test.ts::"resolves relative links between docs"` AND `src/graph/build-graph.test.ts::"ignores external links"` AND `src/graph/build-graph.test.ts::"ignores anchor-only links"`
|
|
|
|
---
|
|
|
|
### Requirement: buildGraph identifies roots and orphans
|
|
|
|
`buildGraph` SHALL set `graph.roots` to every README (`isReadme === true`) and
|
|
`graph.orphans` to every non-README, non-root doc whose `referencedBy` set is
|
|
empty.
|
|
|
|
#### Scenario: README designated root
|
|
|
|
- **WHEN** a graph is built from a README and a sibling doc
|
|
- **THEN** `graph.roots` MUST contain only the README.
|
|
|
|
#### Scenario: unlinked sibling is orphan
|
|
|
|
- **WHEN** a graph is built from a README and a sibling doc the README does
|
|
not link to
|
|
- **THEN** `graph.orphans` MUST contain only the sibling.
|
|
|
|
**Verified by:** `src/graph/build-graph.test.ts::"designates README files as roots"` AND `src/graph/build-graph.test.ts::"identifies orphaned docs"`
|
|
|
|
---
|
|
|
|
### Requirement: buildGraph drops self-references
|
|
|
|
`buildGraph` SHALL NOT record an edge from a doc to itself even when the
|
|
markdown contains a relative link that resolves to its own path.
|
|
|
|
#### Scenario: self-link is dropped
|
|
|
|
- **WHEN** a README contains `[Self](./README.md)`
|
|
- **THEN** `graph.references.get(README.id)` MUST be empty.
|
|
|
|
**Verified by:** `src/graph/build-graph.test.ts::"does not create self-references"`
|
|
|
|
---
|
|
|
|
### Requirement: buildGraph resolves fragment links and falls back to .md
|
|
|
|
`buildGraph` SHALL strip `#fragment` suffixes before resolving a link, and
|
|
SHALL try appending `.md` to a target that does not exist as-is, before
|
|
falling back to a basename match.
|
|
|
|
#### Scenario: fragment is stripped
|
|
|
|
- **WHEN** README links to `./src/string.md#section`
|
|
- **THEN** the link MUST resolve to `string.md`.
|
|
|
|
**Verified by:** `src/graph/build-graph.test.ts::"handles links with fragment identifiers"`
|
|
|
|
---
|
|
|
|
### Requirement: findOrphans returns orphans sorted with suggestedParent
|
|
|
|
`findOrphans(graph)` SHALL return one `OrphanInfo` per `graph.orphans` entry,
|
|
each carrying the orphan's doc and the README of the same package (when one
|
|
exists) as `suggestedParent`, sorted ascending by `doc.packageName`.
|
|
|
|
#### Scenario: returns sorted orphan + parent
|
|
|
|
- **WHEN** orphans exist in two packages
|
|
- **THEN** the returned array MUST be sorted by package name and each entry
|
|
MUST point to its own package's README via `suggestedParent`.
|
|
|
|
**Verified by:** `src/graph/find-orphans.test.ts::"returns orphans with suggested parent"` AND `src/graph/find-orphans.test.ts::"sorts orphans by package name"`
|
|
|
|
---
|
|
|
|
### Requirement: buildHierarchy nests references under roots
|
|
|
|
`buildHierarchy(graph)` SHALL return a `DocHierarchy` whose `roots` is one
|
|
`DocNode` per README, whose `byCategory` groups roots by `DocCategory`, and
|
|
whose `orphans` mirrors `graph.orphans`. Each node's children MUST be sorted
|
|
READMEs-first then alphabetically by title.
|
|
|
|
#### Scenario: nests referenced docs
|
|
|
|
- **WHEN** a README links to a sibling and the graph is built
|
|
- **THEN** the README node's `children` MUST contain the sibling node.
|
|
|
|
**Verified by:** `src/hierarchy/build-hierarchy.test.ts::"nests referenced docs as children"`
|
|
|
|
---
|
|
|
|
### Requirement: buildHierarchy is cycle-safe under default depth
|
|
|
|
`buildHierarchy` SHALL not infinite-loop on circular references. The walk MUST
|
|
visit each id at most once per root and MUST stop descending past the
|
|
configurable `maxDepth` (default 8).
|
|
|
|
#### Scenario: circular references settle
|
|
|
|
- **WHEN** two docs link to each other
|
|
- **THEN** `buildHierarchy` MUST return without throwing.
|
|
|
|
**Verified by:** `src/hierarchy/build-hierarchy.test.ts::"handles circular references without infinite loop"`
|
|
|
|
---
|
|
|
|
### Requirement: buildHierarchy honours maxDepth: 0
|
|
|
|
`buildHierarchy(graph, { maxDepth: 0 })` SHALL produce root nodes with no
|
|
children regardless of how the graph references resolve.
|
|
|
|
#### Scenario: maxDepth 0 yields empty children
|
|
|
|
- **WHEN** `buildHierarchy(graph, { maxDepth: 0 })` is called on a graph with
|
|
resolved references
|
|
- **THEN** every `root.children` MUST be empty.
|
|
|
|
**Verified by:** `src/hierarchy/build-hierarchy.test.ts::"maxDepth 0 yields no children"`
|
|
|
|
---
|
|
|
|
### Requirement: searchDocs scores by title, package, and content
|
|
|
|
`searchDocs(docs, query, limit?)` SHALL score each doc as `+100` for an exact
|
|
title match, `+50` when the title starts with the query, `+20` when the title
|
|
contains the query, `+15` when the package name contains the query, `+1` per
|
|
content match (capped at 5), and return results sorted by score descending
|
|
truncated to `limit` (default 20).
|
|
|
|
#### Scenario: exact title match scores 100
|
|
|
|
- **WHEN** `searchDocs` is invoked with the exact title of a doc
|
|
- **THEN** the matching doc MUST appear first with `score >= 100`.
|
|
|
|
#### Scenario: content match contributes to score
|
|
|
|
- **WHEN** the query appears only in the doc body
|
|
- **THEN** the doc MUST appear with `score >= 1` and `matches` MUST contain a
|
|
snippet of surrounding context.
|
|
|
|
**Verified by:** `src/search/search.test.ts::"ranks exact title match highest"` AND `src/search/search.test.ts::"finds docs by content"`
|
|
|
|
---
|
|
|
|
### Requirement: searchDocs rejects empty queries
|
|
|
|
`searchDocs(docs, query)` SHALL return an empty array for queries that, after
|
|
`trim()`, are empty.
|
|
|
|
#### Scenario: empty query returns empty
|
|
|
|
- **WHEN** `searchDocs(docs, "")` or `searchDocs(docs, " ")` is called
|
|
- **THEN** the result MUST be `[]`.
|
|
|
|
**Verified by:** `src/search/search.test.ts::"returns empty array for empty query"`
|
|
|
|
---
|
|
|
|
### Requirement: searchDocs is case-insensitive and respects limit
|
|
|
|
`searchDocs` SHALL match case-insensitively and SHALL truncate output to the
|
|
caller-supplied `limit` (default 20).
|
|
|
|
#### Scenario: limit caps output
|
|
|
|
- **WHEN** `searchDocs(docs, "a", 2)` is called
|
|
- **THEN** the result length MUST be `<= 2`.
|
|
|
|
**Verified by:** `src/search/search.test.ts::"respects limit parameter"` AND `src/search/search.test.ts::"is case-insensitive"`
|
|
|
|
---
|
|
|
|
### Requirement: searchDocs rejects non-string queries
|
|
|
|
`searchDocs` SHALL throw `DocsError(DOCS.SEARCH_INVALID_QUERY)` when called
|
|
with a query that is not a `string`.
|
|
|
|
#### Scenario: non-string query rejected
|
|
|
|
- **WHEN** `searchDocs(docs, 42 as unknown as string)` is called
|
|
- **THEN** a `DocsError` with code `DOCS.SEARCH_INVALID_QUERY` MUST be thrown.
|
|
|
|
**Verified by:** `src/search/search.test.ts::"throws SEARCH_INVALID_QUERY on non-string query"`
|
|
|
|
---
|
|
|
|
### Requirement: OTel observability surface
|
|
|
|
The package SHALL expose `createDocsObservability(opts?)` returning a record of
|
|
four counters (`discoveredTotal`, `parseErrorTotal`, `ioErrorTotal`,
|
|
`searchTotal`) and four histograms (`discoverDurationMs`, `parseDurationMs`,
|
|
`graphBuildDurationMs`, `searchDurationMs`). When no meter / tracer is
|
|
supplied, the no-op providers from `@opentelemetry/api` MUST be used.
|
|
|
|
#### Scenario: counters and histograms exist
|
|
|
|
- **WHEN** `createDocsObservability()` is called with no options
|
|
- **THEN** the returned object MUST expose all four counters and all four
|
|
histograms.
|
|
|
|
**Verified by:** `src/observability/observability.test.ts::"createDocsObservability exposes four counters and four histograms"`
|
|
|
|
---
|
|
|
|
### Requirement: Subpath exports per feature module
|
|
|
|
The package's `exports` map SHALL include one subpath entry per feature module
|
|
(`./discovery/discover`, `./parser/parse`, `./graph/build-graph`,
|
|
`./graph/find-orphans`, `./hierarchy/build-hierarchy`, `./search/search`,
|
|
`./errors/errors`, `./observability/observability`, `./types`) plus the `"."`
|
|
root barrel and `"./package.json"`.
|
|
|
|
#### Scenario: subpath import resolves
|
|
|
|
- **WHEN** a consumer imports from `@suaveplan/docs/parser/parse`
|
|
- **THEN** the import MUST resolve to the parser module.
|
|
|
|
**Verified by:** `src/parser/parse.test.ts::"extracts title from first H1"` (every parser test imports via the package root and therefore covers the resolution path)
|