@pithyjs/codex
v0.1.0-beta.1
Published
AI-powered documentation generation for PithyJS: extracts API metadata from annotated source, syncs READMEs, and detects breaking API changes.
Downloads
383
Readme
@pithyjs/codex
AI-powered documentation generation and maintenance for PithyJS. Codex scans source files for structured annotations, extracts API metadata, syncs READMEs with auto-generated content, validates documentation integrity, and detects breaking API changes via snapshot diffing.
Installation
npm install @pithyjs/codexpnpm add @pithyjs/codexyarn add @pithyjs/codeximport { runExtractionPipeline } from '@pithyjs/codex/extraction';Overview
Codex is the documentation backbone of PithyJS. It works by:
- Scanning source files for
@codex,@codexApi, and@codex:exampleannotations - Extracting JSDoc comments, TypeScript type signatures, and test examples
- Enriching codex entries with linked API metadata, test coverage, and source locations
- Syncing README files using
@codex:automarkers that auto-populate install commands, API tables, examples, and testing matrices - Validating that all codex entries have consistent metadata, examples, and test references
- Snapshotting API surfaces to detect breaking changes between releases
- Generating changelogs and migration guides via LLM integration (OpenAI)
CLI Commands
All commands are available as standalone binaries after building the package:
| Command | Description |
| --- | --- |
| pithy-codex | Main CLI entry point for the sync workflow |
| pithy-codex-sync | Run the full sync pipeline (scan → extract → enrich → validate) |
| pithy-codex-check | Validate codex entry integrity and consistency |
| pithy-codex-index | Regenerate codex index files |
| pithy-codex-extract | Run the 7-phase extraction pipeline |
| pithy-codex-readme-sync | Sync @codex:auto markers in README files |
| pithy-codex-validate | Validate extraction results against schemas |
| pithy-codex-watch | Watch mode for automatic re-sync on file changes |
| pithy-codex-snapshot | Save, list, or diff API snapshots for breaking change detection |
Via pnpm scripts
pnpm --filter @pithyjs/codex build # Build codex tools (required first)
pnpm --filter @pithyjs/codex sync # Smart sync (only changed entries)
pnpm --filter @pithyjs/codex review # Generate review.md
pnpm --filter @pithyjs/codex apply # Apply approved entries
pnpm --filter @pithyjs/codex check # Validate integrity
pnpm --filter @pithyjs/codex index # Regenerate indexes
pnpm --filter @pithyjs/codex readme-sync # Update READMEs with @codex:auto markers
pnpm --filter @pithyjs/codex snapshot # Save/list/diff API snapshots
pnpm --filter @pithyjs/codex extract # Run extraction pipeline
pnpm --filter @pithyjs/codex validate # Validate extraction results
pnpm --filter @pithyjs/codex watch # Watch mode for auto-syncExtraction Pipeline
The extraction pipeline runs in 7 phases to transform source annotations into rich documentation entries:
- Scan — Discover source files matching configured glob patterns
- Parse — Extract
@codexand@codexApiannotations from source files - JSDoc — Link JSDoc comments to their corresponding declarations
- Types — Extract TypeScript type signatures and interfaces
- Examples — Collect
@codex:examplemarkers from test files - Tests — Analyze test coverage and match tests to codex entries
- Enrich — Combine all extracted data into enriched codex entries with source links
The pipeline is configurable via ExtractionConfig and outputs an ExtractionResult with stats, enriched components, API entries, test references, and README sections.
API Reference
| API | Component | Signature | Stability | Description |
| --- | --- | --- | --- | --- |
| parseCodexAnnotations | annotations | (content: string) => CodexAnnotation[] | stable | Parses @codex annotations from source code |
| parseCodexApiAnnotations | annotations | (content: string) => CodexApiAnnotation[] | stable | Parses @codexApi annotations from source code |
| createSourceDigest | annotations | (content: string) => string | stable | Creates a stable content hash for a source file |
| createCanonicalFingerprint | annotations | (component: DiscoveredComponent) => string | stable | Creates a canonical fingerprint for a discovered component |
| discoverComponentsFromFile | annotations | (filePath: string, content?: string) => Promise<DiscoveredComponent[]> | stable | Discovers components from a single source file using annotations |
| validateAnnotation | annotations | (annotation: unknown, type: 'codex' | 'codexApi') => boolean | stable | Validates annotation format and logs issues in DEV mode |
| CHANGED_ONLY_PATHSPEC | changed-scope | readonly string[] | stable | - |
| loadEnv | env | () => void | stable | Loads environment variables from .env files using dotenv. |
| askLLM | llm | (messages: { role: 'system' | 'user'; content: string }[]) => Promise<string> | stable | Sends a prompt to the OpenAI API and returns the response text. |
| scan | scan | (root: string, files?: string[]) => Promise<Candidate[]> | stable | Scans source files for @codex annotations and returns discovered components/APIs. |
| combineFingerprints | scan | (fingerprints: string[]) => string | internal | Fold the per-file fingerprints of one @codex id into a single stable one. Sorts the COMPLETE list before hashing, which is what makes the result independent of scan order. Folding pairwise — hash(sort([acc, next])) — looks equivalent and is not: SHA-256 is not associative, so sorting at each step only commutes for two inputs. With three or more (and pithy.doctor.rules is declared by 22 files) a different scan order yields a different fingerprint for identical sources, which reports every entry as changed at random. |
| ApiSig | schema | z.ZodObject<{ name: string; stability: string; signature?: string }> | stable | Zod schema for an API signature entry with name, stability, and optional signature. |
| codexSchema | schema | z.ZodObject<CodexEntry> | stable | Zod schema defining the full structure of a codex entry. |
| CodexEntry | schema | z.infer<typeof codexSchema> | stable | Inferred TypeScript type from the codex schema. |
| consumeValue | snapshot-cli | (args: string[], index: number, flag: string) => string | stable | Consume the next argument as a value for an option flag. Throws if the value is missing or looks like another flag. |
| parseArgs | snapshot-cli | (args: string[]) => CliOptions | stable | - |
| validatePath | snapshot-cli | (basePath: string, inputPath: string) => string | stable | Validates and normalizes a path to prevent path traversal attacks |
| validatePath | sync-pipeline | (basePath: string, inputPath: string) => string | stable | Validates and normalizes a path to prevent path traversal attacks |
| runSyncPipeline | sync-pipeline | (options: SyncPipelineOptions, logger?: CodexLogger) => Promise<SyncPipelineResult> | stable | - |
| validateExampleSyntax | validate | (code: string, componentId?: string, apiName?: string, language?: string) => ExampleValidationResult | stable | - |
| validateExtractionResult | validate | (result: ExtendedExtractionResult) => ValidationResult | stable | - |
| formatValidationReport | validate | (result: ValidationResult) => string | stable | - |
| createApiSnapshot | breaking-changes | (result: ExtendedExtractionResult, version: string, timestamp?: string) => ApiSnapshot | stable | Create an API snapshot from extraction results at a given version. Captures all enriched API entries across all components. |
| snapshotFromTypeDefinitions | breaking-changes | (types: ExtractedTypeDefinition[], version: string, timestamp?: string) => ApiSnapshot | stable | Create an API snapshot from extracted type definitions. Only includes exported types (public API surface). |
| diffSnapshots | breaking-changes | (before: ApiSnapshot, after: ApiSnapshot) => SnapshotDiff | stable | Compare two API snapshots and produce a structured diff. Matches entries by qualifiedName (parent.name). |
| classifyChange | breaking-changes | (change: ApiChange) => ChangeClassification | stable | Classify a single API change as major, minor, or patch according to semver conventions. - major: removal or signature/member change of a stable API - minor: addition, removal of experimental/deprecated, or changes to experimental APIs - patch: stability level changes |
| detectBreakingChanges | breaking-changes | (diff: SnapshotDiff) => ApiChange[] | stable | Filter a snapshot diff down to only breaking changes (those classified as "major"). |
| generateMigrationGuide | breaking-changes | (changes: ApiChange[]) => string | stable | Generate a human-readable migration guide for a set of breaking changes. Groups changes by parent component and provides before/after comparisons. |
| generateChangelog | breaking-changes | (diff: SnapshotDiff) => string | stable | Generate a changelog in Markdown from a snapshot diff. Sections: Breaking Changes, Added, Changed. Omits empty sections. |
| extractExamples | example-extractor | (content: string, baseLocation: SourceLocation) => ExtractedExample[] | stable | Extracts all code examples from a markdown/documentation string |
| validateExample | example-extractor | (code: string, language: string) => { valid: boolean; errors: string[] } | stable | Validates that a code example is syntactically valid |
| wrapDoctestInHarness | example-extractor | (example: ExtractedExample, entryId: string) => string | stable | Wraps a doctest example in a test harness |
| extractExamplesFromSource | example-extractor | (content: string, filePath: string) => { functionName: string; examples: ExtractedExample[] }[] | stable | Extracts examples from JSDoc @example tags in source code |
| generateRunnableExampleFile | example-extractor | (examples: ExtractedExample[], imports: string[]) => string | stable | Generates a runnable example file from extracted examples |
| parseJSDocComment | jsdoc-parser | (comment: string, location: SourceLocation) => ParsedJSDoc | stable | Parses a single JSDoc comment block |
| extractJSDocBlocks | jsdoc-parser | (content: string, filePath: string) => { jsdoc: ParsedJSDoc; followingCode: string }[] | stable | Extracts all JSDoc blocks from a source file |
| linkJSDocToDeclarations | jsdoc-parser | (content: string, filePath: string) => Map<string, ParsedJSDoc> | stable | Links JSDoc blocks to their associated function/class declarations |
| extractSignature | jsdoc-parser | (code: string) => string | undefined | stable | Extracts function signature from source code |
| runExtractionPipeline | pipeline | (config?: Partial<ExtractionConfig>) => Promise<ExtendedExtractionResult> | stable | - |
| extractSingleFile | pipeline | (filePath: string, content?: string) => Promise<EnrichedComponent | null> | stable | - |
| generateExtractionReport | pipeline | (result: ExtractionResult) => string | stable | - |
| exportToCodexFormat | pipeline | (result: ExtractionResult) => object | stable | - |
| parseMarkerAttributes | readme-sync | (attrString: string) => Record<string, string> | stable | Parses HTML-style attributes from a marker opening tag string. Only quoted attribute values are supported (double or single quotes). Unquoted values like install=pkg are ignored by design. |
| resolveMarkerType | readme-sync | (attributes: Record<string, string>) => ReadmeMarkerType | null | stable | Determines marker type from its attributes. |
| parseReadmeMarkers | readme-sync | (content: string) => ReadmeMarker[] | stable | Parses all @codex:auto markers from README content. |
| matchIdPattern | readme-sync | (pattern: string, entryId: string) => boolean | stable | Matches codex entry IDs against a glob-style pattern. Supports: exact match, trailing * (one segment), trailing ** (any depth). |
| filterByPattern | readme-sync | (components: EnrichedComponent[], pattern: string) => EnrichedComponent[] | stable | Filters components by an ID pattern. |
| stripTestBoilerplate | readme-sync | (code: string) => string | stable | Strips test framework boilerplate from example code extracted from tests. Removes it()/test() wrapper, dedents, converts expect() to value comments. |
| generateInstallContent | readme-sync | (packageName: string) => string | stable | Generates install command content for a package. |
| generateExamplesContent | readme-sync | (components: EnrichedComponent[], limit?: number, testExamples?: TestExample[], pattern?: string) => string | stable | Generates examples content from extraction data. Prefers API-level JSDoc examples; falls back to @codex:example test examples when no API examples are found (test examples are safe from feedback loops). |
| escapeTableCell | readme-sync | (str: string) => string | stable | Escapes pipe characters and collapses newlines for use in markdown table cells. |
| escapeMd | readme-sync | (str: string) => string | stable | Escapes markdown special characters in inline text (bold markers, brackets, pipes, backticks). |
| sanitizeForInlineCode | readme-sync | (str: string) => string | stable | Strips backticks from a string so it can safely be wrapped in inline code. |
| longestBacktickRun | readme-sync | (str: string) => number | stable | Returns the length of the longest consecutive run of backticks in a string. |
| sanitizeLanguage | readme-sync | (lang: string) => string | stable | Sanitizes a language identifier for use in fenced code blocks. |
| generateApiContent | readme-sync | (components: EnrichedComponent[], format?: string) => string | stable | Generates API reference table from extraction data. |
| generateTestingContent | readme-sync | (components: EnrichedComponent[]) => string | stable | Generates testing pyramid table from extraction data. Includes a Status column showing coverage completeness based on test references and category-aware testing pyramid requirements. |
| generateBundleContent | readme-sync | (_packageName: string) => string | stable | Placeholder for bundle content generation (not yet implemented). |
| syncReadmeContent | readme-sync | (content: string, filePath: string, components: EnrichedComponent[], testExamples?: TestExample[]) => ReadmeSyncResult | stable | Syncs a single README's content: parses markers, generates content, replaces. |
| syncAllReadmes | readme-sync | (extractionResult: ExtendedExtractionResult, config?: ReadmeSyncConfig) => Promise<ReadmeSyncResult[]> | stable | Syncs all README files matching configured patterns. |
| getSnapshotPath | snapshot-store | (dir: string, version: string) => string | stable | Get the file path for a snapshot of a given version. |
| saveSnapshot | snapshot-store | (snapshot: ApiSnapshot, dir: string) => Promise<void> | stable | Save an API snapshot to disk as a JSON file named {version}.json. Creates the target directory if it doesn't exist. |
| loadSnapshot | snapshot-store | (version: string, dir: string) => Promise<ApiSnapshot | null> | stable | Load an API snapshot from disk by version. Returns null if the snapshot file does not exist. Throws if the file exists but cannot be parsed. |
| listSnapshots | snapshot-store | (dir: string) => Promise<string[]> | stable | List all stored snapshot versions, sorted by semver (with lexicographic fallback). Returns an empty array if the directory does not exist. |
| DeclarationLocation | source-linker | interface DeclarationLocation { name: string; type: string; location: SourceLocation; signature?: string; exported: boolean } | stable | Result of finding a declaration in source code |
| findDeclarationLocation | source-linker | (content: string, filePath: string, declarationName: string) => DeclarationLocation | undefined | stable | Finds the source location of a declaration by name |
| extractAllDeclarations | source-linker | (content: string, filePath: string) => DeclarationLocation[] | stable | Extracts all declarations from a source file |
| parseReadmeSections | source-linker | (content: string, filePath: string) => ReadmeSection[] | stable | Parses README content into sections |
| linkReadmeToEntries | source-linker | (sections: ReadmeSection[], entryIds: string[]) => ReadmeSection[] | stable | Links README sections to codex entry IDs based on heading matching |
| generateSourceLink | source-linker | (location: SourceLocation, repoUrl: string, branch?: string) => string | stable | Generates source links for display (GitHub-style URLs) |
| toRelativePath | source-linker | (absolutePath: string, projectRoot: string) => string | stable | Creates a relative path from project root |
| enrichSourceLocation | source-linker | (location: SourceLocation, content: string) => SourceLocation & { context?: string } | stable | Enriches source locations with additional context |
| extractTestExamples | test-example-extractor | (content: string, filePath: string) => TestExample[] | stable | - |
| parseVitestOutput | test-example-extractor | (output: string) => TestStatus[] | stable | - |
| matchExamplesToStatuses | test-example-extractor | (examples: TestExample[], statuses: TestStatus[]) => TestExample[] | stable | - |
| generateExampleReport | test-example-extractor | (examples: TestExample[]) => string | stable | - |
| validateTestExamples | test-example-extractor | (examples: TestExample[]) => { valid: TestExample[]; invalid: TestExample[] } | experimental | - |
| extractTestCases | test-pattern-extractor | (content: string, filePath: string) => ExtractedTestCase[] | stable | Extracts test cases from a single test file |
| extractTestImports | test-pattern-extractor | (content: string) => string[] | stable | Extracts imports from a test file to identify tested modules |
| identifyCoveredApis | test-pattern-extractor | (content: string, knownApis: string[]) => string[] | stable | Identifies which APIs are likely covered by a test file |
| analyzeTestFile | test-pattern-extractor | (content: string, filePath: string, knownApis?: string[]) => TestFileAnalysis | stable | Analyzes a test file and returns structured analysis |
| testCasesToReferences | test-pattern-extractor | (testCases: ExtractedTestCase[], filePath: string) => TestReference[] | stable | Converts test cases to test references for linking to codex entries |
| matchTestToEntry | test-pattern-extractor | (testFilePath: string, entryIds: string[]) => string | undefined | stable | Matches test files to codex entry IDs based on naming conventions |
| generateCoverageSummary | test-pattern-extractor | (analyses: TestFileAnalysis[]) => { totalTests: number; byType: Record<string, number>; coveredApis: string[] } | stable | Generates a test coverage summary for display |
| getTestingDefaults | testing-pyramid | (category: string) => TestingRequirements | stable | Get the default testing requirements for a given codex category. Returns feature defaults for unknown categories. Returns a fresh copy — callers may mutate freely. |
| applyRiskModifiers | testing-pyramid | (base: TestingRequirements, risk: RiskLevel | undefined) => TestingRequirements | stable | Apply risk-level modifiers to testing requirements. Returns a new object — does not mutate the input. |
| resolveTestingRequirements | testing-pyramid | (category: string, risk?: RiskLevel, override?: TestingRequirements) => TestingRequirements | stable | Resolve the final testing requirements for a codex entry. Merges annotation overrides with category defaults and applies risk modifiers. |
| computeTestingStatus | testing-pyramid | (reqs: TestingRequirements) => string | stable | Compute human-readable testing status from requirements. Returns "Complete" if all required levels are covered, "N/A" if no levels are defined, or "Missing X, Y" listing uncovered levels. |
| extractTypesFromFile | type-extractor | (filePath: string, content: string) => ExtractedTypeDefinition[] | stable | - |
| getTypeSignature | type-extractor | (def: ExtractedTypeDefinition) => string | stable | - |
| generateMethodTable | type-extractor | (def: ExtractedTypeDefinition) => string | stable | - |
Examples
parseCodexAnnotations
describe("parseCodexAnnotations", () => {
it("parses a simple flat annotation", () => {
const source = `/** ${CX}{ "id": "pithy.signals.signal", "title": "Signal", "category": "feature" } */`;
const result = parseCodexAnnotations(source);
result; // → 1
result[0].id; // → "pithy.signals.signal"
result[0].title; // → "Signal"
result[0].category; // → "feature"
});
it("parses annotation with risk field", () => {
const source = `/** ${CX}{ "id": "pithy.core.html", "title": "HTML", "category": "runtime", "risk": "high" } */`;
const result = parseCodexAnnotations(source);
result; // → 1
result[0].risk; // → "high"
});
it("parses annotation with nested testing field (2 levels of nesting)", () => {
const source = `/** ${CX}{ "id": "pithy.signals.signal", "title": "Signal", "category": "feature", "testing": { "unit": { "required": true, "covered": true, "file": "signal.test.ts" } } } */`;
const result = parseCodexAnnotations(source);
result; // → 1
result[0].testing; // → defined
result[0].testing!.unit; // → defined
result[0].testing!.unit!.required; // → true
result[0].testing!.unit!.covered; // → true
result[0].testing!.unit!.file; // → "signal.test.ts"
});
it("parses annotation with multiple nested testing levels", () => {
const source = `/** ${CX}{ "id": "x", "title": "X", "category": "feature", "testing": { "unit": { "required": true }, "integration": { "required": true }, "e2e": { "required": false } } } */`;
const result = parseCodexAnnotations(source);
result; // → 1
result[0].testing!.unit!.required; // → true
result[0].testing!.integration!.required; // → true
result[0].testing!.e2e!.required; // → false
});
it("parses multi-line annotation with nested testing", () => {
const source = `/**
* ${CX}
* { "id": "x", "title": "X", "category": "feature", "risk": "critical", "testing": { "unit": { "required": true, "covered": false } } }
*/`;
const result = parseCodexAnnotations(source);
result; // → 1
result[0].risk; // → "critical"
result[0].testing!.unit!.required; // → true
});
it("returns empty array for invalid JSON", () => {
const source = `/** ${CX}{ not valid json } */`;
const result = parseCodexAnnotations(source);
result; // → []
});
it("returns empty array for annotation missing required fields", () => {
const source = `/** ${CX}{ "id": "x" } */`;
const result = parseCodexAnnotations(source);
result; // → []
});
it("rejects annotation with invalid risk via validateAnnotation", () => {
const source = `/** ${CX}{ "id": "x", "title": "X", "category": "feature", "risk": "extreme" } */`;
const result = parseCodexAnnotations(source);
result; // → []
});
it("rejects annotation with invalid testing shape via validateAnnotation", () => {
const source = `/** ${CX}{ "id": "x", "title": "X", "category": "feature", "testing": { "unit": "yes" } } */`;
const result = parseCodexAnnotations(source);
result; // → []
});
it("parses multiple annotations in same file", () => {
const source = `
/** ${CX}{ "id": "a", "title": "A", "category": "feature" } */
export function a() {}
/** ${CX}{ "id": "b", "title": "B", "category": "runtime" } */
export function b() {}
`;
const result = parseCodexAnnotations(source);
result; // → 2
result[0].id; // → "a"
result[1].id; // → "b"
});
it("parses annotation embedded in surrounding code", () => {
const source = `
import { signal } from "./signal.js";
/** ${CX}{ "id": "pithy.core.html", "title": "HTML", "category": "runtime" } */
export function html(template: string) {
return document.createElement("div");
}
export function other() {}
`;
const result = parseCodexAnnotations(source);
result; // → 1
result[0].id; // → "pithy.core.html"
});
it("returns empty array for empty content", () => {
parseCodexAnnotations(""); // → []
});
});parseCodexApiAnnotations
describe("parseCodexApiAnnotations", () => {
it("parses a simple API annotation", () => {
const source = `/** ${CXA}{"parent":"pithy.signals","name":"signal","stability":"stable","signature":"<T>(v: T) => Signal<T>"} */`;
const result = parseCodexApiAnnotations(source);
result; // → 1
result[0].parent; // → "pithy.signals"
result[0].name; // → "signal"
result[0].stability; // → "stable"
});
it("defaults stability to stable", () => {
const source = `/** ${CXA}{"parent":"x","name":"fn"} */`;
const result = parseCodexApiAnnotations(source);
result[0].stability; // → "stable"
});
it("returns empty array for missing required fields", () => {
const source = `/** ${CXA}{"parent":"x"} */`;
const result = parseCodexApiAnnotations(source);
result; // → []
});
it("returns empty array for invalid JSON", () => {
const source = `/** ${CXA}{ bad json } */`;
const result = parseCodexApiAnnotations(source);
result; // → []
});
it("parses multiple API annotations in one file", () => {
const source = `
/** ${CXA}{"parent":"pithy.signals","name":"signal","stability":"stable"} */
export function signal() {}
/** ${CXA}{"parent":"pithy.signals","name":"effect","stability":"stable"} */
export function effect() {}
/** ${CXA}{"parent":"pithy.signals","name":"computed","stability":"experimental"} */
export function computed() {}
`;
const result = parseCodexApiAnnotations(source);
result; // → 3
result[0].name; // → "signal"
result[1].name; // → "effect"
result[2].name; // → "computed"
result[2].stability; // → "experimental"
});
it("preserves deprecated stability", () => {
const source = `/** ${CXA}{"parent":"x","name":"old","stability":"deprecated"} */`;
const result = parseCodexApiAnnotations(source);
result[0].stability; // → "deprecated"
});
it("returns empty array for empty content", () => {
parseCodexApiAnnotations(""); // → []
});
});validateAnnotation
describe("validateAnnotation", () => {
it("validates a correct codex annotation", () => {
validateAnnotation({ id: "x", title: "X", category: "feature" }, "codex"); // → true
});
it("rejects missing required fields", () => {
validateAnnotation({ id: "x" }, "codex"); // → false
});
it("rejects invalid category", () => {
validateAnnotation({ id: "x", title: "X", category: "invalid" }, "codex"); // → false
});
it("validates valid risk level", () => {
validateAnnotation({ id: "x", title: "X", category: "feature", risk: "critical" }, "codex"); // → true
validateAnnotation({ id: "x", title: "X", category: "feature", risk: "low" }, "codex"); // → true
});
it("rejects invalid risk level", () => {
validateAnnotation({ id: "x", title: "X", category: "feature", risk: "ultra" }, "codex"); // → false
});
it("validates valid testing field", () => {
const annotation = {
id: "x", title: "X", category: "feature",
testing: { unit: { required: true }, integration: { required: false } },
};
validateAnnotation(annotation, "codex"); // → true
});
it("rejects non-object testing field", () => {
validateAnnotation({ id: "x", title: "X", category: "feature", testing: "yes" }, "codex"); // → false
});
it("rejects unknown testing levels", () => {
const annotation = {
id: "x", title: "X", category: "feature",
testing: { performance: { required: true } },
};
validateAnnotation(annotation, "codex"); // → false
});
it("validates correct codexApi annotation", () => {
validateAnnotation({ parent: "x", name: "fn" }, "codexApi"); // → true
});
it("rejects codexApi missing parent", () => {
validateAnnotation({ name: "fn" }, "codexApi"); // → false
});
it("rejects codexApi missing name", () => {
validateAnnotation({ parent: "x" }, "codexApi"); // → false
});
it("validates all four risk levels", () => {
for (const risk of ["critical", "high", "medium", "low"]) {
validateAnnotation({ id: "x", title: "X", category: "feature", risk }, "codex"); // → true
}
});
it("rejects testing: null", () => {
validateAnnotation({ id: "x", title: "X", category: "feature", testing: null }, "codex"); // → false
});
it("accepts empty testing object", () => {
validateAnnotation({ id: "x", title: "X", category: "feature", testing: {} }, "codex"); // → true
});
it("rejects non-object testing level value (string)", () => {
validateAnnotation({ id: "x", title: "X", category: "feature", testing: { unit: "yes" } }, "codex"); // → false
});
it("rejects testing level with non-boolean required", () => {
validateAnnotation({ id: "x", title: "X", category: "feature", testing: { unit: { required: "yes" } } }, "codex"); // → false
});
it("rejects testing level with coverage > 100", () => {
validateAnnotation({ id: "x", title: "X", category: "feature", testing: { unit: { coverage: 150 } } }, "codex"); // → false
});
it("rejects testing level with negative coverage", () => {
validateAnnotation({ id: "x", title: "X", category: "feature", testing: { unit: { coverage: -1 } } }, "codex"); // → false
});
it("accepts valid testing level with required and coverage", () => {
validateAnnotation({ id: "x", title: "X", category: "feature", testing: { unit: { required: true, coverage: 80 } } }, "codex"); // → true
});
it("rejects testing level with non-boolean covered", () => {
validateAnnotation({ id: "x", title: "X", category: "feature", testing: { unit: { covered: "yes" } } }, "codex"); // → false
});
it("accepts testing level with boolean covered", () => {
validateAnnotation({ id: "x", title: "X", category: "feature", testing: { unit: { required: true, covered: true } } }, "codex"); // → true
});
});normalizeSourceContent
describe("normalizeSourceContent", () => {
it("removes block comments", () => {
const result = normalizeSourceContent("const a = 1; /* comment */ const b = 2;");
result; // → "comment"
result; // → "const a = 1;"
result; // → "const b = 2;"
});
it("removes line comments", () => {
const result = normalizeSourceContent("const a = 1; // line comment\nconst b = 2;");
result; // → "line comment"
result; // → "const a = 1;"
result; // → "const b = 2;"
});
it("preserves @codex annotations", () => {
const source = `/** ${CX}{ "id": "x" } */\nconst a = 1;`;
const result = normalizeSourceContent(source);
result; // → "ANNOTATION:"
result; // → "const a = 1;"
});
it("preserves @codexApi annotations", () => {
const source = `/** ${CXA}{"parent":"x","name":"fn"} */\nconst a = 1;`;
const result = normalizeSourceContent(source);
result; // → "ANNOTATION:"
});
it("normalizes whitespace to single spaces", () => {
const result = normalizeSourceContent("const a = 1;\n\n\n const b = 2;");
result; // → "const a = 1; const b = 2;"
});
it("trims leading and trailing whitespace", () => {
const result = normalizeSourceContent(" \n const a = 1; \n ");
result; // → "const a = 1;"
});
it("normalizes CRLF to LF", () => {
const lf = normalizeSourceContent("const a = 1;\nconst b = 2;");
const crlf = normalizeSourceContent("const a = 1;\r\nconst b = 2;");
crlf; // → lf
});
it("handles empty content", () => {
normalizeSourceContent(""); // → ""
});
it("handles content with only comments", () => {
const result = normalizeSourceContent("/* only a comment */\n// and a line comment");
result; // → ""
});
});createSourceDigest
describe("createSourceDigest", () => {
it("returns a 64-character hex string (SHA-256)", () => {
const digest = createSourceDigest("const a = 1;");
digest; // → /^[0-9a-f]{64}$/
});
it("is deterministic (same content produces same digest)", () => {
const content = "const a = signal(0);";
createSourceDigest(content); // → createSourceDigest(content)
});
it("produces different digests for different content", () => {
const d1 = createSourceDigest("const a = 1;");
const d2 = createSourceDigest("const b = 2;");
d1; // → d2
});
it("produces same digest for CRLF vs LF", () => {
const lf = createSourceDigest("const a = 1;\nconst b = 2;");
const crlf = createSourceDigest("const a = 1;\r\nconst b = 2;");
crlf; // → lf
});
it("ignores comment differences", () => {
const withComment = createSourceDigest("const a = 1; /* hello */");
const withoutComment = createSourceDigest("const a = 1;");
withComment; // → withoutComment
});
it("handles empty content", () => {
const digest = createSourceDigest("");
digest; // → /^[0-9a-f]{64}$/
});
});createCanonicalFingerprint
describe("createCanonicalFingerprint", () => {
const baseComponent: DiscoveredComponent = {
id: "pithy.signals.signal",
title: "Signal",
category: "feature",
apis: [
{ parent: "pithy.signals", name: "signal", stability: "stable" },
{ parent: "pithy.signals", name: "effect", stability: "stable" },
],
files: ["signal.ts"],
sourceDigest: "abc123",
};
it("returns a 64-character hex string (SHA-256)", () => {
const fp = createCanonicalFingerprint(baseComponent);
fp; // → /^[0-9a-f]{64}$/
});
it("is deterministic", () => {
const fp1 = createCanonicalFingerprint(baseComponent);
const fp2 = createCanonicalFingerprint(baseComponent);
fp1; // → fp2
});
it("produces same fingerprint regardless of API order", () => {
const reversed: DiscoveredComponent = {
...baseComponent,
apis: [
{ parent: "pithy.signals", name: "effect", stability: "stable" },
{ parent: "pithy.signals", name: "signal", stability: "stable" },
],
};
createCanonicalFingerprint(baseComponent); // → createCanonicalFingerprint(reversed)
});
it("produces different fingerprints for different components", () => {
const other: DiscoveredComponent = {
...baseComponent,
id: "pithy.signals.computed",
title: "Computed",
};
createCanonicalFingerprint(baseComponent); // → createCanonicalFingerprint(other)
});
it("produces different fingerprints when sourceDigest differs", () => {
const other: DiscoveredComponent = { ...baseComponent, sourceDigest: "different" };
createCanonicalFingerprint(baseComponent); // → createCanonicalFingerprint(other)
});
it("ignores files array (not part of canonical form)", () => {
const other: DiscoveredComponent = { ...baseComponent, files: ["other.ts", "extra.ts"] };
createCanonicalFingerprint(baseComponent); // → createCanonicalFingerprint(other)
});
});discoverComponentsFromFile
describe("discoverComponentsFromFile", () => {
it("discovers a component with matching API annotations", async () => {
const source = `
/** ${CX}{ "id": "pithy.signals", "title": "Signals", "category": "feature" } */
/** ${CXA}{"parent":"pithy.signals","name":"signal","stability":"stable"} */
export function signal() {}
/** ${CXA}{"parent":"pithy.signals","name":"effect","stability":"stable"} */
export function effect() {}
`;
const result = await discoverComponentsFromFile("signals.ts", source);
result; // → 1
result[0].id; // → "pithy.signals"
result[0].title; // → "Signals"
result[0].category; // → "feature"
result[0].apis; // → 2
result[0].apis[0].name; // → "signal"
result[0].apis[1].name; // → "effect"
result[0].files; // → ["signals.ts"]
result[0].sourceDigest; // → /^[0-9a-f]{64}$/
});
it("discovers multiple components from one file", async () => {
const source = `
/** ${CX}{ "id": "a", "title": "A", "category": "feature" } */
/** ${CX}{ "id": "b", "title": "B", "category": "runtime" } */
`;
const result = await discoverComponentsFromFile("multi.ts", source);
result; // → 2
result[0].id; // → "a"
result[1].id; // → "b"
});
it("assigns APIs only to their parent component", async () => {
const source = `
/** ${CX}{ "id": "comp-a", "title": "A", "category": "feature" } */
/** ${CX}{ "id": "comp-b", "title": "B", "category": "runtime" } */
/** ${CXA}{"parent":"comp-a","name":"fn1"} */
/** ${CXA}{"parent":"comp-b","name":"fn2"} */
/** ${CXA}{"parent":"comp-b","name":"fn3"} */
`;
const result = await discoverComponentsFromFile("multi.ts", source);
result[0].apis; // → 1
result[0].apis[0].name; // → "fn1"
result[1].apis; // → 2
result[1].apis[0].name; // → "fn2"
});
it("returns empty array for content with no annotations", async () => {
const result = await discoverComponentsFromFile("plain.ts", "const a = 1;");
result; // → []
});
it("returns empty array for empty content", async () => {
const result = await discoverComponentsFromFile("empty.ts", "");
result; // → []
});
it("returns empty APIs when no @codexApi matches component", async () => {
const source = `
/** ${CX}{ "id": "lonely", "title": "Lonely", "category": "feature" } */
/** ${CXA}{"parent":"other-component","name":"fn"} */
`;
const result = await discoverComponentsFromFile("lonely.ts", source);
result; // → 1
result[0].apis; // → []
});
it("carries risk and testing fields from annotation to component", async () => {
const source = `
/** ${CX}{ "id": "pithy.core.html", "title": "HTML", "category": "runtime", "risk": "high", "testing": { "unit": { "required": true } } } */
`;
const result = await discoverComponentsFromFile("html.ts", source);
result; // → 1
result[0].risk; // → "high"
result[0].testing; // → { unit: { required: true } }
});
it("leaves risk and testing undefined when annotation omits them", async () => {
const source = `
/** ${CX}{ "id": "pithy.core.simple", "title": "Simple", "category": "feature" } */
`;
const result = await discoverComponentsFromFile("simple.ts", source);
result; // → 1
result[0].risk; // → undefined
result[0].testing; // → undefined
});
});parseArgs
describe("parseArgs", () => {
it("defaults to help command with no args", () => {
const opts = parseArgs([]);
opts.command; // → "help"
});
it("parses save command with version", () => {
const opts = parseArgs(["save", "--version", "1.0.0"]);
opts.command; // → "save"
opts.version; // → "1.0.0"
});
it("parses diff command with from and to", () => {
const opts = parseArgs(["diff", "--from", "1.0.0", "--to", "2.0.0"]);
opts.command; // → "diff"
opts.fromVersion; // → "1.0.0"
opts.toVersion; // → "2.0.0"
});
it("parses list command", () => {
const opts = parseArgs(["list"]);
opts.command; // → "list"
});
it("parses verbose flag", () => {
const opts = parseArgs(["save", "-v", "--version", "1.0.0"]);
opts.verbose; // → true
});
it("parses --help flag overriding command", () => {
const opts = parseArgs(["save", "--help"]);
opts.command; // → "help"
});
it("parses custom root and dir", () => {
const opts = parseArgs(["list", "--root", "/my/project", "--dir", "versions"]);
opts.root; // → "/my/project"
opts.versionsDir; // → "versions"
});
it("throws when --root has no value", () => {
expect(() => parseArgs(["list", "--root"])).toThrow(/Missing value for --root/);
});
it("throws when --version value looks like a flag", () => {
expect(() => parseArgs(["save", "--version", "--from"])).toThrow(/Missing value for --version/);
});
it("throws when --from has no value", () => {
expect(() => parseArgs(["diff", "--from"])).toThrow(/Missing value for --from/);
});
it("throws when --dir value looks like a flag", () => {
expect(() => parseArgs(["list", "--dir", "--verbose"])).toThrow(/Missing value for --dir/);
});
});validatePath
describe("validatePath", () => {
it("accepts a relative path within the base", () => {
const result = validatePath("/project", "codex.versions");
result; // → resolve("/project", "codex.versions")
});
it("accepts a nested relative path", () => {
const result = validatePath("/project", "data/versions");
result; // → resolve("/project", "data/versions")
});
it("rejects a path that escapes the base via ..", () => {
expect(() => validatePath("/project", "../../etc/passwd")).toThrow(/Security error/);
});
it("rejects an absolute path outside the base", () => {
expect(() => validatePath("/project", "/etc/passwd")).toThrow(/Security error/);
});
it("accepts an absolute path within the base", () => {
const result = validatePath("/project", resolve("/project", "codex.versions"));
result; // → resolve("/project", "codex.versions")
});
it("accepts a directory name starting with .. (e.g. ..foo)", () => {
const result = validatePath("/project", "..foo");
result; // → resolve("/project", "..foo")
});
});consumeValue
describe("consumeValue", () => {
it("returns the value at the given index", () => {
consumeValue(["--root", "/foo"], 1, "--root"); // → "/foo"
});
it("throws for undefined value (past end of array)", () => {
expect(() => consumeValue(["--root"], 1, "--root")).toThrow(/Missing value/);
});
it("throws for flag-like value", () => {
expect(() => consumeValue(["--root", "--dir"], 1, "--root")).toThrow(/Missing value/);
});
});allows paths within the base directory
const result = validatePath(base, 'src/file.ts');
result; // → join(base, 'src/file.ts')rejects path traversal attempts
expect(() => validatePath(base, '../../../etc/passwd')).toThrow(
'Security error'
);validateExampleSyntax
describe("validateExampleSyntax", () => {
it("accepts valid TypeScript code", () => {
const result = validateExampleSyntax(
'const x: number = 42;\nconsole.log(x);',
"test.component"
);
result.valid; // → true
result.diagnostics; // → 0
});
it("detects syntax errors in TypeScript", () => {
const result = validateExampleSyntax(
"const x: number = ;",
"test.component"
);
result.valid; // → false
expect(result.diagnostics.length).toBeGreaterThan(0);
result.diagnostics[0]; // → "Line"
});
it("accepts valid JavaScript code", () => {
const result = validateExampleSyntax(
'const x = 42;\nconsole.log(x);',
"test.component",
undefined,
"javascript"
);
result.valid; // → true
});
it("treats unknown languages as valid (skip)", () => {
const result = validateExampleSyntax(
"this is not code at all {}[]",
"test.component",
undefined,
"markdown"
);
result.valid; // → true
result.diagnostics; // → 0
});
it("preserves componentId and apiName in result", () => {
const result = validateExampleSyntax(
"const x = 1;",
"my.component",
"myApi"
);
result.componentId; // → "my.component"
result.apiName; // → "myApi"
});
it("handles empty code string", () => {
const result = validateExampleSyntax("", "test.component");
result.valid; // → true
});
it("handles multiline code with imports", () => {
const code = `
import { signal } from '@pithyjs/signals';
const count = signal(0);
const doubled = computed(() => count() * 2);
console.log(doubled());
`;
const result = validateExampleSyntax(code, "test.component");
result.valid; // → true
});
it("detects multiple syntax errors", () => {
const code = "const x = ;\nconst y = ;";
const result = validateExampleSyntax(code, "test.component");
result.valid; // → false
expect(result.diagnostics.length).toBeGreaterThanOrEqual(1);
});
it("accepts async/await syntax", () => {
const code = `
async function fetchData() {
const res = await fetch('/api');
return res.json();
}
`;
const result = validateExampleSyntax(code, "test.component");
result.valid; // → true
});
it("handles 'js' as a language alias", () => {
const result = validateExampleSyntax(
"const x = 1;",
"test.component",
undefined,
"js"
);
result.valid; // → true
});
it("handles 'ts' as a language alias", () => {
const result = validateExampleSyntax(
"const x: string = 'hello';",
"test.component",
undefined,
"ts"
);
result.valid; // → true
});
it("handles code with Unicode characters", () => {
const result = validateExampleSyntax(
'const msg = "Hello 😀 World";',
"test.component"
);
result.valid; // → true
});
it("accepts type-only errors as valid (only checks syntax)", () => {
// Type error: assigning string to number — but syntactically valid
const result = validateExampleSyntax(
'const x: number = "not a number";',
"test.component"
);
result.valid; // → true
});
});validateExtractionResult
describe("validateExtractionResult", () => {
it("returns zero counts for empty extraction", () => {
const result = validateExtractionResult(makeExtractionResult());
result.totalExamples; // → 0
result.validCount; // → 0
result.invalidCount; // → 0
result.skippedCount; // → 0
result.invalidExamples; // → 0
});
it("validates component-level examples", () => {
const extraction = makeExtractionResult({
components: [
{
id: "test.comp",
title: "Test",
category: "feature",
apis: [],
examples: [
{
code: "const x = 42;",
language: "typescript",
runnable: true,
isDoctest: false,
location: { file: "test.ts", line: 1 },
},
],
testRefs: [],
sourceFiles: [{ file: "test.ts", line: 1 }],
},
],
});
const result = validateExtractionResult(extraction);
result.totalExamples; // → 1
result.validCount; // → 1
result.invalidCount; // → 0
});
it("validates API-level examples", () => {
const extraction = makeExtractionResult({
components: [
{
id: "test.comp",
title: "Test",
category: "feature",
apis: [
{
id: "test.comp.myFn",
name: "myFn",
parent: "test.comp",
stability: "stable",
examples: [
{
code: "const x: number = ;",
language: "typescript",
runnable: true,
isDoctest: false,
location: { file: "test.ts", line: 1 },
},
],
testRefs: [],
sourceLocation: { file: "test.ts", line: 1 },
},
],
examples: [],
testRefs: [],
sourceFiles: [{ file: "test.ts", line: 1 }],
},
],
});
const result = validateExtractionResult(extraction);
result.totalExamples; // → 1
result.invalidCount; // → 1
result.invalidExamples; // → 1
result.invalidExamples[0].apiName; // → "myFn"
});
it("skips non-validatable languages", () => {
const extraction = makeExtractionResult({
components: [
{
id: "test.comp",
title: "Test",
category: "feature",
apis: [],
examples: [
{
code: "npm install something",
language: "bash",
runnable: false,
isDoctest: false,
location: { file: "test.ts", line: 1 },
},
],
testRefs: [],
sourceFiles: [{ file: "test.ts", line: 1 }],
},
],
});
const result = validateExtractionResult(extraction);
result.totalExamples; // → 1
result.skippedCount; // → 1
result.validCount; // → 0
result.invalidCount; // → 0
});
it("handles mix of valid, invalid, and skipped", () => {
const extraction = makeExtractionResult({
components: [
{
id: "test.comp",
title: "Test",
category: "feature",
apis: [],
examples: [
{
code: "const x = 42;",
language: "typescript",
runnable: true,
isDoctest: false,
location: { file: "test.ts", line: 1 },
},
{
code: "const y = ;",
language: "typescript",
runnable: true,
isDoctest: false,
location: { file: "test.ts", line: 5 },
},
{
code: "echo hello",
language: "bash",
runnable: false,
isDoctest: false,
location: { file: "test.ts", line: 10 },
},
],
testRefs: [],
sourceFiles: [{ file: "test.ts", line: 1 }],
},
],
});
const result = validateExtractionResult(extraction);
result.totalExamples; // → 3
result.validCount; // → 1
result.invalidCount; // → 1
result.skippedCount; // → 1
});
it("records totalTimeMs", () => {
const result = validateExtractionResult(makeExtractionResult());
expect(result.totalTimeMs).toBeGreaterThanOrEqual(0);
});
});formatValidationReport
describe("formatValidationReport", () => {
it("formats empty result", () => {
const report = formatValidationReport({
totalExamples: 0,
validCount: 0,
invalidCount: 0,
skippedCount: 0,
invalidExamples: [],
totalTimeMs: 5,
});
report; // → "Total examples: 0"
report; // → "✅ Valid: 0"
report; // → "❌ Invalid: 0"
});
it("formats result with invalid examples", () => {
const report = formatValidationReport({
totalExamples: 3,
validCount: 1,
invalidCount: 1,
skippedCount: 1,
invalidExamples: [
{
code: "const x = ;",
componentId: "test.comp",
apiName: "myFn",
valid: false,
diagnostics: ["Line 1: Expression expected"],
},
],
totalTimeMs: 42,
});
report; // → "Total examples: 3"
report; // → "Invalid Examples:"
report; // → "test.comp.myFn"
report; // → "Expression expected"
});
it("shows component ID without apiName", () => {
const report = formatValidationReport({
totalExamples: 1,
validCount: 0,
invalidCount: 1,
skippedCount: 0,
invalidExamples: [
{
code: "bad code;",
componentId: "test.comp",
valid: false,
diagnostics: ["Line 1: Error"],
},
],
totalTimeMs: 10,
});
report; // → "test.comp"
report; // → "test.comp."
});
it("includes timing information", () => {
const report = formatValidationReport({
totalExamples: 0,
validCount: 0,
invalidCount: 0,
skippedCount: 0,
invalidExamples: [],
totalTimeMs: 123,
});
report; // → "123ms"
});
});createApiSnapshot
describe("createApiSnapshot", () => {
it("creates a snapshot from extraction results", () => {
const snap = makeSnapshot("1.0.0", [
{ name: "signal", signature: "<T>(initial: T) => Signal<T>" },
{ name: "effect", signature: "(fn: () => void) => () => void" },
]);
snap.version; // → "1.0.0"
snap.entries; // → 2
snap.entries[0].name; // → "signal"
snap.entries[0].signature; // → "<T>(initial: T) => Signal<T>"
snap.entries[1].name; // → "effect"
snap.timestamp; // → /^\d{4}-\d{2}-\d{2}/
});
it("captures stability for each API entry", () => {
const snap = makeSnapshot("1.0.0", [
{ name: "oldFn", stability: "deprecated", signature: "() => void" },
{ name: "newFn", stability: "experimental", signature: "() => void" },
]);
snap.entries[0].stability; // → "deprecated"
snap.entries[1].stability; // → "experimental"
});
it("creates an empty snapshot when no APIs exist", () => {
const snap = makeSnapshot("0.0.1", []);
snap.entries; // → 0
snap.version; // → "0.0.1"
});
it("preserves parent component id for each entry", () => {
const snap = makeSnapshot("1.0.0", [{ name: "fn", signature: "()" }]);
snap.entries[0].parent; // → "test"
});
});snapshotFromTypeDefinitions
describe("snapshotFromTypeDefinitions", () => {
it("creates entries from exported type definitions", () => {
const types: ExtractedTypeDefinition[] = [
{
name: "Signal",
kind: "interface",
exported: true,
members: [
{ name: "set", type: "method", optional: false, readonly: false },
{ name: "subscribe", type: "method", optional: false, readonly: false },
],
location: loc,
},
];
const snap = snapshotFromTypeDefinitions(types, "1.0.0");
snap.version; // → "1.0.0"
snap.entries; // → 1
snap.entries[0].name; // → "Signal"
snap.entries[0].kind; // → "interface"
snap.entries[0].signature; // → "export interface Signal"
});
it("skips non-exported type definitions", () => {
const types: ExtractedTypeDefinition[] = [
{
name: "Internal",
kind: "interface",
exported: false,
members: [],
location: loc,
},
];
const snap = snapshotFromTypeDefinitions(types, "1.0.0");
snap.entries; // → 0
});
it("captures member signatures for interfaces", () => {
const types: ExtractedTypeDefinition[] = [
{
name: "Router",
kind: "interface",
exported: true,
members: [
{
name: "navigate",
type: "method",
optional: false,
readonly: false,
signature: "(path: string) => void",
},
],
location: loc,
},
];
const snap = snapshotFromTypeDefinitions(types, "1.0.0");
snap.entries[0].members; // → 1
snap.entries[0].members![0].name; // → "navigate"
});
});diffSnapshots
describe("diffSnapshots", () => {
it("detects added APIs", () => {
const before = makeSnapshot("1.0.0", [{ name: "signal", signature: "()" }]);
const after = makeSnapshot("2.0.0", [
{ name: "signal", signature: "()" },
{ name: "effect", signature: "(fn: () => void) => void" },
]);
const diff = diffSnapshots(before, after);
diff.added; // → 1
diff.added[0].name; // → "effect"
diff.removed; // → 0
diff.changed; // → 0
});
it("detects removed APIs", () => {
const before = makeSnapshot("1.0.0", [
{ name: "signal", signature: "()" },
{ name: "obsolete", signature: "()" },
]);
const after = makeSnapshot("2.0.0", [{ name: "signal", signature: "()" }]);
const diff = diffSnapshots(before, after);
diff.removed; // → 1
diff.removed[0].name; // → "obsolete"
diff.added; // → 0
});
it("detects signature changes", () => {
const before = makeSnapshot("1.0.0", [
{ name: "signal", signature: "<T>(v: T) => Signal<T>" },
]);
const after = makeSnapshot("2.0.0", [
{ name: "signal", signature: "<T>(v: T, opts?: Options) => Signal<T>" },
]);
const diff = diffSnapshots(before, after);
diff.changed; // → 1
diff.changed[0].name; // → "signal"
diff.changed[0].changeKind; // → "signature-changed"
expect(diff.changed[0].before?.signature).toBe(
"<T>(v: T) => Signal<T>",
);
expect(diff.changed[0].after?.signature).toBe(
"<T>(v: T, opts?: Options) => Signal<T>",
);
});
it("detects stability changes", () => {
const before = makeSnapshot("1.0.0", [
{ name: "fn", stability: "stable", signature: "()" },
]);
const after = makeSnapshot("2.0.0", [
{ name: "fn", stability: "deprecated", signature: "()" },
]);
const diff = diffSnapshots(before, after);
diff.changed; // → 1
diff.changed[0].changeKind; // → "stability-changed"
});
it("reports unchanged APIs count", () => {
const before = makeSnapshot("1.0.0", [
{ name: "signal", signature: "()" },
{ name: "effect", signature: "(fn: () => void) => void" },
]);
const after = makeSnapshot("2.0.0", [
{ name: "signal", signature: "()" },
{ name: "effect", signature: "(fn: () => void) => void" },
]);
const diff = diffSnapshots(before, after);
diff.unchanged; // → 2
diff.added; // → 0
diff.removed; // → 0
diff.changed; // → 0
});
it("handles empty snapshots", () => {
const empty = makeSnapshot("0.0.0", []);
const filled = makeSnapshot("1.0.0", [
{ name: "signal", signature: "()" },
]);
const diff = diffSnapshots(empty, filled);
diff.added; // → 1
diff.removed; // → 0
});
it("handles both snapshots empty", () => {
const diff = diffSnapshots(
makeSnapshot("0.0.0", []),
makeSnapshot("1.0.0", []),
);
diff.added; // → 0
diff.removed; // → 0
diff.changed; // → 0
diff.unchanged; // → 0
});
it("uses qualified name (parent.name) for matching", () => {
const before = createApiSnapshot(
{
components: [
makeComponent("signals", [
makeApi({ name: "signal", signature: "()" }),
]),
makeComponent("router", [
makeApi({ name: "signal", signature: "(path: string) => void" }),
]),
],
testAnalysis: [],
readmeSections: [],
stats: {
filesScanned: 0,
componentsFound: 2,
apisExtracted: 2,
examplesExtracted: 0,
testsAnalyzed: 0,
readmeSectionsLinked: 0,
processingTimeMs: 0,
},
typeDefinitions: [],
testExamples: [],
testStatuses: [],
},
"1.0.0",
);
const after = createApiSnapshot(
{
components: [
makeComponent("signals", [
makeApi({ name: "signal", signature: "(changed: boolean) => void" }),
]),
makeComponent("router", [
makeApi({ name: "signal", signature: "(path: string) => void" }),
]),
],
testAnalysis: [],
readmeSections: [],
stats: {
filesScanned: 0,
componentsFound: 2,
apisExtracted: 2,
examplesExtracted: 0,
testsAnalyzed: 0,
readmeSectionsLinked: 0,
processingTimeMs: 0,
},
typeDefinitions: [],
testExamples: [],
testStatuses: [],
},
"2.0.0",
);
const diff = diffSnapshots(before, after);
// Only the signals.signal changed, router.signal stayed
diff.changed; // → 1
diff.changed[0].name; // → "signal"
diff.changed[0].parent; // → "signals"
diff.unchanged; // → 1
});
it("detects combined signature and stability changes", () => {
const before = makeSnapshot("1.0.0", [
{ name: "fn", stability: "stable", signature: "(a: number) => void" },
]);
const after = makeSnapshot("2.0.0", [
{
name: "fn",
stability: "deprecated",
signature: "(a: number, b: string) => void",
},
]);
const diff = diffSnapshots(before, after);
// Both changes detected; signature change takes priority
diff.changed; // → 1
diff.changed[0].changeKind; // → "signature-changed"
});
});classifyChange
describe("classifyChange", () => {
it("classifies removal as major (breaking)", () => {
const change: ApiChange = {
name: "obsolete",
parent: "test",
qualifiedName: "test.obsolete",
changeKind: "removed",
before: { name: "obsolete", signature: "()", stability: "stable", parent: "test", qualifiedName: "test.obsolete" },
};
classifyChange(change); // → "major"
});
it("classifies signature change as major (breaking)", () => {
const change: ApiChange = {
name: "signal",
parent: "test",
qualifiedName: "test.signal",
changeKind: "signature-changed",
before: { name: "signal", signature: "(a: T) => S", stability: "stable", parent: "test", qualifiedName: "test.signal" },
after: { name: "signal", signature: "(a: T, b: U) => S", stability: "stable", parent: "test", qualifiedName: "test.signal" },
};
classifyChange(change); // → "major"
});
it("classifies addition as minor (non-breaking)", () => {
const change: ApiChange = {
name: "effect",
parent: "test",
qualifiedName: "test.effect",
changeKind: "added",
after: { name: "effect", signature: "(fn: () => void) => void", stability: "stable", parent: "test", qualifiedName: "test.effect" },
};
classifyChange(change); // → "minor"
});
it("classifies stable->deprecated as patch", () => {
const change: ApiChange = {
name: "fn",
parent: "test",
qualifiedName: "test.fn",
changeKind: "stability-changed",
before: { name: "fn", signature: "()", stability: "stable", parent: "test", qualifiedName: "test.fn" },
after: { name: "fn", signature: "()", stability: "deprecated", parent: "test", qualifiedName: "test.fn" },
};
classifyChange(change); // → "patch"
});
it("classifies experimental->stable as patch", () => {
const change: ApiChange = {
name: "fn",
parent: "test",
qualifiedName: "test.fn",
changeKind: "stability-changed",
before: { name: "fn", signature: "()", stability: "experimental", parent: "test", qualifiedName: "test.fn" },
after: { name: "fn", signature: "()", stability: "stable", parent: "test", qualifiedName: "test.fn" },
};
classifyChange(change); // → "patch"
});
it("classifies removal of experimental API as minor", () => {
const change: ApiChange = {
name: "beta",
parent: "test",
qualifiedName: "test.beta",
changeKind: "removed",
before: { name: "beta", signature: "()", stability: "experimental", parent: "test", qualifiedName: "test.beta" },
};
classifyChange(change); // → "minor"
});
it("classifies removal of deprecated API as minor", () => {
const change: ApiChange = {
name: "old",
parent: "test",
qualifiedName: "test.old",
changeKind: "removed",
before: { name: "old", signature: "()", stability: "deprecated", parent: "test", qualifiedName: "test.old" },
};
classifyChange(change); // → "minor"
});
});detectBreakingChanges
describe("detectBreakingChanges", () => {
it("returns only breaking changes from a diff", () => {
const before = makeSnapshot("1.0.0", [
{ name: "signal", signature: "()" },
{ name: "removed", signature: "()" },
{ name: "changed", signature: "(a: number) => void" },
]);
const after = makeSnapshot("2.0.0", [
{ name: "signal", signature: "()" },
{ name: "added", signature: "()" },
{ name: "changed", signature: "(a: string) => void" },
]);
const diff = diffSnapshots(before, after);
const breaking = detectBreakingChanges(diff);
breaking; // → 2
const names = breaking.map((c) => c.name);
names; // → "removed"
names; // → "changed"
});
it("returns empty array when no breaking changes exist", () => {
const before = makeSnapshot("1.0.0", [{ name: "signal", signature: "()" }]);
const after = makeSnapshot("2.0.0", [
{ name: "signal", signature: "()" },
{ name: "effect", signature: "()" },
]);
const diff = diffSnapshots(before, after);
const breaking = detectBreakingChanges(diff);
breaking; // → 0
});
it("does not flag removal of experimental APIs as breaking", () => {
const before = makeSnapshot("1.0.0", [
{ name: "beta", stability: "experimental", signature: "()" },
]);
const after = makeSnapshot("2.0.0", []);
const diff = diffSnapshots(before, after);
const breaking = detectBreakingChanges(diff);
breaking; // → 0
});
it("does not flag removal of deprecated APIs as breaking", () => {
const before = makeSnapshot("1.0.0", [
{ name: "old", stability: "deprecated", signature: "()" },
]);
const after = makeSnapshot("2.0.0", []);
const diff = diffSnapshots(before, after);
const breaking = detectBreakingChanges(diff);
breaking; // → 0
});
});generateMigrationGuide
describe("generateMigrationGuide", () => {
it("generates guide for removed APIs", () => {
const changes: ApiChange[] = [
{
name: "obsolete",
parent: "signals",
qu