diff --git a/apps/cli/src/api/api.ts b/apps/cli/src/api/api.ts index 995ffcc9..97c72e24 100644 --- a/apps/cli/src/api/api.ts +++ b/apps/cli/src/api/api.ts @@ -25,7 +25,6 @@ import { TASK_STATUSES, LEARNING_SOURCE_TYPES, RUN_STATUSES, - DOC_KINDS, DOC_STATUSES, DOC_LINK_TYPES, INVARIANT_ENFORCEMENT_TYPES, @@ -993,7 +992,8 @@ const DocSerializedSchema = Schema.Struct({ id: Schema.Number.pipe(Schema.int()), docId: Schema.String, hash: Schema.String, - kind: Schema.Literal(...DOC_KINDS), + // Any configured spec type; see [spec.types.*] in .tx/config.toml. + kind: Schema.String, name: Schema.String, title: Schema.String, version: Schema.Number.pipe(Schema.int()), @@ -1014,7 +1014,8 @@ const DocListParams = Schema.Struct({ }) const CreateDocBody = Schema.Struct({ - kind: Schema.Literal(...DOC_KINDS), + // Any configured spec type; see [spec.types.*] in .tx/config.toml. + kind: Schema.String, name: SafePathString.pipe(Schema.minLength(1)), title: Schema.String.pipe(Schema.minLength(1)), content: Schema.String.pipe(Schema.minLength(1)), diff --git a/apps/cli/src/api/routes/docs.ts b/apps/cli/src/api/routes/docs.ts index de581e9b..f884ccd5 100644 --- a/apps/cli/src/api/routes/docs.ts +++ b/apps/cli/src/api/routes/docs.ts @@ -23,7 +23,8 @@ import { TxApi, mapCoreError } from "../api.js" // Handler Layer // ----------------------------------------------------------------------------- -const PLACEHOLDER_TEXT_BY_KIND: Record = { +// Keyed by built-in kind; user-defined spec types have no placeholder text. +const PLACEHOLDER_TEXT_BY_KIND: Record = { overview: [ "Describe the system overview.", "What problem this system solves.", diff --git a/apps/cli/src/cli.ts b/apps/cli/src/cli.ts index 95e03126..1ba574f7 100644 --- a/apps/cli/src/cli.ts +++ b/apps/cli/src/cli.ts @@ -32,7 +32,7 @@ import { decision } from "./commands/decision.js" import { triangle } from "./commands/triangle.js" import { groupContext } from "./commands/group-context.js" import { scaffoldClaude, scaffoldCodex, scaffoldWatchdog, parseWatchdogRuntimeMode, interactiveScaffold } from "./commands/scaffold.js" -import { scaffoldConfigToml } from "@jamesaphoenix/tx" +import { scaffoldConfigToml, upgradeConfigToml } from "@jamesaphoenix/tx" import { memory } from "./commands/memory.js" import { pin } from "./commands/pin.js" import { mdExport } from "./commands/md-export.js" @@ -454,7 +454,15 @@ if (command === "init") { writeFileSync(gitignorePath, "tasks.db\ntasks.db-wal\ntasks.db-shm\n") } // Scaffold default config.toml with annotated defaults (no-op if exists) - scaffoldConfigToml(workspace.contentRoot) + const created = scaffoldConfigToml(workspace.contentRoot) + if (!created) { + // Existing project: append config sections added since it was initialized. + // Additive and idempotent; existing keys and comments are untouched. + const upgraded = upgradeConfigToml(workspace.contentRoot) + if (upgraded.length > 0) { + console.log(`Updated .tx/config.toml with ${upgraded.length} new section(s): ${upgraded.join(", ")}`) + } + } } const layer = makeAppLayer(dbPath, { diff --git a/apps/cli/src/commands/doc.ts b/apps/cli/src/commands/doc.ts index 5e37e704..5e6e3f95 100644 --- a/apps/cli/src/commands/doc.ts +++ b/apps/cli/src/commands/doc.ts @@ -13,9 +13,12 @@ import { formatEarsValidationErrors, parseMdDocSync, readTxConfig, + resolveSpecTypes, + specTypeNames, validateEarsRequirements, } from "@jamesaphoenix/tx" -import { DOC_KINDS } from "@jamesaphoenix/tx/types" +import type { SpecTypeDefinition, SpecTypeRegistry } from "@jamesaphoenix/tx" +import { DOC_KINDS, asDocKind } from "@jamesaphoenix/tx/types" import type { DocKind, DocLinkType, TaskDocLinkType } from "@jamesaphoenix/tx/types" import { toJson } from "../output.js" import { type Flags, flag, opt } from "../utils/parse.js" @@ -41,10 +44,10 @@ const toEarsAreaSegment = (name: string): string => { const normalizeDocKind = (kind: DocKind): DocKind => { if (kind === "requirement") { - return "prd" + return asDocKind("prd") } if (kind === "system_design") { - return "design" + return asDocKind("design") } return kind } @@ -109,6 +112,7 @@ export const doc = (pos: string[], flags: Flags) => { } switch (sub) { case "add": return docAdd(rest, flags) + case "template": return docTemplate(rest, flags) case "edit": return docEdit(rest, flags) case "show": return docShow(rest, flags) case "list": return docList(rest, flags) @@ -143,8 +147,11 @@ const docAdd = (pos: string[], flags: Flags) => console.error(" --path: register an existing file instead of scaffolding") throw new CliExitError(1) } - if (!docKindStrings.includes(kind)) { - console.error(`Invalid kind: ${kind}. Must be one of: ${DOC_KINDS.join(", ")}`) + const root = contentRoot(flags) + const registry = resolveSpecTypes(readTxConfig(root)) + if (!registry.types.has(kind) && !docKindStrings.includes(kind)) { + console.error(`Invalid kind: ${kind}. Must be one of: ${specTypeNames(registry).join(", ")}`) + console.error(`Define a new one by adding a [spec.types.${kind}] section to .tx/config.toml`) throw new CliExitError(1) } @@ -156,18 +163,18 @@ const docAdd = (pos: string[], flags: Flags) => let relFilePath: string | undefined if (pathFlag) { // Register an existing file at a custom path - const root = docsRoot(flags) - const absPath = resolve(root, pathFlag) + const docsDir = docsRoot(flags) + const absPath = resolve(docsDir, pathFlag) if (!existsSync(absPath)) { console.error(`File not found: ${absPath}`) - console.error(`Provide a path relative to '${root}'`) + console.error(`Provide a path relative to '${docsDir}'`) throw new CliExitError(1) } content = readFileSync(absPath, "utf8") relFilePath = pathFlag } else { const title = opt(flags, "title", "t") ?? name - content = generateTemplate(kind as DocKind, name, title) + content = generateTemplate(kind as DocKind, name, title, registry, root) } // Parse frontmatter to get title (used for both modes) @@ -195,6 +202,31 @@ const docAdd = (pos: string[], flags: Flags) => } }) +/** + * Print the scaffold template for a spec type without touching the DB. + * Lets agents preview the exact structure `tx spec lint` will expect. + */ +const docTemplate = (pos: string[], flags: Flags) => + Effect.sync(() => { + const kind = pos[0] + if (!kind) { + console.error("Usage: tx doc template [--name ] [--title ]") + console.error("Run 'tx spec types' to list the configured spec types.") + throw new CliExitError(1) + } + + const root = contentRoot(flags) + const registry = resolveSpecTypes(readTxConfig(root)) + if (!registry.types.has(kind) && !docKindStrings.includes(kind)) { + console.error(`Unknown spec type: ${kind}. Configured: ${specTypeNames(registry).join(", ")}`) + throw new CliExitError(1) + } + + const name = opt(flags, "name", "n") ?? `example-${kind}` + const title = opt(flags, "title", "t") ?? name + console.log(generateTemplate(kind as DocKind, name, title, registry, root)) + }) + const docEdit = (pos: string[], flags: Flags) => Effect.gen(function* () { const ref = pos[0] @@ -645,6 +677,8 @@ const docSync = (pos: string[], flags: Flags) => const defaultSummary = (kind: DocKind, title: string): string => { switch (kind) { + default: + return `${title}.` case "overview": return `System overview for ${title}.` case "prd": @@ -675,12 +709,116 @@ const defaultTags = (kind: DocKind, domain: string): string[] => { return Array.from(new Set([baseKind, ...tokens])) } -/** Generate markdown-first template content for a doc kind. */ -function generateTemplate( +/** Embedded yaml block seeded under a section, keyed by heading keyword. */ +const SEEDED_BLOCK_BY_HEADING: ReadonlyArray<readonly [RegExp, string]> = [ + [/requirement/i, "ears_requirements: []"], + [/acceptance/i, "acceptance_criteria: []"], + [/invariant/i, "invariants: []"], + [/verification/i, "verification: []"], + [/interface/i, "interfaces: []"], + [/failure/i, "failure_modes: []"], +] + +/** + * Build a template from the configured sections of a spec type. + * Used for user-defined types and for built-ins whose sections were customized, + * so the scaffolded doc always matches what `tx spec lint` will check. + */ +const generateSectionTemplate = ( + def: SpecTypeDefinition, kind: DocKind, name: string, title: string +): string => { + const today = new Date().toISOString().slice(0, 10) + const summary = defaultSummary(kind, title) + const domain = defaultDomain(name, kind) + const tags = defaultTags(kind, domain) + + const lines: string[] = [ + `---`, + `kind: spec`, + `spec_type: ${kind}`, + `name: ${name}`, + `title: "${title}"`, + `status: draft`, + `version: 1`, + `owners:`, + ` - docs-team`, + `summary: "${summary}"`, + `domain: ${domain}`, + `tags:`, + ...tags.map((tag) => ` - ${tag}`), + `depends_on: []`, + `supersedes: []`, + `implements: null`, + `last_reviewed_at: ${today}`, + `---`, + ``, + ] + + for (const section of def.sections) { + lines.push(`# ${section.heading}`) + lines.push(section.description.length > 0 ? section.description : `TODO: fill in.`) + const seeded = SEEDED_BLOCK_BY_HEADING.find(([pattern]) => pattern.test(section.heading)) + if (seeded) { + lines.push(``, "```yaml", seeded[1], "```") + } + lines.push(``) + } + + return lines.join("\n") +} + +/** Render a user-supplied template file, substituting {name}/{title}/{date}/{spec_type}. */ +const renderCustomTemplateFile = ( + templatePath: string, + projectRoot: string, + kind: DocKind, + name: string, + title: string +): string => { + const absolute = resolve(projectRoot, templatePath) + if (!existsSync(absolute)) { + console.error(`Template file not found: ${absolute}`) + console.error(`Referenced by [spec.types.${kind}].template in .tx/config.toml`) + throw new CliExitError(1) + } + const vars: Record<string, string> = { + name, + title, + spec_type: kind, + date: new Date().toISOString().slice(0, 10), + } + return readFileSync(absolute, "utf8").replace( + /\{(\w+)\}/g, + (match, key: string) => vars[key] ?? match + ) +} + +/** + * Generate markdown-first template content for a doc kind. + * + * Precedence: a configured `template` file, then the built-in rich template + * (only when the type's sections are untouched), then a generic template built + * from the configured sections. + */ +function generateTemplate( + kind: DocKind, + name: string, + title: string, + registry?: SpecTypeRegistry, + projectRoot?: string ): string { + const def = registry?.types.get(kind) + + if (def?.templatePath && projectRoot) { + return renderCustomTemplateFile(def.templatePath, projectRoot, kind, name, title) + } + if (def && (!def.builtin || def.sectionsCustomized) && def.sections.length > 0) { + return generateSectionTemplate(def, kind, name, title) + } + const today = new Date().toISOString().slice(0, 10) const summary = defaultSummary(kind, title) const domain = defaultDomain(name, kind) @@ -926,10 +1064,26 @@ function generateTemplate( case "requirement": console.error("The 'requirement' kind is deprecated. Use 'prd' instead.") console.error("Creating as 'prd' with spec_type: prd...") - return generateTemplate("prd", name, title) + return generateTemplate(asDocKind("prd"), name, title, registry, projectRoot) case "system_design": console.error("The 'system_design' kind is deprecated. Use 'design' instead.") console.error("Creating as 'design' with spec_type: design...") - return generateTemplate("design", name, title) + return generateTemplate(asDocKind("design"), name, title, registry, projectRoot) + default: + // A configured type with no sections and no built-in template. + return generateSectionTemplate( + def ?? { + name: kind, + builtin: false, + sections: [], + severity: "error", + subdir: kind, + templatePath: null, + sectionsCustomized: false, + }, + kind, + name, + title + ) } } diff --git a/apps/cli/src/commands/scaffold.ts b/apps/cli/src/commands/scaffold.ts index 3dfa77a6..ca9a10cc 100644 --- a/apps/cli/src/commands/scaffold.ts +++ b/apps/cli/src/commands/scaffold.ts @@ -383,7 +383,7 @@ function scaffoldGeneratedSkills( const selectedSkillIds = normalizeSelectedSkills(options?.skills) try { - generateSkillBundles({ target, outputDir: tempDir, clean: true }) + generateSkillBundles({ target, outputDir: tempDir, clean: true, contentRoot: projectDir }) const root = installRoot(target) const src = join(tempDir, target, root) const dest = join(projectDir, root) diff --git a/apps/cli/src/commands/spec.ts b/apps/cli/src/commands/spec.ts index 7eecc7aa..85daa4e6 100644 --- a/apps/cli/src/commands/spec.ts +++ b/apps/cli/src/commands/spec.ts @@ -11,6 +11,9 @@ import { parseBatchRunInput, parseMdDocSync, readTxConfig, + resolveSpecTypes, + specTypeNames, + lintSpecSections, validateEarsRequirements, type BatchSource, } from "@jamesaphoenix/tx" @@ -95,6 +98,7 @@ export const spec = (pos: string[], flags: Flags) => { case "status": return specStatus(rest, flags) case "health": return specHealthImpl(rest, flags) case "lint": return specLint(rest, flags) + case "types": return specTypes(rest, flags) default: return Effect.sync(() => { console.error(`Unknown spec subcommand: ${sub ?? "(none)"}`) @@ -104,6 +108,75 @@ export const spec = (pos: string[], flags: Flags) => { } } +/** + * Print the effective spec-type registry: sections, descriptions, and the lint + * prompt each missing section produces. + * + * This is the machine-readable contract agents and generated skills consume — + * it always reflects the current `.tx/config.toml`. + */ +const specTypes = (_pos: string[], flags: Flags) => + Effect.sync(() => { + const root = typeof flags["content-root"] === "string" + ? flags["content-root"] + : process.cwd() + const registry = resolveSpecTypes(readTxConfig(root)) + const types = specTypeNames(registry) + .map((name) => registry.types.get(name)!) + .filter((def) => def.sections.length > 0 || def.builtin) + + if (flag(flags, "json")) { + console.log(toJson({ + types: types.map((def) => ({ + name: def.name, + builtin: def.builtin, + customized: def.sectionsCustomized, + severity: def.severity, + subdir: def.subdir, + template: def.templatePath, + sections: def.sections.map((section) => ({ + slug: section.slug, + heading: section.heading, + description: section.description, + message: section.message, + })), + })), + messages: { + missing_section: registry.messages.missingSection, + unknown_spec_type: registry.messages.unknownSpecType, + }, + warnings: registry.warnings, + })) + return + } + + console.log(`Spec Types (${types.length}) — from .tx/config.toml [spec.types.*]`) + for (const def of types) { + const origin = def.builtin + ? def.sectionsCustomized ? "built-in, customized" : "built-in" + : "custom" + console.log("") + console.log(` ${def.name} (${origin}, severity: ${def.severity})`) + console.log(` dir: ${def.subdir === "" ? "<docs root>" : def.subdir}${def.templatePath ? `, template: ${def.templatePath}` : ""}`) + if (def.sections.length === 0) { + console.log(" (no required sections)") + continue + } + for (const section of def.sections) { + console.log(` # ${section.heading}`) + if (section.description) console.log(` ${section.description}`) + } + } + + if (registry.warnings.length > 0) { + console.log("") + console.log(" Warnings:") + for (const warning of registry.warnings) { + console.log(` ⚠ ${warning}`) + } + } + }) + const specDiscover = (_pos: string[], flags: Flags) => Effect.gen(function* () { const doc = opt(flags, "doc") @@ -495,7 +568,34 @@ const specLint = (_pos: string[], flags: Flags) => addIssue("index", "warn", w) } - // --- 4. EARS lint (validate PRD requirements) --- + // --- 4. Configured spec-type sections (lint-only; never blocks doc sync) --- + const registry = resolveSpecTypes(config) + for (const warning of registry.warnings) { + addIssue("config", "warn", warning) + } + + let sectionFindings = 0 + for (const doc of docs) { + const absPath = resolve(docsPath, doc.filePath) + if (!existsSync(absPath) || !doc.filePath.endsWith(".md")) continue + let content: string + try { + content = readFileSync(absPath, "utf8") + } catch { + continue + } + const parsed = parseMdDocSync(content) + if (Either.isLeft(parsed) || parsed.right.kind !== "spec") continue + for (const finding of lintSpecSections(parsed.right, registry, { + docName: doc.name, + filePath: doc.filePath, + })) { + sectionFindings++ + addIssue("sections", finding.severity, finding.message) + } + } + + // --- 5. EARS lint (validate PRD requirements) --- const prdDocs = docs.filter(d => d.kind === "prd") for (const doc of prdDocs) { const absPath = resolve(docsPath, doc.filePath) @@ -544,6 +644,8 @@ const specLint = (_pos: string[], flags: Flags) => drift_count: driftCount, coverage_warnings: taskWarnings.length, index_warnings: indexWarnings.length, + section_warnings: sectionFindings, + config_warnings: registry.warnings.length, fci: fci.fci, phase: fci.phase, issues, @@ -555,6 +657,7 @@ const specLint = (_pos: string[], flags: Flags) => console.log(` Docs: ${docs.length} total, ${driftCount} drifted`) console.log(` Coverage: ${taskWarnings.length} unlinked task(s)`) console.log(` Index: ${indexWarnings.length} searchable metadata warning(s)`) + console.log(` Sections: ${sectionFindings} missing section finding(s)`) console.log(` EARS: ${prdDocs.length} PRD(s) checked`) if (fci.total > 0) { console.log(` Spec-Test: ${fci.covered}/${fci.total} covered (${fci.fci}%, ${fci.phase})`) @@ -563,8 +666,10 @@ const specLint = (_pos: string[], flags: Flags) => } if (issues.length > 0) { - const sectionOrder = ["drift", "coverage", "index", "ears", "spec"] as const + const sectionOrder = ["config", "drift", "coverage", "index", "sections", "ears", "spec"] as const const sectionLabels: Record<string, string> = { + config: "Spec Type Config", + sections: "Required Sections", drift: "Drift", coverage: "Coverage", index: "Index Searchability", diff --git a/apps/cli/src/help.ts b/apps/cli/src/help.ts index fc737987..72a36ec7 100644 --- a/apps/cli/src/help.ts +++ b/apps/cli/src/help.ts @@ -2176,7 +2176,9 @@ Replace generic placeholders with subsystem-specific language. 'tx spec lint' and 'tx doc validate' will explain exactly how to fix missing search metadata. Arguments: - <kind> Required. Doc kind: overview, prd, or design + <kind> Required. Any spec type from 'tx spec types' (built-ins: + overview, prd, design, runbook, decision; plus any custom type + defined under [spec.types.*] in .tx/config.toml) <name> Required. Doc name (alphanumeric with dashes/dots) Options: @@ -2189,6 +2191,26 @@ Examples: tx doc add design auth-impl -t "Auth Implementation" tx doc add overview system-overview`, + "doc template": `tx doc template - Print the scaffold for a spec type + +Usage: tx doc template <type> [--name <name>] [--title <title>] + +Prints the exact markdown 'tx doc add <type>' would scaffold, without writing +anything to disk or the database. Use it to preview the sections that +'tx spec lint' will check for a given spec type. + +Arguments: + <type> Required. Any type listed by 'tx spec types' + +Options: + --name, -n <name> Doc name used in the template (default: example-<type>) + --title, -t <title> Doc title (defaults to name) + --help Show this help + +Examples: + tx doc template prd + tx doc template rfc --name my-rfc --title "My RFC"`, + "doc edit": `tx doc edit - Open doc YAML in editor Usage: tx doc edit <name> @@ -3449,9 +3471,16 @@ Runs all doc and spec checks in a single pass: - Task-doc coverage: tasks not linked to any doc - Index searchability: validates frontmatter used to build Description and Search Keywords in generated specs/index.md + - Spec type config: advisory warnings about [spec.types.*] in .tx/config.toml + - Required sections: missing sections per the configured spec type - EARS lint: validates PRD requirements syntax - Spec-test status: uncovered or failing invariants +Section checks are lint-only: a missing heading never blocks tx doc add, +tx doc update, tx doc sync, or drift detection. Configure required sections, +their descriptions, per-section lint prompts, and severity (error|warn|off) +under [spec.types.*] in .tx/config.toml. Run 'tx spec types' to see them. + Options: --json Output as JSON --help Show this help @@ -3460,6 +3489,36 @@ Examples: tx spec lint tx spec lint --json`, + "spec types": `tx spec types - Show the configured spec types + +Usage: tx spec types [--json] + +Prints the effective spec-type registry resolved from [spec.types.*] in +.tx/config.toml: required sections, what belongs under each heading, the lint +prompt emitted when one is missing, the target subdirectory, and severity. + +Spec structure is user-configurable. Built-in types (prd, design, overview, +runbook, decision) ship with defaults that are written into .tx/config.toml by +'tx init'; edit them freely, or define a new type by adding a +[spec.types.<name>] section. Custom types are scaffolded and linted like +built-ins. + +Not configurable: the frontmatter contract and the embedded yaml block schemas +(ears_requirements with REQ-* ids, invariants with INV-* ids, verification, +interfaces, failure_modes, acceptance_criteria). Those blocks are located +anywhere in the body, so renaming a heading never breaks 'tx spec discover'. + +The --json output is the machine-readable contract that generated skills and +agents consume. + +Options: + --json Output as JSON + --help Show this help + +Examples: + tx spec types + tx spec types --json`, + triangle: `tx triangle is a deprecated alias for 'tx spec health'. Run 'tx spec health --help' for full usage.`, diff --git a/apps/cli/src/skills/generate.ts b/apps/cli/src/skills/generate.ts index 18b8c7d7..76976590 100644 --- a/apps/cli/src/skills/generate.ts +++ b/apps/cli/src/skills/generate.ts @@ -2,6 +2,14 @@ import { createHash } from "node:crypto" import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs" import { dirname, join, relative, resolve } from "node:path" import { fileURLToPath } from "node:url" +import { + readTxConfig, + resolveSpecTypes, + renderLintMessage, + specTypeNames, + type SpecTypeDefinition, + type SpecTypeRegistry, +} from "@jamesaphoenix/tx" import { HELP_TEXT, commandHelp } from "../help.js" import { CLI_VERSION } from "../version.js" @@ -225,6 +233,11 @@ const BUNDLED_SKILLS = [ title: "Ralph Loop", shortDescription: "Run Ralph against the repo queue or one linked design doc.", }, + { + id: "spec-doc", + title: "Spec Doc", + shortDescription: "Author any configured spec type, including project-defined custom types.", + }, { id: "skills-sync", title: "Skills Sync", @@ -493,8 +506,122 @@ function replaceSlashCommandReference(content: string, command: string): string ) } -function renderBundledSkillContent(target: SkillTarget, skillId: string): string { - const content = readFileSync(join(SHARED_SKILLS_DIR, skillId, "SKILL.md"), "utf-8") +const SPEC_STRUCTURE_START = "<!-- tx:spec-structure:start -->" +const SPEC_STRUCTURE_END = "<!-- tx:spec-structure:end -->" + +/** Which spec type a bundled skill authors. `null` renders every configured type. */ +const SKILL_SPEC_TYPE: Record<string, string | null> = { + prd: "prd", + "design-doc": "design", + "overview-spec": "overview", + "spec-doc": null, +} + +const escapeTableCell = (value: string): string => + value.replace(/\|/g, "\\|").replace(/\r?\n/g, " ").trim() + +/** + * Render this project's configured spec structure as markdown. + * + * Each customer's skills embed THEIR headings, descriptions, and lint prompts, + * so an agent following the skill writes docs that pass `tx spec lint` here, + * not the tx defaults. + */ +const renderSpecStructure = ( + registry: SpecTypeRegistry, + specType: string | null +): string => { + const defs = specType + ? [registry.types.get(specType)].filter((def): def is SpecTypeDefinition => def !== undefined) + : specTypeNames(registry) + .map((name) => registry.types.get(name)!) + .filter((def) => def.sections.length > 0) + + const lines: string[] = [ + "> Generated from this project's `.tx/config.toml` (`[spec.types.*]`).", + "> If the config may have changed since the last `tx skills sync`, run", + "> `tx spec types --json`. That output is always authoritative.", + "", + ] + + if (defs.length === 0) { + lines.push("_No spec types with required sections are configured._") + return lines.join("\n") + } + + for (const def of defs) { + const label = def.builtin + ? def.sectionsCustomized + ? "built-in, customized in this project" + : "built-in" + : "custom to this project" + lines.push( + `### \`${def.name}\` (${label})`, + "", + `Scaffold with \`tx doc add ${def.name} <name> --title "<title>"\`; preview with \`tx doc template ${def.name}\`.`, + `Files land in \`${def.subdir === "" ? "<docs root>" : `${def.subdir}/`}\`. Missing sections are reported by \`tx spec lint\` at severity **${def.severity}**.`, + "" + ) + if (def.sections.length === 0) { + lines.push("_No required sections._", "") + continue + } + lines.push( + "| Section | What belongs under it | Lint prompt if missing |", + "| --- | --- | --- |" + ) + for (const section of def.sections) { + // Show the prompt as the agent will actually see it, not the raw template. + const prompt = renderLintMessage(section.message, { + name: "<doc-name>", + spec_type: def.name, + section: section.heading, + description: section.description, + file: "", + }) + lines.push( + `| \`# ${escapeTableCell(section.heading)}\` | ${escapeTableCell(section.description) || "-"} | ${escapeTableCell(prompt)} |` + ) + } + lines.push("") + } + + lines.push( + "The frontmatter contract and the embedded yaml blocks (`ears_requirements` with", + "REQ-* ids, `invariants` with INV-* ids, `verification`, `interfaces`,", + "`failure_modes`, `acceptance_criteria`) are fixed by tx and are NOT configurable.", + "Those blocks are found anywhere in the body, so a renamed heading never breaks", + "`tx spec discover` or FCI scoring." + ) + + return lines.join("\n") +} + +/** Replace the spec-structure marker block, if the skill declares one. */ +const fillSpecStructureBlock = ( + content: string, + skillId: string, + registry: SpecTypeRegistry +): string => { + const startIdx = content.indexOf(SPEC_STRUCTURE_START) + const endIdx = content.indexOf(SPEC_STRUCTURE_END) + if (startIdx === -1 || endIdx === -1 || endIdx < startIdx) return content + + const rendered = renderSpecStructure(registry, SKILL_SPEC_TYPE[skillId] ?? null) + return ( + content.slice(0, startIdx + SPEC_STRUCTURE_START.length) + + `\n${rendered}\n` + + content.slice(endIdx) + ) +} + +function renderBundledSkillContent( + target: SkillTarget, + skillId: string, + registry: SpecTypeRegistry +): string { + const raw = readFileSync(join(SHARED_SKILLS_DIR, skillId, "SKILL.md"), "utf-8") + const content = fillSpecStructureBlock(raw, skillId, registry) if (target === "claude") { return content } @@ -503,7 +630,7 @@ function renderBundledSkillContent(target: SkillTarget, skillId: string): string .replaceAll("~/.claude/plans/", "~/.codex/plans/") .replaceAll("CLAUDE.md", "project instructions (for example `AGENTS.md`, if present)") - for (const command of ["plan", "overview-spec", "design-doc", "prd", "verify-invariants", "map-invariants"]) { + for (const command of ["plan", "overview-spec", "design-doc", "prd", "spec-doc", "verify-invariants", "map-invariants"]) { rewritten = replaceSlashCommandReference(rewritten, command) } @@ -513,12 +640,13 @@ function renderBundledSkillContent(target: SkillTarget, skillId: string): string function buildBundledSkill( target: SkillTarget, skillDefinition: (typeof BUNDLED_SKILLS)[number], + registry: SpecTypeRegistry, ): GeneratedSkill { const skillDir = `${installRoot(target)}/${skillDefinition.id}` const files = [ { relativePath: `${skillDir}/SKILL.md`, - content: renderBundledSkillContent(target, skillDefinition.id), + content: renderBundledSkillContent(target, skillDefinition.id, registry), }, ] @@ -547,7 +675,7 @@ function installRoot(target: SkillTarget): ".claude/skills" | ".codex/skills" { return target === "claude" ? ".claude/skills" : ".codex/skills" } -function buildTargetSkills(target: SkillTarget): GeneratedSkill[] { +function buildTargetSkills(target: SkillTarget, registry: SpecTypeRegistry): GeneratedSkill[] { const grouped = new Map<string, string[]>() const keys = Object.keys(commandHelp).sort((a, b) => a.localeCompare(b)) @@ -589,7 +717,9 @@ function buildTargetSkills(target: SkillTarget): GeneratedSkill[] { validateGeneratedSkillCoverage(target, generatedSkills) - return generatedSkills.concat(BUNDLED_SKILLS.map((skillDefinition) => buildBundledSkill(target, skillDefinition))) + return generatedSkills.concat( + BUNDLED_SKILLS.map((skillDefinition) => buildBundledSkill(target, skillDefinition, registry)) + ) } function renderManifest(target: SkillTarget, skills: GeneratedSkill[]): string { @@ -622,10 +752,15 @@ export function generateSkillBundles(options?: { target?: SkillTargetSelection outputDir?: string clean?: boolean + /** Project root whose .tx/config.toml defines the spec structure. */ + contentRoot?: string }): SkillGenerationResult { const targetSelection = options?.target ?? "all" const outputDir = resolve(options?.outputDir ?? join(process.cwd(), ".tx", "generated-skills")) const clean = options?.clean ?? false + // Skills embed this project's configured spec sections, so they teach agents + // the structure `tx spec lint` actually enforces here. + const registry = resolveSpecTypes(readTxConfig(options?.contentRoot ?? process.cwd())) const targets = selectedTargets(targetSelection) const summaries: GeneratedTargetSummary[] = [] @@ -638,7 +773,7 @@ export function generateSkillBundles(options?: { mkdirSync(targetOutputDir, { recursive: true }) - const skills = buildTargetSkills(target) + const skills = buildTargetSkills(target, registry) const manifestPath = join(targetOutputDir, installRoot(target), "manifest.json") let fileCount = 0 diff --git a/apps/cli/src/skills/sync.ts b/apps/cli/src/skills/sync.ts index 54788d19..b0cc8384 100644 --- a/apps/cli/src/skills/sync.ts +++ b/apps/cli/src/skills/sync.ts @@ -192,6 +192,8 @@ export function syncSkillBundles(options?: { target: targetSelection, outputDir: tempDir, clean: true, + // Render this project's configured spec sections into the skills. + contentRoot: projectDir, }) const targets = selectedTargets(targetSelection).map((target): SkillSyncTargetSummary => { diff --git a/apps/cli/src/templates/shared-skills/design-doc/SKILL.md b/apps/cli/src/templates/shared-skills/design-doc/SKILL.md index 240217bb..b7d1b7ae 100644 --- a/apps/cli/src/templates/shared-skills/design-doc/SKILL.md +++ b/apps/cli/src/templates/shared-skills/design-doc/SKILL.md @@ -197,6 +197,14 @@ If a PRD exists for this feature: If no PRD exists, the design doc stands alone. Suggest creating one after. +## This Project's `design` Structure + +<!-- tx:spec-structure:start --> +<!-- tx:spec-structure:end --> + +The sections above are what `tx spec lint` checks in THIS project. If they differ +from the generic guidance later in this skill, the table wins. + ## Step 1 - Scaffold via tx ```bash diff --git a/apps/cli/src/templates/shared-skills/overview-spec/SKILL.md b/apps/cli/src/templates/shared-skills/overview-spec/SKILL.md index 7bb57c10..4db6d49f 100644 --- a/apps/cli/src/templates/shared-skills/overview-spec/SKILL.md +++ b/apps/cli/src/templates/shared-skills/overview-spec/SKILL.md @@ -145,6 +145,14 @@ DONE The doc's frontmatter gets `plan: ~/.claude/plans/<name>.md` and the `# Plan` section in the document body contains a reference link to the plan file, not the full content. +## This Project's `overview` Structure + +<!-- tx:spec-structure:start --> +<!-- tx:spec-structure:end --> + +The sections above are what `tx spec lint` checks in THIS project. If they differ +from the generic guidance later in this skill, the table wins. + ## Step 1 - Scaffold via tx ```bash diff --git a/apps/cli/src/templates/shared-skills/prd/SKILL.md b/apps/cli/src/templates/shared-skills/prd/SKILL.md index c3918e34..a9355428 100644 --- a/apps/cli/src/templates/shared-skills/prd/SKILL.md +++ b/apps/cli/src/templates/shared-skills/prd/SKILL.md @@ -147,6 +147,14 @@ DONE The plan is saved as a standalone file at `~/.claude/plans/<name>.md` (relative to repo root). The doc's frontmatter gets `plan: ~/.claude/plans/<name>.md` and the `# Plan` section contains a reference link plus a brief summary, not the full verbatim content. +## This Project's `prd` Structure + +<!-- tx:spec-structure:start --> +<!-- tx:spec-structure:end --> + +The sections above are what `tx spec lint` checks in THIS project. If they differ +from the generic guidance later in this skill, the table wins. + ## Step 1 - Scaffold via tx ```bash diff --git a/apps/cli/src/templates/shared-skills/spec-doc/SKILL.md b/apps/cli/src/templates/shared-skills/spec-doc/SKILL.md new file mode 100644 index 00000000..2e58d1a2 --- /dev/null +++ b/apps/cli/src/templates/shared-skills/spec-doc/SKILL.md @@ -0,0 +1,117 @@ +--- +name: spec-doc +description: Author any spec type configured in this project, including custom types defined in .tx/config.toml (for example `rfc`, `postmortem`, `charter`). Use when the requested doc kind is not one of the built-in prd/design/overview skills, when you need to see which spec types this project defines, or when the project has customized the required sections of a built-in type. Scaffolds via `tx doc add`, fills each configured section, and verifies with `tx spec lint`. +argument-hint: <spec-type> <name> +--- + +# Author a Configured Spec Type + +tx spec structure is **project-configurable**. Required sections, their +descriptions, the lint prompt shown when one is missing, the subdirectory, and +even the set of spec types themselves all come from `[spec.types.*]` in +`.tx/config.toml`. This skill authors any of them. + +Use the dedicated `/prd`, `/design-doc`, and `/overview-spec` skills when writing +those specific types; they carry extra domain guidance. Use this skill for +custom types, or when you need to discover what a project defines. + +## Step 0 - Discover the configured types + +```bash +tx spec types # human-readable +tx spec types --json # machine-readable; authoritative +``` + +`tx spec types --json` is the live contract. It reports, per type: `name`, +`builtin`, `customized`, `severity`, `subdir`, `template`, and for each section +its `heading`, `description`, and the exact `message` lint emits when missing. + +If the type you were asked for is not listed, it is not defined. Either pick a +listed type or tell the user to add a `[spec.types.<name>]` section to +`.tx/config.toml`. Do not invent one. + +## This Project's Spec Types + +<!-- tx:spec-structure:start --> +<!-- tx:spec-structure:end --> + +## Step 1 - Preview the template + +```bash +tx doc template <spec-type> --name <name> --title "<Title>" +``` + +Prints the exact scaffold without writing anything. Use it to confirm the +structure before creating the doc. + +## Step 2 - Scaffold + +```bash +tx doc add <spec-type> <name> --title "<Human-Readable Title>" +``` + +The scaffold already contains every configured section, each with its +description as placeholder text. If the doc already exists, edit it in place; +never `tx doc rm` + `tx doc add`, which overwrites content. + +## Step 3 - Fill every section + +Replace each placeholder with real content, guided by that section's +description. Keep the headings exactly as configured: matching is +case-insensitive and heading-level agnostic (`#` through `######`), but the text +must match. + +### Fixed rules that config cannot change + +These are enforced by tx itself and apply to every spec type: + +- **Frontmatter contract**: `kind: spec`, `spec_type`, `name` (kebab-case), + `title`, `status`, `version`, `owners` (non-empty), `summary`, `domain`, + `tags`, `depends_on`, `supersedes`, `implements`, `last_reviewed_at` + (`YYYY-MM-DD`). `doc_id` is managed by tx; never edit it. +- **Embedded yaml blocks** keep fixed schemas wherever you put them: + - `ears_requirements:` requires `id` matching `REQ-*`, `kind` + (`ubiquitous|event-driven|state-driven|unwanted|optional|complex`), + `statement`, `priority` (`must|should|may`). Clause required per kind: + `event-driven` -> `when`, `state-driven` -> `while`, `unwanted` -> `if`, + `optional` -> `where`, `complex` -> at least one of those. + - `invariants:` requires `id` matching `INV-*`, `statement`, `severity` + (`low|medium|high|critical`), `verified_by` (at least one test path). + - `acceptance_criteria:` requires `id` matching `AC-*`, `statement`. + - `verification:` requires `requirement_id` (a `REQ-*` id), `test_type` + (`unit|integration|e2e|property|manual`), `target`. + - `interfaces:` requires `name`, `type` (`http|queue|event|rpc|cron`), `semantics`. + - `failure_modes:` requires `condition`, `impact`, `handling`. + +Blocks are located by fenced ` ```yaml ` + top-level key **anywhere in the +body**, not by which heading they sit under. That is why renaming or removing a +heading never breaks `tx spec discover` or FCI scoring. But a spec type whose +sections no longer prompt for invariants tends to stop getting them written, so +keep a section for any block the project relies on. + +## Step 4 - Sync and verify + +```bash +tx doc sync <name> +tx spec lint +``` + +`tx spec lint` reports missing sections under **Required Sections** using each +section's configured prompt, at the severity configured for that type +(`error` fails the lint, `warn` reports without failing, `off` is silent). +A missing section never blocks `tx doc add`, `tx doc sync`, or drift detection: +it is lint-only. + +If the doc declares invariants, also run: + +```bash +tx spec discover --doc <name> +``` + +## Notes + +- Spec structure is rendered into this skill at `tx skills sync` time. After + editing `[spec.types.*]` in `.tx/config.toml`, re-run `tx skills sync` to + refresh it, or just call `tx spec types --json` for the current definition. +- A type may set `template = "<path>"` to use a project-owned markdown template; + `{name}`, `{title}`, `{date}`, and `{spec_type}` are substituted. diff --git a/apps/dashboard/server/index.ts b/apps/dashboard/server/index.ts index 6ebd4e64..734b7fb0 100644 --- a/apps/dashboard/server/index.ts +++ b/apps/dashboard/server/index.ts @@ -12,6 +12,7 @@ import { parse as parseYaml } from "yaml" import { Effect } from "effect" import { applyMigrations, + asDocKind, computeDocHash, deriveDocStableId, escapeLikePattern, @@ -1538,7 +1539,7 @@ function renderMarkdownFromYaml(yamlContent: string, filePath: string): string { if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { const parsedDoc = parsed as Record<string, unknown> const kindRaw = typeof parsedDoc.kind === "string" ? parsedDoc.kind.toLowerCase() : "overview" - const kind = isValidDocKind(kindRaw) ? kindRaw : "overview" + const kind = isValidDocKind(kindRaw) ? kindRaw : asDocKind("overview") return renderDocToMarkdown(parsedDoc, kind) } } catch { diff --git a/migrations/048_docs_configurable_kinds.sql b/migrations/048_docs_configurable_kinds.sql new file mode 100644 index 00000000..9d5b2c2c --- /dev/null +++ b/migrations/048_docs_configurable_kinds.sql @@ -0,0 +1,79 @@ +-- Version: 048 +-- Migration: Allow user-defined spec types as doc kinds. +-- +-- Spec types are now configurable via [spec.types.*] in .tx/config.toml, so a +-- project can define its own (e.g. `rfc`) alongside the built-ins. The docs +-- table still carried a CHECK (kind IN (...)) allow-list from migrations 041/046, +-- which rejected any custom kind at insert time. Membership is now validated +-- against the resolved spec-type registry in the doc service instead. +-- +-- IMPORTANT: this rebuild follows the pattern established by migration 046 — +-- foreign keys are disabled for the whole rebuild (so DROP TABLE docs cannot +-- cascade-delete child rows), and the self-reference is written with the FINAL +-- table name (`REFERENCES docs(id)`), not the temporary one. SQLite only +-- rewrites a renamed table's own foreign-key references when foreign_keys = ON +-- at rename time; writing the final name keeps the self-FK resolvable either +-- way. Deviating from this reintroduces the "no such table: docs_new" bug that +-- migration 046 exists to repair. + +PRAGMA foreign_keys = OFF; + +CREATE TABLE docs_open_kind ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + doc_id TEXT, + hash TEXT NOT NULL, + kind TEXT NOT NULL, + name TEXT NOT NULL, + title TEXT NOT NULL, + version INTEGER NOT NULL DEFAULT 1, + status TEXT NOT NULL CHECK (status IN ('changing', 'locked')) DEFAULT 'changing', + file_path TEXT NOT NULL, + parent_doc_id INTEGER REFERENCES docs(id) ON DELETE SET NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + locked_at TEXT, + metadata TEXT DEFAULT '{}' +); + +INSERT INTO docs_open_kind ( + id, + doc_id, + hash, + kind, + name, + title, + version, + status, + file_path, + parent_doc_id, + created_at, + locked_at, + metadata +) +SELECT + id, + doc_id, + hash, + kind, + name, + title, + version, + status, + file_path, + parent_doc_id, + created_at, + locked_at, + metadata +FROM docs; + +DROP TABLE docs; +ALTER TABLE docs_open_kind RENAME TO docs; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_docs_kind_name_version ON docs(kind, name, version); +CREATE INDEX IF NOT EXISTS idx_docs_kind ON docs(kind); +CREATE INDEX IF NOT EXISTS idx_docs_doc_id ON docs(doc_id) WHERE doc_id IS NOT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS idx_docs_doc_id_version ON docs(doc_id, version) WHERE doc_id IS NOT NULL; + +PRAGMA foreign_keys = ON; + +-- Record this migration +INSERT OR IGNORE INTO schema_version (version, applied_at) VALUES (48, datetime('now')); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 41602bbe..5826f069 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -522,6 +522,13 @@ export { isValidInvariantStatus, } from "./mappers/doc.js" +export { + asDocKind, + isBuiltinDocKind, + isBuiltinSpecType, + SPEC_TYPE_NAME_PATTERN, +} from "./types/doc.js" + export { rowToMemoryDocument, rowToMemoryDocumentWithoutEmbedding, @@ -574,6 +581,7 @@ export { writeDashboardCycleStartDay, writeDashboardCarryStatuses, scaffoldConfigToml, + upgradeConfigToml, DASHBOARD_DEFAULT_TASK_ASSIGMENT_KEY, DASHBOARD_DEFAULT_TASK_VIEW_KEY, DASHBOARD_CYCLE_LENGTH_DAYS_KEY, @@ -587,7 +595,31 @@ export { type ReviewRuntimeType, type ReviewTransportType, type SpecDesignDocMissingTaskLinksMode, + listTomlSections, + DEFAULT_MISSING_SECTION_MESSAGE, + DEFAULT_UNKNOWN_SPEC_TYPE_MESSAGE, + SPEC_LINT_MESSAGE_KEYS, + type SpecSectionSeverity, + type SpecSectionConfig, + type SpecTypeConfig, + type TxConfig, } from "./utils/toml-config.js" +export { + resolveSpecTypes, + renderLintMessage, + specTypeSubdir, + specTypeNames, + type SpecTypeRegistry, + type SpecTypeDefinition, + type SpecSectionDefinition, + type SpecLintMessages, +} from "./utils/spec-type-registry.js" +export { + lintSpecSections, + type SectionLintFinding, + type SectionLintRule, + type SectionLintContext, +} from "./utils/spec-section-lint.js" export { normalizeClaudeDebugLogPath } from "./utils/claude-debug-log.js" export { parseBlocks, diff --git a/packages/core/src/internal/doc-service-impl.ts b/packages/core/src/internal/doc-service-impl.ts index af3515d1..2fbd8399 100644 --- a/packages/core/src/internal/doc-service-impl.ts +++ b/packages/core/src/internal/doc-service-impl.ts @@ -30,9 +30,16 @@ import { renderIndexToMarkdown } from "../utils/doc-renderer.js" import { generateDocStableId } from "../id.js" import { parseMdDocSync, MdDocParseError } from "../utils/md-doc-parser.js" import { readTxConfig } from "../utils/toml-config.js" +import { + resolveSpecTypes, + specTypeSubdir, + specTypeNames, + type SpecTypeRegistry, +} from "../utils/spec-type-registry.js" import { resolvePathWithin } from "../utils/file-path.js" import { DOC_KINDS, + asDocKind, } from "../types/index.js" import type { Doc, @@ -69,8 +76,14 @@ const inferLinkType = ( return null } -/** Get the subdirectory for a doc kind. overview lives at root. */ -const kindSubdir = (kind: DocKind): string => { +/** + * Get the subdirectory for a doc kind, honouring `[spec.types.<kind>].subdir`. + * `overview` lives at the docs root by default. + */ +const kindSubdir = (kind: DocKind, registry?: SpecTypeRegistry): string => + registry ? specTypeSubdir(registry, kind) : defaultKindSubdir(kind) + +const defaultKindSubdir = (kind: DocKind): string => { if (kind === "overview") return "" if (kind === "requirement") return "requirements" if (kind === "system_design") return "system-design" @@ -81,9 +94,10 @@ const kindSubdir = (kind: DocKind): string => { const resolveDocPath = ( docsPath: string, kind: DocKind, - name: string + name: string, + registry?: SpecTypeRegistry ): string => { - const sub = kindSubdir(kind) + const sub = kindSubdir(kind, registry) const relativeDocPath = sub ? join(sub, `${name}.md`) : `${name}.md` const resolvedDocPath = resolvePathWithin(docsPath, relativeDocPath, { useRealpath: true, @@ -347,11 +361,25 @@ const upsertDocIdInMarkdown = (content: string, docId: string): string => { return nextContent === content ? content : nextContent } -const parseSpecTypeAsDocKind = (name: string, specType: string): DocKind => { - if (!docKindStrings.includes(specType)) { +/** + * Validate a doc's `spec_type` against the configured registry. + * Built-in kinds always resolve; user-defined types resolve once declared in + * `[spec.types.<name>]`. + */ +const parseSpecTypeAsDocKind = ( + name: string, + specType: string, + registry?: SpecTypeRegistry +): DocKind => { + const known = registry + ? registry.types.has(specType) || docKindStrings.includes(specType) + : docKindStrings.includes(specType) + if (!known) { throw new InvalidDocYamlError({ name, - reason: `Unsupported spec_type '${specType}' for docs service.`, + reason: `Unsupported spec_type '${specType}' for docs service. Configured types: ${ + registry ? specTypeNames(registry).join(", ") : docKindStrings.join(", ") + }. Add a [spec.types.${specType}] section to .tx/config.toml to define it.`, }) } return specType as DocKind @@ -553,6 +581,13 @@ export const makeDocServiceLive = ( return resolve(root, config.docs.path) } + /** + * Effective spec types for this content root. Read per call (not cached at + * layer construction) so config edits take effect without a restart. + */ + const getSpecRegistry = (): SpecTypeRegistry => + resolveSpecTypes(readTxConfig(getContentRoot())) + const resolveRegisteredDocPath = (docsPath: string, doc: Doc): string => { const docPath = resolvePathWithin(docsPath, doc.filePath, { useRealpath: true, @@ -635,7 +670,7 @@ export const makeDocServiceLive = ( } const parsed = parseMarkdownSpecDocContent(doc.name, normalized) - const parsedKind = parseSpecTypeAsDocKind(doc.name, parsed.frontmatter.spec_type) + const parsedKind = parseSpecTypeAsDocKind(doc.name, parsed.frontmatter.spec_type, getSpecRegistry()) if (parsed.frontmatter.name !== doc.name) { throw new InvalidDocYamlError({ @@ -723,7 +758,7 @@ export const makeDocServiceLive = ( const normalizedContent = upsertDocIdInMarkdown(content, doc.docId) const parsed = parseMarkdownSpecDocContent(doc.name, normalizedContent) - const parsedKind = parseSpecTypeAsDocKind(doc.name, parsed.frontmatter.spec_type) + const parsedKind = parseSpecTypeAsDocKind(doc.name, parsed.frontmatter.spec_type, getSpecRegistry()) if (parsed.frontmatter.name !== doc.name) { throw new InvalidDocYamlError({ @@ -1079,9 +1114,12 @@ export const makeDocServiceLive = ( create: (input) => Effect.gen(function* () { const { kind, name, title, content, metadata, relFilePath } = input - if (!docKindStrings.includes(kind)) { + const registry = getSpecRegistry() + if (!registry.types.has(kind) && !docKindStrings.includes(kind)) { return yield* Effect.fail( - new ValidationError({ reason: `Invalid doc kind: ${kind}` }) + new ValidationError({ + reason: `Invalid doc kind: ${kind}. Configured types: ${specTypeNames(registry).join(", ")}. Add a [spec.types.${kind}] section to .tx/config.toml to define a new one.`, + }) ) } if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(name)) { @@ -1106,7 +1144,7 @@ export const makeDocServiceLive = ( : content const parsedDoc = parseMarkdownSpecDocContent(name, contentForParse) const frontmatter = parsedDoc.frontmatter - const parsedKind = parseSpecTypeAsDocKind(name, frontmatter.spec_type) + const parsedKind = parseSpecTypeAsDocKind(name, frontmatter.spec_type, getSpecRegistry()) // Deprecation warnings for legacy PRD frontmatter fields if (parsedKind === "prd") { @@ -1192,7 +1230,7 @@ export const makeDocServiceLive = ( relPath = relFilePath } else { // Scaffolding a new file at the standard path - const filePath = resolveDocPath(docsPath, parsedKind, frontmatter.name) + const filePath = resolveDocPath(docsPath, parsedKind, frontmatter.name, getSpecRegistry()) if (existsSync(filePath)) { return yield* Effect.fail( new ValidationError({ @@ -1203,7 +1241,7 @@ export const makeDocServiceLive = ( ensureDir(filePath) writeFileSync(filePath, contentWithDocId, "utf8") - const sub = kindSubdir(parsedKind) + const sub = kindSubdir(parsedKind, getSpecRegistry()) relPath = sub ? join(sub, `${frontmatter.name}.md`) : `${frontmatter.name}.md` @@ -1353,7 +1391,7 @@ export const makeDocServiceLive = ( const hash = computeDocHash(content) const newVersion = doc.version + 1 - const versionSub = kindSubdir(doc.kind) + const versionSub = kindSubdir(doc.kind, getSpecRegistry()) const relPath = versionSub ? join(versionSub, `${doc.name}.md`) : `${doc.name}.md` @@ -1441,7 +1479,7 @@ export const makeDocServiceLive = ( const hash = computeDocHash(patchContent) const docsPath = getDocsPath() - const filePath = resolveDocPath(docsPath, "design", patchName) + const filePath = resolveDocPath(docsPath, asDocKind("design"), patchName, getSpecRegistry()) ensureDir(filePath) writeFileSync(filePath, patchContent, "utf8") @@ -1449,7 +1487,7 @@ export const makeDocServiceLive = ( const patchDoc = yield* docRepo.insert({ docId: patchDocId, hash, - kind: "design", + kind: asDocKind("design"), name: patchName, title: patchTitle, version: 1, diff --git a/packages/core/src/mappers/doc.ts b/packages/core/src/mappers/doc.ts index f504cc2f..cb30c038 100644 --- a/packages/core/src/mappers/doc.ts +++ b/packages/core/src/mappers/doc.ts @@ -3,6 +3,7 @@ */ import { DOC_KINDS, + SPEC_TYPE_NAME_PATTERN, DOC_STATUSES, DOC_LINK_TYPES, TASK_DOC_LINK_TYPES, @@ -62,8 +63,13 @@ const earsPatternStrings: readonly string[] = EARS_PATTERNS // TYPE GUARDS // ============================================================================= +/** + * Doc kinds are user-extensible via [spec.types.*] in .tx/config.toml, so any + * well-formed identifier is a valid row value. Membership in the configured + * registry is enforced by the doc service, not by row mapping. + */ export const isValidDocKind = (s: string): s is DocKind => - docKindStrings.includes(s) + docKindStrings.includes(s) || SPEC_TYPE_NAME_PATTERN.test(s) export const isValidDocStatus = (s: string): s is DocStatus => docStatusStrings.includes(s) export const isValidDocLinkType = (s: string): s is DocLinkType => diff --git a/packages/core/src/migrations-embedded.ts b/packages/core/src/migrations-embedded.ts index 0df10c4b..0a678eb8 100644 --- a/packages/core/src/migrations-embedded.ts +++ b/packages/core/src/migrations-embedded.ts @@ -242,5 +242,10 @@ export const EMBEDDED_MIGRATIONS: readonly Migration[] = [ "version": 47, "description": "worktree spec projections", "sql": "-- Version: 047\n-- Migration: Scope document-derived spec projections by checkout\n\nPRAGMA foreign_keys = OFF;\n\nCREATE TABLE IF NOT EXISTS spec_projections (\n projection_key TEXT PRIMARY KEY,\n content_root TEXT NOT NULL,\n git_common_dir TEXT,\n branch TEXT,\n head_sha TEXT,\n dirty INTEGER CHECK (dirty IN (0, 1)),\n created_at TEXT NOT NULL DEFAULT (datetime('now')),\n updated_at TEXT NOT NULL DEFAULT (datetime('now'))\n);\n\nINSERT OR IGNORE INTO spec_projections (\n projection_key,\n content_root,\n git_common_dir,\n branch,\n head_sha,\n dirty\n) VALUES ('legacy', '', NULL, NULL, NULL, NULL);\n\nCREATE TABLE IF NOT EXISTS doc_projection_snapshots (\n projection_key TEXT NOT NULL REFERENCES spec_projections(projection_key) ON DELETE CASCADE,\n doc_id INTEGER NOT NULL REFERENCES docs(id) ON DELETE CASCADE,\n content_hash TEXT NOT NULL,\n title TEXT NOT NULL,\n file_path TEXT NOT NULL,\n head_sha TEXT,\n synced_at TEXT NOT NULL DEFAULT (datetime('now')),\n PRIMARY KEY (projection_key, doc_id)\n);\n\nINSERT OR IGNORE INTO doc_projection_snapshots (\n projection_key,\n doc_id,\n content_hash,\n title,\n file_path,\n head_sha,\n synced_at\n)\nSELECT 'legacy', id, hash, title, file_path, NULL, created_at\nFROM docs;\n\nCREATE TABLE invariants_scoped (\n projection_key TEXT NOT NULL DEFAULT 'legacy' REFERENCES spec_projections(projection_key) ON DELETE CASCADE,\n id TEXT NOT NULL,\n rule TEXT NOT NULL,\n enforcement TEXT NOT NULL CHECK (enforcement IN ('integration_test', 'linter', 'llm_as_judge')),\n doc_id INTEGER NOT NULL REFERENCES docs(id) ON DELETE CASCADE,\n subsystem TEXT,\n test_ref TEXT,\n lint_rule TEXT,\n prompt_ref TEXT,\n status TEXT NOT NULL CHECK (status IN ('active', 'deprecated')) DEFAULT 'active',\n created_at TEXT NOT NULL DEFAULT (datetime('now')),\n metadata TEXT DEFAULT '{}',\n source TEXT DEFAULT 'explicit',\n source_ref TEXT,\n pattern TEXT,\n trigger_text TEXT,\n state_text TEXT,\n condition_text TEXT,\n feature TEXT,\n system_name TEXT,\n response TEXT,\n rationale TEXT,\n test_hint TEXT,\n PRIMARY KEY (projection_key, id)\n);\n\nINSERT INTO invariants_scoped (\n projection_key, id, rule, enforcement, doc_id, subsystem, test_ref,\n lint_rule, prompt_ref, status, created_at, metadata, source, source_ref,\n pattern, trigger_text, state_text, condition_text, feature, system_name,\n response, rationale, test_hint\n)\nSELECT\n 'legacy', id, rule, enforcement, doc_id, subsystem, test_ref,\n lint_rule, prompt_ref, status, created_at, metadata, source, source_ref,\n pattern, trigger_text, state_text, condition_text, feature, system_name,\n response, rationale, test_hint\nFROM invariants;\n\nDROP TABLE invariants;\nALTER TABLE invariants_scoped RENAME TO invariants;\n\nCREATE INDEX idx_invariants_id ON invariants(id);\nCREATE INDEX idx_invariants_doc ON invariants(projection_key, doc_id);\nCREATE INDEX idx_invariants_source ON invariants(projection_key, source);\n\nCREATE TABLE invariant_checks_scoped (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n projection_key TEXT NOT NULL DEFAULT 'legacy',\n invariant_id TEXT NOT NULL,\n passed INTEGER NOT NULL CHECK (passed IN (0, 1)),\n details TEXT,\n checked_at TEXT NOT NULL DEFAULT (datetime('now')),\n duration_ms INTEGER,\n FOREIGN KEY (projection_key, invariant_id)\n REFERENCES invariants(projection_key, id) ON DELETE CASCADE\n);\n\nINSERT INTO invariant_checks_scoped (\n id, projection_key, invariant_id, passed, details, checked_at, duration_ms\n)\nSELECT id, 'legacy', invariant_id, passed, details, checked_at, duration_ms\nFROM invariant_checks;\n\nDROP TABLE invariant_checks;\nALTER TABLE invariant_checks_scoped RENAME TO invariant_checks;\n\nCREATE INDEX idx_invariant_checks_invariant_id\n ON invariant_checks(projection_key, invariant_id);\n\nCREATE TABLE spec_tests_scoped (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n projection_key TEXT NOT NULL DEFAULT 'legacy',\n invariant_id TEXT NOT NULL,\n test_id TEXT NOT NULL,\n test_file TEXT NOT NULL,\n test_name TEXT,\n framework TEXT,\n discovery TEXT NOT NULL CHECK (discovery IN ('tag', 'comment', 'manifest', 'manual')),\n created_at TEXT NOT NULL DEFAULT (datetime('now')),\n updated_at TEXT NOT NULL DEFAULT (datetime('now')),\n FOREIGN KEY (projection_key, invariant_id)\n REFERENCES invariants(projection_key, id) ON DELETE CASCADE,\n UNIQUE(projection_key, invariant_id, test_id)\n);\n\nINSERT INTO spec_tests_scoped (\n id, projection_key, invariant_id, test_id, test_file, test_name,\n framework, discovery, created_at, updated_at\n)\nSELECT\n id, 'legacy', invariant_id, test_id, test_file, test_name,\n framework, discovery, created_at, updated_at\nFROM spec_tests;\n\nDROP TABLE spec_tests;\nALTER TABLE spec_tests_scoped RENAME TO spec_tests;\n\nCREATE INDEX idx_spec_tests_invariant\n ON spec_tests(projection_key, invariant_id);\nCREATE INDEX idx_spec_tests_test\n ON spec_tests(projection_key, test_id);\n\nCREATE TABLE spec_signoffs_scoped (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n projection_key TEXT NOT NULL DEFAULT 'legacy' REFERENCES spec_projections(projection_key) ON DELETE CASCADE,\n scope_type TEXT NOT NULL CHECK (scope_type IN ('doc', 'subsystem', 'global')),\n scope_value TEXT,\n signed_off_by TEXT NOT NULL,\n notes TEXT,\n signed_off_at TEXT NOT NULL DEFAULT (datetime('now'))\n);\n\nINSERT INTO spec_signoffs_scoped (\n id, projection_key, scope_type, scope_value, signed_off_by, notes, signed_off_at\n)\nSELECT id, 'legacy', scope_type, scope_value, signed_off_by, notes, signed_off_at\nFROM spec_signoffs;\n\nDROP TABLE spec_signoffs;\nALTER TABLE spec_signoffs_scoped RENAME TO spec_signoffs;\n\nCREATE UNIQUE INDEX idx_spec_signoffs_scope_nonnull\n ON spec_signoffs(projection_key, scope_type, scope_value)\n WHERE scope_value IS NOT NULL;\n\nCREATE UNIQUE INDEX idx_spec_signoffs_scope_null\n ON spec_signoffs(projection_key, scope_type)\n WHERE scope_value IS NULL;\n\nPRAGMA foreign_keys = ON;\n\nINSERT OR IGNORE INTO schema_version (version, applied_at) VALUES (47, datetime('now'));\n" + }, + { + "version": 48, + "description": "docs configurable kinds", + "sql": "-- Version: 048\n-- Migration: Allow user-defined spec types as doc kinds.\n--\n-- Spec types are now configurable via [spec.types.*] in .tx/config.toml, so a\n-- project can define its own (e.g. `rfc`) alongside the built-ins. The docs\n-- table still carried a CHECK (kind IN (...)) allow-list from migrations 041/046,\n-- which rejected any custom kind at insert time. Membership is now validated\n-- against the resolved spec-type registry in the doc service instead.\n--\n-- IMPORTANT: this rebuild follows the pattern established by migration 046 —\n-- foreign keys are disabled for the whole rebuild (so DROP TABLE docs cannot\n-- cascade-delete child rows), and the self-reference is written with the FINAL\n-- table name (`REFERENCES docs(id)`), not the temporary one. SQLite only\n-- rewrites a renamed table's own foreign-key references when foreign_keys = ON\n-- at rename time; writing the final name keeps the self-FK resolvable either\n-- way. Deviating from this reintroduces the \"no such table: docs_new\" bug that\n-- migration 046 exists to repair.\n\nPRAGMA foreign_keys = OFF;\n\nCREATE TABLE docs_open_kind (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n doc_id TEXT,\n hash TEXT NOT NULL,\n kind TEXT NOT NULL,\n name TEXT NOT NULL,\n title TEXT NOT NULL,\n version INTEGER NOT NULL DEFAULT 1,\n status TEXT NOT NULL CHECK (status IN ('changing', 'locked')) DEFAULT 'changing',\n file_path TEXT NOT NULL,\n parent_doc_id INTEGER REFERENCES docs(id) ON DELETE SET NULL,\n created_at TEXT NOT NULL DEFAULT (datetime('now')),\n locked_at TEXT,\n metadata TEXT DEFAULT '{}'\n);\n\nINSERT INTO docs_open_kind (\n id,\n doc_id,\n hash,\n kind,\n name,\n title,\n version,\n status,\n file_path,\n parent_doc_id,\n created_at,\n locked_at,\n metadata\n)\nSELECT\n id,\n doc_id,\n hash,\n kind,\n name,\n title,\n version,\n status,\n file_path,\n parent_doc_id,\n created_at,\n locked_at,\n metadata\nFROM docs;\n\nDROP TABLE docs;\nALTER TABLE docs_open_kind RENAME TO docs;\n\nCREATE UNIQUE INDEX IF NOT EXISTS idx_docs_kind_name_version ON docs(kind, name, version);\nCREATE INDEX IF NOT EXISTS idx_docs_kind ON docs(kind);\nCREATE INDEX IF NOT EXISTS idx_docs_doc_id ON docs(doc_id) WHERE doc_id IS NOT NULL;\nCREATE UNIQUE INDEX IF NOT EXISTS idx_docs_doc_id_version ON docs(doc_id, version) WHERE doc_id IS NOT NULL;\n\nPRAGMA foreign_keys = ON;\n\n-- Record this migration\nINSERT OR IGNORE INTO schema_version (version, applied_at) VALUES (48, datetime('now'));\n" } ] diff --git a/packages/core/src/schemas/sync.ts b/packages/core/src/schemas/sync.ts index 20add63c..4f2efff4 100644 --- a/packages/core/src/schemas/sync.ts +++ b/packages/core/src/schemas/sync.ts @@ -5,7 +5,7 @@ import { Schema } from "effect" import { TASK_STATUSES, TaskAssigneeTypeSchema, ANCHOR_TYPES, EDGE_TYPES, NODE_TYPES, - DOC_KINDS, DOC_STATUSES, DOC_LINK_TYPES, DOC_STABLE_ID_PATTERN, TASK_DOC_LINK_TYPES, + DOC_STATUSES, DOC_LINK_TYPES, DOC_STABLE_ID_PATTERN, TASK_DOC_LINK_TYPES, INVARIANT_ENFORCEMENT_TYPES, INVARIANT_STATUSES, DECISION_STATUSES, DECISION_SOURCES, } from "../types/index.js" @@ -371,7 +371,11 @@ export type EdgeSyncOperation = typeof EdgeSyncOperationSchema.Type // ----- Doc Sync Operations ----- // Doc kind schema -export const SyncDocKindSchema = Schema.Literal(...DOC_KINDS) +/** + * Doc kind on the wire. Accepts user-defined spec types from `.tx/config.toml` + * in addition to `DOC_KINDS`, so synced JSONL round-trips custom types. + */ +export const SyncDocKindSchema = Schema.String // Doc status schema export const SyncDocStatusSchema = Schema.Literal(...DOC_STATUSES) // Doc names must be simple identifiers (no path separators/traversal). diff --git a/packages/core/src/types/doc.ts b/packages/core/src/types/doc.ts index 6d794200..69f2b2e9 100644 --- a/packages/core/src/types/doc.ts +++ b/packages/core/src/types/doc.ts @@ -83,18 +83,46 @@ export const MD_REQUIRED_SECTIONS_BY_SPEC_TYPE = { // SCHEMAS & TYPES — Docs // ============================================================================= -/** Doc kind — overview, requirement, prd, design, or system_design. */ -export const DocKindSchema = Schema.Literal(...DOC_KINDS) +/** + * Doc kind. Built-in kinds are listed in `DOC_KINDS`, but users can define their + * own spec types in `.tx/config.toml`, so this accepts any kebab/snake-case + * identifier. Membership is validated against the resolved spec-type registry + * (see utils/spec-type-registry.ts), not by this schema. + */ +export const SPEC_TYPE_NAME_PATTERN = /^[a-z][a-z0-9_-]*$/ +export const DocKindSchema = Schema.String.pipe( + Schema.pattern(SPEC_TYPE_NAME_PATTERN), + Schema.brand("DocKind") +) export type DocKind = typeof DocKindSchema.Type +/** True when `kind` is one of tx's built-in doc kinds. */ +export const isBuiltinDocKind = (kind: string): boolean => + (DOC_KINDS as readonly string[]).includes(kind) + +/** Brand a known-good kind string. Throws if it is not a valid identifier. */ +export const asDocKind = (kind: string): DocKind => DocKindSchema.make(kind) + /** Markdown-first doc kind — `spec` or `task`. */ export const MdDocKindSchema = Schema.Literal(...MD_DOC_KINDS) export type MdDocKind = typeof MdDocKindSchema.Type -/** Markdown-first spec subtype. */ -export const MdSpecTypeSchema = Schema.Literal(...MD_SPEC_TYPES) +/** + * Markdown-first spec subtype. Built-ins are in `MD_SPEC_TYPES`, but users can + * declare their own in `.tx/config.toml`, so any identifier parses here and + * membership is checked against the resolved registry by the doc service and + * `tx spec lint`. + */ +export const MdSpecTypeSchema = Schema.String.pipe( + Schema.pattern(SPEC_TYPE_NAME_PATTERN), + Schema.brand("MdSpecType") +) export type MdSpecType = typeof MdSpecTypeSchema.Type +/** True when `specType` is one of tx's built-in markdown spec types. */ +export const isBuiltinSpecType = (specType: string): boolean => + (MD_SPEC_TYPES as readonly string[]).includes(specType) + /** Markdown-first spec status. */ export const MdSpecStatusSchema = Schema.Literal(...MD_SPEC_STATUSES) export type MdSpecStatus = typeof MdSpecStatusSchema.Type @@ -754,7 +782,7 @@ export type RecordInvariantCheckInput = * Check if a string is a valid doc kind. */ export const isValidDocKind = (kind: string): kind is DocKind => { - return (DOC_KINDS as readonly string[]).includes(kind) + return (DOC_KINDS as readonly string[]).includes(kind) || SPEC_TYPE_NAME_PATTERN.test(kind) } export class InvalidDocKindError extends Error { @@ -831,16 +859,8 @@ export const assertDocLinkType = (linkType: string): DocLinkType => { export const DocGraphNodeSchema = Schema.Struct({ id: Schema.String, label: Schema.String, - kind: Schema.Literal( - "overview", - "prd", - "design", - "requirement", - "system_design", - "runbook", - "decision", - "task" - ), + // Any configured spec type, plus "task" for task nodes. + kind: Schema.String, status: Schema.optional(Schema.String), }) export type DocGraphNode = typeof DocGraphNodeSchema.Type diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index adbe8e67..2af97947 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -349,6 +349,9 @@ export { MD_REQUIRED_SECTIONS_BY_SPEC_TYPE, EARS_PATTERNS, DocKindSchema, + SPEC_TYPE_NAME_PATTERN, + isBuiltinDocKind, + asDocKind, MdDocKindSchema, MdSpecTypeSchema, MdSpecStatusSchema, diff --git a/packages/core/src/utils/doc-renderer.ts b/packages/core/src/utils/doc-renderer.ts index bc77d866..f0604d1e 100644 --- a/packages/core/src/utils/doc-renderer.ts +++ b/packages/core/src/utils/doc-renderer.ts @@ -103,12 +103,10 @@ export const renderDocToMarkdown = (parsed: ParsedYaml, kind: DocKind): string = case "decision": renderDecision(parsed, lines) break - default: { - // Compile-time exhaustive check — adding a new DocKind without - // a case above will fail here with a type error. - const _exhaustive: never = kind - return _exhaustive - } + default: + // User-defined spec types have no bespoke renderer. Legacy YAML rendering + // only applies to the built-in kinds, so emit the metadata header alone. + break } return lines.join("\n") diff --git a/packages/core/src/utils/md-doc-parser.ts b/packages/core/src/utils/md-doc-parser.ts index fc259f2a..2b9c566b 100644 --- a/packages/core/src/utils/md-doc-parser.ts +++ b/packages/core/src/utils/md-doc-parser.ts @@ -1,7 +1,6 @@ import { Data, Effect, Either, Schema } from "effect" import { parse as parseYaml } from "yaml" import { - MD_REQUIRED_SECTIONS_BY_SPEC_TYPE, MdAcceptanceCriterionSchema, MdEmbeddedBlocksSchema, MdEarsRequirementSchema, @@ -312,29 +311,6 @@ const extractSections = (body: string): ParseEither<readonly MdSection[]> => { return decodeUnknown(Schema.Array(MdSectionSchema), sections, "Section extraction validation failed") } -const normalizeHeading = (heading: string): string => heading.trim().toLowerCase() - -const validateRequiredSections = ( - frontmatter: MdFrontmatter, - sections: readonly MdSection[] -): ParseEither<void> => { - const requiredSections = MD_REQUIRED_SECTIONS_BY_SPEC_TYPE[frontmatter.spec_type] - const present = new Set(sections.map((section) => normalizeHeading(section.heading))) - const missing = requiredSections.filter( - (requiredHeading) => !present.has(normalizeHeading(requiredHeading)) - ) - - if (missing.length > 0) { - return Either.left( - new MdDocParseError({ - reason: `Missing required section(s) for spec_type '${frontmatter.spec_type}': ${missing.join(", ")}`, - }) - ) - } - - return Either.right(undefined) -} - const parseFrontmatterByKind = ( rawFrontmatter: Record<string, unknown> ): ParseEither< @@ -393,14 +369,9 @@ export const parseMdDocSync = (content: string): ParseEither<MdParsedDoc> => { } if (parsedFrontmatterResult.right.kind === "spec") { - const sectionValidation = validateRequiredSections( - parsedFrontmatterResult.right.frontmatter, - sectionsResult.right - ) - if (Either.isLeft(sectionValidation)) { - return Either.left(sectionValidation.left) - } - + // Required sections are NOT validated here. They are configurable per spec + // type and checked by `tx spec lint` (see utils/spec-section-lint.ts), so a + // missing heading never blocks doc add/update/sync or drift detection. return decodeUnknown( MdParsedDocSchema, { diff --git a/packages/core/src/utils/spec-section-lint.ts b/packages/core/src/utils/spec-section-lint.ts new file mode 100644 index 00000000..39f26273 --- /dev/null +++ b/packages/core/src/utils/spec-section-lint.ts @@ -0,0 +1,83 @@ +/** + * Required-section linting for markdown specs. + * + * Sections used to be enforced inside the parser, which made a missing heading a + * hard failure for `tx doc add/update/sync/render` and even for drift detection. + * They are now lint-only: docs always parse, and `tx spec lint` reports missing + * sections at the severity configured for that spec type. + */ +import type { MdParsedDoc } from "../types/doc.js" +import { + renderLintMessage, + type SpecTypeRegistry, +} from "./spec-type-registry.js" + +export type SectionLintRule = "missing_section" | "unknown_spec_type" + +export type SectionLintFinding = { + readonly rule: SectionLintRule + readonly severity: "error" | "warn" + readonly specType: string + /** Heading that is missing. Absent for `unknown_spec_type`. */ + readonly section?: string + readonly message: string +} + +export type SectionLintContext = { + readonly docName: string + readonly filePath?: string +} + +const normalizeHeading = (heading: string): string => heading.trim().toLowerCase() + +/** + * Check a parsed spec doc against its configured sections. + * Returns one finding per missing section so each can carry its own prompt. + */ +export const lintSpecSections = ( + parsed: MdParsedDoc, + registry: SpecTypeRegistry, + ctx: SectionLintContext +): readonly SectionLintFinding[] => { + if (parsed.kind !== "spec") return [] + + const specType = parsed.frontmatter.spec_type + const definition = registry.types.get(specType) + + if (!definition) { + return [ + { + rule: "unknown_spec_type", + severity: "warn", + specType, + message: renderLintMessage(registry.messages.unknownSpecType, { + name: ctx.docName, + spec_type: specType, + file: ctx.filePath ?? "", + description: "", + section: "", + }), + }, + ] + } + + if (definition.severity === "off" || definition.sections.length === 0) return [] + + const present = new Set(parsed.sections.map((section) => normalizeHeading(section.heading))) + + return definition.sections + .filter((section) => !present.has(normalizeHeading(section.heading))) + .map((section) => ({ + rule: "missing_section" as const, + severity: definition.severity === "warn" ? ("warn" as const) : ("error" as const), + specType, + section: section.heading, + message: renderLintMessage(section.message, { + name: ctx.docName, + spec_type: specType, + section: section.heading, + description: section.description, + file: ctx.filePath ?? "", + }), + })) +} diff --git a/packages/core/src/utils/spec-type-registry.ts b/packages/core/src/utils/spec-type-registry.ts new file mode 100644 index 00000000..298eb3e8 --- /dev/null +++ b/packages/core/src/utils/spec-type-registry.ts @@ -0,0 +1,164 @@ +/** + * Resolve the effective spec-type registry from tx config. + * + * Spec structure is user-configurable: required markdown sections, their + * descriptions, per-section lint prompts, subdirectories, and entirely new spec + * types all come from `[spec.types.*]` in `.tx/config.toml`. + * + * What is NOT configurable (because tx functionality depends on it) is the + * frontmatter contract (`MdFrontmatterSchema`) and the embedded yaml block + * schemas (`ears_requirements` with REQ-* ids, `invariants` with INV-* ids, + * `verification`, `interfaces`, `failure_modes`, `acceptance_criteria`). Those + * blocks are located by fence + top-level key anywhere in the body, so renaming + * or removing a heading never breaks `tx spec discover` or FCI scoring. + */ +import { MD_REQUIRED_SECTIONS_BY_SPEC_TYPE } from "../types/doc.js" +import { + DEFAULT_MISSING_SECTION_MESSAGE, + DEFAULT_UNKNOWN_SPEC_TYPE_MESSAGE, + type SpecSectionSeverity, + type TxConfig, +} from "./toml-config.js" + +export type SpecSectionDefinition = { + readonly slug: string + readonly heading: string + /** What belongs under this heading. Empty string when not configured. */ + readonly description: string + /** Resolved missing-section prompt: per-section ?? global ?? built-in. */ + readonly message: string +} + +export type SpecTypeDefinition = { + readonly name: string + readonly builtin: boolean + readonly sections: readonly SpecSectionDefinition[] + readonly severity: SpecSectionSeverity + /** Subdirectory under the docs root. `""` means the docs root itself. */ + readonly subdir: string + readonly templatePath: string | null + /** True when a built-in type's sections differ from tx defaults. */ + readonly sectionsCustomized: boolean +} + +export type SpecLintMessages = { + readonly missingSection: string + readonly unknownSpecType: string +} + +export type SpecTypeRegistry = { + readonly types: ReadonlyMap<string, SpecTypeDefinition> + readonly messages: SpecLintMessages + /** Advisory config warnings surfaced by `tx spec lint` and `tx spec types`. */ + readonly warnings: readonly string[] +} + +/** + * Legacy doc kinds that predate markdown-first specs. They are never authored + * via config but must stay resolvable so path/kind lookups keep working. + */ +const LEGACY_TYPE_SUBDIRS: Record<string, string> = { + requirement: "requirements", + system_design: "system-design", +} + +/** + * Sections whose yaml blocks feed tx's spec machinery. Dropping one does not + * break discovery (blocks are found anywhere in the body) but it does stop + * agents being told to write the block, so it earns an advisory warning. + */ +const BLOCK_BEARING_SECTIONS: Record<string, readonly string[]> = { + design: ["Invariants", "Verification"], + prd: ["Requirements"], +} + +/** Substitute {placeholders}; unknown keys are left untouched. */ +export const renderLintMessage = ( + template: string, + vars: Record<string, string> +): string => template.replace(/\{(\w+)\}/g, (match, key: string) => vars[key] ?? match) + +const normalizeHeadingKey = (heading: string): string => heading.trim().toLowerCase() + +const defaultSubdirFor = (typeName: string): string => + LEGACY_TYPE_SUBDIRS[typeName] ?? (typeName === "overview" ? "" : typeName) + +/** Build the effective registry from config. Pure, so it is safe to call anywhere. */ +export const resolveSpecTypes = (config: TxConfig): SpecTypeRegistry => { + const messages: SpecLintMessages = { + missingSection: config.spec.lintMessages.missing_section ?? DEFAULT_MISSING_SECTION_MESSAGE, + unknownSpecType: config.spec.lintMessages.unknown_spec_type ?? DEFAULT_UNKNOWN_SPEC_TYPE_MESSAGE, + } + + const types = new Map<string, SpecTypeDefinition>() + const warnings: string[] = [] + + for (const [name, typeConfig] of Object.entries(config.spec.types)) { + const builtinSections = MD_REQUIRED_SECTIONS_BY_SPEC_TYPE[ + name as keyof typeof MD_REQUIRED_SECTIONS_BY_SPEC_TYPE + ] as readonly string[] | undefined + const builtin = builtinSections !== undefined + + const sections = typeConfig.sections.map((section) => ({ + slug: section.slug, + heading: section.heading, + description: section.description, + message: section.message ?? messages.missingSection, + })) + + const sectionsCustomized = + builtin && + (sections.length !== builtinSections.length || + sections.some( + (section, index) => + normalizeHeadingKey(section.heading) !== + normalizeHeadingKey(builtinSections[index]!) + )) + + types.set(name, { + name, + builtin, + sections, + severity: typeConfig.severity, + subdir: typeConfig.subdir ?? defaultSubdirFor(name), + templatePath: typeConfig.template, + sectionsCustomized, + }) + + const blockBearing = BLOCK_BEARING_SECTIONS[name] + if (blockBearing) { + const present = new Set(sections.map((section) => normalizeHeadingKey(section.heading))) + for (const required of blockBearing) { + if (!present.has(normalizeHeadingKey(required))) { + warnings.push( + `spec.types.${name}: '${required}' section removed. Embedded yaml blocks are still discovered anywhere in the body, but agents are no longer told to write them, so spec coverage may drop.` + ) + } + } + } + } + + // Legacy kinds are not config-authored but must remain resolvable. + for (const [name, subdir] of Object.entries(LEGACY_TYPE_SUBDIRS)) { + if (types.has(name)) continue + types.set(name, { + name, + builtin: true, + sections: [], + severity: "off", + subdir, + templatePath: null, + sectionsCustomized: false, + }) + } + + return { types, messages, warnings } +} + +/** Subdirectory for a spec type, falling back to conventions for unknown types. */ +export const specTypeSubdir = (registry: SpecTypeRegistry, typeName: string): string => + registry.types.get(typeName)?.subdir ?? defaultSubdirFor(typeName) + +/** Sorted list of configured type names, for error messages and CLI output. */ +export const specTypeNames = (registry: SpecTypeRegistry): string[] => + [...registry.types.keys()].sort() diff --git a/packages/core/src/utils/toml-config.ts b/packages/core/src/utils/toml-config.ts index b2816e8a..aeb53451 100644 --- a/packages/core/src/utils/toml-config.ts +++ b/packages/core/src/utils/toml-config.ts @@ -24,6 +24,28 @@ export type GuardMode = "advisory" | "enforce" export type ReviewRuntimeType = "pi" | "custom" export type ReviewTransportType = "rpc" | "sdk" export type SpecDesignDocMissingTaskLinksMode = "always" | "locked_only" | "never" +export type SpecSectionSeverity = "error" | "warn" | "off" + +/** + * A single required section of a spec type. + * `description` explains what belongs under the heading (bundled into generated + * skills and doc templates); `message` overrides the missing-section lint prompt. + */ +export type SpecSectionConfig = { + slug: string + heading: string + description: string + message: string | null +} + +export type SpecTypeConfig = { + sections: SpecSectionConfig[] + severity: SpecSectionSeverity + /** Subdirectory under the docs root. `null` = derive from the type name. */ + subdir: string | null + /** Project-relative path to a custom markdown template. */ + template: string | null +} export type ReviewDesignDocsConfig = { enabled: boolean @@ -40,6 +62,10 @@ export type TxConfig = { spec: { testPatterns: string[] designDocMissingTaskLinks: SpecDesignDocMissingTaskLinksMode + /** Effective spec types: built-in defaults merged with user overrides. */ + types: Record<string, SpecTypeConfig> + /** Global lint message templates keyed by rule id. */ + lintMessages: Record<string, string> } memory: { defaultDir: string } cycles: { scanPrompt: string | null; agents: number; model: string } @@ -86,9 +112,89 @@ const isSpecDesignDocMissingTaskLinksMode = ( ): v is SpecDesignDocMissingTaskLinksMode => v === "always" || v === "locked_only" || v === "never" +/** Built-in missing-section lint prompt, used when no override is configured. */ +export const DEFAULT_MISSING_SECTION_MESSAGE = + "{name}: missing required section '{section}' for spec_type '{spec_type}'. {description}" + +/** Built-in prompt for a doc whose spec_type is not defined in config. */ +export const DEFAULT_UNKNOWN_SPEC_TYPE_MESSAGE = + "{name}: spec_type '{spec_type}' is not defined in .tx/config.toml. Add a [spec.types.{spec_type}] section or fix the frontmatter." + +export const SPEC_LINT_MESSAGE_KEYS = ["missing_section", "unknown_spec_type"] as const + +const DEFAULT_LINT_MESSAGES: Record<string, string> = { + missing_section: DEFAULT_MISSING_SECTION_MESSAGE, + unknown_spec_type: DEFAULT_UNKNOWN_SPEC_TYPE_MESSAGE, +} + +/** + * Built-in section definitions per spec type. + * Tuples are [slug, heading, description, message | null]. + * These are the sections `tx spec lint` checks and the descriptions bundled + * into generated skills and `tx doc template` output. + */ +const DEFAULT_SECTION_SPECS: Record<string, ReadonlyArray<readonly [string, string, string, string | null]>> = { + prd: [ + ["summary", "Summary", "One paragraph stating what this feature is and why it matters.", null], + ["problem", "Problem", "The user or system problem being solved, with evidence or a motivating scenario.", "{name}: PRD is missing '# Problem'. State the problem before listing requirements. {description}"], + ["scope", "Scope", "Explicit Included and Excluded lists that bound this work.", null], + ["requirements", "Requirements", "EARS requirements in an embedded yaml `ears_requirements:` block with REQ-* ids.", "{name}: PRD is missing '# Requirements'. Add the section with an `ears_requirements:` yaml block. {description}"], + ["acceptance-criteria", "Acceptance Criteria", "Testable criteria in an embedded yaml `acceptance_criteria:` block with AC-* ids.", null], + ], + design: [ + ["summary", "Summary", "One paragraph stating the technical approach.", null], + ["architecture", "Architecture", "Components, their responsibilities, and how they fit together.", null], + ["interfaces", "Interfaces", "Public surfaces in an embedded yaml `interfaces:` block (name, type, semantics, contract).", null], + ["data-model", "Data Model", "Tables, schemas, and types this design introduces or changes.", null], + ["invariants", "Invariants", "Invariants in an embedded yaml `invariants:` block with INV-* ids and verified_by test paths.", "{name}: design doc is missing '# Invariants'. tx derives spec coverage from this section's yaml block. {description}"], + ["failure-modes", "Failure Modes", "Failure modes in an embedded yaml `failure_modes:` block (condition, impact, handling).", null], + ["verification", "Verification", "Requirement-to-test mapping in an embedded yaml `verification:` block.", null], + ], + overview: [ + ["summary", "Summary", "One paragraph describing the system this overview maps.", null], + ["architecture", "Architecture", "The high-level architectural shape and its boundaries.", null], + ["components", "Components", "Each major component and the responsibility it owns.", null], + ["data-flows", "Data Flows", "How data moves between components, including entry and exit points.", null], + ], + runbook: [ + ["summary", "Summary", "One paragraph describing the operational scenario this runbook covers.", null], + ["symptoms", "Symptoms", "Observable signals that indicate this runbook applies.", null], + ["diagnosis", "Diagnosis", "Steps and queries that confirm the root cause.", null], + ["mitigation", "Mitigation", "Concrete actions that restore service, in order.", null], + ["escalation", "Escalation", "Who to page, when to escalate, and what context to hand over.", null], + ], + decision: [ + ["summary", "Summary", "One paragraph stating the decision made.", null], + ["context", "Context", "The forces, constraints, and background driving this decision.", null], + ["alternatives", "Alternatives", "Options considered and why each was or was not chosen.", null], + ["decision", "Decision", "The option chosen, stated unambiguously.", null], + ["consequences", "Consequences", "What becomes easier or harder as a result, including follow-on work.", null], + ], +} + +const buildDefaultSpecTypes = (): Record<string, SpecTypeConfig> => { + const out: Record<string, SpecTypeConfig> = {} + for (const [typeName, sectionSpecs] of Object.entries(DEFAULT_SECTION_SPECS)) { + out[typeName] = { + sections: sectionSpecs.map(([slug, heading, description, message]) => ({ + slug, + heading, + description, + message, + })), + severity: "error", + subdir: typeName === "overview" ? "" : null, + template: null, + } + } + return out +} + const DEFAULT_CONFIG: TxConfig = { docs: { path: "specs" }, spec: { + types: buildDefaultSpecTypes(), + lintMessages: { ...DEFAULT_LINT_MESSAGES }, testPatterns: [ "test/**/*.test.{ts,js,tsx,jsx}", "tests/**/*.py", @@ -191,6 +297,210 @@ const parseBooleanOrDefault = (value: string | null, fallback: boolean): boolean return fallback } +const SPEC_TYPE_NAME_PATTERN = /^[a-z][a-z0-9_-]*$/ + +const isSpecSectionSeverity = (v: string | null): v is SpecSectionSeverity => + v === "error" || v === "warn" || v === "off" + +/** "acceptance-criteria" -> "Acceptance Criteria" */ +const titleCaseSlug = (slug: string): string => + slug + .split(/[-_]/) + .filter((part) => part.length > 0) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" ") + +/** + * List TOML section header names equal to `prefix` or starting with `prefix + "."`. + * Returns unique names in first-occurrence file order. + * + * The hand-rolled parser can only read sections whose names are known up front; + * user-defined spec types are not, so this enumerates them. + */ +export const listTomlSections = (toml: string, prefix: string): string[] => { + const seen = new Set<string>() + const out: string[] = [] + for (const line of toml.split("\n")) { + const match = line.trim().match(/^\[([^\]]+)\]\s*(?:#.*)?$/) + if (!match) continue + const name = match[1]!.trim() + if (name !== prefix && !name.startsWith(`${prefix}.`)) continue + if (seen.has(name)) continue + seen.add(name) + out.push(name) + } + return out +} + +type TomlTable = { + readonly scalars: Map<string, string> + readonly arrays: Map<string, string[]> +} + +const parseTomlScalar = (raw: string): string | null => { + const trimmed = raw.trim() + const quoted = trimmed.match(/^["'](.*)["']\s*(?:#.*)?$/) + if (quoted) return quoted[1]! + const unquoted = trimmed.match(/^([^#\s]+)/) + return unquoted ? unquoted[1]! : null +} + +const parseTomlInlineArray = (collected: string): string[] => { + const out: string[] = [] + const quoted = /["']([^"']*)["']/g + let match: RegExpExecArray | null + while ((match = quoted.exec(collected)) !== null) { + const value = match[1]!.trim() + if (value.length > 0) out.push(value) + } + return out +} + +/** + * Collect every TOML table whose name matches `prefix`, keyed by table name. + * + * Unlike `extractTomlValue`, this walks the whole file and merges repeated + * tables (later keys win). Spec-type config is commonly appended to a file that + * already scaffolds the same table, and a silently ignored second `[spec.types.design]` + * would be a confusing no-op. + */ +const collectTomlTables = (toml: string, prefix: string): Map<string, TomlTable> => { + const tables = new Map<string, TomlTable>() + const lines = toml.split("\n") + let current: TomlTable | null = null + + for (let i = 0; i < lines.length; i++) { + const trimmed = lines[i]!.trim() + if (trimmed.length === 0 || trimmed.startsWith("#")) continue + + const header = trimmed.match(/^\[([^\]]+)\]\s*(?:#.*)?$/) + if (header) { + const name = header[1]!.trim() + if (name !== prefix && !name.startsWith(`${prefix}.`)) { + current = null + continue + } + let table = tables.get(name) + if (!table) { + table = { scalars: new Map(), arrays: new Map() } + tables.set(name, table) + } + current = table + continue + } + + if (!current) continue + + const assignment = trimmed.match(/^([A-Za-z0-9_-]+)\s*=\s*(.*)$/) + if (!assignment) continue + const key = assignment[1]! + const value = assignment[2]! + + if (value.trimStart().startsWith("[")) { + let collected = value + while (!collected.includes("]") && i + 1 < lines.length) { + i += 1 + collected += lines[i]!.trim() + } + current.arrays.set(key, parseTomlInlineArray(collected)) + continue + } + + const scalar = parseTomlScalar(value) + if (scalar !== null) current.scalars.set(key, scalar) + } + + return tables +} + +/** + * Build the section list for one spec type from its + * `[spec.types.<type>.section.<slug>]` tables, falling back to the + * `sections = [...]` string-array shorthand. + */ +const parseSpecSections = ( + tables: Map<string, TomlTable>, + typeName: string +): SpecSectionConfig[] | null => { + const prefix = `${SPEC_SECTION}.types.${typeName}.section.` + + const sections: SpecSectionConfig[] = [] + for (const [tableName, table] of tables) { + if (!tableName.startsWith(prefix)) continue + const slug = tableName.slice(prefix.length) + if (slug.length === 0 || slug.includes(".") || !SPEC_TYPE_NAME_PATTERN.test(slug)) continue + + const heading = table.scalars.get("heading") + const description = table.scalars.get("description") + const message = table.scalars.get("message") + sections.push({ + slug, + heading: heading && heading.trim().length > 0 ? heading.trim() : titleCaseSlug(slug), + description: description ?? "", + message: message && message.trim().length > 0 ? message : null, + }) + } + if (sections.length > 0) return sections + + const shorthand = tables.get(`${SPEC_SECTION}.types.${typeName}`)?.arrays.get("sections") + if (!shorthand || shorthand.length === 0) return null + return shorthand.map((heading) => ({ + slug: heading.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""), + heading: heading.trim(), + description: "", + message: null, + })) +} + +/** + * Merge user-declared `[spec.types.*]` tables over the built-in defaults. + * Built-in types keep their default sections unless the file declares its own. + */ +const parseSpecTypes = (raw: string): Record<string, SpecTypeConfig> => { + const merged: Record<string, SpecTypeConfig> = {} + for (const [name, def] of Object.entries(DEFAULT_CONFIG.spec.types)) { + merged[name] = { ...def, sections: def.sections.map((section) => ({ ...section })) } + } + + const typesPrefix = `${SPEC_SECTION}.types` + const tables = collectTomlTables(raw, typesPrefix) + + for (const [tableName, table] of tables) { + const suffix = tableName.slice(typesPrefix.length + 1) + // Skip the bare prefix and the nested .section.* tables. + if (tableName === typesPrefix || suffix.length === 0 || suffix.includes(".")) continue + const typeName = suffix + if (!SPEC_TYPE_NAME_PATTERN.test(typeName)) continue + + const existing = merged[typeName] + const sections = parseSpecSections(tables, typeName) + const severity = table.scalars.get("severity") ?? null + const subdir = table.scalars.get("subdir") ?? null + const template = table.scalars.get("template") ?? null + + merged[typeName] = { + sections: sections ?? existing?.sections ?? [], + severity: isSpecSectionSeverity(severity) ? severity : existing?.severity ?? "error", + subdir: subdir ?? existing?.subdir ?? null, + template: template ?? existing?.template ?? null, + } + } + return merged +} + +const parseSpecLintMessages = (raw: string): Record<string, string> => { + const messages: Record<string, string> = { ...DEFAULT_LINT_MESSAGES } + const table = collectTomlTables(raw, `${SPEC_SECTION}.lint.messages`).get( + `${SPEC_SECTION}.lint.messages` + ) + if (!table) return messages + for (const key of SPEC_LINT_MESSAGE_KEYS) { + const value = table.scalars.get(key) + if (value && value.trim().length > 0) messages[key] = value + } + return messages +} + /** * Read .tx/config.toml and return parsed config. * Falls back to defaults if file doesn't exist or is invalid. @@ -276,6 +586,8 @@ export const readTxConfig = (cwd: string = process.cwd()): TxConfig => { designDocMissingTaskLinks: isSpecDesignDocMissingTaskLinksMode(specDesignDocMissingTaskLinks) ? specDesignDocMissingTaskLinks : DEFAULT_CONFIG.spec.designDocMissingTaskLinks, + types: parseSpecTypes(raw), + lintMessages: parseSpecLintMessages(raw), }, memory: { defaultDir: memoryDefaultDir ?? DEFAULT_CONFIG.memory.defaultDir, @@ -656,6 +968,73 @@ function escapeRegex(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") } +/** + * Render the `[spec.types.*]` tables from DEFAULT_CONFIG so the scaffolded file + * and the in-code defaults can never drift (see the round-trip test). + * Keys whose default is already correct (subdir, template) are left out so the + * merge in `parseSpecTypes` keeps them. + */ +const renderDefaultSpecTypesToml = (): string => { + const lines: string[] = [] + for (const [typeName, def] of Object.entries(DEFAULT_CONFIG.spec.types)) { + lines.push("", `[${SPEC_SECTION}.types.${typeName}]`, `severity = "${def.severity}"`) + for (const section of def.sections) { + lines.push("", `[${SPEC_SECTION}.types.${typeName}.section.${section.slug}]`) + lines.push(`heading = "${section.heading}"`) + lines.push(`description = "${section.description}"`) + if (section.message !== null) { + lines.push(`message = "${section.message}"`) + } + } + } + return lines.join("\n") +} + +/** Header comment for the [spec.types.*] block, shared by scaffold and upgrade. */ +const SPEC_TYPES_TOML_HEADER = `# ─── Spec Types ──────────────────────────────────────────────────── +# Required markdown sections per spec type, checked by \`tx spec lint\`. +# These are LINT-ONLY: a missing section never blocks tx doc add/update/sync. +# +# severity "error" (fails tx spec lint) | "warn" | "off" +# heading the markdown heading text matched in the doc (case-insensitive) +# description what belongs under the heading. Bundled into generated skills +# and used as placeholder text by \`tx doc template\`. +# message the lint prompt shown when the section is missing. Falls back to +# [spec.lint.messages].missing_section, then a built-in default. +# Placeholders: {name} {spec_type} {section} {description} {file} +# +# Edit, reorder, or delete any section below. Define a new spec type by adding +# a [spec.types.<name>] table. It is scaffolded, linted, and exposed to agents +# via \`tx spec types --json\` exactly like the built-ins. +# +# NOT configurable (tx functionality depends on them): the frontmatter contract +# and the embedded yaml block schemas (ears_requirements with REQ-* ids, +# invariants with INV-* ids, verification, interfaces, failure_modes, +# acceptance_criteria). Those blocks are found anywhere in the body, so renaming +# a heading never breaks \`tx spec discover\` or FCI scoring. +` + +/** Commented examples that follow the generated [spec.types.*] tables. */ +const SPEC_TYPES_TOML_FOOTER = ` +# Custom spec type example. Uncomment to enable \`tx doc add rfc <name>\`: +# [spec.types.rfc] +# severity = "warn" +# subdir = "rfc" # defaults to the type name +# template = ".tx/templates/rfc.md" # optional; {name} {title} {date} {spec_type} substituted +# sections = ["Summary", "Motivation", "Proposal", "Drawbacks", "Alternatives"] +# +# ...or use per-section tables to attach descriptions and lint prompts: +# [spec.types.rfc.section.motivation] +# heading = "Motivation" +# description = "Why this change is worth making now." +# message = "{name}: every RFC needs '# Motivation'. {description}" + +# Global lint prompt overrides, used when a section has no \`message\` of its own. +# [spec.lint.messages] +# missing_section = "{name}: missing required section '{section}' for spec_type '{spec_type}'. {description}" +# unknown_spec_type = "{name}: spec_type '{spec_type}' is not defined in .tx/config.toml." +` + /** * The default config.toml content with comments and doc links. * Written by `tx init` if config.toml does not exist. @@ -707,6 +1086,8 @@ test_patterns = [ # "never" = suppress this warning entirely. design_doc_missing_task_links = "always" +${SPEC_TYPES_TOML_HEADER}${renderDefaultSpecTypesToml()} +${SPEC_TYPES_TOML_FOOTER} # ─── Memory ───────────────────────────────────────────────────────── # Filesystem-backed markdown search over your project's documentation. # Index directories with \`tx memory source add <dir>\`, then search @@ -883,3 +1264,48 @@ export const scaffoldConfigToml = (cwd: string = process.cwd()): boolean => { writeFileSync(configPath, DEFAULT_CONFIG_TOML, "utf8") return true } + +/** Config sections added after a project was first initialized. */ +const UPGRADEABLE_SECTIONS: ReadonlyArray<{ + readonly marker: string + readonly render: () => string +}> = [ + { + // Spec types became configurable; older configs predate [spec.types.*]. + marker: `[${SPEC_SECTION}.types.`, + render: () => `${SPEC_TYPES_TOML_HEADER}${renderDefaultSpecTypesToml()}\n${SPEC_TYPES_TOML_FOOTER}`, + }, +] + +/** + * Append config sections that did not exist when the project was initialized. + * + * Additive and idempotent: a section is only written when its marker is absent, + * and the appended values match the built-in defaults, so behaviour does not + * change. Existing keys and comments are never touched. + * + * Returns the markers that were added. + */ +export const upgradeConfigToml = (cwd: string = process.cwd()): string[] => { + const configPath = resolve(cwd, ".tx", "config.toml") + if (!existsSync(configPath)) return [] + + let raw: string + try { + raw = readFileSync(configPath, "utf8") + } catch { + return [] + } + + const added: string[] = [] + let next = raw + for (const section of UPGRADEABLE_SECTIONS) { + if (next.includes(section.marker)) continue + next = `${ensureTrailingNewline(next)}\n${section.render()}` + added.push(section.marker) + } + + if (added.length === 0) return [] + writeFileSync(configPath, ensureTrailingNewline(next), "utf8") + return added +} diff --git a/specs/design/configurable-spec-types-design.md b/specs/design/configurable-spec-types-design.md new file mode 100644 index 00000000..8c26e30f --- /dev/null +++ b/specs/design/configurable-spec-types-design.md @@ -0,0 +1,330 @@ +--- +kind: spec +spec_type: design +doc_id: doc-e0437e3e9019 +name: configurable-spec-types-design +title: "Spec Type Registry Design" +status: draft +version: 1 +owners: + - docs-team +summary: "Resolves a spec-type registry from .tx/config.toml and threads it through the parser, doc service, CLI lint, templates, and generated skills." +domain: docs-specs +tags: + - spec-types + - configuration + - registry + - lint +depends_on: + - configurable-spec-types +supersedes: [] +implements: configurable-spec-types +last_reviewed_at: 2026-08-13 +--- + +# Summary + +A pure `resolveSpecTypes(config)` function turns `[spec.types.*]` config into a +`SpecTypeRegistry`. Everything that used to consult the hardcoded +`MD_REQUIRED_SECTIONS_BY_SPEC_TYPE` constant now consults the registry instead. +Section checking moves out of `parseMdDocSync` into a separate +`lintSpecSections` function called by `tx spec lint`, so documents always parse. + +# Architecture + +Four layers, each depending only on the one above it. + +**Config** (`packages/core/src/utils/toml-config.ts`). `TxConfig.spec` gains +`types: Record<string, SpecTypeConfig>` and `lintMessages: Record<string, string>`. +`readTxConfig` merges user tables over `DEFAULT_CONFIG.spec.types` per type and +per key, so a type declaring only `severity` keeps its default sections. Two new +helpers support this: `listTomlSections(toml, prefix)` enumerates table names the +hand-rolled parser could not otherwise discover, and `collectTomlTables` walks the +whole file once, merging repeated tables (later keys win) rather than stopping at +the first occurrence as `extractTomlValue` does. + +`DEFAULT_CONFIG_TOML` interpolates `renderDefaultSpecTypesToml()`, which renders +the tables from `DEFAULT_CONFIG` itself. The scaffolded file and the in-code +defaults therefore cannot drift, and a round-trip test asserts it. + +**Registry** (`packages/core/src/utils/spec-type-registry.ts`). `resolveSpecTypes` +is pure: it resolves each section's message through the per-section, then global, +then built-in fallback chain; computes `sectionsCustomized` by comparing headings +against the built-in list; derives `subdir`; adds the legacy `requirement` and +`system_design` kinds so path lookups keep working; and collects advisory +warnings when a block-bearing section is dropped. + +**Enforcement** (`packages/core/src/utils/spec-section-lint.ts`). +`lintSpecSections(parsed, registry, ctx)` returns one `SectionLintFinding` per +missing section, each carrying its rendered message and the type's severity. +`validateRequiredSections` is deleted from the parser. + +**Consumers**. The doc service resolves the registry per call from its content +root (not at layer construction) so config edits apply without a restart; +`kindSubdir` and `parseSpecTypeAsDocKind` take it as a parameter. The CLI adds +`config` and `sections` lint groups, plus `tx spec types` and `tx doc template`. +Skill generation fills a marker block in bundled SKILL.md files from the registry. + +To let a project declare its own types, `DocKindSchema` and `MdSpecTypeSchema` +change from `Schema.Literal` unions to branded strings validated against +`SPEC_TYPE_NAME_PATTERN`. Membership is checked against the registry at the +service boundary instead. Migration 048 drops the `CHECK (kind IN (...))` +allow-list from the `docs` table. + +# Interfaces + +```yaml +interfaces: + - name: resolveSpecTypes + type: rpc + semantics: Pure. Maps TxConfig to a SpecTypeRegistry with resolved messages, subdirs, customization flags, and advisory warnings. Never throws. + contract: packages/core/src/utils/spec-type-registry.ts#resolveSpecTypes + - name: lintSpecSections + type: rpc + semantics: Pure. Returns one finding per missing section at the type's severity, or a single warn finding for an unconfigured spec_type. Returns empty for task docs and for severity off. + contract: packages/core/src/utils/spec-section-lint.ts#lintSpecSections + - name: renderLintMessage + type: rpc + semantics: Substitutes {placeholder} tokens; unknown placeholders are left verbatim. + contract: packages/core/src/utils/spec-type-registry.ts#renderLintMessage + - name: listTomlSections + type: rpc + semantics: Returns unique TOML table names equal to a prefix or beginning with prefix + dot, in first-occurrence file order. + contract: packages/core/src/utils/toml-config.ts#listTomlSections + - name: tx spec types + type: rpc + semantics: Prints the effective registry. The --json form is the stable contract consumed by agents and skill generation. + contract: apps/cli/src/commands/spec.ts#specTypes + - name: tx doc template + type: rpc + semantics: Prints the scaffold for a spec type without writing to disk or the database. + contract: apps/cli/src/commands/doc.ts#docTemplate +``` + +# Data Model + +`SpecSectionConfig` is `{ slug, heading, description, message }`, where a null +message means "inherit". `SpecTypeConfig` adds `{ sections, severity, subdir, +template }`, with null `subdir` meaning "derive from the type name". The resolved +`SpecSectionDefinition` differs in one way: `message` is always a concrete string. + +TOML shape, one table per section: + +``` +[spec.types.prd] +severity = "error" + +[spec.types.prd.section.problem] +heading = "Problem" +description = "..." +message = "{name}: PRD is missing '# Problem'. ..." +``` + +A `sections = ["A", "B"]` array shorthand is also accepted for quick custom +types; per-section tables win when both are present. + +Migration 048 rebuilds `docs` with `kind TEXT NOT NULL` and no allow-list, +following migration 046's pattern: foreign keys off for the rebuild, and the +self-reference written with the final table name (`REFERENCES docs(id)`), because +SQLite only rewrites a renamed table's own foreign keys when `foreign_keys = ON`. + +# Invariants + +```yaml +invariants: + - id: INV-SPECCFG-001 + statement: With no spec type configuration present, the resolved registry equals the built-in defaults. + severity: critical + verified_by: + - test/unit/spec-type-registry.test.ts + - test/integration/spec-types-config.test.ts + - id: INV-SPECCFG-002 + statement: A scaffolded .tx/config.toml parses back to exactly the built-in default config. + severity: high + verified_by: + - test/unit/toml-config.test.ts + - id: INV-SPECCFG-003 + statement: A spec document parses successfully regardless of which required sections are missing. + severity: critical + verified_by: + - test/unit/spec-section-lint.test.ts + - test/integration/spec-types-config.test.ts + - test/integration/doc-schema-validation.test.ts + - id: INV-SPECCFG-004 + statement: Every missing-section finding carries a fully substituted message with no remaining placeholder braces. + severity: medium + verified_by: + - test/unit/spec-section-lint.test.ts + - test/integration/spec-types-config.test.ts + - id: INV-SPECCFG-005 + statement: A spec type whose severity is off produces no section findings. + severity: medium + verified_by: + - test/unit/spec-section-lint.test.ts + - id: INV-SPECCFG-006 + statement: A user-defined spec type round-trips through SQLite as its own kind value. + severity: high + verified_by: + - test/integration/spec-types-config.test.ts + - id: INV-SPECCFG-007 + statement: A document whose spec_type is absent from the registry yields exactly one warn finding and never fails a command. + severity: high + verified_by: + - test/unit/spec-section-lint.test.ts + - test/integration/spec-types-config.test.ts + - id: INV-SPECCFG-008 + statement: Generated skills contain the project's configured headings, descriptions, and resolved lint prompts. + severity: high + verified_by: + - test/integration/spec-types-config.test.ts + - id: INV-SPECCFG-009 + statement: A malformed or unreadable config file resolves to the built-in defaults without throwing. + severity: high + verified_by: + - test/unit/toml-config.test.ts + - id: INV-SPECCFG-010 + statement: Heading matching is case-insensitive, whitespace-trimmed, heading-level agnostic, and ignores headings inside fenced code blocks. + severity: medium + verified_by: + - test/unit/spec-section-lint.test.ts + - id: INV-SPECCFG-011 + statement: Dropping a section that conventionally holds an embedded yaml block yields an advisory warning, never an error. + severity: medium + verified_by: + - test/unit/spec-type-registry.test.ts + - test/integration/spec-types-config.test.ts +``` + +# Failure Modes + +```yaml +failure_modes: + - condition: The config file is malformed, unreadable, or contains an invalid severity or type name. + impact: Configuration would otherwise be partially applied or the command would crash. + handling: readTxConfig keeps its never-throw contract, falling back per key; invalid type and slug names are skipped and invalid severities fall back to the default. + - condition: A spec type is removed from config while documents of that type remain on disk. + impact: Those documents have no section definition to check against. + handling: lintSpecSections emits a single unknown_spec_type warning; parsing, sync, discovery, and FCI are unaffected because embedded blocks are found by fence, not heading. + - condition: A design or PRD type is configured without its block-bearing section. + impact: Agents stop being prompted to write invariants or requirements blocks, so spec coverage silently decays. + handling: resolveSpecTypes returns an advisory warning, surfaced by the lint config group and by tx spec types. + - condition: A type sets template to a path that does not exist. + impact: Scaffolding would emit an empty or confusing document. + handling: tx doc add exits 1 naming the missing file and the config key that referenced it. + - condition: A table such as [spec.types.design] appears more than once in the file. + impact: The first-occurrence-wins parser would silently ignore the later table. + handling: collectTomlTables merges repeated tables, later keys winning. + - condition: A project edits config after skills were synced. + impact: Generated skills would describe a stale structure. + handling: Skills carry an instruction to treat tx spec types --json as authoritative, and re-running tx skills sync re-renders the block. +``` + +# Verification + +```yaml +verification: + - requirement_id: REQ-SPECCFG-001 + test_type: integration + target: test/integration/spec-types-config.test.ts + - requirement_id: REQ-SPECCFG-002 + test_type: unit + target: test/unit/toml-config.test.ts + - requirement_id: REQ-SPECCFG-003 + test_type: integration + target: test/integration/spec-types-config.test.ts + - requirement_id: REQ-SPECCFG-004 + test_type: unit + target: test/unit/spec-section-lint.test.ts + - requirement_id: REQ-SPECCFG-005 + test_type: unit + target: test/unit/spec-section-lint.test.ts + - requirement_id: REQ-SPECCFG-006 + test_type: integration + target: test/integration/spec-types-config.test.ts + - requirement_id: REQ-SPECCFG-007 + test_type: integration + target: test/integration/doc-schema-validation.test.ts + - requirement_id: REQ-SPECCFG-008 + test_type: integration + target: test/integration/spec-types-config.test.ts + - requirement_id: REQ-SPECCFG-009 + test_type: integration + target: test/integration/spec-types-config.test.ts + - requirement_id: REQ-SPECCFG-010 + test_type: integration + target: test/integration/spec-types-config.test.ts + - requirement_id: REQ-SPECCFG-011 + test_type: unit + target: test/unit/spec-type-registry.test.ts + - requirement_id: REQ-SPECCFG-012 + test_type: unit + target: test/unit/toml-config.test.ts +``` + +# Testing Strategy + +## Unit Tests + +`test/unit/toml-config.test.ts` covers `listTomlSections` (prefix matching, +ordering, dedupe, partial-segment rejection), per-section table parsing, the +array shorthand and its precedence, per-type merge behaviour, invalid-value +fallbacks, lint message overrides, and the `DEFAULT_CONFIG_TOML` round trip. + +`test/unit/spec-type-registry.test.ts` covers registry composition, subdir +derivation including legacy kinds, message resolution precedence, +`sectionsCustomized` detection under case and whitespace variation, advisory +warnings, and `renderLintMessage` placeholder handling. + +`test/unit/spec-section-lint.test.ts` covers findings per missing section, +severity mapping, message rendering, heading matching rules, fenced-code +exclusion, unknown spec types, and task documents. + +Nothing is mocked; these functions are pure and take config as input. + +## Integration Tests + +`test/integration/spec-types-config.test.ts` drives the real CLI against a +temporary project. Fourteen numbered scenarios cover: default config lints clean; +missing sections do not block sync or drift; lint reports and exits 1; warn and +off severities; custom type scaffolding, subdirectory, SQLite kind, and listing; +custom message rendering; an unconfigured spec type; template file use and its +missing-file error; customized built-in sections switching to the generic +template with blocks still seeded; the advisory warning; `tx doc template` +previewing without writing; generated skills embedding and refreshing the +configured structure; `tx spec types --json` shape; and `tx init` scaffolding. + +`test/integration/doc-schema-validation.test.ts` scenarios 2, 5, and 10 were +inverted from asserting parse rejection to asserting creation plus a lint +finding. + +## Edge Cases + +Repeated TOML tables, invalid type and section slugs, empty section lists, a +section list that drops a block-bearing heading, config removed after documents +exist, and template files with unknown placeholders. + +# Open Questions + +- [ ] Should `tx spec lint` gain a `--fix` that inserts missing headings with + their configured descriptions as placeholder text? +- [ ] Should the API and dashboard docs-health endpoints reuse `lintSpecSections` + instead of their duplicated placeholder check? Currently out of scope. + +# Migration + +Projects with no `[spec.types.*]` configuration keep the previous behaviour +exactly, because `readTxConfig` falls back to the built-in defaults. `tx init` +writes the defaults into new config files; existing files are untouched and may +be upgraded by copying the block from a freshly scaffolded config. + +Migration 048 runs automatically and only widens the `docs.kind` column, so it is +safe on existing databases. It is a loosening and is not reversible without +re-adding the allow-list. + +# References + +- PRD: `specs/prd/configurable-spec-types.md` +- Migration: `migrations/048_docs_configurable_kinds.sql`, which follows the + pattern documented in `migrations/046_docs_self_fk_repair.sql` +- CLAUDE.md doctrine: Rule 5 (Effect-TS patterns), Rule 8 (singleton test + database), Rule 10 (Effect Schema for domain types) diff --git a/specs/index.md b/specs/index.md index 2c366399..45be711f 100644 --- a/specs/index.md +++ b/specs/index.md @@ -2,10 +2,45 @@ **Description**: Search map for subsystem PRDs and design docs. Use this file to find the authoritative spec by feature area, domain term, or implementation concern. -**Search Keywords**: sample-prd, Sample PRD, sample, prd +**Search Keywords**: configurable-spec-types-design, Spec Type Registry Design, docs-specs, spec-types, configuration, registry, lint, design-doc-review-runs-design, DD-040: Design Doc Review Runs Design, error-handling, Error Handling, platform, paired-prd-dd-ralph-workflow-design, DD-038: PRD/DD Pair Workflow For RALPH Design, orchestration, design, prd, ralph, workflow, docs, task-graph, ralph-supervision-dashboard-design, DD-039: Ralph Supervision Dashboard Design, dashboard, supervision, terminals, domain-events, pi, configurable-spec-types, Configurable Spec Types and Lint-Only Section Enforcement, design-doc-review-runs-prd, PRD-040: Design Doc Review Runs, paired-prd-dd-ralph-workflow-prd, PRD-038: PRD/DD Pair Workflow For RALPH, ralph-supervision-dashboard-prd, PRD-039: Ralph Supervision Dashboard, events, review, req-test, Req Test, product-area, test-auth-flows, Auth Flows, auth ## Product Requirements Documents | Name | Title | Description | Search Keywords | Status | |------|-------|-------------|-----------------|--------| -| [sample-prd](prd/sample-prd.md) | Sample PRD | Product requirements for Sample PRD. | sample, prd | changing | +| [configurable-spec-types](prd/configurable-spec-types.md) | Configurable Spec Types and Lint-Only Section Enforcement | Let projects define their own spec types, required sections, heading descriptions, and lint prompts in .tx/config.toml, enforced by tx spec lint rather than the parser. | docs-specs, spec-types, configuration, lint, docs | changing | +| [design-doc-review-runs-prd](prd/design-doc-review-runs-prd.md) | PRD-040: Design Doc Review Runs | | - | changing | +| [paired-prd-dd-ralph-workflow-prd](prd/paired-prd-dd-ralph-workflow-prd.md) | PRD-038: PRD/DD Pair Workflow For RALPH | Standardize non-trivial execution work on paired PRD and design docs, with tx tasks as the living implementation plan. | orchestration, prd, ralph, workflow, docs, task-graph | changing | +| [ralph-supervision-dashboard-prd](prd/ralph-supervision-dashboard-prd.md) | PRD-039: Ralph Supervision Dashboard | Add a live Ralph supervision surface with tmux-backed browser terminals, pause and takeover controls, canonical domain events, and optional design-doc completion review loops. | orchestration, ralph, dashboard, supervision, terminals, events, review | changing | +| [req-test](prd/req-test.md) | Req Test | One-line summary of Req Test | product-area, prd | changing | +| [test-auth-flows](prd/test-auth-flows.md) | Auth Flows | Draft coverage of core authentication user flows for testing and review. | auth | changing | + +## Design Documents + +| Name | Title | Description | Search Keywords | Implements | Status | +|------|-------|-------------|-----------------|------------|--------| +| [configurable-spec-types-design](design/configurable-spec-types-design.md) | Spec Type Registry Design | Resolves a spec-type registry from .tx/config.toml and threads it through the parser, doc service, CLI lint, templates, and generated skills. | docs-specs, spec-types, configuration, registry, lint | - | changing | +| [design-doc-review-runs-design](design/design-doc-review-runs-design.md) | DD-040: Design Doc Review Runs Design | | - | design-doc-review-runs-prd | changing | +| [error-handling](design/error-handling.md) | Error Handling | Draft system design guidance for consistent error handling across tx services. | platform | - | changing | +| [paired-prd-dd-ralph-workflow-design](design/paired-prd-dd-ralph-workflow-design.md) | DD-038: PRD/DD Pair Workflow For RALPH Design | Update workflow docs, planner guidance, and RALPH prompts so paired PRD/design docs plus tx tasks become the default execution contract. | orchestration, design, prd, ralph, workflow, docs, task-graph | paired-prd-dd-ralph-workflow-prd | changing | +| [ralph-supervision-dashboard-design](design/ralph-supervision-dashboard-design.md) | DD-039: Ralph Supervision Dashboard Design | Implement core-owned Ralph supervision sessions, tmux-backed browser terminals, canonical domain events, and config-gated design-doc review triggers. | orchestration, ralph, dashboard, supervision, terminals, domain-events, pi | - | changing | + +## Invariant Summary + +**Total invariants**: 23 + +**By enforcement type**: + +- integration_test: 23 + +**By subsystem**: + +- prd: 12 +- design: 11 + +## Document Links + +| From | To | Type | +|------|-----|------| +| paired-prd-dd-ralph-workflow-prd | paired-prd-dd-ralph-workflow-design | prd_to_design | +| design-doc-review-runs-prd | design-doc-review-runs-design | prd_to_design | diff --git a/specs/prd/configurable-spec-types.md b/specs/prd/configurable-spec-types.md new file mode 100644 index 00000000..2143b92d --- /dev/null +++ b/specs/prd/configurable-spec-types.md @@ -0,0 +1,176 @@ +--- +kind: spec +spec_type: prd +doc_id: doc-51db774c31f3 +name: configurable-spec-types +title: "Configurable Spec Types and Lint-Only Section Enforcement" +status: draft +version: 1 +owners: + - docs-team +summary: "Let projects define their own spec types, required sections, heading descriptions, and lint prompts in .tx/config.toml, enforced by tx spec lint rather than the parser." +domain: docs-specs +tags: + - spec-types + - configuration + - lint + - docs +depends_on: [] +supersedes: [] +implements: null +last_reviewed_at: 2026-08-13 +--- + +# Summary + +Spec structure in tx is fixed: five spec types (`prd`, `design`, `overview`, +`runbook`, `decision`) with hardcoded required markdown sections. This makes spec +structure configurable per project, adds user-defined spec types, and moves +section enforcement out of the parser into `tx spec lint`, where severity and +message wording are configurable per section. + +# Problem + +`MD_REQUIRED_SECTIONS_BY_SPEC_TYPE` in `packages/core/src/types/doc.ts` is the +only definition of required sections, and `validateRequiredSections` in the +markdown parser treats a missing heading as a hard parse failure. Three problems +follow. + +First, teams cannot express their own documentation conventions. A team whose +design reviews hinge on a "Rollout" or "Security Review" section has no way to +require one, and a team that wants an `rfc` or `postmortem` spec type cannot have +one at all. + +Second, enforcement is disproportionate. A missing heading blocks `tx doc add`, +`tx doc update`, `tx doc sync`, `tx doc render`, and even drift detection, which +reports "Unable to validate markdown structure for drift detection" rather than a +hash comparison. A structural preference should not break state reconciliation. + +Third, the prompts agents receive are fixed. Teams driving spec adherence through +agents cannot tailor the message an agent sees when a section is missing, and +have nowhere to record what each heading is actually for. + +Contrast this with what tx genuinely depends on: the frontmatter contract and the +embedded yaml blocks. `tx spec discover`, invariant sync, and FCI scoring locate +those blocks by fence language and top-level key anywhere in the document body, +never by heading. Headings are therefore a convention, enforced as if they were a +contract. + +# Scope + +Included: + +- Required sections per spec type, defined in `[spec.types.*]` in `.tx/config.toml`. +- A `description` per section explaining what belongs under the heading, and a + `message` per section overriding the lint prompt when it is missing. +- Per-type lint severity: `error`, `warn`, or `off`. +- User-defined spec types with their own sections, subdirectory, and optional + markdown template file. +- Lint-only enforcement: `tx spec lint` reports missing sections; nothing else fails. +- Built-in defaults written into every scaffolded `.tx/config.toml` by `tx init`. +- `tx spec types` and `tx doc template` for inspecting the effective structure. +- Generated agent skills that embed the project's configured structure. + +Excluded: + +- Changing the frontmatter contract or the embedded yaml block schemas. +- Configurable EARS validation rules or invariant id formats. +- Migrating existing spec documents. +- Per-directory or per-branch configuration overrides. + +# Requirements + +```yaml +ears_requirements: + - id: REQ-SPECCFG-001 + kind: ubiquitous + statement: the system shall resolve required sections for each spec type from the [spec.types.*] tables in .tx/config.toml + priority: must + rationale: Section structure is the primary thing teams need to vary. + - id: REQ-SPECCFG-002 + kind: ubiquitous + statement: the system shall behave identically to the previous release when no spec type configuration is present + priority: must + rationale: Existing projects must not change behaviour on upgrade. + - id: REQ-SPECCFG-003 + kind: event-driven + when: a spec document is parsed + statement: the system shall parse the document successfully regardless of which required sections are absent + priority: must + rationale: Section structure is a convention; it must not block doc sync or drift detection. + - id: REQ-SPECCFG-004 + kind: event-driven + when: tx spec lint runs + statement: the system shall report each missing required section at the severity configured for that spec type + priority: must + - id: REQ-SPECCFG-005 + kind: optional + where: a section defines a message template + statement: the system shall render that template, substituting name, spec_type, section, description, and file placeholders + priority: must + rationale: Teams drive agent adherence through the wording of the prompt. + - id: REQ-SPECCFG-006 + kind: ubiquitous + statement: the system shall accept spec types that are not built in, scaffolding, storing, and linting them like built-in types + priority: must + - id: REQ-SPECCFG-007 + kind: ubiquitous + statement: the system shall keep the frontmatter contract and the embedded yaml block schemas non-configurable + priority: must + rationale: tx spec discover, invariant sync, and FCI scoring depend on them. + - id: REQ-SPECCFG-008 + kind: event-driven + when: tx init scaffolds a config file + statement: the system shall write the built-in spec type definitions into that file as active, editable configuration + priority: must + rationale: Discoverability; users should not have to read source to learn the defaults. + - id: REQ-SPECCFG-009 + kind: ubiquitous + statement: the system shall expose the effective spec type registry through tx spec types in both human and JSON form + priority: must + - id: REQ-SPECCFG-010 + kind: event-driven + when: agent skills are generated or synced + statement: the system shall embed this project's configured sections, descriptions, and lint prompts into the generated skill content + priority: must + rationale: Otherwise scaffolded skills instruct agents to write a structure the project does not use. + - id: REQ-SPECCFG-011 + kind: unwanted + if: a spec type's configuration removes a section that conventionally holds an embedded yaml block + statement: the system shall emit an advisory warning rather than an error + priority: should + - id: REQ-SPECCFG-012 + kind: unwanted + if: the configuration file is malformed or contains invalid values + statement: the system shall fall back to the built-in defaults without throwing + priority: must +``` + +# Acceptance Criteria + +```yaml +acceptance_criteria: + - id: AC-SPECCFG-001 + statement: With no [spec.types.*] configuration, tx doc add and tx spec lint produce the same results as the previous release. + - id: AC-SPECCFG-002 + statement: A document missing a required section syncs successfully and reports no drift, while tx spec lint reports the missing section and exits 1. + - id: AC-SPECCFG-003 + statement: Setting a type's severity to warn makes tx spec lint exit 0 while still reporting; setting it to off silences the check. + - id: AC-SPECCFG-004 + statement: A [spec.types.rfc] table makes tx doc add rfc scaffold into the configured subdirectory, persists kind rfc in SQLite, and lists the type in tx spec types. + - id: AC-SPECCFG-005 + statement: A section's own message template is rendered with all placeholders substituted and no braces remaining. + - id: AC-SPECCFG-006 + statement: A scaffolded .tx/config.toml contains the built-in section tables and parses back to the built-in defaults exactly. + - id: AC-SPECCFG-007 + statement: Generated skills contain the project's configured headings, descriptions, and resolved lint prompts, and refresh when the config changes. + - id: AC-SPECCFG-008 + statement: A document whose spec_type is no longer configured produces a warning without crashing any command. +``` + +# Non-goals + +- Replacing EARS validation or invariant id conventions with configurable rules. +- A migration tool for restructuring existing spec documents. +- Configurable rules for the other `tx spec lint` sections (drift, coverage, + index searchability, spec-test status). diff --git a/test/integration/api-invariants.test.ts b/test/integration/api-invariants.test.ts index 49540ac9..de84352d 100644 --- a/test/integration/api-invariants.test.ts +++ b/test/integration/api-invariants.test.ts @@ -21,6 +21,7 @@ import { tmpdir } from "node:os" import { join } from "node:path" import type { Invariant, InvariantCheck } from "@jamesaphoenix/tx/types" +import { asDocKind } from "@jamesaphoenix/tx/types" // ============================================================================= // Helpers @@ -183,7 +184,7 @@ describe("API Invariant Endpoints Integration", () => { ].join("\n") yield* docService.create({ - kind: "prd", + kind: asDocKind("prd"), name: "inv-test-doc", title: "Invariant Test Doc", content, @@ -283,7 +284,7 @@ describe("API Invariant Endpoints Integration", () => { ].join("\n") yield* docService.create({ - kind: "prd", + kind: asDocKind("prd"), name: "inv-pass-doc", title: "Pass Check Doc", content, @@ -375,7 +376,7 @@ describe("API Invariant Endpoints Integration", () => { ].join("\n") yield* docService.create({ - kind: "prd", + kind: asDocKind("prd"), name: "inv-fail-doc", title: "Fail Check Doc", content, diff --git a/test/integration/api-spec-trace.test.ts b/test/integration/api-spec-trace.test.ts index 09bb13a2..bbaaf971 100644 --- a/test/integration/api-spec-trace.test.ts +++ b/test/integration/api-spec-trace.test.ts @@ -14,6 +14,7 @@ import { getSharedTestLayer, type SharedTestLayerResult } from "@jamesaphoenix/t import { DocService, SpecTraceService, parseBatchRunInput } from "@jamesaphoenix/tx" import { mapCoreError } from "../../apps/cli/src/api/api.js" import type { SpecSignoff, TraceabilityMatrix } from "@jamesaphoenix/tx/types" +import { asDocKind } from "@jamesaphoenix/tx/types" type InvariantInput = { id: string @@ -100,7 +101,7 @@ const createDocWithInvariants = (docName: string, invariants: readonly Invariant ].join("\n") yield* docService.create({ - kind: "prd", + kind: asDocKind("prd"), name: docName, title: docName, content, diff --git a/test/integration/doc-schema-validation.test.ts b/test/integration/doc-schema-validation.test.ts index c7625b37..9591b5ac 100644 --- a/test/integration/doc-schema-validation.test.ts +++ b/test/integration/doc-schema-validation.test.ts @@ -3,11 +3,17 @@ import { spawnSync } from "node:child_process" import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import { join, resolve } from "node:path" -import { Effect } from "effect" +import { Effect, Either } from "effect" import { getSharedTestLayer, type SharedTestLayerResult } from "@jamesaphoenix/tx/testing" -import { DocService } from "@jamesaphoenix/tx" +import { + DocService, + lintSpecSections, + parseMdDocSync, + readTxConfig, + resolveSpecTypes, +} from "@jamesaphoenix/tx" import { fixtureId } from "../fixtures.js" -import type { DocKind } from "@jamesaphoenix/tx/types" +import { asDocKind } from "@jamesaphoenix/tx/types" const CLI_SRC = resolve(__dirname, "../../apps/cli/src/cli.ts") const BUN_BIN = process.execPath.includes("bun") ? process.execPath : "bun" @@ -79,7 +85,7 @@ describe("Markdown content schema validation integration", () => { let tempProjectDir: string const createDoc = async (input: { - kind: DocKind + kind: string name: string title: string content: string @@ -87,7 +93,7 @@ describe("Markdown content schema validation integration", () => { Effect.runPromise( Effect.gen(function* () { const svc = yield* DocService - return yield* svc.create(input) + return yield* svc.create({ ...input, kind: asDocKind(input.kind) }) }).pipe(Effect.provide(shared.layer)) ) @@ -154,7 +160,7 @@ ears_requirements: expect(doc.name).toBe(name) }) - it("2. PRD missing problem fails with problem error", async () => { + it("2. [INV-SPECCFG-003] PRD missing problem is created, then reported by section lint", async () => { const name = shortName("doc-schema-prd-missing-problem", "prd") const content = withSpecFrontmatter( "prd", @@ -183,14 +189,22 @@ ears_requirements: - None.` ) - await expect( - createDoc({ - kind: "prd", - name, - title: "Missing Problem PRD", - content, - }) - ).rejects.toThrow(/problem/i) + // Lint-only: creation succeeds; `tx spec lint` surfaces the missing section. + const doc = await createDoc({ + kind: "prd", + name, + title: "Missing Problem PRD", + content, + }) + expect(doc.kind).toBe("prd") + + const parsed = parseMdDocSync(content) + if (Either.isLeft(parsed)) throw new Error("expected parse to succeed") + + const registry = resolveSpecTypes(readTxConfig(tempProjectDir)) + const findings = lintSpecSections(parsed.right, registry, { docName: name }) + expect(findings.map((finding) => finding.section)).toEqual(["Problem"]) + expect(findings[0]!.message).toMatch(/problem/i) }) it("3. PRD with deprecated requirements passes and keeps deprecation warning path", async () => { @@ -318,7 +332,7 @@ verification: expect(doc.name).toBe(name) }) - it("5. design doc missing architecture fails", async () => { + it("5. design doc missing architecture is created, then reported by section lint", async () => { const name = shortName("doc-schema-design-missing-architecture", "design") const content = withSpecFrontmatter( "design", @@ -351,14 +365,21 @@ verification: [] \`\`\`` ) - await expect( - createDoc({ - kind: "design", - name, - title: "Missing Architecture Design", - content, - }) - ).rejects.toThrow(/architecture/i) + // Lint-only: creation succeeds; `tx spec lint` surfaces the missing section. + const doc = await createDoc({ + kind: "design", + name, + title: "Missing Architecture Design", + content, + }) + expect(doc.kind).toBe("design") + + const parsed = parseMdDocSync(content) + if (Either.isLeft(parsed)) throw new Error("expected parse to succeed") + + const registry = resolveSpecTypes(readTxConfig(tempProjectDir)) + const findings = lintSpecSections(parsed.right, registry, { docName: name }) + expect(findings.map((finding) => finding.section)).toEqual(["Architecture"]) }) it("6. design doc with null testing_strategy renders successfully", () => { @@ -547,7 +568,9 @@ verification: [] expect(doc.kind).toBe("design") }) - it("10. overview doc missing problem_definition fails", async () => { + it("10. overview doc missing a required section is created, then reported by lint", async () => { + // Required sections are configurable per spec type, so they are lint-only: + // a missing heading must not block doc creation, sync, or drift detection. const name = shortName("doc-schema-overview-missing-problem-definition", "overview") const content = withSpecFrontmatter( "overview", @@ -563,14 +586,22 @@ Architecture summary. Primary data flow description.` ) - await expect( - createDoc({ - kind: "overview", - name, - title: "Missing Problem Definition Overview", - content, - }) - ).rejects.toThrow(/components/i) + const doc = await createDoc({ + kind: "overview", + name, + title: "Missing Problem Definition Overview", + content, + }) + expect(doc.kind).toBe("overview") + + const parsed = parseMdDocSync(content) + expect(Either.isRight(parsed)).toBe(true) + if (Either.isLeft(parsed)) throw new Error("expected parse to succeed") + + const registry = resolveSpecTypes(readTxConfig(tempProjectDir)) + const findings = lintSpecSections(parsed.right, registry, { docName: name }) + expect(findings.map((finding) => finding.section)).toEqual(["Components"]) + expect(findings[0]!.severity).toBe("error") }) it("11. EARS validation still fails invalid pattern entries", () => { diff --git a/test/integration/ears-requirements.test.ts b/test/integration/ears-requirements.test.ts index affd9ec7..c779d5ff 100644 --- a/test/integration/ears-requirements.test.ts +++ b/test/integration/ears-requirements.test.ts @@ -17,6 +17,7 @@ import { join, resolve } from "node:path" import { getSharedTestLayer, type SharedTestLayerResult } from "@jamesaphoenix/tx/testing" import { DocService } from "@jamesaphoenix/tx" import { fixtureId } from "../fixtures.js" +import { asDocKind } from "@jamesaphoenix/tx/types" const CLI_SRC = resolve(__dirname, "../../apps/cli/src/cli.ts") @@ -168,7 +169,7 @@ ears_requirements: Effect.gen(function* () { const svc = yield* DocService yield* svc.create({ - kind: "prd", + kind: asDocKind("prd"), name, title: "EARS PRD", content, @@ -205,7 +206,7 @@ ears_requirements: Effect.gen(function* () { const svc = yield* DocService yield* svc.create({ - kind: "prd", + kind: asDocKind("prd"), name, title: "Invalid EARS PRD", content, @@ -239,7 +240,7 @@ ears_requirements: Effect.gen(function* () { const svc = yield* DocService yield* svc.create({ - kind: "prd", + kind: asDocKind("prd"), name, title: "Duplicate EARS IDs", content, @@ -269,7 +270,7 @@ ears_requirements: Effect.gen(function* () { const svc = yield* DocService yield* svc.create({ - kind: "prd", + kind: asDocKind("prd"), name, title: "Invalid non-array EARS", content, @@ -292,7 +293,7 @@ ears_requirements: Effect.gen(function* () { const svc = yield* DocService return yield* svc.create({ - kind: "prd", + kind: asDocKind("prd"), name, title: "Legacy PRD", content, @@ -318,7 +319,7 @@ ears_requirements: Effect.gen(function* () { const svc = yield* DocService return yield* svc.create({ - kind: "prd", + kind: asDocKind("prd"), name, title: "Legacy PRD", content, @@ -351,7 +352,7 @@ ears_requirements: Effect.gen(function* () { const svc = yield* DocService yield* svc.create({ - kind: "prd", + kind: asDocKind("prd"), name, title: "Mixed PRD", content, @@ -395,7 +396,7 @@ ears_requirements: Effect.gen(function* () { const svc = yield* DocService yield* svc.create({ - kind: "prd", + kind: asDocKind("prd"), name, title: "Update PRD", content: initialContent, @@ -437,7 +438,7 @@ ears_requirements: Effect.gen(function* () { const svc = yield* DocService yield* svc.create({ - kind: "prd", + kind: asDocKind("prd"), name, title: "Update Invalid PRD", content: initialContent, @@ -474,7 +475,7 @@ ears_requirements: Effect.gen(function* () { const svc = yield* DocService yield* svc.create({ - kind: "prd", + kind: asDocKind("prd"), name, title: "Pipe Escape PRD", content, diff --git a/test/integration/mcp-invariant.test.ts b/test/integration/mcp-invariant.test.ts index 53eb1bb5..7f76a52c 100644 --- a/test/integration/mcp-invariant.test.ts +++ b/test/integration/mcp-invariant.test.ts @@ -27,6 +27,7 @@ import { tmpdir } from "node:os" import { join } from "node:path" import type { Invariant, InvariantCheck } from "@jamesaphoenix/tx/types" +import { asDocKind } from "@jamesaphoenix/tx/types" // ============================================================================= // Helpers @@ -164,7 +165,7 @@ const createDocWithInvariants = ( "```", ].join("\n") - yield* docService.create({ kind: "prd", name, title, content }) + yield* docService.create({ kind: asDocKind("prd"), name, title, content }) const synced = yield* docService.syncInvariants(name) return synced }) diff --git a/test/integration/mcp-spec-trace.test.ts b/test/integration/mcp-spec-trace.test.ts index d17b5e41..a2666aaa 100644 --- a/test/integration/mcp-spec-trace.test.ts +++ b/test/integration/mcp-spec-trace.test.ts @@ -15,6 +15,7 @@ import { tmpdir } from "node:os" import { getSharedTestLayer, type SharedTestLayerResult } from "@jamesaphoenix/tx/testing" import { DocService, SpecTraceService, parseBatchRunInput } from "@jamesaphoenix/tx" import { registerSpecTraceTools } from "../../apps/cli/src/mcp/tools/spec-trace.js" +import { asDocKind } from "@jamesaphoenix/tx/types" type InvariantInput = { id: string @@ -102,7 +103,7 @@ const createDocWithInvariants = (docName: string, invariants: readonly Invariant ].join("\n") yield* docService.create({ - kind: "prd", + kind: asDocKind("prd"), name: docName, title: docName, content, diff --git a/test/integration/spec-trace.test.ts b/test/integration/spec-trace.test.ts index 1580178f..c7fd1bfb 100644 --- a/test/integration/spec-trace.test.ts +++ b/test/integration/spec-trace.test.ts @@ -25,6 +25,7 @@ import { parseBatchRunInput, } from "@jamesaphoenix/tx" import type { BatchRunInput } from "@jamesaphoenix/tx/types" +import { asDocKind } from "@jamesaphoenix/tx/types" type InvariantInput = { id: string @@ -118,7 +119,7 @@ const createDocWithInvariants = (docName: string, invariants: readonly Invariant ].join("\n") yield* docService.create({ - kind: "prd", + kind: asDocKind("prd"), name: docName, title: docName, content, @@ -229,7 +230,7 @@ describe("SpecTraceService Integration", () => { Effect.gen(function* () { const docService = yield* DocService const valid = yield* docService.create({ - kind: "prd", + kind: asDocKind("prd"), name: "stable-id-discovery-doc", title: "stable-id-discovery-doc", content: [ diff --git a/test/integration/spec-types-config.test.ts b/test/integration/spec-types-config.test.ts new file mode 100644 index 00000000..14f3637f --- /dev/null +++ b/test/integration/spec-types-config.test.ts @@ -0,0 +1,454 @@ +/** + * Configurable spec types, end to end through the CLI. + * + * Covers: default config behaves exactly as before; missing sections are + * lint-only; per-type severity and per-section lint prompts; user-defined spec + * types (scaffold, subdir, DB kind, lint); custom template files; and the + * advisory warning when a block-bearing section is dropped. + */ +import { describe, it, expect, beforeEach, afterEach } from "vitest" +import { spawnSync } from "node:child_process" +import { mkdtempSync, mkdirSync, rmSync, existsSync, readFileSync, writeFileSync, appendFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { dirname, join, resolve } from "node:path" + +const CLI_SRC = resolve(__dirname, "../../apps/cli/src/cli.ts") +const BUN_BIN = process.execPath.includes("bun") ? process.execPath : "bun" + +interface ExecResult { + status: number + stdout: string + stderr: string +} + +const runTx = (args: string[], cwd: string): ExecResult => { + const res = spawnSync(BUN_BIN, [CLI_SRC, ...args], { + cwd, + encoding: "utf-8", + timeout: 60000, + }) + return { + status: res.status ?? 1, + stdout: res.stdout ?? "", + stderr: res.stderr ?? "", + } +} + +let projectDir: string + +const configPath = () => join(projectDir, ".tx", "config.toml") + +const appendConfig = (content: string): void => { + appendFileSync(configPath(), `\n${content}\n`) +} + +/** + * Replace every `[spec.types.<type>...]` table with `block`. + * + * Mirrors how a user actually edits the scaffolded config (in place) rather + * than appending a duplicate table, which is not valid TOML. + */ +const setSpecTypeBlock = (typeName: string, block: string): void => { + const prefix = `[spec.types.${typeName}` + const kept: string[] = [] + let skipping = false + for (const line of readFileSync(configPath(), "utf-8").split("\n")) { + const header = line.trim().match(/^\[([^\]]+)\]/) + if (header) { + skipping = line.trim().startsWith(`${prefix}]`) || line.trim().startsWith(`${prefix}.`) + } + if (!skipping) kept.push(line) + } + writeFileSync(configPath(), `${kept.join("\n")}\n\n${block}\n`) +} + +const specPath = (...parts: string[]) => join(projectDir, "specs", ...parts) + +/** Delete a `# Heading` section (heading + body up to the next heading). */ +const removeSection = (file: string, heading: string): void => { + const lines = readFileSync(file, "utf-8").split("\n") + const out: string[] = [] + let skipping = false + for (const line of lines) { + const isHeading = /^#{1,6}\s+/.test(line) + if (isHeading) { + skipping = line.replace(/^#{1,6}\s+/, "").trim().toLowerCase() === heading.toLowerCase() + } + if (!skipping) out.push(line) + } + writeFileSync(file, out.join("\n")) +} + +const lintJson = (): { + ok: boolean + section_warnings: number + config_warnings: number + issues: Array<{ section: string; severity: string; message: string }> +} => { + const res = runTx(["spec", "lint", "--json"], projectDir) + return JSON.parse(res.stdout) +} + +const sectionIssues = () => lintJson().issues.filter((issue) => issue.section === "sections") + +beforeEach(() => { + projectDir = mkdtempSync(join(tmpdir(), "tx-spec-types-")) + const init = runTx(["init"], projectDir) + expect(init.status).toBe(0) +}) + +afterEach(() => { + if (existsSync(projectDir)) { + rmSync(projectDir, { recursive: true, force: true }) + } +}) + +describe("configurable spec types", () => { + it("1. [INV-SPECCFG-001] default config: a scaffolded PRD lints clean", () => { + expect(runTx(["doc", "add", "prd", "auth-prd", "--title", "Auth"], projectDir).status).toBe(0) + + expect(sectionIssues()).toEqual([]) + expect(lintJson().config_warnings).toBe(0) + }) + + it("2. [INV-SPECCFG-003] a missing section no longer blocks doc sync or drift detection", () => { + runTx(["doc", "add", "prd", "auth-prd", "--title", "Auth"], projectDir) + removeSection(specPath("prd", "auth-prd.md"), "Problem") + + const sync = runTx(["doc", "sync"], projectDir) + expect(sync.status).toBe(0) + expect(sync.stdout).toContain("auth-prd") + + // Drift must resolve normally rather than failing to parse the file. + const drift = runTx(["doc", "drift", "auth-prd"], projectDir) + expect(drift.status).toBe(0) + expect(drift.stdout + drift.stderr).not.toMatch(/Unable to validate markdown structure/) + }) + + it("3. tx spec lint reports the missing section as an error and exits 1", () => { + runTx(["doc", "add", "prd", "auth-prd", "--title", "Auth"], projectDir) + removeSection(specPath("prd", "auth-prd.md"), "Problem") + runTx(["doc", "sync"], projectDir) + + const issues = sectionIssues() + expect(issues).toHaveLength(1) + expect(issues[0]!.severity).toBe("error") + expect(issues[0]!.message).toContain("Problem") + expect(lintJson().ok).toBe(false) + expect(runTx(["spec", "lint"], projectDir).status).toBe(1) + }) + + it('4. severity "warn" reports without failing, and "off" is silent', () => { + runTx(["doc", "add", "prd", "auth-prd", "--title", "Auth"], projectDir) + removeSection(specPath("prd", "auth-prd.md"), "Problem") + runTx(["doc", "sync"], projectDir) + + appendConfig('[spec.types.prd.severity-probe]\n') // no-op table, keeps parser honest + writeFileSync( + configPath(), + readFileSync(configPath(), "utf-8").replace( + /(\[spec\.types\.prd\]\nseverity = )"error"/, + '$1"warn"', + ), + ) + + const warnIssues = sectionIssues() + expect(warnIssues).toHaveLength(1) + expect(warnIssues[0]!.severity).toBe("warn") + expect(runTx(["spec", "lint"], projectDir).status).toBe(0) + + writeFileSync( + configPath(), + readFileSync(configPath(), "utf-8").replace( + /(\[spec\.types\.prd\]\nseverity = )"warn"/, + '$1"off"', + ), + ) + expect(sectionIssues()).toEqual([]) + }) + + it("5. [INV-SPECCFG-006] a custom spec type scaffolds into its subdir, persists its kind, and is listed", () => { + appendConfig( + [ + "[spec.types.rfc]", + 'severity = "warn"', + 'subdir = "rfc"', + "", + "[spec.types.rfc.section.summary]", + 'description = "What this proposes."', + "", + "[spec.types.rfc.section.motivation]", + 'description = "Why now."', + ].join("\n"), + ) + + const add = runTx(["doc", "add", "rfc", "my-rfc", "--title", "My RFC"], projectDir) + expect(add.status).toBe(0) + + // File lands in the configured subdirectory with the configured sections. + const file = specPath("rfc", "my-rfc.md") + expect(existsSync(file)).toBe(true) + const content = readFileSync(file, "utf-8") + expect(content).toContain("spec_type: rfc") + expect(content).toContain("# Summary") + expect(content).toContain("# Motivation") + expect(content).toContain("What this proposes.") + + // The kind round-trips through SQLite (exercises the migration that dropped + // the docs.kind CHECK allow-list). + const list = JSON.parse(runTx(["doc", "list", "--json"], projectDir).stdout) + expect(list.map((doc: { kind: string }) => doc.kind)).toContain("rfc") + + const types = JSON.parse(runTx(["spec", "types", "--json"], projectDir).stdout) + const rfc = types.types.find((type: { name: string }) => type.name === "rfc") + expect(rfc).toMatchObject({ name: "rfc", builtin: false, severity: "warn", subdir: "rfc" }) + expect(rfc.sections.map((section: { heading: string }) => section.heading)).toEqual([ + "Summary", + "Motivation", + ]) + }) + + it("6. [INV-SPECCFG-004] a custom section's own message template is rendered with its placeholders", () => { + appendConfig( + [ + "[spec.types.rfc]", + 'severity = "warn"', + "", + "[spec.types.rfc.section.summary]", + 'description = "What this proposes."', + "", + "[spec.types.rfc.section.motivation]", + 'description = "Why now."', + 'message = "{name} [{spec_type}] must document {section}: {description}"', + ].join("\n"), + ) + runTx(["doc", "add", "rfc", "my-rfc", "--title", "My RFC"], projectDir) + removeSection(specPath("rfc", "my-rfc.md"), "Motivation") + runTx(["doc", "sync"], projectDir) + + const issues = sectionIssues() + expect(issues).toHaveLength(1) + expect(issues[0]!.message).toBe("my-rfc [rfc] must document Motivation: Why now.") + expect(issues[0]!.message).not.toContain("{") + }) + + it("7. [INV-SPECCFG-007] a doc whose spec_type is no longer configured warns without crashing", () => { + appendConfig(['[spec.types.rfc]', 'sections = ["Summary"]'].join("\n")) + runTx(["doc", "add", "rfc", "my-rfc", "--title", "My RFC"], projectDir) + + // Drop the type from config while the doc remains on disk and in the DB. + writeFileSync( + configPath(), + readFileSync(configPath(), "utf-8").replace( + /\[spec\.types\.rfc\]\nsections = \["Summary"\]\n/, + "", + ), + ) + + const issues = sectionIssues() + expect(issues).toHaveLength(1) + expect(issues[0]!.severity).toBe("warn") + expect(issues[0]!.message).toContain("rfc") + // Lint still completes and other checks still run. + expect(runTx(["spec", "lint"], projectDir).status).toBe(0) + }) + + it("8. a configured template file is used, and a missing one errors clearly", () => { + const templatePath = join(projectDir, ".tx", "templates", "rfc.md") + appendConfig( + [ + "[spec.types.rfc]", + 'template = ".tx/templates/rfc.md"', + 'sections = ["Summary"]', + ].join("\n"), + ) + + // Missing template file -> actionable error, no doc created. + const missing = runTx(["doc", "add", "rfc", "no-template", "--title", "X"], projectDir) + expect(missing.status).toBe(1) + expect(missing.stderr).toContain("Template file not found") + expect(missing.stderr).toContain("[spec.types.rfc].template") + + mkdirSync(dirname(templatePath), { recursive: true }) + writeFileSync( + templatePath, + [ + "---", + "kind: spec", + "spec_type: {spec_type}", + "name: {name}", + 'title: "{title}"', + "status: draft", + "version: 1", + "owners:", + " - docs-team", + 'summary: "Custom template."', + "domain: custom", + "tags:", + " - custom", + "depends_on: []", + "supersedes: []", + "implements: null", + "last_reviewed_at: {date}", + "---", + "", + "# Summary", + "From the project template.", + "", + ].join("\n"), + ) + + const add = runTx(["doc", "add", "rfc", "templated", "--title", "Templated RFC"], projectDir) + expect(add.status).toBe(0) + const content = readFileSync(specPath("rfc", "templated.md"), "utf-8") + expect(content).toContain("From the project template.") + expect(content).toContain("name: templated") + expect(content).toContain('title: "Templated RFC"') + expect(content).toContain("spec_type: rfc") + expect(content).not.toContain("{name}") + expect(content).not.toContain("{date}") + }) + + it("9. customizing a built-in's sections switches it to the generic template and seeds blocks", () => { + setSpecTypeBlock( + "design", + [ + "[spec.types.design]", + 'severity = "error"', + "", + "[spec.types.design.section.summary]", + 'description = "The approach."', + "", + "[spec.types.design.section.invariants]", + 'description = "INV-* entries with verified_by paths."', + "", + "[spec.types.design.section.rollout]", + 'description = "How this ships."', + ].join("\n"), + ) + + expect(runTx(["doc", "add", "design", "auth-design", "--title", "Auth"], projectDir).status).toBe(0) + + const content = readFileSync(specPath("design", "auth-design.md"), "utf-8") + expect(content).toContain("# Summary") + expect(content).toContain("# Invariants") + expect(content).toContain("# Rollout") + // Built-in sections that were dropped are gone. + expect(content).not.toContain("# Data Model") + // The invariants yaml block is still seeded so spec discovery keeps working. + expect(content).toContain("invariants: []") + + expect(sectionIssues()).toEqual([]) + }) + + it("10. [INV-SPECCFG-011] dropping a block-bearing section raises an advisory config warning", () => { + setSpecTypeBlock( + "design", + ["[spec.types.design]", 'sections = ["Summary", "Architecture"]'].join("\n"), + ) + + const lint = lintJson() + expect(lint.config_warnings).toBe(2) + const configIssues = lint.issues.filter((issue) => issue.section === "config") + expect(configIssues.map((issue) => issue.severity)).toEqual(["warn", "warn"]) + expect(configIssues[0]!.message).toContain("Invariants") + expect(configIssues[1]!.message).toContain("Verification") + + const types = JSON.parse(runTx(["spec", "types", "--json"], projectDir).stdout) + expect(types.warnings).toHaveLength(2) + }) + + it("11. tx doc template previews the configured structure without writing", () => { + appendConfig( + [ + "[spec.types.rfc]", + "", + "[spec.types.rfc.section.summary]", + 'description = "What this proposes."', + ].join("\n"), + ) + + const res = runTx(["doc", "template", "rfc", "--name", "preview-rfc", "--title", "Preview"], projectDir) + expect(res.status).toBe(0) + expect(res.stdout).toContain("spec_type: rfc") + expect(res.stdout).toContain("# Summary") + expect(res.stdout).toContain("What this proposes.") + // Nothing was persisted. + expect(existsSync(specPath("rfc", "preview-rfc.md"))).toBe(false) + expect(JSON.parse(runTx(["doc", "list", "--json"], projectDir).stdout)).toEqual([]) + }) + + it("12. [INV-SPECCFG-008] generated skills embed this project's configured sections and prompts", () => { + appendConfig( + [ + "[spec.types.rfc]", + 'severity = "warn"', + "", + "[spec.types.rfc.section.motivation]", + 'description = "Motivation description v1."', + 'message = "{name}: every RFC needs {section}"', + ].join("\n"), + ) + + expect(runTx(["skills", "generate", "--target", "claude", "--clean"], projectDir).status).toBe(0) + + const skillPath = join( + projectDir, + ".tx", + "generated-skills", + "claude", + ".claude", + "skills", + "spec-doc", + "SKILL.md", + ) + const skill = readFileSync(skillPath, "utf-8") + expect(skill).toContain("### `rfc` (custom to this project)") + expect(skill).toContain("Motivation description v1.") + expect(skill).toContain("every RFC needs Motivation") + expect(skill).toContain("severity **warn**") + // The fixed core is still documented as non-configurable. + expect(skill).toContain("are fixed by tx and are NOT configurable") + + // Editing config and re-generating refreshes the rendered structure. + writeFileSync( + configPath(), + readFileSync(configPath(), "utf-8").replace( + "Motivation description v1.", + "Motivation description v2.", + ), + ) + runTx(["skills", "generate", "--target", "claude", "--clean"], projectDir) + expect(readFileSync(skillPath, "utf-8")).toContain("Motivation description v2.") + }) + + it("13. tx spec types lists built-ins with their descriptions by default", () => { + const res = runTx(["spec", "types", "--json"], projectDir) + expect(res.status).toBe(0) + + const parsed = JSON.parse(res.stdout) + const names = parsed.types.map((type: { name: string }) => type.name) + expect(names).toEqual(expect.arrayContaining(["prd", "design", "overview", "runbook", "decision"])) + + const prd = parsed.types.find((type: { name: string }) => type.name === "prd") + expect(prd.builtin).toBe(true) + expect(prd.customized).toBe(false) + for (const section of prd.sections) { + expect(section.description.length).toBeGreaterThan(0) + expect(section.message.length).toBeGreaterThan(0) + } + expect(parsed.warnings).toEqual([]) + }) + + it("14. tx init scaffolds the spec type config into .tx/config.toml", () => { + const raw = readFileSync(configPath(), "utf-8") + + expect(raw).toContain("[spec.types.prd]") + expect(raw).toContain("[spec.types.prd.section.acceptance-criteria]") + expect(raw).toContain("[spec.types.design.section.invariants]") + expect(raw).toContain('heading = "Acceptance Criteria"') + expect(raw).toContain("# [spec.types.rfc]") + expect(raw).toContain("# [spec.lint.messages]") + expect(raw).toContain("NOT configurable") + }) +}) diff --git a/test/unit/decompose-service.test.ts b/test/unit/decompose-service.test.ts index cd26c3c7..d3b16ade 100644 --- a/test/unit/decompose-service.test.ts +++ b/test/unit/decompose-service.test.ts @@ -13,6 +13,7 @@ import type { Doc, Task, TaskId, TaskWithDeps } from "@jamesaphoenix/tx/types" import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import { join } from "node:path" +import { asDocKind } from "@jamesaphoenix/tx/types" let sandboxDir = "" let previousCwd = "" @@ -21,7 +22,7 @@ const DESIGN_DOC: Doc = { id: 1 as any, docId: "doc-abc123def456" as any, hash: "hash", - kind: "design", + kind: asDocKind("design"), name: "auth-flow-design", title: "Auth Flow Design", version: 1, @@ -307,7 +308,7 @@ describe("DecomposeService", () => { Layer.mergeAll( harness.layer, Layer.succeed(DocService, { - get: () => Effect.succeed({ ...DESIGN_DOC, kind: "prd" } as Doc), + get: () => Effect.succeed({ ...DESIGN_DOC, kind: asDocKind("prd") } as Doc), } as any), Layer.succeed(DependencyService, { addBlocker: () => Effect.die(new Error("not implemented")), diff --git a/test/unit/doc-renderer-ears.test.ts b/test/unit/doc-renderer-ears.test.ts index f7c72e2b..fefb83b0 100644 --- a/test/unit/doc-renderer-ears.test.ts +++ b/test/unit/doc-renderer-ears.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest" import { composeEarsSentence, renderDocToMarkdown } from "@jamesaphoenix/tx" +import { asDocKind } from "@jamesaphoenix/tx/types" describe("EARS sentence composition", () => { it("composes each EARS pattern into deterministic prose", () => { @@ -67,7 +68,7 @@ describe("EARS rendering in PRDs", () => { it("renders structured EARS section with summary table and details", () => { const markdown = renderDocToMarkdown( { - kind: "prd", + kind: asDocKind("prd"), title: "EARS test", ears_requirements: [ { @@ -88,7 +89,7 @@ describe("EARS rendering in PRDs", () => { }, ], }, - "prd" + asDocKind("prd") ) expect(markdown).toContain("## Structured Requirements (EARS)") @@ -107,21 +108,21 @@ describe("EARS rendering in PRDs", () => { it("omits EARS section when ears_requirements is empty or missing", () => { const withoutEars = renderDocToMarkdown( { - kind: "prd", + kind: asDocKind("prd"), title: "No EARS", requirements: ["Requirement 1"], }, - "prd" + asDocKind("prd") ) expect(withoutEars).not.toContain("Structured Requirements (EARS)") const emptyEars = renderDocToMarkdown( { - kind: "prd", + kind: asDocKind("prd"), title: "Empty EARS", ears_requirements: [], }, - "prd" + asDocKind("prd") ) expect(emptyEars).not.toContain("Structured Requirements (EARS)") }) @@ -129,7 +130,7 @@ describe("EARS rendering in PRDs", () => { it("escapes pipe characters in EARS content", () => { const markdown = renderDocToMarkdown( { - kind: "prd", + kind: asDocKind("prd"), title: "Pipe escaping", ears_requirements: [ { @@ -140,7 +141,7 @@ describe("EARS rendering in PRDs", () => { }, ], }, - "prd" + asDocKind("prd") ) expect(markdown).toContain( @@ -151,7 +152,7 @@ describe("EARS rendering in PRDs", () => { it("renders both legacy requirements and EARS requirements", () => { const markdown = renderDocToMarkdown( { - kind: "prd", + kind: asDocKind("prd"), title: "Mixed requirements", requirements: ["Legacy requirement"], ears_requirements: [ @@ -163,7 +164,7 @@ describe("EARS rendering in PRDs", () => { }, ], }, - "prd" + asDocKind("prd") ) expect(markdown).toContain("## Requirements") diff --git a/test/unit/doc-renderer.test.ts b/test/unit/doc-renderer.test.ts index 821f1512..e9bd35b7 100644 --- a/test/unit/doc-renderer.test.ts +++ b/test/unit/doc-renderer.test.ts @@ -1,10 +1,11 @@ import { describe, it, expect } from "vitest" import { renderDocToMarkdown } from "@jamesaphoenix/tx" +import { asDocKind } from "@jamesaphoenix/tx/types" describe("Doc renderer structured section normalization", () => { it("renders failure_modes scenario entries and string edge_cases without undefined", () => { const parsed: Record<string, unknown> = { - kind: "design", + kind: asDocKind("design"), title: "Cycle-Based Issue Discovery", status: "changing", version: 1, @@ -23,7 +24,7 @@ describe("Doc renderer structured section normalization", () => { ], } - const markdown = renderDocToMarkdown(parsed, "design") + const markdown = renderDocToMarkdown(parsed, asDocKind("design")) // Renderer uses Condition | Impact | Handling columns (no ID column) expect(markdown).toContain( @@ -39,21 +40,21 @@ describe("Doc renderer structured section normalization", () => { it("keeps object-form failure_modes rendering intact", () => { const parsed: Record<string, unknown> = { - kind: "design", + kind: asDocKind("design"), title: "Failure Modes Shape", failure_modes: [ { condition: "Service timeout", impact: "Request fails", handling: "Retry once" }, ], } - const markdown = renderDocToMarkdown(parsed, "design") + const markdown = renderDocToMarkdown(parsed, asDocKind("design")) // Renderer uses Condition | Impact | Handling columns expect(markdown).toContain("| Service timeout | Request fails | Retry once |") }) it("renders requirement doc with expected sections", () => { const parsed: Record<string, unknown> = { - kind: "requirement", + kind: asDocKind("requirement"), title: "Auth Flows", status: "changing", actors: [ @@ -73,7 +74,7 @@ describe("Doc renderer structured section normalization", () => { ], } - const markdown = renderDocToMarkdown(parsed, "requirement") + const markdown = renderDocToMarkdown(parsed, asDocKind("requirement")) expect(markdown).toContain("# Auth Flows") expect(markdown).toContain("**Kind**: requirement") expect(markdown).toContain("## Actors") @@ -88,7 +89,7 @@ describe("Doc renderer structured section normalization", () => { it("renders system_design doc with expected sections", () => { const parsed: Record<string, unknown> = { - kind: "system_design", + kind: asDocKind("system_design"), title: "Error Handling", status: "changing", scope: "All services", @@ -105,7 +106,7 @@ describe("Doc renderer structured section normalization", () => { ], } - const markdown = renderDocToMarkdown(parsed, "system_design") + const markdown = renderDocToMarkdown(parsed, asDocKind("system_design")) expect(markdown).toContain("# Error Handling") expect(markdown).toContain("**Kind**: system_design") expect(markdown).toContain("## Scope") diff --git a/test/unit/format-tasks-markdown.test.ts b/test/unit/format-tasks-markdown.test.ts index 847f0de4..2da43d3c 100644 --- a/test/unit/format-tasks-markdown.test.ts +++ b/test/unit/format-tasks-markdown.test.ts @@ -7,6 +7,7 @@ import { describe, it, expect } from "vitest" import { formatTasksMarkdown } from "../../apps/cli/src/output.js" import type { ContextResult, LearningId, TaskId, TaskWithDeps } from "@jamesaphoenix/tx/types" +import { asDocKind } from "@jamesaphoenix/tx/types" // Helper to create a minimal TaskWithDeps for testing type TaskOverride = @@ -181,7 +182,7 @@ describe("formatTasksMarkdown", () => { docId: 1 as any, name: "auth-flow", title: "Auth Flow", - kind: "prd", + kind: asDocKind("prd"), version: 1, status: "changing", filePath: "specs/prd/auth-flow.md", diff --git a/test/unit/spec-section-lint.test.ts b/test/unit/spec-section-lint.test.ts new file mode 100644 index 00000000..2d254636 --- /dev/null +++ b/test/unit/spec-section-lint.test.ts @@ -0,0 +1,211 @@ +/** + * Required-section linting. + * + * Sections are lint-only: `parseMdDocSync` must succeed regardless, and + * `lintSpecSections` reports what is missing at the configured severity. + */ +import { describe, it, expect, afterEach } from "vitest" +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs" +import { dirname, join } from "node:path" +import { tmpdir } from "node:os" +import { Either } from "effect" +import { + lintSpecSections, + parseMdDocSync, + readTxConfig, + resolveSpecTypes, +} from "@jamesaphoenix/tx" + +const tempDirs: string[] = [] + +function registryFor(content?: string): ReturnType<typeof resolveSpecTypes> { + const cwd = mkdtempSync(join(tmpdir(), "tx-section-lint-")) + tempDirs.push(cwd) + if (content !== undefined) { + const path = join(cwd, ".tx", "config.toml") + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, content) + } + return resolveSpecTypes(readTxConfig(cwd)) +} + +const frontmatter = (specType: string) => + [ + "---", + "kind: spec", + `spec_type: ${specType}`, + "name: sample-doc", + 'title: "Sample Doc"', + "status: draft", + "version: 1", + "owners:", + " - docs-team", + 'summary: "A sample."', + "domain: sample", + "tags:", + " - sample", + "depends_on: []", + "supersedes: []", + "implements: null", + "last_reviewed_at: 2026-01-01", + "---", + "", + ].join("\n") + +function parseSpec(specType: string, body: string) { + const parsed = parseMdDocSync(frontmatter(specType) + body) + if (Either.isLeft(parsed)) { + throw new Error(`expected parse to succeed, got: ${parsed.left.reason}`) + } + return parsed.right +} + +const lint = ( + specType: string, + body: string, + registry: ReturnType<typeof resolveSpecTypes>, +) => lintSpecSections(parseSpec(specType, body), registry, { docName: "sample-doc" }) + +afterEach(() => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop() + if (!dir) continue + rmSync(dir, { recursive: true, force: true }) + } +}) + +const FULL_PRD = [ + "# Summary", + "s", + "# Problem", + "p", + "# Scope", + "sc", + "# Requirements", + "r", + "# Acceptance Criteria", + "ac", + "", +].join("\n") + +describe("lintSpecSections", () => { + it("returns nothing when every configured section is present", () => { + expect(lint("prd", FULL_PRD, registryFor())).toEqual([]) + }) + + it("[INV-SPECCFG-003] parses a doc with missing sections instead of failing", () => { + // The parse itself must succeed — this is the lint-only guarantee. + const parsed = parseSpec("prd", "# Summary\ns\n") + expect(parsed.kind).toBe("spec") + expect(parsed.sections.map((s) => s.heading)).toEqual(["Summary"]) + }) + + it("emits one error finding per missing section", () => { + const findings = lint("prd", "# Summary\ns\n", registryFor()) + + expect(findings.map((f) => f.section)).toEqual([ + "Problem", + "Scope", + "Requirements", + "Acceptance Criteria", + ]) + expect(findings.every((f) => f.severity === "error")).toBe(true) + expect(findings.every((f) => f.rule === "missing_section")).toBe(true) + }) + + it("[INV-SPECCFG-004] renders the per-section message with placeholders substituted", () => { + const findings = lint( + "prd", + "# Summary\ns\n# Scope\nsc\n# Requirements\nr\n# Acceptance Criteria\nac\n", + registryFor(), + ) + + expect(findings).toHaveLength(1) + expect(findings[0]!.message).toBe( + "sample-doc: PRD is missing '# Problem'. State the problem before listing requirements. The user or system problem being solved, with evidence or a motivating scenario.", + ) + expect(findings[0]!.message).not.toContain("{") + }) + + it("honours a custom message template from config", () => { + const registry = registryFor( + [ + "[spec.types.rfc]", + "", + "[spec.types.rfc.section.motivation]", + 'description = "Why now."', + 'message = "{name} [{spec_type}] needs {section}: {description}"', + "", + ].join("\n"), + ) + + const findings = lint("rfc", "# Summary\ns\n", registry) + expect(findings).toHaveLength(1) + expect(findings[0]!.message).toBe("sample-doc [rfc] needs Motivation: Why now.") + }) + + it("downgrades findings to warnings when severity is warn", () => { + const registry = registryFor(['[spec.types.prd]', 'severity = "warn"', ""].join("\n")) + + const findings = lint("prd", "# Summary\ns\n", registry) + expect(findings).toHaveLength(4) + expect(findings.every((f) => f.severity === "warn")).toBe(true) + }) + + it("[INV-SPECCFG-005] emits nothing when severity is off", () => { + const registry = registryFor(['[spec.types.prd]', 'severity = "off"', ""].join("\n")) + + expect(lint("prd", "", registry)).toEqual([]) + }) + + it("[INV-SPECCFG-010] matches headings case-insensitively and at any heading level", () => { + const body = [ + "## summary", + "s", + "###### PROBLEM", + "p", + "# scope", + "sc", + "# requirements", + "r", + "# ACCEPTANCE criteria", + "ac", + "", + ].join("\n") + + expect(lint("prd", body, registryFor())).toEqual([]) + }) + + it("[INV-SPECCFG-010] ignores headings inside fenced code blocks", () => { + const body = ["# Summary", "```md", "# Problem", "```", ""].join("\n") + + const findings = lint("prd", body, registryFor()) + expect(findings.map((f) => f.section)).toContain("Problem") + }) + + it("[INV-SPECCFG-007] warns once for a spec_type that is not configured", () => { + const findings = lint("postmortem", "# Anything\nx\n", registryFor()) + + expect(findings).toHaveLength(1) + expect(findings[0]!.rule).toBe("unknown_spec_type") + expect(findings[0]!.severity).toBe("warn") + expect(findings[0]!.message).toContain("postmortem") + expect(findings[0]!.message).not.toContain("{") + }) + + it("checks a custom type's configured sections", () => { + const registry = registryFor( + ["[spec.types.rfc]", 'sections = ["Summary", "Motivation", "Proposal"]', ""].join("\n"), + ) + + const findings = lint("rfc", "# Summary\ns\n# Proposal\np\n", registry) + expect(findings.map((f) => f.section)).toEqual(["Motivation"]) + }) + + it("returns nothing for task docs", () => { + const parsed = parseMdDocSync("---\nkind: task\nid: tx-123\n---\n\n# Anything\n") + if (Either.isLeft(parsed)) throw new Error("expected task doc to parse") + + expect(lintSpecSections(parsed.right, registryFor(), { docName: "t" })).toEqual([]) + }) +}) diff --git a/test/unit/spec-type-registry.test.ts b/test/unit/spec-type-registry.test.ts new file mode 100644 index 00000000..f4ef512c --- /dev/null +++ b/test/unit/spec-type-registry.test.ts @@ -0,0 +1,195 @@ +/** + * Spec-type registry: merging built-in defaults with user config, resolving + * per-section lint prompts, and the advisory warnings for dropped block-bearing + * sections. + */ +import { describe, it, expect, afterEach } from "vitest" +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs" +import { dirname, join } from "node:path" +import { tmpdir } from "node:os" +import { + readTxConfig, + renderLintMessage, + resolveSpecTypes, + specTypeNames, + specTypeSubdir, + DEFAULT_MISSING_SECTION_MESSAGE, +} from "@jamesaphoenix/tx" + +const tempDirs: string[] = [] + +function makeTempDir(): string { + const dir = mkdtempSync(join(tmpdir(), "tx-spec-registry-")) + tempDirs.push(dir) + return dir +} + +function withConfig(content: string): ReturnType<typeof resolveSpecTypes> { + const cwd = makeTempDir() + const path = join(cwd, ".tx", "config.toml") + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, content) + return resolveSpecTypes(readTxConfig(cwd)) +} + +afterEach(() => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop() + if (!dir) continue + rmSync(dir, { recursive: true, force: true }) + } +}) + +describe("resolveSpecTypes", () => { + it("[INV-SPECCFG-001] resolves the five built-in spec types plus legacy kinds", () => { + const registry = resolveSpecTypes(readTxConfig(makeTempDir())) + + expect(specTypeNames(registry)).toEqual([ + "decision", + "design", + "overview", + "prd", + "requirement", + "runbook", + "system_design", + ]) + expect(registry.types.get("prd")!.builtin).toBe(true) + expect(registry.types.get("prd")!.sectionsCustomized).toBe(false) + expect(registry.warnings).toEqual([]) + }) + + it("derives subdirs, with overview at the docs root and legacy kinds preserved", () => { + const registry = resolveSpecTypes(readTxConfig(makeTempDir())) + + expect(specTypeSubdir(registry, "prd")).toBe("prd") + expect(specTypeSubdir(registry, "overview")).toBe("") + expect(specTypeSubdir(registry, "requirement")).toBe("requirements") + expect(specTypeSubdir(registry, "system_design")).toBe("system-design") + // Unknown types fall back to the type name. + expect(specTypeSubdir(registry, "rfc")).toBe("rfc") + }) + + it("registers a custom type with its configured subdir and severity", () => { + const registry = withConfig( + [ + "[spec.types.rfc]", + 'severity = "warn"', + 'subdir = "rfcs"', + 'sections = ["Summary", "Motivation"]', + "", + ].join("\n"), + ) + + const rfc = registry.types.get("rfc")! + expect(rfc.builtin).toBe(false) + expect(rfc.severity).toBe("warn") + expect(rfc.subdir).toBe("rfcs") + expect(rfc.sections.map((s) => s.heading)).toEqual(["Summary", "Motivation"]) + }) + + it("resolves each section's message: per-section, then global, then built-in", () => { + const registry = withConfig( + [ + "[spec.lint.messages]", + 'missing_section = "GLOBAL {section}"', + "", + "[spec.types.rfc]", + "", + "[spec.types.rfc.section.summary]", + 'message = "PER-SECTION {section}"', + "", + "[spec.types.rfc.section.motivation]", + "", + ].join("\n"), + ) + + const rfc = registry.types.get("rfc")! + expect(rfc.sections[0]!.message).toBe("PER-SECTION {section}") + expect(rfc.sections[1]!.message).toBe("GLOBAL {section}") + // The global override also applies to built-in sections without their own. + expect(registry.types.get("design")!.sections[0]!.message).toBe("GLOBAL {section}") + // ...but a built-in section that ships its own message keeps it. + const invariants = registry.types + .get("design")! + .sections.find((section) => section.heading === "Invariants")! + expect(invariants.message).toContain("tx derives spec coverage") + }) + + it("falls back to the built-in template when nothing is configured", () => { + const registry = resolveSpecTypes(readTxConfig(makeTempDir())) + + expect(registry.types.get("design")!.sections[0]!.message).toBe( + DEFAULT_MISSING_SECTION_MESSAGE, + ) + }) + + it("flags a built-in whose sections were customized", () => { + const registry = withConfig( + ["[spec.types.prd]", 'sections = ["Summary", "Why Now", "Requirements"]', ""].join("\n"), + ) + + expect(registry.types.get("prd")!.sectionsCustomized).toBe(true) + expect(registry.types.get("design")!.sectionsCustomized).toBe(false) + }) + + it("treats heading case and whitespace as equivalent when detecting customization", () => { + const registry = withConfig( + [ + "[spec.types.overview]", + 'sections = ["summary", "ARCHITECTURE", "Components", "Data Flows"]', + "", + ].join("\n"), + ) + + expect(registry.types.get("overview")!.sectionsCustomized).toBe(false) + }) + + it("[INV-SPECCFG-011] warns when a design doc drops a block-bearing section", () => { + const registry = withConfig( + ["[spec.types.design]", 'sections = ["Summary", "Architecture"]', ""].join("\n"), + ) + + expect(registry.warnings).toHaveLength(2) + expect(registry.warnings[0]).toContain("spec.types.design") + expect(registry.warnings[0]).toContain("'Invariants' section removed") + expect(registry.warnings[1]).toContain("'Verification' section removed") + }) + + it("warns when a PRD drops its Requirements section", () => { + const registry = withConfig( + ["[spec.types.prd]", 'sections = ["Summary", "Problem"]', ""].join("\n"), + ) + + expect(registry.warnings).toHaveLength(1) + expect(registry.warnings[0]).toContain("'Requirements' section removed") + }) + + it("does not warn about custom types", () => { + const registry = withConfig( + ["[spec.types.rfc]", 'sections = ["Summary"]', ""].join("\n"), + ) + + expect(registry.warnings).toEqual([]) + }) +}) + +describe("renderLintMessage", () => { + it("substitutes known placeholders", () => { + expect( + renderLintMessage("{name}: add '{section}' to {spec_type} — {description}", { + name: "auth-prd", + section: "Problem", + spec_type: "prd", + description: "State the problem.", + }), + ).toBe("auth-prd: add 'Problem' to prd — State the problem.") + }) + + it("leaves unknown placeholders untouched", () => { + expect(renderLintMessage("{name} {nope}", { name: "x" })).toBe("x {nope}") + }) + + it("returns the template unchanged when it has no placeholders", () => { + expect(renderLintMessage("plain text", { name: "x" })).toBe("plain text") + }) +}) diff --git a/test/unit/toml-config.test.ts b/test/unit/toml-config.test.ts index 3e46a827..085c1c95 100644 --- a/test/unit/toml-config.test.ts +++ b/test/unit/toml-config.test.ts @@ -10,6 +10,7 @@ import { import { dirname, join } from "node:path"; import { tmpdir } from "node:os"; import { + listTomlSections, readTxConfig, writeDashboardDefaultTaskAssigmentType, scaffoldConfigToml, @@ -17,6 +18,15 @@ import { } from "@jamesaphoenix/tx"; const tempDirs: string[] = []; + +// Built-in spec-type defaults, read from a directory with no config file. +// Keeping these derived (rather than duplicated) keeps the round-trip +// assertions meaningful: the scaffolded TOML must parse back to these exact +// values. +const NO_CONFIG_DEFAULTS = readTxConfig(mkdtempSync(join(tmpdir(), "tx-toml-defaults-"))); +const BUILTIN_SPEC_TYPES = NO_CONFIG_DEFAULTS.spec.types; +const BUILTIN_LINT_MESSAGES = NO_CONFIG_DEFAULTS.spec.lintMessages; + const DEFAULTS = { docs: { path: "specs" }, spec: { @@ -34,6 +44,10 @@ const DEFAULTS = { "**/*_test.{c,cpp,cc}", ], designDocMissingTaskLinks: "always", + // Section definitions are large and are asserted structurally below; reuse + // the values readTxConfig produces for a project with no config file. + types: BUILTIN_SPEC_TYPES, + lintMessages: BUILTIN_LINT_MESSAGES, }, memory: { defaultDir: "specs" }, cycles: { scanPrompt: null, agents: 3, model: "claude-opus-4-6" }, @@ -94,13 +108,13 @@ afterEach(() => { }); describe("toml-config", () => { - it("returns defaults when config is missing", () => { + it("[INV-SPECCFG-001] returns defaults when config is missing", () => { const cwd = makeTempDir(); const config = readTxConfig(cwd); expect(config).toEqual(DEFAULTS); }); - it("returns defaults when config exists but cannot be read", () => { + it("[INV-SPECCFG-009] returns defaults when config exists but cannot be read", () => { const cwd = makeTempDir(); const invalidPath = join(cwd, ".tx", "config.toml"); mkdirSync(invalidPath, { recursive: true }); @@ -376,7 +390,7 @@ describe("scaffoldConfigToml", () => { expect(existsSync(join(cwd, ".tx", "config.toml"))).toBe(true); }); - it("produces a file that readTxConfig parses correctly", () => { + it("[INV-SPECCFG-002] produces a file that readTxConfig parses correctly", () => { const cwd = makeTempDir(); scaffoldConfigToml(cwd); @@ -384,3 +398,203 @@ describe("scaffoldConfigToml", () => { expect(config).toEqual(DEFAULTS); }); }); + +describe("listTomlSections", () => { + it("lists sections matching a prefix in file order", () => { + const toml = [ + "[docs]", + 'path = "specs"', + "[spec.types.prd]", + 'severity = "error"', + "[spec.types.prd.section.summary]", + 'heading = "Summary"', + "[spec.types.rfc]", + "[dashboard]", + ].join("\n"); + + expect(listTomlSections(toml, "spec.types")).toEqual([ + "spec.types.prd", + "spec.types.prd.section.summary", + "spec.types.rfc", + ]); + }); + + it("ignores trailing comments, indentation, and duplicates", () => { + const toml = [ + " [spec.types.prd] # the PRD type", + "[spec.types.prd]", + "[other]", + ].join("\n"); + + expect(listTomlSections(toml, "spec.types")).toEqual(["spec.types.prd"]); + }); + + it("returns an empty array when nothing matches", () => { + expect(listTomlSections("[docs]\npath = \"specs\"\n", "spec.types")).toEqual([]); + }); + + it("does not match a prefix that is only a partial name segment", () => { + expect(listTomlSections("[spec.typesetting]\n", "spec.types")).toEqual([]); + }); +}); + +describe("spec type configuration", () => { + it("ships built-in types with headings, descriptions, and severities", () => { + const config = readTxConfig(makeTempDir()); + + expect(Object.keys(config.spec.types).sort()).toEqual([ + "decision", + "design", + "overview", + "prd", + "runbook", + ]); + expect(config.spec.types.prd.sections.map((s) => s.heading)).toEqual([ + "Summary", + "Problem", + "Scope", + "Requirements", + "Acceptance Criteria", + ]); + expect(config.spec.types.prd.severity).toBe("error"); + // overview docs live at the docs root + expect(config.spec.types.overview.subdir).toBe(""); + for (const section of config.spec.types.design.sections) { + expect(section.description.length).toBeGreaterThan(0); + } + }); + + it("parses per-section tables with heading, description, and message", () => { + const cwd = makeTempDir(); + writeConfig( + cwd, + [ + "[spec.types.rfc]", + 'severity = "warn"', + 'subdir = "rfc"', + "", + "[spec.types.rfc.section.summary]", + 'description = "What this proposes."', + "", + "[spec.types.rfc.section.open-questions]", + 'message = "{name}: add {section}"', + "", + ].join("\n"), + ); + + const rfc = readTxConfig(cwd).spec.types.rfc; + expect(rfc.severity).toBe("warn"); + expect(rfc.subdir).toBe("rfc"); + expect(rfc.sections).toEqual([ + { slug: "summary", heading: "Summary", description: "What this proposes.", message: null }, + // heading falls back to the title-cased slug + { slug: "open-questions", heading: "Open Questions", description: "", message: "{name}: add {section}" }, + ]); + }); + + it("accepts the sections array shorthand", () => { + const cwd = makeTempDir(); + writeConfig( + cwd, + ["[spec.types.rfc]", 'sections = ["Summary", "Motivation"]', ""].join("\n"), + ); + + const rfc = readTxConfig(cwd).spec.types.rfc; + expect(rfc.sections.map((s) => s.heading)).toEqual(["Summary", "Motivation"]); + expect(rfc.sections.map((s) => s.slug)).toEqual(["summary", "motivation"]); + expect(rfc.severity).toBe("error"); + // subdir defaults to the type name at registry-resolution time + expect(rfc.subdir).toBeNull(); + }); + + it("prefers per-section tables over the array shorthand", () => { + const cwd = makeTempDir(); + writeConfig( + cwd, + [ + "[spec.types.rfc]", + 'sections = ["Ignored"]', + "", + "[spec.types.rfc.section.summary]", + 'description = "Wins."', + "", + ].join("\n"), + ); + + expect(readTxConfig(cwd).spec.types.rfc.sections.map((s) => s.heading)).toEqual([ + "Summary", + ]); + }); + + it("overrides a built-in type's sections while keeping other built-ins", () => { + const cwd = makeTempDir(); + writeConfig( + cwd, + [ + "[spec.types.prd]", + 'sections = ["Summary", "Why Now"]', + "", + ].join("\n"), + ); + + const config = readTxConfig(cwd); + expect(config.spec.types.prd.sections.map((s) => s.heading)).toEqual([ + "Summary", + "Why Now", + ]); + expect(config.spec.types.design.sections).toEqual(BUILTIN_SPEC_TYPES.design.sections); + }); + + it("keeps built-in sections when a type declares only severity", () => { + const cwd = makeTempDir(); + writeConfig(cwd, ['[spec.types.prd]', 'severity = "off"', ""].join("\n")); + + const prd = readTxConfig(cwd).spec.types.prd; + expect(prd.severity).toBe("off"); + expect(prd.sections).toEqual(BUILTIN_SPEC_TYPES.prd.sections); + }); + + it("falls back to the default severity when the value is invalid", () => { + const cwd = makeTempDir(); + writeConfig(cwd, ['[spec.types.prd]', 'severity = "loud"', ""].join("\n")); + + expect(readTxConfig(cwd).spec.types.prd.severity).toBe("error"); + }); + + it("skips type names that are not valid identifiers", () => { + const cwd = makeTempDir(); + writeConfig(cwd, ['[spec.types.Not Valid]', 'severity = "warn"', ""].join("\n")); + + expect(readTxConfig(cwd).spec.types["Not Valid"]).toBeUndefined(); + expect(Object.keys(readTxConfig(cwd).spec.types).sort()).toEqual( + Object.keys(BUILTIN_SPEC_TYPES).sort(), + ); + }); + + it("reads global lint message overrides", () => { + const cwd = makeTempDir(); + writeConfig( + cwd, + [ + "[spec.lint.messages]", + 'missing_section = "custom {section}"', + "", + ].join("\n"), + ); + + const messages = readTxConfig(cwd).spec.lintMessages; + expect(messages.missing_section).toBe("custom {section}"); + // untouched keys keep their defaults + expect(messages.unknown_spec_type).toBe(BUILTIN_LINT_MESSAGES.unknown_spec_type); + }); + + it("keeps [spec] scalar keys readable alongside [spec.types.*] subtables", () => { + const cwd = makeTempDir(); + scaffoldConfigToml(cwd); + + const config = readTxConfig(cwd); + expect(config.spec.designDocMissingTaskLinks).toBe("always"); + expect(config.spec.testPatterns.length).toBe(11); + expect(config.memory.defaultDir).toBe("specs"); + }); +});