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..0a995de5 --- /dev/null +++ b/scripts/verify-exports.mjs @@ -0,0 +1,99 @@ +#!/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); +} + +/** + * 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 absolutePath = resolve(rootDir, mapping); + assertInsideRoot(absolutePath, entryPoint); + if (!existsSync(absolutePath)) { + console.error(` MISSING ${entryPoint} → ${mapping}`); + missing++; + } else { + verified++; + } + continue; + } + + // 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++; + } else { + verified++; + } + } +} + +// 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++; + } 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..4e0d4a14 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("architecture", patternGraph); + * 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}". Available types: ${CodecRegistry.getRegisteredTypes().join(', ')}`, + 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}". Available types: ${CodecRegistry.getRegisteredTypes().join(', ')}` + ); + } + + 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. * @@ -416,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', }); } @@ -497,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) 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'; // ═══════════════════════════════════════════════════════════════════════════