Files
suaveplan/openspec/specs/docs/spec.md
2026-05-27 23:29:53 +01:00

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 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"


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).

  • 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"


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.

  • 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.
  • 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.

  • 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"


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)