17 KiB
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
DocsErroris constructed withcause: nativeErr - THEN
error.causeMUST return the original native error anderror.codeMUST 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
DocsErrorwith codeDOCS.INVALID_OPTIONSMUST 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
.mdfile 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
DocsErrorwith codeDOCS.ABORTEDMUST 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
readFilerejects - THEN the
docs_io_error_totalcounter 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 containingapps/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
discoverDocsreturns - 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
parseDocis called with"# My Package\n\nText" - THEN
doc.titleMUST equal"My Package".
Scenario: title from frontmatter override
- WHEN the document carries
---\ntitle: "Override"\n---\n# H1\n… - THEN
doc.titleMUST equal"Override".
Scenario: title from filename fallback
- WHEN the document has no H1
- THEN
doc.titleMUST equal the filename without the.mdextension.
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
parseDocis called with content containing two markdown links - THEN
doc.linksMUST have length 2 with the correcthrefandtextfor 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
parseDocis called withrelativePath = "packages/devtools/docs/docs/README.md" - THEN
doc.categoryMUST equal"devtools".
Scenario: root category for top-level files
- WHEN
parseDocis called withrelativePath = "README.md" - THEN
doc.categoryMUST 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
parseDocis called with"packages/devtools/docs/docs/README.md" - THEN
doc.packageNameMUST 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
parseDocis called twice with the samerelativePath - 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
parseDocis called with content containing frontmatter - THEN
doc.contentMUST equal the raw input verbatim anddoc.frontmatterMUST 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.mdandstring.mdexists as a doc - THEN
graph.references.get(A.id)MUST containstring.idandgraph.referencedBy.get(string.id)MUST containA.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.rootsMUST 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.orphansMUST 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
childrenMUST 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
buildHierarchyMUST 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.childrenMUST 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
searchDocsis 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 >= 1andmatchesMUST 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, "")orsearchDocs(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
DocsErrorwith codeDOCS.SEARCH_INVALID_QUERYMUST 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)