From ca227208183ff570cde8672a3d03430b0746bd48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Darko=20Mijic=CC=81?= Date: Fri, 3 Apr 2026 07:42:55 +0200 Subject: [PATCH 1/2] feat(renderable): add decodeDocument API for live documentation serving MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add decodeDocument() and decodeDocumentSafe() — the codec decode step without markdown rendering. Interactive consumers (Studio desktop, MCP clients) use these to receive typed RenderableDocument blocks for native UI rendering instead of parsing static markdown. Also fix prepublishOnly to guarantee clean builds: pnpm clean runs first (removing stale tsbuildinfo), and a new verify:exports script checks all 34 declared export paths exist in dist/ before publish proceeds. New public API from @libar-dev/architect/renderable: - decodeDocument(type, dataset, options?) → RenderableDocument - decodeDocumentSafe(type, dataset, options?) → Result - type CodecOptions (codec-specific option union) - type GenerationError (structured decode/render error) --- package.json | 5 +- scripts/verify-exports.mjs | 75 ++++++++++++++++++++++++++ src/renderable/generate.ts | 105 +++++++++++++++++++++++++++++++++++++ src/renderable/index.ts | 4 ++ 4 files changed, 187 insertions(+), 2 deletions(-) create mode 100644 scripts/verify-exports.mjs diff --git a/package.json b/package.json index eeeb8c80..7f525a71 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@libar-dev/architect", - "version": "1.0.0-pre.6", + "version": "1.0.0-pre.7", "description": "Context engineering platform: extract patterns from TypeScript and Gherkin into a queryable state with living docs, architecture graphs, and FSM-enforced workflows.", "type": "module", "sideEffects": false, @@ -115,7 +115,8 @@ "format": "prettier --write \"src/**/*.ts\" \"tests/**/*.ts\" \"*.{json,md,yml}\"", "format:check": "prettier --check \"src/**/*.ts\" \"tests/**/*.ts\" \"*.{json,md,yml}\"", "preversion": "pnpm test && pnpm typecheck && pnpm lint && pnpm format:check", - "prepublishOnly": "pnpm build && pnpm test && pnpm typecheck && pnpm lint", + "verify:exports": "node scripts/verify-exports.mjs", + "prepublishOnly": "pnpm clean && pnpm build && pnpm verify:exports && pnpm test && pnpm typecheck && pnpm lint", "postversion": "git push && git push --tags", "release:pre": "npm version prerelease --preid=pre", "release:patch": "npm version patch", diff --git a/scripts/verify-exports.mjs b/scripts/verify-exports.mjs new file mode 100644 index 00000000..a9236a24 --- /dev/null +++ b/scripts/verify-exports.mjs @@ -0,0 +1,75 @@ +#!/usr/bin/env node + +/** + * Verify that all declared package.json exports resolve to existing files in dist/. + * + * Runs as part of prepublishOnly to prevent publishing packages with missing + * entry points — the exact issue that caused incomplete dist/ in pre.5. + * + * Exit code 0: all exports verified. + * Exit code 1: one or more exports missing. + */ + +import { readFileSync, existsSync } from 'node:fs'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const rootDir = resolve(__dirname, '..'); + +const pkg = JSON.parse(readFileSync(resolve(rootDir, 'package.json'), 'utf8')); +const exports = pkg.exports; + +if (!exports || typeof exports !== 'object') { + console.error('No exports field found in package.json'); + process.exit(1); +} + +let missing = 0; +let verified = 0; + +for (const [entryPoint, mapping] of Object.entries(exports)) { + // Skip non-object entries (e.g., "./package.json": "./package.json") + if (typeof mapping === 'string') { + const filePath = resolve(rootDir, mapping); + if (!existsSync(filePath)) { + console.error(` MISSING ${entryPoint} → ${mapping}`); + missing++; + } else { + verified++; + } + continue; + } + + // Check both types and import paths + for (const [condition, filePath] of Object.entries(mapping)) { + const absolutePath = resolve(rootDir, filePath); + if (!existsSync(absolutePath)) { + console.error(` MISSING ${entryPoint} [${condition}] → ${filePath}`); + missing++; + } else { + verified++; + } + } +} + +// Also verify bin entries +if (pkg.bin && typeof pkg.bin === 'object') { + for (const [cmd, filePath] of Object.entries(pkg.bin)) { + const absolutePath = resolve(rootDir, filePath); + if (!existsSync(absolutePath)) { + console.error(` MISSING bin.${cmd} → ${filePath}`); + missing++; + } else { + verified++; + } + } +} + +if (missing > 0) { + console.error(`\nExport verification failed: ${missing} missing, ${verified} verified.`); + console.error('Run "pnpm clean && pnpm build" to fix.'); + process.exit(1); +} + +console.log(`Exports verified: ${verified} paths exist.`); diff --git a/src/renderable/generate.ts b/src/renderable/generate.ts index 7b9e3f0d..77f42cc6 100644 --- a/src/renderable/generate.ts +++ b/src/renderable/generate.ts @@ -380,6 +380,111 @@ function resolveCodec(type: DocumentType, options?: CodecOptions): DocumentCodec // Generation Functions // ═══════════════════════════════════════════════════════════════════════════ +/** + * Decode a document type to RenderableDocument without rendering to markdown. + * + * This is the Live Documentation API entry point: it invokes the codec's decode() + * step and returns the structured RenderableDocument, skipping the markdown render + * step entirely. Interactive consumers (Studio desktop, MCP clients) use this to + * receive typed document blocks for native rendering. + * + * @param type - Document type to decode + * @param dataset - PatternGraph with pattern data + * @param options - Optional codec-specific options (e.g., patternName for design-review) + * @param contextEnrichment - Optional runtime context (projectMetadata, tagExampleOverrides) + * @returns Result containing RenderableDocument on success, or GenerationError on failure + * + * @example + * ```typescript + * const result = decodeDocumentSafe("design-review", patternGraph, { + * "design-review": { patternName: "SetupCommand" } + * }); + * if (Result.isOk(result)) { + * // result.value is RenderableDocument — structured JSON, not markdown + * const doc = result.value; + * console.log(doc.title, doc.sections.length); + * } + * ``` + */ +export function decodeDocumentSafe( + type: DocumentType, + dataset: PatternGraph, + options?: CodecOptions, + contextEnrichment?: CodecContextEnrichment +): Result { + const codec = resolveCodec(type, options); + if (codec === undefined) { + return Result.err({ + documentType: type, + message: `No codec registered for document type: ${type}`, + phase: 'decode', + }); + } + + if (contextEnrichment) { + setCodecContextEnrichment(contextEnrichment); + } + + try { + const doc = codec.decode(dataset) as RenderableDocument; + return Result.ok(doc); + } catch (err) { + return Result.err({ + documentType: type, + message: err instanceof Error ? err.message : String(err), + cause: err instanceof Error ? err : undefined, + phase: 'decode', + }); + } finally { + if (contextEnrichment) { + clearCodecContextEnrichment(); + } + } +} + +/** + * Decode a document type to RenderableDocument without rendering to markdown. + * + * Throwing variant of `decodeDocumentSafe()`. Use when you prefer exceptions + * over Result-based error handling. + * + * @param type - Document type to decode + * @param dataset - PatternGraph with pattern data + * @param options - Optional codec-specific options + * @param contextEnrichment - Optional runtime context + * @returns RenderableDocument — the structured intermediate format + * @throws Error if the codec is not registered or decode fails + * + * @example + * ```typescript + * const doc = decodeDocument("architecture", patternGraph); + * // doc.sections contains heading, mermaid, table, paragraph blocks + * ``` + */ +export function decodeDocument( + type: DocumentType, + dataset: PatternGraph, + options?: CodecOptions, + contextEnrichment?: CodecContextEnrichment +): RenderableDocument { + const codec = resolveCodec(type, options); + if (codec === undefined) { + throw new Error(`No codec registered for document type: ${type}`); + } + + if (contextEnrichment) { + setCodecContextEnrichment(contextEnrichment); + } + + try { + return codec.decode(dataset) as RenderableDocument; + } finally { + if (contextEnrichment) { + clearCodecContextEnrichment(); + } + } +} + /** * Generate a single document type with Result-based error handling. * diff --git a/src/renderable/index.ts b/src/renderable/index.ts index 6976c097..3edf109b 100644 --- a/src/renderable/index.ts +++ b/src/renderable/index.ts @@ -106,6 +106,8 @@ export { // ═══════════════════════════════════════════════════════════════════════════ export { + decodeDocument, + decodeDocumentSafe, generateDocument, generateDocuments, generateAllDocuments, @@ -113,7 +115,9 @@ export { isValidDocumentType, getDocumentTypeInfo, DOCUMENT_TYPES, + type CodecOptions, type DocumentType, + type GenerationError, } from './generate.js'; // ═══════════════════════════════════════════════════════════════════════════ From 938a6a34932f6744068a934d1c1df47c44bc309e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Darko=20Mijic=CC=81?= Date: Sat, 4 Apr 2026 11:12:33 +0200 Subject: [PATCH 2/2] fix(review): harden verify-exports and improve decode API error messages Multi-agent review findings: - Fix JSDoc example using invalid "design-review" DocumentType - Add path containment guard in verify-exports.mjs (prevents traversal) - Handle pkg.bin string shorthand (was silently skipped) - Guard nested conditional exports (skip with warning vs misleading error) - Include available types in "no codec registered" error messages --- scripts/verify-exports.mjs | 34 +++++++++++++++++++++++++++++----- src/renderable/generate.ts | 16 +++++++++------- 2 files changed, 38 insertions(+), 12 deletions(-) diff --git a/scripts/verify-exports.mjs b/scripts/verify-exports.mjs index a9236a24..0a995de5 100644 --- a/scripts/verify-exports.mjs +++ b/scripts/verify-exports.mjs @@ -25,14 +25,27 @@ if (!exports || typeof exports !== 'object') { process.exit(1); } +/** + * Verify that a resolved path stays inside the project root. + * Prevents crafted export values (absolute paths, ../ traversal) from + * probing arbitrary filesystem locations. + */ +function assertInsideRoot(absolutePath, label) { + if (!absolutePath.startsWith(rootDir + '/') && absolutePath !== rootDir) { + console.error(` SECURITY ${label} resolves outside project root: ${absolutePath}`); + process.exit(1); + } +} + let missing = 0; let verified = 0; for (const [entryPoint, mapping] of Object.entries(exports)) { // Skip non-object entries (e.g., "./package.json": "./package.json") if (typeof mapping === 'string') { - const filePath = resolve(rootDir, mapping); - if (!existsSync(filePath)) { + const absolutePath = resolve(rootDir, mapping); + assertInsideRoot(absolutePath, entryPoint); + if (!existsSync(absolutePath)) { console.error(` MISSING ${entryPoint} → ${mapping}`); missing++; } else { @@ -43,7 +56,12 @@ for (const [entryPoint, mapping] of Object.entries(exports)) { // Check both types and import paths for (const [condition, filePath] of Object.entries(mapping)) { + if (typeof filePath !== 'string') { + console.warn(` SKIPPED ${entryPoint} [${condition}] — nested conditional mapping (not verified)`); + continue; + } const absolutePath = resolve(rootDir, filePath); + assertInsideRoot(absolutePath, `${entryPoint} [${condition}]`); if (!existsSync(absolutePath)) { console.error(` MISSING ${entryPoint} [${condition}] → ${filePath}`); missing++; @@ -53,10 +71,16 @@ for (const [entryPoint, mapping] of Object.entries(exports)) { } } -// Also verify bin entries -if (pkg.bin && typeof pkg.bin === 'object') { - for (const [cmd, filePath] of Object.entries(pkg.bin)) { +// Also verify bin entries (supports both string and object forms) +if (pkg.bin) { + const binEntries = + typeof pkg.bin === 'string' + ? [[pkg.name?.split('/').pop() ?? '(default)', pkg.bin]] + : Object.entries(pkg.bin); + + for (const [cmd, filePath] of binEntries) { const absolutePath = resolve(rootDir, filePath); + assertInsideRoot(absolutePath, `bin.${cmd}`); if (!existsSync(absolutePath)) { console.error(` MISSING bin.${cmd} → ${filePath}`); missing++; diff --git a/src/renderable/generate.ts b/src/renderable/generate.ts index 77f42cc6..4e0d4a14 100644 --- a/src/renderable/generate.ts +++ b/src/renderable/generate.ts @@ -396,9 +396,7 @@ function resolveCodec(type: DocumentType, options?: CodecOptions): DocumentCodec * * @example * ```typescript - * const result = decodeDocumentSafe("design-review", patternGraph, { - * "design-review": { patternName: "SetupCommand" } - * }); + * const result = decodeDocumentSafe("architecture", patternGraph); * if (Result.isOk(result)) { * // result.value is RenderableDocument — structured JSON, not markdown * const doc = result.value; @@ -416,7 +414,7 @@ export function decodeDocumentSafe( if (codec === undefined) { return Result.err({ documentType: type, - message: `No codec registered for document type: ${type}`, + message: `No codec registered for document type: "${type}". Available types: ${CodecRegistry.getRegisteredTypes().join(', ')}`, phase: 'decode', }); } @@ -469,7 +467,9 @@ export function decodeDocument( ): RenderableDocument { const codec = resolveCodec(type, options); if (codec === undefined) { - throw new Error(`No codec registered for document type: ${type}`); + throw new Error( + `No codec registered for document type: "${type}". Available types: ${CodecRegistry.getRegisteredTypes().join(', ')}` + ); } if (contextEnrichment) { @@ -521,7 +521,7 @@ export function generateDocumentSafe( if (codec === undefined) { return Result.err({ documentType: type, - message: `No codec registered for document type: ${type}`, + message: `No codec registered for document type: "${type}". Available types: ${CodecRegistry.getRegisteredTypes().join(', ')}`, phase: 'decode', }); } @@ -602,7 +602,9 @@ export function generateDocument( const codec = resolveCodec(type, options); if (codec === undefined) { - throw new Error(`No codec registered for document type: ${type}`); + throw new Error( + `No codec registered for document type: "${type}". Available types: ${CodecRegistry.getRegisteredTypes().join(', ')}` + ); } // Set context enrichment before decode (cleared in finally)