@pithyjs/codex 0.1.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +4401 -0
- package/dist/annotations.d.ts +106 -0
- package/dist/annotations.js +306 -0
- package/dist/apply.d.ts +2 -0
- package/dist/apply.js +80 -0
- package/dist/changed-scope.d.ts +23 -0
- package/dist/changed-scope.js +31 -0
- package/dist/check.d.ts +2 -0
- package/dist/check.js +117 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +67 -0
- package/dist/env.d.ts +5 -0
- package/dist/env.js +35 -0
- package/dist/extract-cli.d.ts +23 -0
- package/dist/extract-cli.js +192 -0
- package/dist/extraction/breaking-changes.d.ts +66 -0
- package/dist/extraction/breaking-changes.js +352 -0
- package/dist/extraction/example-extractor.d.ts +55 -0
- package/dist/extraction/example-extractor.js +272 -0
- package/dist/extraction/index.d.ts +27 -0
- package/dist/extraction/index.js +31 -0
- package/dist/extraction/jsdoc-parser.d.ts +43 -0
- package/dist/extraction/jsdoc-parser.js +274 -0
- package/dist/extraction/pipeline.d.ts +54 -0
- package/dist/extraction/pipeline.js +526 -0
- package/dist/extraction/readme-sync.d.ts +108 -0
- package/dist/extraction/readme-sync.js +592 -0
- package/dist/extraction/snapshot-store.d.ts +40 -0
- package/dist/extraction/snapshot-store.js +153 -0
- package/dist/extraction/source-linker.d.ts +80 -0
- package/dist/extraction/source-linker.js +316 -0
- package/dist/extraction/test-example-extractor.d.ts +69 -0
- package/dist/extraction/test-example-extractor.js +400 -0
- package/dist/extraction/test-pattern-extractor.d.ts +68 -0
- package/dist/extraction/test-pattern-extractor.js +261 -0
- package/dist/extraction/testing-pyramid.d.ts +44 -0
- package/dist/extraction/testing-pyramid.js +163 -0
- package/dist/extraction/type-extractor.d.ts +34 -0
- package/dist/extraction/type-extractor.js +494 -0
- package/dist/extraction/types.d.ts +401 -0
- package/dist/extraction/types.js +34 -0
- package/dist/indexer.d.ts +1 -0
- package/dist/indexer.js +107 -0
- package/dist/llm.d.ts +8 -0
- package/dist/llm.js +75 -0
- package/dist/readme-sync-cli.d.ts +20 -0
- package/dist/readme-sync-cli.js +167 -0
- package/dist/review.d.ts +2 -0
- package/dist/review.js +93 -0
- package/dist/scan.d.ts +29 -0
- package/dist/scan.js +221 -0
- package/dist/schema.d.ts +169 -0
- package/dist/schema.js +70 -0
- package/dist/snapshot-cli.d.ts +45 -0
- package/dist/snapshot-cli.js +217 -0
- package/dist/sync-cli.d.ts +22 -0
- package/dist/sync-cli.js +154 -0
- package/dist/sync-pipeline.d.ts +58 -0
- package/dist/sync-pipeline.js +104 -0
- package/dist/sync.d.ts +2 -0
- package/dist/sync.js +318 -0
- package/dist/validate-cli.d.ts +20 -0
- package/dist/validate-cli.js +144 -0
- package/dist/validate.d.ts +76 -0
- package/dist/validate.js +183 -0
- package/dist/watch-cli.d.ts +21 -0
- package/dist/watch-cli.js +220 -0
- package/package.json +62 -0
package/README.md
ADDED
|
@@ -0,0 +1,4401 @@
|
|
|
1
|
+
# @pithyjs/codex
|
|
2
|
+
|
|
3
|
+
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.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
<!-- @codex:auto install="@pithyjs/codex" -->
|
|
8
|
+
```bash
|
|
9
|
+
npm install @pithyjs/codex
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pnpm add @pithyjs/codex
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
yarn add @pithyjs/codex
|
|
18
|
+
```
|
|
19
|
+
<!-- @codex:end -->
|
|
20
|
+
|
|
21
|
+
```typescript
|
|
22
|
+
import { runExtractionPipeline } from '@pithyjs/codex/extraction';
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Overview
|
|
26
|
+
|
|
27
|
+
Codex is the documentation backbone of PithyJS. It works by:
|
|
28
|
+
|
|
29
|
+
1. **Scanning** source files for `@codex`, `@codexApi`, and `@codex:example` annotations
|
|
30
|
+
2. **Extracting** JSDoc comments, TypeScript type signatures, and test examples
|
|
31
|
+
3. **Enriching** codex entries with linked API metadata, test coverage, and source locations
|
|
32
|
+
4. **Syncing** README files using `@codex:auto` markers that auto-populate install commands, API tables, examples, and testing matrices
|
|
33
|
+
5. **Validating** that all codex entries have consistent metadata, examples, and test references
|
|
34
|
+
6. **Snapshotting** API surfaces to detect breaking changes between releases
|
|
35
|
+
7. **Generating** changelogs and migration guides via LLM integration (OpenAI)
|
|
36
|
+
|
|
37
|
+
## CLI Commands
|
|
38
|
+
|
|
39
|
+
All commands are available as standalone binaries after building the package:
|
|
40
|
+
|
|
41
|
+
| Command | Description |
|
|
42
|
+
| --- | --- |
|
|
43
|
+
| `pithy-codex` | Main CLI entry point for the sync workflow |
|
|
44
|
+
| `pithy-codex-sync` | Run the full sync pipeline (scan → extract → enrich → validate) |
|
|
45
|
+
| `pithy-codex-check` | Validate codex entry integrity and consistency |
|
|
46
|
+
| `pithy-codex-index` | Regenerate codex index files |
|
|
47
|
+
| `pithy-codex-extract` | Run the 7-phase extraction pipeline |
|
|
48
|
+
| `pithy-codex-readme-sync` | Sync `@codex:auto` markers in README files |
|
|
49
|
+
| `pithy-codex-validate` | Validate extraction results against schemas |
|
|
50
|
+
| `pithy-codex-watch` | Watch mode for automatic re-sync on file changes |
|
|
51
|
+
| `pithy-codex-snapshot` | Save, list, or diff API snapshots for breaking change detection |
|
|
52
|
+
|
|
53
|
+
### Via pnpm scripts
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
pnpm --filter @pithyjs/codex build # Build codex tools (required first)
|
|
57
|
+
pnpm --filter @pithyjs/codex sync # Smart sync (only changed entries)
|
|
58
|
+
pnpm --filter @pithyjs/codex review # Generate review.md
|
|
59
|
+
pnpm --filter @pithyjs/codex apply # Apply approved entries
|
|
60
|
+
pnpm --filter @pithyjs/codex check # Validate integrity
|
|
61
|
+
pnpm --filter @pithyjs/codex index # Regenerate indexes
|
|
62
|
+
pnpm --filter @pithyjs/codex readme-sync # Update READMEs with @codex:auto markers
|
|
63
|
+
pnpm --filter @pithyjs/codex snapshot # Save/list/diff API snapshots
|
|
64
|
+
pnpm --filter @pithyjs/codex extract # Run extraction pipeline
|
|
65
|
+
pnpm --filter @pithyjs/codex validate # Validate extraction results
|
|
66
|
+
pnpm --filter @pithyjs/codex watch # Watch mode for auto-sync
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Extraction Pipeline
|
|
70
|
+
|
|
71
|
+
The extraction pipeline runs in 7 phases to transform source annotations into rich documentation entries:
|
|
72
|
+
|
|
73
|
+
1. **Scan** — Discover source files matching configured glob patterns
|
|
74
|
+
2. **Parse** — Extract `@codex` and `@codexApi` annotations from source files
|
|
75
|
+
3. **JSDoc** — Link JSDoc comments to their corresponding declarations
|
|
76
|
+
4. **Types** — Extract TypeScript type signatures and interfaces
|
|
77
|
+
5. **Examples** — Collect `@codex:example` markers from test files
|
|
78
|
+
6. **Tests** — Analyze test coverage and match tests to codex entries
|
|
79
|
+
7. **Enrich** — Combine all extracted data into enriched codex entries with source links
|
|
80
|
+
|
|
81
|
+
The pipeline is configurable via `ExtractionConfig` and outputs an `ExtractionResult` with stats, enriched components, API entries, test references, and README sections.
|
|
82
|
+
|
|
83
|
+
## API Reference
|
|
84
|
+
|
|
85
|
+
<!-- @codex:auto api="pithy.codex.**" format="table" -->
|
|
86
|
+
| API | Component | Signature | Stability | Description |
|
|
87
|
+
| --- | --- | --- | --- | --- |
|
|
88
|
+
| parseCodexAnnotations | annotations | `(content: string) => CodexAnnotation[]` | stable | Parses @codex annotations from source code |
|
|
89
|
+
| parseCodexApiAnnotations | annotations | `(content: string) => CodexApiAnnotation[]` | stable | Parses @codexApi annotations from source code |
|
|
90
|
+
| createSourceDigest | annotations | `(content: string) => string` | stable | Creates a stable content hash for a source file |
|
|
91
|
+
| createCanonicalFingerprint | annotations | `(component: DiscoveredComponent) => string` | stable | Creates a canonical fingerprint for a discovered component |
|
|
92
|
+
| discoverComponentsFromFile | annotations | `(filePath: string, content?: string) => Promise<DiscoveredComponent[]>` | stable | Discovers components from a single source file using annotations |
|
|
93
|
+
| validateAnnotation | annotations | `(annotation: unknown, type: 'codex' | 'codexApi') => boolean` | stable | Validates annotation format and logs issues in DEV mode |
|
|
94
|
+
| CHANGED_ONLY_PATHSPEC | changed-scope | `readonly string[]` | stable | - |
|
|
95
|
+
| loadEnv | env | `() => void` | stable | Loads environment variables from .env files using dotenv. |
|
|
96
|
+
| askLLM | llm | `(messages: { role: 'system' | 'user'; content: string }[]) => Promise<string>` | stable | Sends a prompt to the OpenAI API and returns the response text. |
|
|
97
|
+
| scan | scan | `(root: string, files?: string[]) => Promise<Candidate[]>` | stable | Scans source files for @codex annotations and returns discovered components/APIs. |
|
|
98
|
+
| 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. |
|
|
99
|
+
| ApiSig | schema | `z.ZodObject<{ name: string; stability: string; signature?: string }>` | stable | Zod schema for an API signature entry with name, stability, and optional signature. |
|
|
100
|
+
| codexSchema | schema | `z.ZodObject<CodexEntry>` | stable | Zod schema defining the full structure of a codex entry. |
|
|
101
|
+
| CodexEntry | schema | `z.infer<typeof codexSchema>` | stable | Inferred TypeScript type from the codex schema. |
|
|
102
|
+
| 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. |
|
|
103
|
+
| parseArgs | snapshot-cli | `(args: string[]) => CliOptions` | stable | - |
|
|
104
|
+
| validatePath | snapshot-cli | `(basePath: string, inputPath: string) => string` | stable | Validates and normalizes a path to prevent path traversal attacks |
|
|
105
|
+
| validatePath | sync-pipeline | `(basePath: string, inputPath: string) => string` | stable | Validates and normalizes a path to prevent path traversal attacks |
|
|
106
|
+
| runSyncPipeline | sync-pipeline | `(options: SyncPipelineOptions, logger?: CodexLogger) => Promise<SyncPipelineResult>` | stable | - |
|
|
107
|
+
| validateExampleSyntax | validate | `(code: string, componentId?: string, apiName?: string, language?: string) => ExampleValidationResult` | stable | - |
|
|
108
|
+
| validateExtractionResult | validate | `(result: ExtendedExtractionResult) => ValidationResult` | stable | - |
|
|
109
|
+
| formatValidationReport | validate | `(result: ValidationResult) => string` | stable | - |
|
|
110
|
+
| 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. |
|
|
111
|
+
| 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). |
|
|
112
|
+
| diffSnapshots | breaking-changes | `(before: ApiSnapshot, after: ApiSnapshot) => SnapshotDiff` | stable | Compare two API snapshots and produce a structured diff. Matches entries by qualifiedName (parent.name). |
|
|
113
|
+
| 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 |
|
|
114
|
+
| detectBreakingChanges | breaking-changes | `(diff: SnapshotDiff) => ApiChange[]` | stable | Filter a snapshot diff down to only breaking changes (those classified as "major"). |
|
|
115
|
+
| 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. |
|
|
116
|
+
| generateChangelog | breaking-changes | `(diff: SnapshotDiff) => string` | stable | Generate a changelog in Markdown from a snapshot diff. Sections: Breaking Changes, Added, Changed. Omits empty sections. |
|
|
117
|
+
| extractExamples | example-extractor | `(content: string, baseLocation: SourceLocation) => ExtractedExample[]` | stable | Extracts all code examples from a markdown/documentation string |
|
|
118
|
+
| validateExample | example-extractor | `(code: string, language: string) => { valid: boolean; errors: string[] }` | stable | Validates that a code example is syntactically valid |
|
|
119
|
+
| wrapDoctestInHarness | example-extractor | `(example: ExtractedExample, entryId: string) => string` | stable | Wraps a doctest example in a test harness |
|
|
120
|
+
| extractExamplesFromSource | example-extractor | `(content: string, filePath: string) => { functionName: string; examples: ExtractedExample[] }[]` | stable | Extracts examples from JSDoc @example tags in source code |
|
|
121
|
+
| generateRunnableExampleFile | example-extractor | `(examples: ExtractedExample[], imports: string[]) => string` | stable | Generates a runnable example file from extracted examples |
|
|
122
|
+
| parseJSDocComment | jsdoc-parser | `(comment: string, location: SourceLocation) => ParsedJSDoc` | stable | Parses a single JSDoc comment block |
|
|
123
|
+
| extractJSDocBlocks | jsdoc-parser | `(content: string, filePath: string) => { jsdoc: ParsedJSDoc; followingCode: string }[]` | stable | Extracts all JSDoc blocks from a source file |
|
|
124
|
+
| linkJSDocToDeclarations | jsdoc-parser | `(content: string, filePath: string) => Map<string, ParsedJSDoc>` | stable | Links JSDoc blocks to their associated function/class declarations |
|
|
125
|
+
| extractSignature | jsdoc-parser | `(code: string) => string | undefined` | stable | Extracts function signature from source code |
|
|
126
|
+
| runExtractionPipeline | pipeline | `(config?: Partial<ExtractionConfig>) => Promise<ExtendedExtractionResult>` | stable | - |
|
|
127
|
+
| extractSingleFile | pipeline | `(filePath: string, content?: string) => Promise<EnrichedComponent | null>` | stable | - |
|
|
128
|
+
| generateExtractionReport | pipeline | `(result: ExtractionResult) => string` | stable | - |
|
|
129
|
+
| exportToCodexFormat | pipeline | `(result: ExtractionResult) => object` | stable | - |
|
|
130
|
+
| 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. |
|
|
131
|
+
| resolveMarkerType | readme-sync | `(attributes: Record<string, string>) => ReadmeMarkerType | null` | stable | Determines marker type from its attributes. |
|
|
132
|
+
| parseReadmeMarkers | readme-sync | `(content: string) => ReadmeMarker[]` | stable | Parses all @codex:auto markers from README content. |
|
|
133
|
+
| 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). |
|
|
134
|
+
| filterByPattern | readme-sync | `(components: EnrichedComponent[], pattern: string) => EnrichedComponent[]` | stable | Filters components by an ID pattern. |
|
|
135
|
+
| 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. |
|
|
136
|
+
| generateInstallContent | readme-sync | `(packageName: string) => string` | stable | Generates install command content for a package. |
|
|
137
|
+
| 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). |
|
|
138
|
+
| escapeTableCell | readme-sync | `(str: string) => string` | stable | Escapes pipe characters and collapses newlines for use in markdown table cells. |
|
|
139
|
+
| escapeMd | readme-sync | `(str: string) => string` | stable | Escapes markdown special characters in inline text (bold markers, brackets, pipes, backticks). |
|
|
140
|
+
| sanitizeForInlineCode | readme-sync | `(str: string) => string` | stable | Strips backticks from a string so it can safely be wrapped in inline code. |
|
|
141
|
+
| longestBacktickRun | readme-sync | `(str: string) => number` | stable | Returns the length of the longest consecutive run of backticks in a string. |
|
|
142
|
+
| sanitizeLanguage | readme-sync | `(lang: string) => string` | stable | Sanitizes a language identifier for use in fenced code blocks. |
|
|
143
|
+
| generateApiContent | readme-sync | `(components: EnrichedComponent[], format?: string) => string` | stable | Generates API reference table from extraction data. |
|
|
144
|
+
| 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. |
|
|
145
|
+
| generateBundleContent | readme-sync | `(_packageName: string) => string` | stable | Placeholder for bundle content generation (not yet implemented). |
|
|
146
|
+
| syncReadmeContent | readme-sync | `(content: string, filePath: string, components: EnrichedComponent[], testExamples?: TestExample[]) => ReadmeSyncResult` | stable | Syncs a single README's content: parses markers, generates content, replaces. |
|
|
147
|
+
| syncAllReadmes | readme-sync | `(extractionResult: ExtendedExtractionResult, config?: ReadmeSyncConfig) => Promise<ReadmeSyncResult[]>` | stable | Syncs all README files matching configured patterns. |
|
|
148
|
+
| getSnapshotPath | snapshot-store | `(dir: string, version: string) => string` | stable | Get the file path for a snapshot of a given version. |
|
|
149
|
+
| 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. |
|
|
150
|
+
| 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. |
|
|
151
|
+
| 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. |
|
|
152
|
+
| DeclarationLocation | source-linker | `interface DeclarationLocation { name: string; type: string; location: SourceLocation; signature?: string; exported: boolean }` | stable | Result of finding a declaration in source code |
|
|
153
|
+
| findDeclarationLocation | source-linker | `(content: string, filePath: string, declarationName: string) => DeclarationLocation | undefined` | stable | Finds the source location of a declaration by name |
|
|
154
|
+
| extractAllDeclarations | source-linker | `(content: string, filePath: string) => DeclarationLocation[]` | stable | Extracts all declarations from a source file |
|
|
155
|
+
| parseReadmeSections | source-linker | `(content: string, filePath: string) => ReadmeSection[]` | stable | Parses README content into sections |
|
|
156
|
+
| linkReadmeToEntries | source-linker | `(sections: ReadmeSection[], entryIds: string[]) => ReadmeSection[]` | stable | Links README sections to codex entry IDs based on heading matching |
|
|
157
|
+
| generateSourceLink | source-linker | `(location: SourceLocation, repoUrl: string, branch?: string) => string` | stable | Generates source links for display (GitHub-style URLs) |
|
|
158
|
+
| toRelativePath | source-linker | `(absolutePath: string, projectRoot: string) => string` | stable | Creates a relative path from project root |
|
|
159
|
+
| enrichSourceLocation | source-linker | `(location: SourceLocation, content: string) => SourceLocation & { context?: string }` | stable | Enriches source locations with additional context |
|
|
160
|
+
| extractTestExamples | test-example-extractor | `(content: string, filePath: string) => TestExample[]` | stable | - |
|
|
161
|
+
| parseVitestOutput | test-example-extractor | `(output: string) => TestStatus[]` | stable | - |
|
|
162
|
+
| matchExamplesToStatuses | test-example-extractor | `(examples: TestExample[], statuses: TestStatus[]) => TestExample[]` | stable | - |
|
|
163
|
+
| generateExampleReport | test-example-extractor | `(examples: TestExample[]) => string` | stable | - |
|
|
164
|
+
| validateTestExamples | test-example-extractor | `(examples: TestExample[]) => { valid: TestExample[]; invalid: TestExample[] }` | experimental | - |
|
|
165
|
+
| extractTestCases | test-pattern-extractor | `(content: string, filePath: string) => ExtractedTestCase[]` | stable | Extracts test cases from a single test file |
|
|
166
|
+
| extractTestImports | test-pattern-extractor | `(content: string) => string[]` | stable | Extracts imports from a test file to identify tested modules |
|
|
167
|
+
| identifyCoveredApis | test-pattern-extractor | `(content: string, knownApis: string[]) => string[]` | stable | Identifies which APIs are likely covered by a test file |
|
|
168
|
+
| analyzeTestFile | test-pattern-extractor | `(content: string, filePath: string, knownApis?: string[]) => TestFileAnalysis` | stable | Analyzes a test file and returns structured analysis |
|
|
169
|
+
| testCasesToReferences | test-pattern-extractor | `(testCases: ExtractedTestCase[], filePath: string) => TestReference[]` | stable | Converts test cases to test references for linking to codex entries |
|
|
170
|
+
| matchTestToEntry | test-pattern-extractor | `(testFilePath: string, entryIds: string[]) => string | undefined` | stable | Matches test files to codex entry IDs based on naming conventions |
|
|
171
|
+
| generateCoverageSummary | test-pattern-extractor | `(analyses: TestFileAnalysis[]) => { totalTests: number; byType: Record<string, number>; coveredApis: string[] }` | stable | Generates a test coverage summary for display |
|
|
172
|
+
| 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. |
|
|
173
|
+
| 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. |
|
|
174
|
+
| 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. |
|
|
175
|
+
| 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. |
|
|
176
|
+
| extractTypesFromFile | type-extractor | `(filePath: string, content: string) => ExtractedTypeDefinition[]` | stable | - |
|
|
177
|
+
| getTypeSignature | type-extractor | `(def: ExtractedTypeDefinition) => string` | stable | - |
|
|
178
|
+
| generateMethodTable | type-extractor | `(def: ExtractedTypeDefinition) => string` | stable | - |
|
|
179
|
+
<!-- @codex:end -->
|
|
180
|
+
|
|
181
|
+
## Examples
|
|
182
|
+
|
|
183
|
+
<!-- @codex:auto examples="pithy.codex.**" -->
|
|
184
|
+
**parseCodexAnnotations**
|
|
185
|
+
|
|
186
|
+
```typescript
|
|
187
|
+
describe("parseCodexAnnotations", () => {
|
|
188
|
+
it("parses a simple flat annotation", () => {
|
|
189
|
+
const source = `/** ${CX}{ "id": "pithy.signals.signal", "title": "Signal", "category": "feature" } */`;
|
|
190
|
+
const result = parseCodexAnnotations(source);
|
|
191
|
+
result; // → 1
|
|
192
|
+
result[0].id; // → "pithy.signals.signal"
|
|
193
|
+
result[0].title; // → "Signal"
|
|
194
|
+
result[0].category; // → "feature"
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("parses annotation with risk field", () => {
|
|
198
|
+
const source = `/** ${CX}{ "id": "pithy.core.html", "title": "HTML", "category": "runtime", "risk": "high" } */`;
|
|
199
|
+
const result = parseCodexAnnotations(source);
|
|
200
|
+
result; // → 1
|
|
201
|
+
result[0].risk; // → "high"
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("parses annotation with nested testing field (2 levels of nesting)", () => {
|
|
205
|
+
const source = `/** ${CX}{ "id": "pithy.signals.signal", "title": "Signal", "category": "feature", "testing": { "unit": { "required": true, "covered": true, "file": "signal.test.ts" } } } */`;
|
|
206
|
+
const result = parseCodexAnnotations(source);
|
|
207
|
+
result; // → 1
|
|
208
|
+
result[0].testing; // → defined
|
|
209
|
+
result[0].testing!.unit; // → defined
|
|
210
|
+
result[0].testing!.unit!.required; // → true
|
|
211
|
+
result[0].testing!.unit!.covered; // → true
|
|
212
|
+
result[0].testing!.unit!.file; // → "signal.test.ts"
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("parses annotation with multiple nested testing levels", () => {
|
|
216
|
+
const source = `/** ${CX}{ "id": "x", "title": "X", "category": "feature", "testing": { "unit": { "required": true }, "integration": { "required": true }, "e2e": { "required": false } } } */`;
|
|
217
|
+
const result = parseCodexAnnotations(source);
|
|
218
|
+
result; // → 1
|
|
219
|
+
result[0].testing!.unit!.required; // → true
|
|
220
|
+
result[0].testing!.integration!.required; // → true
|
|
221
|
+
result[0].testing!.e2e!.required; // → false
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("parses multi-line annotation with nested testing", () => {
|
|
225
|
+
const source = `/**
|
|
226
|
+
* ${CX}
|
|
227
|
+
* { "id": "x", "title": "X", "category": "feature", "risk": "critical", "testing": { "unit": { "required": true, "covered": false } } }
|
|
228
|
+
*/`;
|
|
229
|
+
const result = parseCodexAnnotations(source);
|
|
230
|
+
result; // → 1
|
|
231
|
+
result[0].risk; // → "critical"
|
|
232
|
+
result[0].testing!.unit!.required; // → true
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("returns empty array for invalid JSON", () => {
|
|
236
|
+
const source = `/** ${CX}{ not valid json } */`;
|
|
237
|
+
const result = parseCodexAnnotations(source);
|
|
238
|
+
result; // → []
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("returns empty array for annotation missing required fields", () => {
|
|
242
|
+
const source = `/** ${CX}{ "id": "x" } */`;
|
|
243
|
+
const result = parseCodexAnnotations(source);
|
|
244
|
+
result; // → []
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("rejects annotation with invalid risk via validateAnnotation", () => {
|
|
248
|
+
const source = `/** ${CX}{ "id": "x", "title": "X", "category": "feature", "risk": "extreme" } */`;
|
|
249
|
+
const result = parseCodexAnnotations(source);
|
|
250
|
+
result; // → []
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("rejects annotation with invalid testing shape via validateAnnotation", () => {
|
|
254
|
+
const source = `/** ${CX}{ "id": "x", "title": "X", "category": "feature", "testing": { "unit": "yes" } } */`;
|
|
255
|
+
const result = parseCodexAnnotations(source);
|
|
256
|
+
result; // → []
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("parses multiple annotations in same file", () => {
|
|
260
|
+
const source = `
|
|
261
|
+
/** ${CX}{ "id": "a", "title": "A", "category": "feature" } */
|
|
262
|
+
export function a() {}
|
|
263
|
+
|
|
264
|
+
/** ${CX}{ "id": "b", "title": "B", "category": "runtime" } */
|
|
265
|
+
export function b() {}
|
|
266
|
+
`;
|
|
267
|
+
const result = parseCodexAnnotations(source);
|
|
268
|
+
result; // → 2
|
|
269
|
+
result[0].id; // → "a"
|
|
270
|
+
result[1].id; // → "b"
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("parses annotation embedded in surrounding code", () => {
|
|
274
|
+
const source = `
|
|
275
|
+
import { signal } from "./signal.js";
|
|
276
|
+
|
|
277
|
+
/** ${CX}{ "id": "pithy.core.html", "title": "HTML", "category": "runtime" } */
|
|
278
|
+
export function html(template: string) {
|
|
279
|
+
return document.createElement("div");
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export function other() {}
|
|
283
|
+
`;
|
|
284
|
+
const result = parseCodexAnnotations(source);
|
|
285
|
+
result; // → 1
|
|
286
|
+
result[0].id; // → "pithy.core.html"
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("returns empty array for empty content", () => {
|
|
290
|
+
parseCodexAnnotations(""); // → []
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
**parseCodexApiAnnotations**
|
|
296
|
+
|
|
297
|
+
```typescript
|
|
298
|
+
describe("parseCodexApiAnnotations", () => {
|
|
299
|
+
it("parses a simple API annotation", () => {
|
|
300
|
+
const source = `/** ${CXA}{"parent":"pithy.signals","name":"signal","stability":"stable","signature":"<T>(v: T) => Signal<T>"} */`;
|
|
301
|
+
const result = parseCodexApiAnnotations(source);
|
|
302
|
+
result; // → 1
|
|
303
|
+
result[0].parent; // → "pithy.signals"
|
|
304
|
+
result[0].name; // → "signal"
|
|
305
|
+
result[0].stability; // → "stable"
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("defaults stability to stable", () => {
|
|
309
|
+
const source = `/** ${CXA}{"parent":"x","name":"fn"} */`;
|
|
310
|
+
const result = parseCodexApiAnnotations(source);
|
|
311
|
+
result[0].stability; // → "stable"
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("returns empty array for missing required fields", () => {
|
|
315
|
+
const source = `/** ${CXA}{"parent":"x"} */`;
|
|
316
|
+
const result = parseCodexApiAnnotations(source);
|
|
317
|
+
result; // → []
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("returns empty array for invalid JSON", () => {
|
|
321
|
+
const source = `/** ${CXA}{ bad json } */`;
|
|
322
|
+
const result = parseCodexApiAnnotations(source);
|
|
323
|
+
result; // → []
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it("parses multiple API annotations in one file", () => {
|
|
327
|
+
const source = `
|
|
328
|
+
/** ${CXA}{"parent":"pithy.signals","name":"signal","stability":"stable"} */
|
|
329
|
+
export function signal() {}
|
|
330
|
+
|
|
331
|
+
/** ${CXA}{"parent":"pithy.signals","name":"effect","stability":"stable"} */
|
|
332
|
+
export function effect() {}
|
|
333
|
+
|
|
334
|
+
/** ${CXA}{"parent":"pithy.signals","name":"computed","stability":"experimental"} */
|
|
335
|
+
export function computed() {}
|
|
336
|
+
`;
|
|
337
|
+
const result = parseCodexApiAnnotations(source);
|
|
338
|
+
result; // → 3
|
|
339
|
+
result[0].name; // → "signal"
|
|
340
|
+
result[1].name; // → "effect"
|
|
341
|
+
result[2].name; // → "computed"
|
|
342
|
+
result[2].stability; // → "experimental"
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it("preserves deprecated stability", () => {
|
|
346
|
+
const source = `/** ${CXA}{"parent":"x","name":"old","stability":"deprecated"} */`;
|
|
347
|
+
const result = parseCodexApiAnnotations(source);
|
|
348
|
+
result[0].stability; // → "deprecated"
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it("returns empty array for empty content", () => {
|
|
352
|
+
parseCodexApiAnnotations(""); // → []
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
**validateAnnotation**
|
|
358
|
+
|
|
359
|
+
```typescript
|
|
360
|
+
describe("validateAnnotation", () => {
|
|
361
|
+
it("validates a correct codex annotation", () => {
|
|
362
|
+
validateAnnotation({ id: "x", title: "X", category: "feature" }, "codex"); // → true
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it("rejects missing required fields", () => {
|
|
366
|
+
validateAnnotation({ id: "x" }, "codex"); // → false
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it("rejects invalid category", () => {
|
|
370
|
+
validateAnnotation({ id: "x", title: "X", category: "invalid" }, "codex"); // → false
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it("validates valid risk level", () => {
|
|
374
|
+
validateAnnotation({ id: "x", title: "X", category: "feature", risk: "critical" }, "codex"); // → true
|
|
375
|
+
validateAnnotation({ id: "x", title: "X", category: "feature", risk: "low" }, "codex"); // → true
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it("rejects invalid risk level", () => {
|
|
379
|
+
validateAnnotation({ id: "x", title: "X", category: "feature", risk: "ultra" }, "codex"); // → false
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it("validates valid testing field", () => {
|
|
383
|
+
const annotation = {
|
|
384
|
+
id: "x", title: "X", category: "feature",
|
|
385
|
+
testing: { unit: { required: true }, integration: { required: false } },
|
|
386
|
+
};
|
|
387
|
+
validateAnnotation(annotation, "codex"); // → true
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it("rejects non-object testing field", () => {
|
|
391
|
+
validateAnnotation({ id: "x", title: "X", category: "feature", testing: "yes" }, "codex"); // → false
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it("rejects unknown testing levels", () => {
|
|
395
|
+
const annotation = {
|
|
396
|
+
id: "x", title: "X", category: "feature",
|
|
397
|
+
testing: { performance: { required: true } },
|
|
398
|
+
};
|
|
399
|
+
validateAnnotation(annotation, "codex"); // → false
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it("validates correct codexApi annotation", () => {
|
|
403
|
+
validateAnnotation({ parent: "x", name: "fn" }, "codexApi"); // → true
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it("rejects codexApi missing parent", () => {
|
|
407
|
+
validateAnnotation({ name: "fn" }, "codexApi"); // → false
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
it("rejects codexApi missing name", () => {
|
|
411
|
+
validateAnnotation({ parent: "x" }, "codexApi"); // → false
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it("validates all four risk levels", () => {
|
|
415
|
+
for (const risk of ["critical", "high", "medium", "low"]) {
|
|
416
|
+
validateAnnotation({ id: "x", title: "X", category: "feature", risk }, "codex"); // → true
|
|
417
|
+
}
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it("rejects testing: null", () => {
|
|
421
|
+
validateAnnotation({ id: "x", title: "X", category: "feature", testing: null }, "codex"); // → false
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it("accepts empty testing object", () => {
|
|
425
|
+
validateAnnotation({ id: "x", title: "X", category: "feature", testing: {} }, "codex"); // → true
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
it("rejects non-object testing level value (string)", () => {
|
|
429
|
+
validateAnnotation({ id: "x", title: "X", category: "feature", testing: { unit: "yes" } }, "codex"); // → false
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it("rejects testing level with non-boolean required", () => {
|
|
433
|
+
validateAnnotation({ id: "x", title: "X", category: "feature", testing: { unit: { required: "yes" } } }, "codex"); // → false
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
it("rejects testing level with coverage > 100", () => {
|
|
437
|
+
validateAnnotation({ id: "x", title: "X", category: "feature", testing: { unit: { coverage: 150 } } }, "codex"); // → false
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it("rejects testing level with negative coverage", () => {
|
|
441
|
+
validateAnnotation({ id: "x", title: "X", category: "feature", testing: { unit: { coverage: -1 } } }, "codex"); // → false
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
it("accepts valid testing level with required and coverage", () => {
|
|
445
|
+
validateAnnotation({ id: "x", title: "X", category: "feature", testing: { unit: { required: true, coverage: 80 } } }, "codex"); // → true
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
it("rejects testing level with non-boolean covered", () => {
|
|
449
|
+
validateAnnotation({ id: "x", title: "X", category: "feature", testing: { unit: { covered: "yes" } } }, "codex"); // → false
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
it("accepts testing level with boolean covered", () => {
|
|
453
|
+
validateAnnotation({ id: "x", title: "X", category: "feature", testing: { unit: { required: true, covered: true } } }, "codex"); // → true
|
|
454
|
+
});
|
|
455
|
+
});
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
**normalizeSourceContent**
|
|
459
|
+
|
|
460
|
+
```typescript
|
|
461
|
+
describe("normalizeSourceContent", () => {
|
|
462
|
+
it("removes block comments", () => {
|
|
463
|
+
const result = normalizeSourceContent("const a = 1; /* comment */ const b = 2;");
|
|
464
|
+
result; // → "comment"
|
|
465
|
+
result; // → "const a = 1;"
|
|
466
|
+
result; // → "const b = 2;"
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
it("removes line comments", () => {
|
|
470
|
+
const result = normalizeSourceContent("const a = 1; // line comment\nconst b = 2;");
|
|
471
|
+
result; // → "line comment"
|
|
472
|
+
result; // → "const a = 1;"
|
|
473
|
+
result; // → "const b = 2;"
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
it("preserves @codex annotations", () => {
|
|
477
|
+
const source = `/** ${CX}{ "id": "x" } */\nconst a = 1;`;
|
|
478
|
+
const result = normalizeSourceContent(source);
|
|
479
|
+
result; // → "ANNOTATION:"
|
|
480
|
+
result; // → "const a = 1;"
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
it("preserves @codexApi annotations", () => {
|
|
484
|
+
const source = `/** ${CXA}{"parent":"x","name":"fn"} */\nconst a = 1;`;
|
|
485
|
+
const result = normalizeSourceContent(source);
|
|
486
|
+
result; // → "ANNOTATION:"
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
it("normalizes whitespace to single spaces", () => {
|
|
490
|
+
const result = normalizeSourceContent("const a = 1;\n\n\n const b = 2;");
|
|
491
|
+
result; // → "const a = 1; const b = 2;"
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
it("trims leading and trailing whitespace", () => {
|
|
495
|
+
const result = normalizeSourceContent(" \n const a = 1; \n ");
|
|
496
|
+
result; // → "const a = 1;"
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
it("normalizes CRLF to LF", () => {
|
|
500
|
+
const lf = normalizeSourceContent("const a = 1;\nconst b = 2;");
|
|
501
|
+
const crlf = normalizeSourceContent("const a = 1;\r\nconst b = 2;");
|
|
502
|
+
crlf; // → lf
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
it("handles empty content", () => {
|
|
506
|
+
normalizeSourceContent(""); // → ""
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
it("handles content with only comments", () => {
|
|
510
|
+
const result = normalizeSourceContent("/* only a comment */\n// and a line comment");
|
|
511
|
+
result; // → ""
|
|
512
|
+
});
|
|
513
|
+
});
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
**createSourceDigest**
|
|
517
|
+
|
|
518
|
+
```typescript
|
|
519
|
+
describe("createSourceDigest", () => {
|
|
520
|
+
it("returns a 64-character hex string (SHA-256)", () => {
|
|
521
|
+
const digest = createSourceDigest("const a = 1;");
|
|
522
|
+
digest; // → /^[0-9a-f]{64}$/
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
it("is deterministic (same content produces same digest)", () => {
|
|
526
|
+
const content = "const a = signal(0);";
|
|
527
|
+
createSourceDigest(content); // → createSourceDigest(content)
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
it("produces different digests for different content", () => {
|
|
531
|
+
const d1 = createSourceDigest("const a = 1;");
|
|
532
|
+
const d2 = createSourceDigest("const b = 2;");
|
|
533
|
+
d1; // → d2
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
it("produces same digest for CRLF vs LF", () => {
|
|
537
|
+
const lf = createSourceDigest("const a = 1;\nconst b = 2;");
|
|
538
|
+
const crlf = createSourceDigest("const a = 1;\r\nconst b = 2;");
|
|
539
|
+
crlf; // → lf
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
it("ignores comment differences", () => {
|
|
543
|
+
const withComment = createSourceDigest("const a = 1; /* hello */");
|
|
544
|
+
const withoutComment = createSourceDigest("const a = 1;");
|
|
545
|
+
withComment; // → withoutComment
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
it("handles empty content", () => {
|
|
549
|
+
const digest = createSourceDigest("");
|
|
550
|
+
digest; // → /^[0-9a-f]{64}$/
|
|
551
|
+
});
|
|
552
|
+
});
|
|
553
|
+
```
|
|
554
|
+
|
|
555
|
+
**createCanonicalFingerprint**
|
|
556
|
+
|
|
557
|
+
```typescript
|
|
558
|
+
describe("createCanonicalFingerprint", () => {
|
|
559
|
+
const baseComponent: DiscoveredComponent = {
|
|
560
|
+
id: "pithy.signals.signal",
|
|
561
|
+
title: "Signal",
|
|
562
|
+
category: "feature",
|
|
563
|
+
apis: [
|
|
564
|
+
{ parent: "pithy.signals", name: "signal", stability: "stable" },
|
|
565
|
+
{ parent: "pithy.signals", name: "effect", stability: "stable" },
|
|
566
|
+
],
|
|
567
|
+
files: ["signal.ts"],
|
|
568
|
+
sourceDigest: "abc123",
|
|
569
|
+
};
|
|
570
|
+
|
|
571
|
+
it("returns a 64-character hex string (SHA-256)", () => {
|
|
572
|
+
const fp = createCanonicalFingerprint(baseComponent);
|
|
573
|
+
fp; // → /^[0-9a-f]{64}$/
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
it("is deterministic", () => {
|
|
577
|
+
const fp1 = createCanonicalFingerprint(baseComponent);
|
|
578
|
+
const fp2 = createCanonicalFingerprint(baseComponent);
|
|
579
|
+
fp1; // → fp2
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
it("produces same fingerprint regardless of API order", () => {
|
|
583
|
+
const reversed: DiscoveredComponent = {
|
|
584
|
+
...baseComponent,
|
|
585
|
+
apis: [
|
|
586
|
+
{ parent: "pithy.signals", name: "effect", stability: "stable" },
|
|
587
|
+
{ parent: "pithy.signals", name: "signal", stability: "stable" },
|
|
588
|
+
],
|
|
589
|
+
};
|
|
590
|
+
createCanonicalFingerprint(baseComponent); // → createCanonicalFingerprint(reversed)
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
it("produces different fingerprints for different components", () => {
|
|
594
|
+
const other: DiscoveredComponent = {
|
|
595
|
+
...baseComponent,
|
|
596
|
+
id: "pithy.signals.computed",
|
|
597
|
+
title: "Computed",
|
|
598
|
+
};
|
|
599
|
+
createCanonicalFingerprint(baseComponent); // → createCanonicalFingerprint(other)
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
it("produces different fingerprints when sourceDigest differs", () => {
|
|
603
|
+
const other: DiscoveredComponent = { ...baseComponent, sourceDigest: "different" };
|
|
604
|
+
createCanonicalFingerprint(baseComponent); // → createCanonicalFingerprint(other)
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
it("ignores files array (not part of canonical form)", () => {
|
|
608
|
+
const other: DiscoveredComponent = { ...baseComponent, files: ["other.ts", "extra.ts"] };
|
|
609
|
+
createCanonicalFingerprint(baseComponent); // → createCanonicalFingerprint(other)
|
|
610
|
+
});
|
|
611
|
+
});
|
|
612
|
+
```
|
|
613
|
+
|
|
614
|
+
**discoverComponentsFromFile**
|
|
615
|
+
|
|
616
|
+
```typescript
|
|
617
|
+
describe("discoverComponentsFromFile", () => {
|
|
618
|
+
it("discovers a component with matching API annotations", async () => {
|
|
619
|
+
const source = `
|
|
620
|
+
/** ${CX}{ "id": "pithy.signals", "title": "Signals", "category": "feature" } */
|
|
621
|
+
|
|
622
|
+
/** ${CXA}{"parent":"pithy.signals","name":"signal","stability":"stable"} */
|
|
623
|
+
export function signal() {}
|
|
624
|
+
|
|
625
|
+
/** ${CXA}{"parent":"pithy.signals","name":"effect","stability":"stable"} */
|
|
626
|
+
export function effect() {}
|
|
627
|
+
`;
|
|
628
|
+
const result = await discoverComponentsFromFile("signals.ts", source);
|
|
629
|
+
result; // → 1
|
|
630
|
+
result[0].id; // → "pithy.signals"
|
|
631
|
+
result[0].title; // → "Signals"
|
|
632
|
+
result[0].category; // → "feature"
|
|
633
|
+
result[0].apis; // → 2
|
|
634
|
+
result[0].apis[0].name; // → "signal"
|
|
635
|
+
result[0].apis[1].name; // → "effect"
|
|
636
|
+
result[0].files; // → ["signals.ts"]
|
|
637
|
+
result[0].sourceDigest; // → /^[0-9a-f]{64}$/
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
it("discovers multiple components from one file", async () => {
|
|
641
|
+
const source = `
|
|
642
|
+
/** ${CX}{ "id": "a", "title": "A", "category": "feature" } */
|
|
643
|
+
/** ${CX}{ "id": "b", "title": "B", "category": "runtime" } */
|
|
644
|
+
`;
|
|
645
|
+
const result = await discoverComponentsFromFile("multi.ts", source);
|
|
646
|
+
result; // → 2
|
|
647
|
+
result[0].id; // → "a"
|
|
648
|
+
result[1].id; // → "b"
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
it("assigns APIs only to their parent component", async () => {
|
|
652
|
+
const source = `
|
|
653
|
+
/** ${CX}{ "id": "comp-a", "title": "A", "category": "feature" } */
|
|
654
|
+
/** ${CX}{ "id": "comp-b", "title": "B", "category": "runtime" } */
|
|
655
|
+
|
|
656
|
+
/** ${CXA}{"parent":"comp-a","name":"fn1"} */
|
|
657
|
+
/** ${CXA}{"parent":"comp-b","name":"fn2"} */
|
|
658
|
+
/** ${CXA}{"parent":"comp-b","name":"fn3"} */
|
|
659
|
+
`;
|
|
660
|
+
const result = await discoverComponentsFromFile("multi.ts", source);
|
|
661
|
+
result[0].apis; // → 1
|
|
662
|
+
result[0].apis[0].name; // → "fn1"
|
|
663
|
+
result[1].apis; // → 2
|
|
664
|
+
result[1].apis[0].name; // → "fn2"
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
it("returns empty array for content with no annotations", async () => {
|
|
668
|
+
const result = await discoverComponentsFromFile("plain.ts", "const a = 1;");
|
|
669
|
+
result; // → []
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
it("returns empty array for empty content", async () => {
|
|
673
|
+
const result = await discoverComponentsFromFile("empty.ts", "");
|
|
674
|
+
result; // → []
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
it("returns empty APIs when no @codexApi matches component", async () => {
|
|
678
|
+
const source = `
|
|
679
|
+
/** ${CX}{ "id": "lonely", "title": "Lonely", "category": "feature" } */
|
|
680
|
+
/** ${CXA}{"parent":"other-component","name":"fn"} */
|
|
681
|
+
`;
|
|
682
|
+
const result = await discoverComponentsFromFile("lonely.ts", source);
|
|
683
|
+
result; // → 1
|
|
684
|
+
result[0].apis; // → []
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
it("carries risk and testing fields from annotation to component", async () => {
|
|
688
|
+
const source = `
|
|
689
|
+
/** ${CX}{ "id": "pithy.core.html", "title": "HTML", "category": "runtime", "risk": "high", "testing": { "unit": { "required": true } } } */
|
|
690
|
+
`;
|
|
691
|
+
const result = await discoverComponentsFromFile("html.ts", source);
|
|
692
|
+
result; // → 1
|
|
693
|
+
result[0].risk; // → "high"
|
|
694
|
+
result[0].testing; // → { unit: { required: true } }
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
it("leaves risk and testing undefined when annotation omits them", async () => {
|
|
698
|
+
const source = `
|
|
699
|
+
/** ${CX}{ "id": "pithy.core.simple", "title": "Simple", "category": "feature" } */
|
|
700
|
+
`;
|
|
701
|
+
const result = await discoverComponentsFromFile("simple.ts", source);
|
|
702
|
+
result; // → 1
|
|
703
|
+
result[0].risk; // → undefined
|
|
704
|
+
result[0].testing; // → undefined
|
|
705
|
+
});
|
|
706
|
+
});
|
|
707
|
+
```
|
|
708
|
+
|
|
709
|
+
**parseArgs**
|
|
710
|
+
|
|
711
|
+
```typescript
|
|
712
|
+
describe("parseArgs", () => {
|
|
713
|
+
it("defaults to help command with no args", () => {
|
|
714
|
+
const opts = parseArgs([]);
|
|
715
|
+
opts.command; // → "help"
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
it("parses save command with version", () => {
|
|
719
|
+
const opts = parseArgs(["save", "--version", "1.0.0"]);
|
|
720
|
+
opts.command; // → "save"
|
|
721
|
+
opts.version; // → "1.0.0"
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
it("parses diff command with from and to", () => {
|
|
725
|
+
const opts = parseArgs(["diff", "--from", "1.0.0", "--to", "2.0.0"]);
|
|
726
|
+
opts.command; // → "diff"
|
|
727
|
+
opts.fromVersion; // → "1.0.0"
|
|
728
|
+
opts.toVersion; // → "2.0.0"
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
it("parses list command", () => {
|
|
732
|
+
const opts = parseArgs(["list"]);
|
|
733
|
+
opts.command; // → "list"
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
it("parses verbose flag", () => {
|
|
737
|
+
const opts = parseArgs(["save", "-v", "--version", "1.0.0"]);
|
|
738
|
+
opts.verbose; // → true
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
it("parses --help flag overriding command", () => {
|
|
742
|
+
const opts = parseArgs(["save", "--help"]);
|
|
743
|
+
opts.command; // → "help"
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
it("parses custom root and dir", () => {
|
|
747
|
+
const opts = parseArgs(["list", "--root", "/my/project", "--dir", "versions"]);
|
|
748
|
+
opts.root; // → "/my/project"
|
|
749
|
+
opts.versionsDir; // → "versions"
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
it("throws when --root has no value", () => {
|
|
753
|
+
expect(() => parseArgs(["list", "--root"])).toThrow(/Missing value for --root/);
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
it("throws when --version value looks like a flag", () => {
|
|
757
|
+
expect(() => parseArgs(["save", "--version", "--from"])).toThrow(/Missing value for --version/);
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
it("throws when --from has no value", () => {
|
|
761
|
+
expect(() => parseArgs(["diff", "--from"])).toThrow(/Missing value for --from/);
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
it("throws when --dir value looks like a flag", () => {
|
|
765
|
+
expect(() => parseArgs(["list", "--dir", "--verbose"])).toThrow(/Missing value for --dir/);
|
|
766
|
+
});
|
|
767
|
+
});
|
|
768
|
+
```
|
|
769
|
+
|
|
770
|
+
**validatePath**
|
|
771
|
+
|
|
772
|
+
```typescript
|
|
773
|
+
describe("validatePath", () => {
|
|
774
|
+
it("accepts a relative path within the base", () => {
|
|
775
|
+
const result = validatePath("/project", "codex.versions");
|
|
776
|
+
result; // → resolve("/project", "codex.versions")
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
it("accepts a nested relative path", () => {
|
|
780
|
+
const result = validatePath("/project", "data/versions");
|
|
781
|
+
result; // → resolve("/project", "data/versions")
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
it("rejects a path that escapes the base via ..", () => {
|
|
785
|
+
expect(() => validatePath("/project", "../../etc/passwd")).toThrow(/Security error/);
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
it("rejects an absolute path outside the base", () => {
|
|
789
|
+
expect(() => validatePath("/project", "/etc/passwd")).toThrow(/Security error/);
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
it("accepts an absolute path within the base", () => {
|
|
793
|
+
const result = validatePath("/project", resolve("/project", "codex.versions"));
|
|
794
|
+
result; // → resolve("/project", "codex.versions")
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
it("accepts a directory name starting with .. (e.g. ..foo)", () => {
|
|
798
|
+
const result = validatePath("/project", "..foo");
|
|
799
|
+
result; // → resolve("/project", "..foo")
|
|
800
|
+
});
|
|
801
|
+
});
|
|
802
|
+
```
|
|
803
|
+
|
|
804
|
+
**consumeValue**
|
|
805
|
+
|
|
806
|
+
```typescript
|
|
807
|
+
describe("consumeValue", () => {
|
|
808
|
+
it("returns the value at the given index", () => {
|
|
809
|
+
consumeValue(["--root", "/foo"], 1, "--root"); // → "/foo"
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
it("throws for undefined value (past end of array)", () => {
|
|
813
|
+
expect(() => consumeValue(["--root"], 1, "--root")).toThrow(/Missing value/);
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
it("throws for flag-like value", () => {
|
|
817
|
+
expect(() => consumeValue(["--root", "--dir"], 1, "--root")).toThrow(/Missing value/);
|
|
818
|
+
});
|
|
819
|
+
});
|
|
820
|
+
```
|
|
821
|
+
|
|
822
|
+
**allows paths within the base directory**
|
|
823
|
+
|
|
824
|
+
```typescript
|
|
825
|
+
const result = validatePath(base, 'src/file.ts');
|
|
826
|
+
result; // → join(base, 'src/file.ts')
|
|
827
|
+
```
|
|
828
|
+
|
|
829
|
+
**rejects path traversal attempts**
|
|
830
|
+
|
|
831
|
+
```typescript
|
|
832
|
+
expect(() => validatePath(base, '../../../etc/passwd')).toThrow(
|
|
833
|
+
'Security error'
|
|
834
|
+
);
|
|
835
|
+
```
|
|
836
|
+
|
|
837
|
+
**validateExampleSyntax**
|
|
838
|
+
|
|
839
|
+
```typescript
|
|
840
|
+
describe("validateExampleSyntax", () => {
|
|
841
|
+
it("accepts valid TypeScript code", () => {
|
|
842
|
+
const result = validateExampleSyntax(
|
|
843
|
+
'const x: number = 42;\nconsole.log(x);',
|
|
844
|
+
"test.component"
|
|
845
|
+
);
|
|
846
|
+
result.valid; // → true
|
|
847
|
+
result.diagnostics; // → 0
|
|
848
|
+
});
|
|
849
|
+
|
|
850
|
+
it("detects syntax errors in TypeScript", () => {
|
|
851
|
+
const result = validateExampleSyntax(
|
|
852
|
+
"const x: number = ;",
|
|
853
|
+
"test.component"
|
|
854
|
+
);
|
|
855
|
+
result.valid; // → false
|
|
856
|
+
expect(result.diagnostics.length).toBeGreaterThan(0);
|
|
857
|
+
result.diagnostics[0]; // → "Line"
|
|
858
|
+
});
|
|
859
|
+
|
|
860
|
+
it("accepts valid JavaScript code", () => {
|
|
861
|
+
const result = validateExampleSyntax(
|
|
862
|
+
'const x = 42;\nconsole.log(x);',
|
|
863
|
+
"test.component",
|
|
864
|
+
undefined,
|
|
865
|
+
"javascript"
|
|
866
|
+
);
|
|
867
|
+
result.valid; // → true
|
|
868
|
+
});
|
|
869
|
+
|
|
870
|
+
it("treats unknown languages as valid (skip)", () => {
|
|
871
|
+
const result = validateExampleSyntax(
|
|
872
|
+
"this is not code at all {}[]",
|
|
873
|
+
"test.component",
|
|
874
|
+
undefined,
|
|
875
|
+
"markdown"
|
|
876
|
+
);
|
|
877
|
+
result.valid; // → true
|
|
878
|
+
result.diagnostics; // → 0
|
|
879
|
+
});
|
|
880
|
+
|
|
881
|
+
it("preserves componentId and apiName in result", () => {
|
|
882
|
+
const result = validateExampleSyntax(
|
|
883
|
+
"const x = 1;",
|
|
884
|
+
"my.component",
|
|
885
|
+
"myApi"
|
|
886
|
+
);
|
|
887
|
+
result.componentId; // → "my.component"
|
|
888
|
+
result.apiName; // → "myApi"
|
|
889
|
+
});
|
|
890
|
+
|
|
891
|
+
it("handles empty code string", () => {
|
|
892
|
+
const result = validateExampleSyntax("", "test.component");
|
|
893
|
+
result.valid; // → true
|
|
894
|
+
});
|
|
895
|
+
|
|
896
|
+
it("handles multiline code with imports", () => {
|
|
897
|
+
const code = `
|
|
898
|
+
import { signal } from '@pithyjs/signals';
|
|
899
|
+
|
|
900
|
+
const count = signal(0);
|
|
901
|
+
const doubled = computed(() => count() * 2);
|
|
902
|
+
console.log(doubled());
|
|
903
|
+
`;
|
|
904
|
+
const result = validateExampleSyntax(code, "test.component");
|
|
905
|
+
result.valid; // → true
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
it("detects multiple syntax errors", () => {
|
|
909
|
+
const code = "const x = ;\nconst y = ;";
|
|
910
|
+
const result = validateExampleSyntax(code, "test.component");
|
|
911
|
+
result.valid; // → false
|
|
912
|
+
expect(result.diagnostics.length).toBeGreaterThanOrEqual(1);
|
|
913
|
+
});
|
|
914
|
+
|
|
915
|
+
it("accepts async/await syntax", () => {
|
|
916
|
+
const code = `
|
|
917
|
+
async function fetchData() {
|
|
918
|
+
const res = await fetch('/api');
|
|
919
|
+
return res.json();
|
|
920
|
+
}
|
|
921
|
+
`;
|
|
922
|
+
const result = validateExampleSyntax(code, "test.component");
|
|
923
|
+
result.valid; // → true
|
|
924
|
+
});
|
|
925
|
+
|
|
926
|
+
it("handles 'js' as a language alias", () => {
|
|
927
|
+
const result = validateExampleSyntax(
|
|
928
|
+
"const x = 1;",
|
|
929
|
+
"test.component",
|
|
930
|
+
undefined,
|
|
931
|
+
"js"
|
|
932
|
+
);
|
|
933
|
+
result.valid; // → true
|
|
934
|
+
});
|
|
935
|
+
|
|
936
|
+
it("handles 'ts' as a language alias", () => {
|
|
937
|
+
const result = validateExampleSyntax(
|
|
938
|
+
"const x: string = 'hello';",
|
|
939
|
+
"test.component",
|
|
940
|
+
undefined,
|
|
941
|
+
"ts"
|
|
942
|
+
);
|
|
943
|
+
result.valid; // → true
|
|
944
|
+
});
|
|
945
|
+
|
|
946
|
+
it("handles code with Unicode characters", () => {
|
|
947
|
+
const result = validateExampleSyntax(
|
|
948
|
+
'const msg = "Hello 😀 World";',
|
|
949
|
+
"test.component"
|
|
950
|
+
);
|
|
951
|
+
result.valid; // → true
|
|
952
|
+
});
|
|
953
|
+
|
|
954
|
+
it("accepts type-only errors as valid (only checks syntax)", () => {
|
|
955
|
+
// Type error: assigning string to number — but syntactically valid
|
|
956
|
+
const result = validateExampleSyntax(
|
|
957
|
+
'const x: number = "not a number";',
|
|
958
|
+
"test.component"
|
|
959
|
+
);
|
|
960
|
+
result.valid; // → true
|
|
961
|
+
});
|
|
962
|
+
});
|
|
963
|
+
```
|
|
964
|
+
|
|
965
|
+
**validateExtractionResult**
|
|
966
|
+
|
|
967
|
+
```typescript
|
|
968
|
+
describe("validateExtractionResult", () => {
|
|
969
|
+
it("returns zero counts for empty extraction", () => {
|
|
970
|
+
const result = validateExtractionResult(makeExtractionResult());
|
|
971
|
+
result.totalExamples; // → 0
|
|
972
|
+
result.validCount; // → 0
|
|
973
|
+
result.invalidCount; // → 0
|
|
974
|
+
result.skippedCount; // → 0
|
|
975
|
+
result.invalidExamples; // → 0
|
|
976
|
+
});
|
|
977
|
+
|
|
978
|
+
it("validates component-level examples", () => {
|
|
979
|
+
const extraction = makeExtractionResult({
|
|
980
|
+
components: [
|
|
981
|
+
{
|
|
982
|
+
id: "test.comp",
|
|
983
|
+
title: "Test",
|
|
984
|
+
category: "feature",
|
|
985
|
+
apis: [],
|
|
986
|
+
examples: [
|
|
987
|
+
{
|
|
988
|
+
code: "const x = 42;",
|
|
989
|
+
language: "typescript",
|
|
990
|
+
runnable: true,
|
|
991
|
+
isDoctest: false,
|
|
992
|
+
location: { file: "test.ts", line: 1 },
|
|
993
|
+
},
|
|
994
|
+
],
|
|
995
|
+
testRefs: [],
|
|
996
|
+
sourceFiles: [{ file: "test.ts", line: 1 }],
|
|
997
|
+
},
|
|
998
|
+
],
|
|
999
|
+
});
|
|
1000
|
+
|
|
1001
|
+
const result = validateExtractionResult(extraction);
|
|
1002
|
+
result.totalExamples; // → 1
|
|
1003
|
+
result.validCount; // → 1
|
|
1004
|
+
result.invalidCount; // → 0
|
|
1005
|
+
});
|
|
1006
|
+
|
|
1007
|
+
it("validates API-level examples", () => {
|
|
1008
|
+
const extraction = makeExtractionResult({
|
|
1009
|
+
components: [
|
|
1010
|
+
{
|
|
1011
|
+
id: "test.comp",
|
|
1012
|
+
title: "Test",
|
|
1013
|
+
category: "feature",
|
|
1014
|
+
apis: [
|
|
1015
|
+
{
|
|
1016
|
+
id: "test.comp.myFn",
|
|
1017
|
+
name: "myFn",
|
|
1018
|
+
parent: "test.comp",
|
|
1019
|
+
stability: "stable",
|
|
1020
|
+
examples: [
|
|
1021
|
+
{
|
|
1022
|
+
code: "const x: number = ;",
|
|
1023
|
+
language: "typescript",
|
|
1024
|
+
runnable: true,
|
|
1025
|
+
isDoctest: false,
|
|
1026
|
+
location: { file: "test.ts", line: 1 },
|
|
1027
|
+
},
|
|
1028
|
+
],
|
|
1029
|
+
testRefs: [],
|
|
1030
|
+
sourceLocation: { file: "test.ts", line: 1 },
|
|
1031
|
+
},
|
|
1032
|
+
],
|
|
1033
|
+
examples: [],
|
|
1034
|
+
testRefs: [],
|
|
1035
|
+
sourceFiles: [{ file: "test.ts", line: 1 }],
|
|
1036
|
+
},
|
|
1037
|
+
],
|
|
1038
|
+
});
|
|
1039
|
+
|
|
1040
|
+
const result = validateExtractionResult(extraction);
|
|
1041
|
+
result.totalExamples; // → 1
|
|
1042
|
+
result.invalidCount; // → 1
|
|
1043
|
+
result.invalidExamples; // → 1
|
|
1044
|
+
result.invalidExamples[0].apiName; // → "myFn"
|
|
1045
|
+
});
|
|
1046
|
+
|
|
1047
|
+
it("skips non-validatable languages", () => {
|
|
1048
|
+
const extraction = makeExtractionResult({
|
|
1049
|
+
components: [
|
|
1050
|
+
{
|
|
1051
|
+
id: "test.comp",
|
|
1052
|
+
title: "Test",
|
|
1053
|
+
category: "feature",
|
|
1054
|
+
apis: [],
|
|
1055
|
+
examples: [
|
|
1056
|
+
{
|
|
1057
|
+
code: "npm install something",
|
|
1058
|
+
language: "bash",
|
|
1059
|
+
runnable: false,
|
|
1060
|
+
isDoctest: false,
|
|
1061
|
+
location: { file: "test.ts", line: 1 },
|
|
1062
|
+
},
|
|
1063
|
+
],
|
|
1064
|
+
testRefs: [],
|
|
1065
|
+
sourceFiles: [{ file: "test.ts", line: 1 }],
|
|
1066
|
+
},
|
|
1067
|
+
],
|
|
1068
|
+
});
|
|
1069
|
+
|
|
1070
|
+
const result = validateExtractionResult(extraction);
|
|
1071
|
+
result.totalExamples; // → 1
|
|
1072
|
+
result.skippedCount; // → 1
|
|
1073
|
+
result.validCount; // → 0
|
|
1074
|
+
result.invalidCount; // → 0
|
|
1075
|
+
});
|
|
1076
|
+
|
|
1077
|
+
it("handles mix of valid, invalid, and skipped", () => {
|
|
1078
|
+
const extraction = makeExtractionResult({
|
|
1079
|
+
components: [
|
|
1080
|
+
{
|
|
1081
|
+
id: "test.comp",
|
|
1082
|
+
title: "Test",
|
|
1083
|
+
category: "feature",
|
|
1084
|
+
apis: [],
|
|
1085
|
+
examples: [
|
|
1086
|
+
{
|
|
1087
|
+
code: "const x = 42;",
|
|
1088
|
+
language: "typescript",
|
|
1089
|
+
runnable: true,
|
|
1090
|
+
isDoctest: false,
|
|
1091
|
+
location: { file: "test.ts", line: 1 },
|
|
1092
|
+
},
|
|
1093
|
+
{
|
|
1094
|
+
code: "const y = ;",
|
|
1095
|
+
language: "typescript",
|
|
1096
|
+
runnable: true,
|
|
1097
|
+
isDoctest: false,
|
|
1098
|
+
location: { file: "test.ts", line: 5 },
|
|
1099
|
+
},
|
|
1100
|
+
{
|
|
1101
|
+
code: "echo hello",
|
|
1102
|
+
language: "bash",
|
|
1103
|
+
runnable: false,
|
|
1104
|
+
isDoctest: false,
|
|
1105
|
+
location: { file: "test.ts", line: 10 },
|
|
1106
|
+
},
|
|
1107
|
+
],
|
|
1108
|
+
testRefs: [],
|
|
1109
|
+
sourceFiles: [{ file: "test.ts", line: 1 }],
|
|
1110
|
+
},
|
|
1111
|
+
],
|
|
1112
|
+
});
|
|
1113
|
+
|
|
1114
|
+
const result = validateExtractionResult(extraction);
|
|
1115
|
+
result.totalExamples; // → 3
|
|
1116
|
+
result.validCount; // → 1
|
|
1117
|
+
result.invalidCount; // → 1
|
|
1118
|
+
result.skippedCount; // → 1
|
|
1119
|
+
});
|
|
1120
|
+
|
|
1121
|
+
it("records totalTimeMs", () => {
|
|
1122
|
+
const result = validateExtractionResult(makeExtractionResult());
|
|
1123
|
+
expect(result.totalTimeMs).toBeGreaterThanOrEqual(0);
|
|
1124
|
+
});
|
|
1125
|
+
});
|
|
1126
|
+
```
|
|
1127
|
+
|
|
1128
|
+
**formatValidationReport**
|
|
1129
|
+
|
|
1130
|
+
```typescript
|
|
1131
|
+
describe("formatValidationReport", () => {
|
|
1132
|
+
it("formats empty result", () => {
|
|
1133
|
+
const report = formatValidationReport({
|
|
1134
|
+
totalExamples: 0,
|
|
1135
|
+
validCount: 0,
|
|
1136
|
+
invalidCount: 0,
|
|
1137
|
+
skippedCount: 0,
|
|
1138
|
+
invalidExamples: [],
|
|
1139
|
+
totalTimeMs: 5,
|
|
1140
|
+
});
|
|
1141
|
+
report; // → "Total examples: 0"
|
|
1142
|
+
report; // → "✅ Valid: 0"
|
|
1143
|
+
report; // → "❌ Invalid: 0"
|
|
1144
|
+
});
|
|
1145
|
+
|
|
1146
|
+
it("formats result with invalid examples", () => {
|
|
1147
|
+
const report = formatValidationReport({
|
|
1148
|
+
totalExamples: 3,
|
|
1149
|
+
validCount: 1,
|
|
1150
|
+
invalidCount: 1,
|
|
1151
|
+
skippedCount: 1,
|
|
1152
|
+
invalidExamples: [
|
|
1153
|
+
{
|
|
1154
|
+
code: "const x = ;",
|
|
1155
|
+
componentId: "test.comp",
|
|
1156
|
+
apiName: "myFn",
|
|
1157
|
+
valid: false,
|
|
1158
|
+
diagnostics: ["Line 1: Expression expected"],
|
|
1159
|
+
},
|
|
1160
|
+
],
|
|
1161
|
+
totalTimeMs: 42,
|
|
1162
|
+
});
|
|
1163
|
+
report; // → "Total examples: 3"
|
|
1164
|
+
report; // → "Invalid Examples:"
|
|
1165
|
+
report; // → "test.comp.myFn"
|
|
1166
|
+
report; // → "Expression expected"
|
|
1167
|
+
});
|
|
1168
|
+
|
|
1169
|
+
it("shows component ID without apiName", () => {
|
|
1170
|
+
const report = formatValidationReport({
|
|
1171
|
+
totalExamples: 1,
|
|
1172
|
+
validCount: 0,
|
|
1173
|
+
invalidCount: 1,
|
|
1174
|
+
skippedCount: 0,
|
|
1175
|
+
invalidExamples: [
|
|
1176
|
+
{
|
|
1177
|
+
code: "bad code;",
|
|
1178
|
+
componentId: "test.comp",
|
|
1179
|
+
valid: false,
|
|
1180
|
+
diagnostics: ["Line 1: Error"],
|
|
1181
|
+
},
|
|
1182
|
+
],
|
|
1183
|
+
totalTimeMs: 10,
|
|
1184
|
+
});
|
|
1185
|
+
report; // → "test.comp"
|
|
1186
|
+
report; // → "test.comp."
|
|
1187
|
+
});
|
|
1188
|
+
|
|
1189
|
+
it("includes timing information", () => {
|
|
1190
|
+
const report = formatValidationReport({
|
|
1191
|
+
totalExamples: 0,
|
|
1192
|
+
validCount: 0,
|
|
1193
|
+
invalidCount: 0,
|
|
1194
|
+
skippedCount: 0,
|
|
1195
|
+
invalidExamples: [],
|
|
1196
|
+
totalTimeMs: 123,
|
|
1197
|
+
});
|
|
1198
|
+
report; // → "123ms"
|
|
1199
|
+
});
|
|
1200
|
+
});
|
|
1201
|
+
```
|
|
1202
|
+
|
|
1203
|
+
**createApiSnapshot**
|
|
1204
|
+
|
|
1205
|
+
```typescript
|
|
1206
|
+
describe("createApiSnapshot", () => {
|
|
1207
|
+
it("creates a snapshot from extraction results", () => {
|
|
1208
|
+
const snap = makeSnapshot("1.0.0", [
|
|
1209
|
+
{ name: "signal", signature: "<T>(initial: T) => Signal<T>" },
|
|
1210
|
+
{ name: "effect", signature: "(fn: () => void) => () => void" },
|
|
1211
|
+
]);
|
|
1212
|
+
|
|
1213
|
+
snap.version; // → "1.0.0"
|
|
1214
|
+
snap.entries; // → 2
|
|
1215
|
+
snap.entries[0].name; // → "signal"
|
|
1216
|
+
snap.entries[0].signature; // → "<T>(initial: T) => Signal<T>"
|
|
1217
|
+
snap.entries[1].name; // → "effect"
|
|
1218
|
+
snap.timestamp; // → /^\d{4}-\d{2}-\d{2}/
|
|
1219
|
+
});
|
|
1220
|
+
|
|
1221
|
+
it("captures stability for each API entry", () => {
|
|
1222
|
+
const snap = makeSnapshot("1.0.0", [
|
|
1223
|
+
{ name: "oldFn", stability: "deprecated", signature: "() => void" },
|
|
1224
|
+
{ name: "newFn", stability: "experimental", signature: "() => void" },
|
|
1225
|
+
]);
|
|
1226
|
+
|
|
1227
|
+
snap.entries[0].stability; // → "deprecated"
|
|
1228
|
+
snap.entries[1].stability; // → "experimental"
|
|
1229
|
+
});
|
|
1230
|
+
|
|
1231
|
+
it("creates an empty snapshot when no APIs exist", () => {
|
|
1232
|
+
const snap = makeSnapshot("0.0.1", []);
|
|
1233
|
+
snap.entries; // → 0
|
|
1234
|
+
snap.version; // → "0.0.1"
|
|
1235
|
+
});
|
|
1236
|
+
|
|
1237
|
+
it("preserves parent component id for each entry", () => {
|
|
1238
|
+
const snap = makeSnapshot("1.0.0", [{ name: "fn", signature: "()" }]);
|
|
1239
|
+
snap.entries[0].parent; // → "test"
|
|
1240
|
+
});
|
|
1241
|
+
});
|
|
1242
|
+
```
|
|
1243
|
+
|
|
1244
|
+
**snapshotFromTypeDefinitions**
|
|
1245
|
+
|
|
1246
|
+
```typescript
|
|
1247
|
+
describe("snapshotFromTypeDefinitions", () => {
|
|
1248
|
+
it("creates entries from exported type definitions", () => {
|
|
1249
|
+
const types: ExtractedTypeDefinition[] = [
|
|
1250
|
+
{
|
|
1251
|
+
name: "Signal",
|
|
1252
|
+
kind: "interface",
|
|
1253
|
+
exported: true,
|
|
1254
|
+
members: [
|
|
1255
|
+
{ name: "set", type: "method", optional: false, readonly: false },
|
|
1256
|
+
{ name: "subscribe", type: "method", optional: false, readonly: false },
|
|
1257
|
+
],
|
|
1258
|
+
location: loc,
|
|
1259
|
+
},
|
|
1260
|
+
];
|
|
1261
|
+
|
|
1262
|
+
const snap = snapshotFromTypeDefinitions(types, "1.0.0");
|
|
1263
|
+
snap.version; // → "1.0.0"
|
|
1264
|
+
snap.entries; // → 1
|
|
1265
|
+
snap.entries[0].name; // → "Signal"
|
|
1266
|
+
snap.entries[0].kind; // → "interface"
|
|
1267
|
+
snap.entries[0].signature; // → "export interface Signal"
|
|
1268
|
+
});
|
|
1269
|
+
|
|
1270
|
+
it("skips non-exported type definitions", () => {
|
|
1271
|
+
const types: ExtractedTypeDefinition[] = [
|
|
1272
|
+
{
|
|
1273
|
+
name: "Internal",
|
|
1274
|
+
kind: "interface",
|
|
1275
|
+
exported: false,
|
|
1276
|
+
members: [],
|
|
1277
|
+
location: loc,
|
|
1278
|
+
},
|
|
1279
|
+
];
|
|
1280
|
+
|
|
1281
|
+
const snap = snapshotFromTypeDefinitions(types, "1.0.0");
|
|
1282
|
+
snap.entries; // → 0
|
|
1283
|
+
});
|
|
1284
|
+
|
|
1285
|
+
it("captures member signatures for interfaces", () => {
|
|
1286
|
+
const types: ExtractedTypeDefinition[] = [
|
|
1287
|
+
{
|
|
1288
|
+
name: "Router",
|
|
1289
|
+
kind: "interface",
|
|
1290
|
+
exported: true,
|
|
1291
|
+
members: [
|
|
1292
|
+
{
|
|
1293
|
+
name: "navigate",
|
|
1294
|
+
type: "method",
|
|
1295
|
+
optional: false,
|
|
1296
|
+
readonly: false,
|
|
1297
|
+
signature: "(path: string) => void",
|
|
1298
|
+
},
|
|
1299
|
+
],
|
|
1300
|
+
location: loc,
|
|
1301
|
+
},
|
|
1302
|
+
];
|
|
1303
|
+
|
|
1304
|
+
const snap = snapshotFromTypeDefinitions(types, "1.0.0");
|
|
1305
|
+
snap.entries[0].members; // → 1
|
|
1306
|
+
snap.entries[0].members![0].name; // → "navigate"
|
|
1307
|
+
});
|
|
1308
|
+
});
|
|
1309
|
+
```
|
|
1310
|
+
|
|
1311
|
+
**diffSnapshots**
|
|
1312
|
+
|
|
1313
|
+
```typescript
|
|
1314
|
+
describe("diffSnapshots", () => {
|
|
1315
|
+
it("detects added APIs", () => {
|
|
1316
|
+
const before = makeSnapshot("1.0.0", [{ name: "signal", signature: "()" }]);
|
|
1317
|
+
const after = makeSnapshot("2.0.0", [
|
|
1318
|
+
{ name: "signal", signature: "()" },
|
|
1319
|
+
{ name: "effect", signature: "(fn: () => void) => void" },
|
|
1320
|
+
]);
|
|
1321
|
+
|
|
1322
|
+
const diff = diffSnapshots(before, after);
|
|
1323
|
+
diff.added; // → 1
|
|
1324
|
+
diff.added[0].name; // → "effect"
|
|
1325
|
+
diff.removed; // → 0
|
|
1326
|
+
diff.changed; // → 0
|
|
1327
|
+
});
|
|
1328
|
+
|
|
1329
|
+
it("detects removed APIs", () => {
|
|
1330
|
+
const before = makeSnapshot("1.0.0", [
|
|
1331
|
+
{ name: "signal", signature: "()" },
|
|
1332
|
+
{ name: "obsolete", signature: "()" },
|
|
1333
|
+
]);
|
|
1334
|
+
const after = makeSnapshot("2.0.0", [{ name: "signal", signature: "()" }]);
|
|
1335
|
+
|
|
1336
|
+
const diff = diffSnapshots(before, after);
|
|
1337
|
+
diff.removed; // → 1
|
|
1338
|
+
diff.removed[0].name; // → "obsolete"
|
|
1339
|
+
diff.added; // → 0
|
|
1340
|
+
});
|
|
1341
|
+
|
|
1342
|
+
it("detects signature changes", () => {
|
|
1343
|
+
const before = makeSnapshot("1.0.0", [
|
|
1344
|
+
{ name: "signal", signature: "<T>(v: T) => Signal<T>" },
|
|
1345
|
+
]);
|
|
1346
|
+
const after = makeSnapshot("2.0.0", [
|
|
1347
|
+
{ name: "signal", signature: "<T>(v: T, opts?: Options) => Signal<T>" },
|
|
1348
|
+
]);
|
|
1349
|
+
|
|
1350
|
+
const diff = diffSnapshots(before, after);
|
|
1351
|
+
diff.changed; // → 1
|
|
1352
|
+
diff.changed[0].name; // → "signal"
|
|
1353
|
+
diff.changed[0].changeKind; // → "signature-changed"
|
|
1354
|
+
expect(diff.changed[0].before?.signature).toBe(
|
|
1355
|
+
"<T>(v: T) => Signal<T>",
|
|
1356
|
+
);
|
|
1357
|
+
expect(diff.changed[0].after?.signature).toBe(
|
|
1358
|
+
"<T>(v: T, opts?: Options) => Signal<T>",
|
|
1359
|
+
);
|
|
1360
|
+
});
|
|
1361
|
+
|
|
1362
|
+
it("detects stability changes", () => {
|
|
1363
|
+
const before = makeSnapshot("1.0.0", [
|
|
1364
|
+
{ name: "fn", stability: "stable", signature: "()" },
|
|
1365
|
+
]);
|
|
1366
|
+
const after = makeSnapshot("2.0.0", [
|
|
1367
|
+
{ name: "fn", stability: "deprecated", signature: "()" },
|
|
1368
|
+
]);
|
|
1369
|
+
|
|
1370
|
+
const diff = diffSnapshots(before, after);
|
|
1371
|
+
diff.changed; // → 1
|
|
1372
|
+
diff.changed[0].changeKind; // → "stability-changed"
|
|
1373
|
+
});
|
|
1374
|
+
|
|
1375
|
+
it("reports unchanged APIs count", () => {
|
|
1376
|
+
const before = makeSnapshot("1.0.0", [
|
|
1377
|
+
{ name: "signal", signature: "()" },
|
|
1378
|
+
{ name: "effect", signature: "(fn: () => void) => void" },
|
|
1379
|
+
]);
|
|
1380
|
+
const after = makeSnapshot("2.0.0", [
|
|
1381
|
+
{ name: "signal", signature: "()" },
|
|
1382
|
+
{ name: "effect", signature: "(fn: () => void) => void" },
|
|
1383
|
+
]);
|
|
1384
|
+
|
|
1385
|
+
const diff = diffSnapshots(before, after);
|
|
1386
|
+
diff.unchanged; // → 2
|
|
1387
|
+
diff.added; // → 0
|
|
1388
|
+
diff.removed; // → 0
|
|
1389
|
+
diff.changed; // → 0
|
|
1390
|
+
});
|
|
1391
|
+
|
|
1392
|
+
it("handles empty snapshots", () => {
|
|
1393
|
+
const empty = makeSnapshot("0.0.0", []);
|
|
1394
|
+
const filled = makeSnapshot("1.0.0", [
|
|
1395
|
+
{ name: "signal", signature: "()" },
|
|
1396
|
+
]);
|
|
1397
|
+
|
|
1398
|
+
const diff = diffSnapshots(empty, filled);
|
|
1399
|
+
diff.added; // → 1
|
|
1400
|
+
diff.removed; // → 0
|
|
1401
|
+
});
|
|
1402
|
+
|
|
1403
|
+
it("handles both snapshots empty", () => {
|
|
1404
|
+
const diff = diffSnapshots(
|
|
1405
|
+
makeSnapshot("0.0.0", []),
|
|
1406
|
+
makeSnapshot("1.0.0", []),
|
|
1407
|
+
);
|
|
1408
|
+
diff.added; // → 0
|
|
1409
|
+
diff.removed; // → 0
|
|
1410
|
+
diff.changed; // → 0
|
|
1411
|
+
diff.unchanged; // → 0
|
|
1412
|
+
});
|
|
1413
|
+
|
|
1414
|
+
it("uses qualified name (parent.name) for matching", () => {
|
|
1415
|
+
const before = createApiSnapshot(
|
|
1416
|
+
{
|
|
1417
|
+
components: [
|
|
1418
|
+
makeComponent("signals", [
|
|
1419
|
+
makeApi({ name: "signal", signature: "()" }),
|
|
1420
|
+
]),
|
|
1421
|
+
makeComponent("router", [
|
|
1422
|
+
makeApi({ name: "signal", signature: "(path: string) => void" }),
|
|
1423
|
+
]),
|
|
1424
|
+
],
|
|
1425
|
+
testAnalysis: [],
|
|
1426
|
+
readmeSections: [],
|
|
1427
|
+
stats: {
|
|
1428
|
+
filesScanned: 0,
|
|
1429
|
+
componentsFound: 2,
|
|
1430
|
+
apisExtracted: 2,
|
|
1431
|
+
examplesExtracted: 0,
|
|
1432
|
+
testsAnalyzed: 0,
|
|
1433
|
+
readmeSectionsLinked: 0,
|
|
1434
|
+
processingTimeMs: 0,
|
|
1435
|
+
},
|
|
1436
|
+
typeDefinitions: [],
|
|
1437
|
+
testExamples: [],
|
|
1438
|
+
testStatuses: [],
|
|
1439
|
+
},
|
|
1440
|
+
"1.0.0",
|
|
1441
|
+
);
|
|
1442
|
+
|
|
1443
|
+
const after = createApiSnapshot(
|
|
1444
|
+
{
|
|
1445
|
+
components: [
|
|
1446
|
+
makeComponent("signals", [
|
|
1447
|
+
makeApi({ name: "signal", signature: "(changed: boolean) => void" }),
|
|
1448
|
+
]),
|
|
1449
|
+
makeComponent("router", [
|
|
1450
|
+
makeApi({ name: "signal", signature: "(path: string) => void" }),
|
|
1451
|
+
]),
|
|
1452
|
+
],
|
|
1453
|
+
testAnalysis: [],
|
|
1454
|
+
readmeSections: [],
|
|
1455
|
+
stats: {
|
|
1456
|
+
filesScanned: 0,
|
|
1457
|
+
componentsFound: 2,
|
|
1458
|
+
apisExtracted: 2,
|
|
1459
|
+
examplesExtracted: 0,
|
|
1460
|
+
testsAnalyzed: 0,
|
|
1461
|
+
readmeSectionsLinked: 0,
|
|
1462
|
+
processingTimeMs: 0,
|
|
1463
|
+
},
|
|
1464
|
+
typeDefinitions: [],
|
|
1465
|
+
testExamples: [],
|
|
1466
|
+
testStatuses: [],
|
|
1467
|
+
},
|
|
1468
|
+
"2.0.0",
|
|
1469
|
+
);
|
|
1470
|
+
|
|
1471
|
+
const diff = diffSnapshots(before, after);
|
|
1472
|
+
// Only the signals.signal changed, router.signal stayed
|
|
1473
|
+
diff.changed; // → 1
|
|
1474
|
+
diff.changed[0].name; // → "signal"
|
|
1475
|
+
diff.changed[0].parent; // → "signals"
|
|
1476
|
+
diff.unchanged; // → 1
|
|
1477
|
+
});
|
|
1478
|
+
|
|
1479
|
+
it("detects combined signature and stability changes", () => {
|
|
1480
|
+
const before = makeSnapshot("1.0.0", [
|
|
1481
|
+
{ name: "fn", stability: "stable", signature: "(a: number) => void" },
|
|
1482
|
+
]);
|
|
1483
|
+
const after = makeSnapshot("2.0.0", [
|
|
1484
|
+
{
|
|
1485
|
+
name: "fn",
|
|
1486
|
+
stability: "deprecated",
|
|
1487
|
+
signature: "(a: number, b: string) => void",
|
|
1488
|
+
},
|
|
1489
|
+
]);
|
|
1490
|
+
|
|
1491
|
+
const diff = diffSnapshots(before, after);
|
|
1492
|
+
// Both changes detected; signature change takes priority
|
|
1493
|
+
diff.changed; // → 1
|
|
1494
|
+
diff.changed[0].changeKind; // → "signature-changed"
|
|
1495
|
+
});
|
|
1496
|
+
});
|
|
1497
|
+
```
|
|
1498
|
+
|
|
1499
|
+
**classifyChange**
|
|
1500
|
+
|
|
1501
|
+
```typescript
|
|
1502
|
+
describe("classifyChange", () => {
|
|
1503
|
+
it("classifies removal as major (breaking)", () => {
|
|
1504
|
+
const change: ApiChange = {
|
|
1505
|
+
name: "obsolete",
|
|
1506
|
+
parent: "test",
|
|
1507
|
+
qualifiedName: "test.obsolete",
|
|
1508
|
+
changeKind: "removed",
|
|
1509
|
+
before: { name: "obsolete", signature: "()", stability: "stable", parent: "test", qualifiedName: "test.obsolete" },
|
|
1510
|
+
};
|
|
1511
|
+
classifyChange(change); // → "major"
|
|
1512
|
+
});
|
|
1513
|
+
|
|
1514
|
+
it("classifies signature change as major (breaking)", () => {
|
|
1515
|
+
const change: ApiChange = {
|
|
1516
|
+
name: "signal",
|
|
1517
|
+
parent: "test",
|
|
1518
|
+
qualifiedName: "test.signal",
|
|
1519
|
+
changeKind: "signature-changed",
|
|
1520
|
+
before: { name: "signal", signature: "(a: T) => S", stability: "stable", parent: "test", qualifiedName: "test.signal" },
|
|
1521
|
+
after: { name: "signal", signature: "(a: T, b: U) => S", stability: "stable", parent: "test", qualifiedName: "test.signal" },
|
|
1522
|
+
};
|
|
1523
|
+
classifyChange(change); // → "major"
|
|
1524
|
+
});
|
|
1525
|
+
|
|
1526
|
+
it("classifies addition as minor (non-breaking)", () => {
|
|
1527
|
+
const change: ApiChange = {
|
|
1528
|
+
name: "effect",
|
|
1529
|
+
parent: "test",
|
|
1530
|
+
qualifiedName: "test.effect",
|
|
1531
|
+
changeKind: "added",
|
|
1532
|
+
after: { name: "effect", signature: "(fn: () => void) => void", stability: "stable", parent: "test", qualifiedName: "test.effect" },
|
|
1533
|
+
};
|
|
1534
|
+
classifyChange(change); // → "minor"
|
|
1535
|
+
});
|
|
1536
|
+
|
|
1537
|
+
it("classifies stable->deprecated as patch", () => {
|
|
1538
|
+
const change: ApiChange = {
|
|
1539
|
+
name: "fn",
|
|
1540
|
+
parent: "test",
|
|
1541
|
+
qualifiedName: "test.fn",
|
|
1542
|
+
changeKind: "stability-changed",
|
|
1543
|
+
before: { name: "fn", signature: "()", stability: "stable", parent: "test", qualifiedName: "test.fn" },
|
|
1544
|
+
after: { name: "fn", signature: "()", stability: "deprecated", parent: "test", qualifiedName: "test.fn" },
|
|
1545
|
+
};
|
|
1546
|
+
classifyChange(change); // → "patch"
|
|
1547
|
+
});
|
|
1548
|
+
|
|
1549
|
+
it("classifies experimental->stable as patch", () => {
|
|
1550
|
+
const change: ApiChange = {
|
|
1551
|
+
name: "fn",
|
|
1552
|
+
parent: "test",
|
|
1553
|
+
qualifiedName: "test.fn",
|
|
1554
|
+
changeKind: "stability-changed",
|
|
1555
|
+
before: { name: "fn", signature: "()", stability: "experimental", parent: "test", qualifiedName: "test.fn" },
|
|
1556
|
+
after: { name: "fn", signature: "()", stability: "stable", parent: "test", qualifiedName: "test.fn" },
|
|
1557
|
+
};
|
|
1558
|
+
classifyChange(change); // → "patch"
|
|
1559
|
+
});
|
|
1560
|
+
|
|
1561
|
+
it("classifies removal of experimental API as minor", () => {
|
|
1562
|
+
const change: ApiChange = {
|
|
1563
|
+
name: "beta",
|
|
1564
|
+
parent: "test",
|
|
1565
|
+
qualifiedName: "test.beta",
|
|
1566
|
+
changeKind: "removed",
|
|
1567
|
+
before: { name: "beta", signature: "()", stability: "experimental", parent: "test", qualifiedName: "test.beta" },
|
|
1568
|
+
};
|
|
1569
|
+
classifyChange(change); // → "minor"
|
|
1570
|
+
});
|
|
1571
|
+
|
|
1572
|
+
it("classifies removal of deprecated API as minor", () => {
|
|
1573
|
+
const change: ApiChange = {
|
|
1574
|
+
name: "old",
|
|
1575
|
+
parent: "test",
|
|
1576
|
+
qualifiedName: "test.old",
|
|
1577
|
+
changeKind: "removed",
|
|
1578
|
+
before: { name: "old", signature: "()", stability: "deprecated", parent: "test", qualifiedName: "test.old" },
|
|
1579
|
+
};
|
|
1580
|
+
classifyChange(change); // → "minor"
|
|
1581
|
+
});
|
|
1582
|
+
});
|
|
1583
|
+
```
|
|
1584
|
+
|
|
1585
|
+
**detectBreakingChanges**
|
|
1586
|
+
|
|
1587
|
+
```typescript
|
|
1588
|
+
describe("detectBreakingChanges", () => {
|
|
1589
|
+
it("returns only breaking changes from a diff", () => {
|
|
1590
|
+
const before = makeSnapshot("1.0.0", [
|
|
1591
|
+
{ name: "signal", signature: "()" },
|
|
1592
|
+
{ name: "removed", signature: "()" },
|
|
1593
|
+
{ name: "changed", signature: "(a: number) => void" },
|
|
1594
|
+
]);
|
|
1595
|
+
const after = makeSnapshot("2.0.0", [
|
|
1596
|
+
{ name: "signal", signature: "()" },
|
|
1597
|
+
{ name: "added", signature: "()" },
|
|
1598
|
+
{ name: "changed", signature: "(a: string) => void" },
|
|
1599
|
+
]);
|
|
1600
|
+
|
|
1601
|
+
const diff = diffSnapshots(before, after);
|
|
1602
|
+
const breaking = detectBreakingChanges(diff);
|
|
1603
|
+
|
|
1604
|
+
breaking; // → 2
|
|
1605
|
+
const names = breaking.map((c) => c.name);
|
|
1606
|
+
names; // → "removed"
|
|
1607
|
+
names; // → "changed"
|
|
1608
|
+
});
|
|
1609
|
+
|
|
1610
|
+
it("returns empty array when no breaking changes exist", () => {
|
|
1611
|
+
const before = makeSnapshot("1.0.0", [{ name: "signal", signature: "()" }]);
|
|
1612
|
+
const after = makeSnapshot("2.0.0", [
|
|
1613
|
+
{ name: "signal", signature: "()" },
|
|
1614
|
+
{ name: "effect", signature: "()" },
|
|
1615
|
+
]);
|
|
1616
|
+
|
|
1617
|
+
const diff = diffSnapshots(before, after);
|
|
1618
|
+
const breaking = detectBreakingChanges(diff);
|
|
1619
|
+
breaking; // → 0
|
|
1620
|
+
});
|
|
1621
|
+
|
|
1622
|
+
it("does not flag removal of experimental APIs as breaking", () => {
|
|
1623
|
+
const before = makeSnapshot("1.0.0", [
|
|
1624
|
+
{ name: "beta", stability: "experimental", signature: "()" },
|
|
1625
|
+
]);
|
|
1626
|
+
const after = makeSnapshot("2.0.0", []);
|
|
1627
|
+
|
|
1628
|
+
const diff = diffSnapshots(before, after);
|
|
1629
|
+
const breaking = detectBreakingChanges(diff);
|
|
1630
|
+
breaking; // → 0
|
|
1631
|
+
});
|
|
1632
|
+
|
|
1633
|
+
it("does not flag removal of deprecated APIs as breaking", () => {
|
|
1634
|
+
const before = makeSnapshot("1.0.0", [
|
|
1635
|
+
{ name: "old", stability: "deprecated", signature: "()" },
|
|
1636
|
+
]);
|
|
1637
|
+
const after = makeSnapshot("2.0.0", []);
|
|
1638
|
+
|
|
1639
|
+
const diff = diffSnapshots(before, after);
|
|
1640
|
+
const breaking = detectBreakingChanges(diff);
|
|
1641
|
+
breaking; // → 0
|
|
1642
|
+
});
|
|
1643
|
+
});
|
|
1644
|
+
```
|
|
1645
|
+
|
|
1646
|
+
**generateMigrationGuide**
|
|
1647
|
+
|
|
1648
|
+
```typescript
|
|
1649
|
+
describe("generateMigrationGuide", () => {
|
|
1650
|
+
it("generates guide for removed APIs", () => {
|
|
1651
|
+
const changes: ApiChange[] = [
|
|
1652
|
+
{
|
|
1653
|
+
name: "obsolete",
|
|
1654
|
+
parent: "signals",
|
|
1655
|
+
qualifiedName: "signals.obsolete",
|
|
1656
|
+
changeKind: "removed",
|
|
1657
|
+
before: { name: "obsolete", signature: "(val: T) => void", stability: "stable", parent: "signals", qualifiedName: "signals.obsolete" },
|
|
1658
|
+
},
|
|
1659
|
+
];
|
|
1660
|
+
|
|
1661
|
+
const guide = generateMigrationGuide(changes);
|
|
1662
|
+
guide; // → "obsolete"
|
|
1663
|
+
guide; // → "removed"
|
|
1664
|
+
guide; // → "Migration Guide"
|
|
1665
|
+
});
|
|
1666
|
+
|
|
1667
|
+
it("generates guide for changed signatures", () => {
|
|
1668
|
+
const changes: ApiChange[] = [
|
|
1669
|
+
{
|
|
1670
|
+
name: "signal",
|
|
1671
|
+
parent: "signals",
|
|
1672
|
+
qualifiedName: "signals.signal",
|
|
1673
|
+
changeKind: "signature-changed",
|
|
1674
|
+
before: { name: "signal", signature: "<T>(v: T) => Signal<T>", stability: "stable", parent: "signals", qualifiedName: "signals.signal" },
|
|
1675
|
+
after: { name: "signal", signature: "<T>(v: T, opts?: Options) => Signal<T>", stability: "stable", parent: "signals", qualifiedName: "signals.signal" },
|
|
1676
|
+
},
|
|
1677
|
+
];
|
|
1678
|
+
|
|
1679
|
+
const guide = generateMigrationGuide(changes);
|
|
1680
|
+
guide; // → "signal"
|
|
1681
|
+
guide; // → "signature"
|
|
1682
|
+
guide; // → "<T>(v: T) => Signal<T>"
|
|
1683
|
+
guide; // → "<T>(v: T, opts?: Options) => Signal<T>"
|
|
1684
|
+
});
|
|
1685
|
+
|
|
1686
|
+
it("returns empty string for empty changes list", () => {
|
|
1687
|
+
const guide = generateMigrationGuide([]);
|
|
1688
|
+
guide; // → ""
|
|
1689
|
+
});
|
|
1690
|
+
|
|
1691
|
+
it("groups changes by component/parent", () => {
|
|
1692
|
+
const changes: ApiChange[] = [
|
|
1693
|
+
{
|
|
1694
|
+
name: "a",
|
|
1695
|
+
parent: "signals",
|
|
1696
|
+
qualifiedName: "signals.a",
|
|
1697
|
+
changeKind: "removed",
|
|
1698
|
+
before: { name: "a", signature: "()", stability: "stable", parent: "signals", qualifiedName: "signals.a" },
|
|
1699
|
+
},
|
|
1700
|
+
{
|
|
1701
|
+
name: "b",
|
|
1702
|
+
parent: "router",
|
|
1703
|
+
qualifiedName: "router.b",
|
|
1704
|
+
changeKind: "removed",
|
|
1705
|
+
before: { name: "b", signature: "()", stability: "stable", parent: "router", qualifiedName: "router.b" },
|
|
1706
|
+
},
|
|
1707
|
+
];
|
|
1708
|
+
|
|
1709
|
+
const guide = generateMigrationGuide(changes);
|
|
1710
|
+
guide; // → "signals"
|
|
1711
|
+
guide; // → "router"
|
|
1712
|
+
});
|
|
1713
|
+
});
|
|
1714
|
+
```
|
|
1715
|
+
|
|
1716
|
+
**generateChangelog**
|
|
1717
|
+
|
|
1718
|
+
```typescript
|
|
1719
|
+
describe("generateChangelog", () => {
|
|
1720
|
+
it("generates changelog with breaking changes section", () => {
|
|
1721
|
+
const before = makeSnapshot("1.0.0", [
|
|
1722
|
+
{ name: "removed", signature: "()" },
|
|
1723
|
+
]);
|
|
1724
|
+
const after = makeSnapshot("2.0.0", [
|
|
1725
|
+
{ name: "added", signature: "()" },
|
|
1726
|
+
]);
|
|
1727
|
+
|
|
1728
|
+
const diff = diffSnapshots(before, after);
|
|
1729
|
+
const log = generateChangelog(diff);
|
|
1730
|
+
|
|
1731
|
+
log; // → "Breaking Changes"
|
|
1732
|
+
log; // → "removed"
|
|
1733
|
+
log; // → "added"
|
|
1734
|
+
});
|
|
1735
|
+
|
|
1736
|
+
it("generates changelog with only additions (no breaking section)", () => {
|
|
1737
|
+
const before = makeSnapshot("1.0.0", [{ name: "signal", signature: "()" }]);
|
|
1738
|
+
const after = makeSnapshot("2.0.0", [
|
|
1739
|
+
{ name: "signal", signature: "()" },
|
|
1740
|
+
{ name: "effect", signature: "()" },
|
|
1741
|
+
]);
|
|
1742
|
+
|
|
1743
|
+
const diff = diffSnapshots(before, after);
|
|
1744
|
+
const log = generateChangelog(diff);
|
|
1745
|
+
|
|
1746
|
+
log; // → "Breaking Changes"
|
|
1747
|
+
log; // → "Added"
|
|
1748
|
+
log; // → "effect"
|
|
1749
|
+
});
|
|
1750
|
+
|
|
1751
|
+
it("generates changelog for stability changes", () => {
|
|
1752
|
+
const before = makeSnapshot("1.0.0", [
|
|
1753
|
+
{ name: "fn", stability: "experimental", signature: "()" },
|
|
1754
|
+
]);
|
|
1755
|
+
const after = makeSnapshot("2.0.0", [
|
|
1756
|
+
{ name: "fn", stability: "stable", signature: "()" },
|
|
1757
|
+
]);
|
|
1758
|
+
|
|
1759
|
+
const diff = diffSnapshots(before, after);
|
|
1760
|
+
const log = generateChangelog(diff);
|
|
1761
|
+
|
|
1762
|
+
log; // → "Changed"
|
|
1763
|
+
log; // → "fn"
|
|
1764
|
+
});
|
|
1765
|
+
|
|
1766
|
+
it("returns empty string for no changes", () => {
|
|
1767
|
+
const before = makeSnapshot("1.0.0", [{ name: "signal", signature: "()" }]);
|
|
1768
|
+
const after = makeSnapshot("2.0.0", [{ name: "signal", signature: "()" }]);
|
|
1769
|
+
|
|
1770
|
+
const diff = diffSnapshots(before, after);
|
|
1771
|
+
const log = generateChangelog(diff);
|
|
1772
|
+
log; // → ""
|
|
1773
|
+
});
|
|
1774
|
+
|
|
1775
|
+
it("includes version information in header", () => {
|
|
1776
|
+
const before = makeSnapshot("1.0.0", [
|
|
1777
|
+
{ name: "signal", signature: "()" },
|
|
1778
|
+
]);
|
|
1779
|
+
const after = makeSnapshot("2.0.0", [
|
|
1780
|
+
{ name: "signal", signature: "()" },
|
|
1781
|
+
{ name: "effect", signature: "()" },
|
|
1782
|
+
]);
|
|
1783
|
+
|
|
1784
|
+
const diff = diffSnapshots(before, after);
|
|
1785
|
+
const log = generateChangelog(diff);
|
|
1786
|
+
log; // → "1.0.0"
|
|
1787
|
+
log; // → "2.0.0"
|
|
1788
|
+
});
|
|
1789
|
+
|
|
1790
|
+
it("generates changelog with mixed breaking and non-breaking removals", () => {
|
|
1791
|
+
const before = makeSnapshot("1.0.0", [
|
|
1792
|
+
{ name: "stableRemoved", stability: "stable", signature: "()" },
|
|
1793
|
+
{ name: "deprecatedRemoved", stability: "deprecated", signature: "()" },
|
|
1794
|
+
]);
|
|
1795
|
+
const after = makeSnapshot("2.0.0", []);
|
|
1796
|
+
|
|
1797
|
+
const diff = diffSnapshots(before, after);
|
|
1798
|
+
const log = generateChangelog(diff);
|
|
1799
|
+
|
|
1800
|
+
log; // → "Breaking Changes"
|
|
1801
|
+
log; // → "stableRemoved"
|
|
1802
|
+
log; // → "Changed"
|
|
1803
|
+
log; // → "deprecatedRemoved"
|
|
1804
|
+
log; // → "was deprecated"
|
|
1805
|
+
});
|
|
1806
|
+
});
|
|
1807
|
+
```
|
|
1808
|
+
|
|
1809
|
+
**parseMarkerAttributes**
|
|
1810
|
+
|
|
1811
|
+
```typescript
|
|
1812
|
+
describe("parseMarkerAttributes", () => {
|
|
1813
|
+
it("parses single attribute with double quotes", () => {
|
|
1814
|
+
const result = parseMarkerAttributes('install="@pithyjs/signals"');
|
|
1815
|
+
result; // → { install: "@pithyjs/signals" }
|
|
1816
|
+
});
|
|
1817
|
+
|
|
1818
|
+
it("parses multiple attributes", () => {
|
|
1819
|
+
const result = parseMarkerAttributes('examples="pithy.signals.*" limit="3"');
|
|
1820
|
+
result; // → { examples: "pithy.signals.*", limit: "3" }
|
|
1821
|
+
});
|
|
1822
|
+
|
|
1823
|
+
it("parses attribute with single quotes", () => {
|
|
1824
|
+
const result = parseMarkerAttributes("api='pithy.signals.*' format='table'");
|
|
1825
|
+
result; // → { api: "pithy.signals.*", format: "table" }
|
|
1826
|
+
});
|
|
1827
|
+
|
|
1828
|
+
it("returns empty object for empty string", () => {
|
|
1829
|
+
parseMarkerAttributes(""); // → {}
|
|
1830
|
+
});
|
|
1831
|
+
|
|
1832
|
+
it("handles whitespace between attributes", () => {
|
|
1833
|
+
const result = parseMarkerAttributes(' install = "@pithyjs/core" ');
|
|
1834
|
+
result; // → { install: "@pithyjs/core" }
|
|
1835
|
+
});
|
|
1836
|
+
});
|
|
1837
|
+
```
|
|
1838
|
+
|
|
1839
|
+
**resolveMarkerType**
|
|
1840
|
+
|
|
1841
|
+
```typescript
|
|
1842
|
+
describe("resolveMarkerType", () => {
|
|
1843
|
+
it("resolves 'install' type", () => {
|
|
1844
|
+
resolveMarkerType({ install: "@pithyjs/signals" }); // → "install"
|
|
1845
|
+
});
|
|
1846
|
+
|
|
1847
|
+
it("resolves 'examples' type", () => {
|
|
1848
|
+
resolveMarkerType({ examples: "pithy.signals.*" }); // → "examples"
|
|
1849
|
+
});
|
|
1850
|
+
|
|
1851
|
+
it("resolves 'api' type", () => {
|
|
1852
|
+
resolveMarkerType({ api: "pithy.signals.*" }); // → "api"
|
|
1853
|
+
});
|
|
1854
|
+
|
|
1855
|
+
it("resolves 'bundle' type", () => {
|
|
1856
|
+
resolveMarkerType({ bundle: "@pithyjs/signals" }); // → "bundle"
|
|
1857
|
+
});
|
|
1858
|
+
|
|
1859
|
+
it("resolves 'testing' type", () => {
|
|
1860
|
+
resolveMarkerType({ testing: "pithy.signals.*" }); // → "testing"
|
|
1861
|
+
});
|
|
1862
|
+
|
|
1863
|
+
it("returns null for unknown attributes", () => {
|
|
1864
|
+
resolveMarkerType({ foo: "bar" }); // → null
|
|
1865
|
+
});
|
|
1866
|
+
});
|
|
1867
|
+
```
|
|
1868
|
+
|
|
1869
|
+
**parseReadmeMarkers**
|
|
1870
|
+
|
|
1871
|
+
```typescript
|
|
1872
|
+
describe("parseReadmeMarkers", () => {
|
|
1873
|
+
it("parses a single install marker", () => {
|
|
1874
|
+
const content = `# Package
|
|
1875
|
+
|
|
1876
|
+
<!-- @codex:auto install="@pithyjs/signals" -->
|
|
1877
|
+
```bash
|
|
1878
|
+
npm install @pithyjs/signals
|
|
1879
|
+
```
|
|
1880
|
+
|
|
1881
|
+
```bash
|
|
1882
|
+
pnpm add @pithyjs/signals
|
|
1883
|
+
```
|
|
1884
|
+
|
|
1885
|
+
```bash
|
|
1886
|
+
yarn add @pithyjs/signals
|
|
1887
|
+
```
|
|
1888
|
+
<!-- @codex:end -->
|
|
1889
|
+
|
|
1890
|
+
## More`;
|
|
1891
|
+
|
|
1892
|
+
const markers = parseReadmeMarkers(content);
|
|
1893
|
+
markers; // → 1
|
|
1894
|
+
markers[0].type; // → "install"
|
|
1895
|
+
markers[0].attributes; // → { install: "@pithyjs/signals" }
|
|
1896
|
+
markers[0].innerContent; // → "\nold content\n"
|
|
1897
|
+
});
|
|
1898
|
+
|
|
1899
|
+
it("parses multiple markers in one file", () => {
|
|
1900
|
+
const content = `<!-- @codex:auto install="@pithyjs/core" -->
|
|
1901
|
+
```bash
|
|
1902
|
+
npm install @pithyjs/core
|
|
1903
|
+
```
|
|
1904
|
+
|
|
1905
|
+
```bash
|
|
1906
|
+
pnpm add @pithyjs/core
|
|
1907
|
+
```
|
|
1908
|
+
|
|
1909
|
+
```bash
|
|
1910
|
+
yarn add @pithyjs/core
|
|
1911
|
+
```
|
|
1912
|
+
<!-- @codex:end -->
|
|
1913
|
+
|
|
1914
|
+
<!-- @codex:auto api="pithy.core.*" format="table" -->
|
|
1915
|
+
| API | Component | Signature | Stability | Description |
|
|
1916
|
+
| --- | --- | --- | --- | --- |
|
|
1917
|
+
| LazyState | lazy | `type LazyState = 'idle' | 'loading' | 'loaded' | 'error'` | stable | Possible states for a lazy component |
|
|
1918
|
+
| LazyOptions | lazy | `interface LazyOptions` | stable | Options for configuring lazy loading behavior |
|
|
1919
|
+
| LazyHandle | lazy | `interface LazyHandle<T>` | stable | Handle returned by lazy() for controlling lazy component loading |
|
|
1920
|
+
| lazy | lazy | `<T>(loader: () => Promise<T>, options?: LazyOptions) => LazyHandle<T>` | stable | - |
|
|
1921
|
+
| isLazyHandle | lazy | `(value: unknown) => value is LazyHandle<unknown>` | stable | - |
|
|
1922
|
+
| preloadLazy | lazy | `<T>(handle: LazyHandle<T>) => Promise<T>` | stable | - |
|
|
1923
|
+
| ModuleRecord | module-cache | `interface ModuleRecord<T>` | stable | - |
|
|
1924
|
+
| loadModule | module-cache | `<T>(key: string, loader: () => Promise<T>) => Promise<T>` | stable | - |
|
|
1925
|
+
| getModuleRecord | module-cache | `<T>(key: string) => ModuleRecord<T> | undefined` | stable | - |
|
|
1926
|
+
| invalidateModule | module-cache | `(key: string) => boolean` | stable | - |
|
|
1927
|
+
| getResolvedModuleKeys | module-cache | `() => string[]` | stable | - |
|
|
1928
|
+
| clearModuleCache | module-cache | `() => void` | stable | - |
|
|
1929
|
+
| bindDeferDirectives | bind-defer-directives | `(fragment: DocumentFragment, context: Record<string, unknown>, disposers: Array<() => void>) => void` | stable | - |
|
|
1930
|
+
| registerDirectiveBinder | directive-registry | `(binder: DirectiveBinder) => void` | stable | - |
|
|
1931
|
+
| unregisterDirectiveBinder | directive-registry | `(binder: DirectiveBinder) => void` | stable | - |
|
|
1932
|
+
| executeCustomDirectiveBinders | directive-registry | `(root: Element | DocumentFragment, context: Record<string, unknown>, disposers: Array<() => void>) => void` | stable | - |
|
|
1933
|
+
| createResource | resource | `<T, E = unknown>(fetcher: ResourceFetcher<T>, options?: ResourceOptions) => ResourceHandle<T, E>` | stable | - |
|
|
1934
|
+
<!-- @codex:end -->`;
|
|
1935
|
+
|
|
1936
|
+
const markers = parseReadmeMarkers(content);
|
|
1937
|
+
markers; // → 2
|
|
1938
|
+
markers[0].type; // → "install"
|
|
1939
|
+
markers[1].type; // → "api"
|
|
1940
|
+
});
|
|
1941
|
+
|
|
1942
|
+
it("handles markers with no inner content", () => {
|
|
1943
|
+
const content = `<!-- @codex:auto install="@pithyjs/signals" -->
|
|
1944
|
+
```bash
|
|
1945
|
+
npm install @pithyjs/signals
|
|
1946
|
+
```
|
|
1947
|
+
|
|
1948
|
+
```bash
|
|
1949
|
+
pnpm add @pithyjs/signals
|
|
1950
|
+
```
|
|
1951
|
+
|
|
1952
|
+
```bash
|
|
1953
|
+
yarn add @pithyjs/signals
|
|
1954
|
+
```
|
|
1955
|
+
<!-- @codex:end -->`;
|
|
1956
|
+
|
|
1957
|
+
const markers = parseReadmeMarkers(content);
|
|
1958
|
+
markers; // → 1
|
|
1959
|
+
markers[0].innerContent; // → "\n"
|
|
1960
|
+
});
|
|
1961
|
+
|
|
1962
|
+
it("preserves content outside markers", () => {
|
|
1963
|
+
const content = `# Title
|
|
1964
|
+
|
|
1965
|
+
Some manual content.
|
|
1966
|
+
|
|
1967
|
+
<!-- @codex:auto install="pkg" -->
|
|
1968
|
+
```bash
|
|
1969
|
+
npm install pkg
|
|
1970
|
+
```
|
|
1971
|
+
|
|
1972
|
+
```bash
|
|
1973
|
+
pnpm add pkg
|
|
1974
|
+
```
|
|
1975
|
+
|
|
1976
|
+
```bash
|
|
1977
|
+
yarn add pkg
|
|
1978
|
+
```
|
|
1979
|
+
<!-- @codex:end -->
|
|
1980
|
+
|
|
1981
|
+
More manual content.`;
|
|
1982
|
+
|
|
1983
|
+
const markers = parseReadmeMarkers(content);
|
|
1984
|
+
markers; // → 1
|
|
1985
|
+
// Markers should have correct indices that don't span the whole file
|
|
1986
|
+
expect(markers[0].startIndex).toBeGreaterThan(0);
|
|
1987
|
+
expect(markers[0].endIndex).toBeLessThan(content.length);
|
|
1988
|
+
});
|
|
1989
|
+
|
|
1990
|
+
it("returns empty array when no markers found", () => {
|
|
1991
|
+
parseReadmeMarkers("# Just a regular README\n\nNo markers here."); // → []
|
|
1992
|
+
});
|
|
1993
|
+
|
|
1994
|
+
it("handles malformed marker (missing @codex:end) gracefully", () => {
|
|
1995
|
+
const content = `<!-- @codex:auto install="@pithyjs/signals" -->
|
|
1996
|
+
some content without closing tag`;
|
|
1997
|
+
|
|
1998
|
+
const markers = parseReadmeMarkers(content);
|
|
1999
|
+
markers; // → 0
|
|
2000
|
+
});
|
|
2001
|
+
|
|
2002
|
+
it("assigns correct startIndex and endIndex", () => {
|
|
2003
|
+
const content = `before\n<!-- @codex:auto install="pkg" -->
|
|
2004
|
+
```bash
|
|
2005
|
+
npm install pkg
|
|
2006
|
+
```
|
|
2007
|
+
|
|
2008
|
+
```bash
|
|
2009
|
+
pnpm add pkg
|
|
2010
|
+
```
|
|
2011
|
+
|
|
2012
|
+
```bash
|
|
2013
|
+
yarn add pkg
|
|
2014
|
+
```
|
|
2015
|
+
<!-- @codex:end -->\nafter`;
|
|
2016
|
+
const markers = parseReadmeMarkers(content);
|
|
2017
|
+
markers; // → 1
|
|
2018
|
+
|
|
2019
|
+
const reconstructed =
|
|
2020
|
+
content.slice(0, markers[0].startIndex) +
|
|
2021
|
+
markers[0].originalContent +
|
|
2022
|
+
content.slice(markers[0].endIndex);
|
|
2023
|
+
reconstructed; // → content
|
|
2024
|
+
});
|
|
2025
|
+
|
|
2026
|
+
it("stores the original open tag string", () => {
|
|
2027
|
+
const content = `<!-- @codex:auto examples="pithy.signals.*" limit="3" -->
|
|
2028
|
+
**creates an effect**
|
|
2029
|
+
|
|
2030
|
+
```typescript
|
|
2031
|
+
const count = signal(0);
|
|
2032
|
+
count(); // → 0
|
|
2033
|
+
```
|
|
2034
|
+
<!-- @codex:end -->`;
|
|
2035
|
+
const markers = parseReadmeMarkers(content);
|
|
2036
|
+
markers; // → 1
|
|
2037
|
+
markers[0].openTag; // → `<!-- @codex:auto examples="pithy.signals.*" limit="3" -->`
|
|
2038
|
+
});
|
|
2039
|
+
});
|
|
2040
|
+
```
|
|
2041
|
+
|
|
2042
|
+
**generateInstallContent**
|
|
2043
|
+
|
|
2044
|
+
````typescript
|
|
2045
|
+
describe("generateInstallContent", () => {
|
|
2046
|
+
it("generates npm/pnpm/yarn commands", () => {
|
|
2047
|
+
const content = generateInstallContent("@pithyjs/signals");
|
|
2048
|
+
content; // → "npm install @pithyjs/signals"
|
|
2049
|
+
content; // → "pnpm add @pithyjs/signals"
|
|
2050
|
+
content; // → "yarn add @pithyjs/signals"
|
|
2051
|
+
});
|
|
2052
|
+
|
|
2053
|
+
it("wraps in bash code blocks", () => {
|
|
2054
|
+
const content = generateInstallContent("@pithyjs/signals");
|
|
2055
|
+
content; // → "```bash"
|
|
2056
|
+
content; // → "```"
|
|
2057
|
+
});
|
|
2058
|
+
});
|
|
2059
|
+
````
|
|
2060
|
+
|
|
2061
|
+
**generateExamplesContent**
|
|
2062
|
+
|
|
2063
|
+
`````typescript
|
|
2064
|
+
describe("generateExamplesContent", () => {
|
|
2065
|
+
it("generates markdown with code blocks from component examples", () => {
|
|
2066
|
+
const comp = makeComponentWithApis();
|
|
2067
|
+
const content = generateExamplesContent([comp]);
|
|
2068
|
+
content; // → "```ts"
|
|
2069
|
+
content; // → "const count = signal(0)"
|
|
2070
|
+
});
|
|
2071
|
+
|
|
2072
|
+
it("respects limit parameter", () => {
|
|
2073
|
+
const comp = makeComponentWithApis();
|
|
2074
|
+
// Add multiple API-level examples (generateExamplesContent reads api.examples, not comp.examples)
|
|
2075
|
+
comp.apis[0].examples = [
|
|
2076
|
+
{ code: "example1", language: "ts", runnable: true, isDoctest: false, location: { file: "f", line: 1 } },
|
|
2077
|
+
{ code: "example2", language: "ts", runnable: true, isDoctest: false, location: { file: "f", line: 2 } },
|
|
2078
|
+
{ code: "example3", language: "ts", runnable: true, isDoctest: false, location: { file: "f", line: 3 } },
|
|
2079
|
+
];
|
|
2080
|
+
const content = generateExamplesContent([comp], 1);
|
|
2081
|
+
// Should only show 1 example total
|
|
2082
|
+
const codeBlockCount = (content.match(/```ts/g) || []).length;
|
|
2083
|
+
codeBlockCount; // → 1
|
|
2084
|
+
});
|
|
2085
|
+
|
|
2086
|
+
it("treats limit=0 as zero (not unlimited)", () => {
|
|
2087
|
+
const comp = makeComponentWithApis();
|
|
2088
|
+
const content = generateExamplesContent([comp], 0);
|
|
2089
|
+
// limit=0 should select zero examples, not all of them
|
|
2090
|
+
content; // → ""
|
|
2091
|
+
// Verify without limit it would have returned examples
|
|
2092
|
+
const unlimited = generateExamplesContent([comp]);
|
|
2093
|
+
unlimited; // → "```ts"
|
|
2094
|
+
});
|
|
2095
|
+
|
|
2096
|
+
it("uses dynamic fence length when code contains triple backticks", () => {
|
|
2097
|
+
const comp = makeComponent({
|
|
2098
|
+
apis: [{
|
|
2099
|
+
id: "test.api",
|
|
2100
|
+
name: "fn",
|
|
2101
|
+
parent: "test",
|
|
2102
|
+
stability: "stable",
|
|
2103
|
+
examples: [{
|
|
2104
|
+
code: 'const md = `\n```js\nconsole.log("hi");\n```\n`;',
|
|
2105
|
+
language: "ts",
|
|
2106
|
+
runnable: false,
|
|
2107
|
+
isDoctest: false,
|
|
2108
|
+
location: { file: "f.ts", line: 1 },
|
|
2109
|
+
}],
|
|
2110
|
+
testRefs: [],
|
|
2111
|
+
sourceLocation: { file: "f.ts", line: 1 },
|
|
2112
|
+
}],
|
|
2113
|
+
});
|
|
2114
|
+
const content = generateExamplesContent([comp]);
|
|
2115
|
+
// Should use 4 backticks since content contains triple backticks
|
|
2116
|
+
content; // → "````ts"
|
|
2117
|
+
content; // → /````\s*$/m
|
|
2118
|
+
// Should NOT contain a bare triple-backtick fence
|
|
2119
|
+
content; // → /^```ts$/m
|
|
2120
|
+
});
|
|
2121
|
+
|
|
2122
|
+
it("returns placeholder when no examples available", () => {
|
|
2123
|
+
const comp = makeComponent({ apis: [] });
|
|
2124
|
+
const content = generateExamplesContent([comp]);
|
|
2125
|
+
content; // → "No examples"
|
|
2126
|
+
});
|
|
2127
|
+
|
|
2128
|
+
it("includes example descriptions when available", () => {
|
|
2129
|
+
const comp = makeComponentWithApis();
|
|
2130
|
+
const content = generateExamplesContent([comp]);
|
|
2131
|
+
content; // → "Basic usage"
|
|
2132
|
+
});
|
|
2133
|
+
|
|
2134
|
+
it("falls back to test examples when no API examples exist", () => {
|
|
2135
|
+
const comp = makeComponent({ id: "pithy.signals", apis: [] });
|
|
2136
|
+
const testExamples: TestExample[] = [
|
|
2137
|
+
{
|
|
2138
|
+
entryId: "pithy.signals",
|
|
2139
|
+
name: "creates a signal",
|
|
2140
|
+
code: 'const count = signal(0);\nexpect(count()).toBe(0);',
|
|
2141
|
+
location: { file: "signal.test.ts", line: 5 },
|
|
2142
|
+
testFile: "signal.test.ts",
|
|
2143
|
+
status: "passing",
|
|
2144
|
+
},
|
|
2145
|
+
];
|
|
2146
|
+
const content = generateExamplesContent([comp], undefined, testExamples, "pithy.signals");
|
|
2147
|
+
content; // → "creates a signal"
|
|
2148
|
+
content; // → "const count = signal(0)"
|
|
2149
|
+
// Verify expect() was stripped by stripTestBoilerplate
|
|
2150
|
+
content; // → "expect("
|
|
2151
|
+
content; // → "count(); // → 0"
|
|
2152
|
+
});
|
|
2153
|
+
|
|
2154
|
+
it("prefers API examples over test examples", () => {
|
|
2155
|
+
const comp = makeComponentWithApis(); // has API-level example "Basic usage"
|
|
2156
|
+
const testExamples: TestExample[] = [
|
|
2157
|
+
{
|
|
2158
|
+
entryId: "pithy.signals",
|
|
2159
|
+
name: "test example",
|
|
2160
|
+
code: 'test code',
|
|
2161
|
+
location: { file: "signal.test.ts", line: 5 },
|
|
2162
|
+
testFile: "signal.test.ts",
|
|
2163
|
+
status: "passing",
|
|
2164
|
+
},
|
|
2165
|
+
];
|
|
2166
|
+
const content = generateExamplesContent([comp], undefined, testExamples, "pithy.signals.*");
|
|
2167
|
+
// Should use API examples, not test examples
|
|
2168
|
+
content; // → "Basic usage"
|
|
2169
|
+
content; // → "test example"
|
|
2170
|
+
});
|
|
2171
|
+
|
|
2172
|
+
it("filters test examples by pattern", () => {
|
|
2173
|
+
const comp = makeComponent({ id: "pithy.signals", apis: [] });
|
|
2174
|
+
const testExamples: TestExample[] = [
|
|
2175
|
+
{
|
|
2176
|
+
entryId: "pithy.signals",
|
|
2177
|
+
name: "signal test",
|
|
2178
|
+
code: 'signal code',
|
|
2179
|
+
location: { file: "signal.test.ts", line: 5 },
|
|
2180
|
+
testFile: "signal.test.ts",
|
|
2181
|
+
status: "passing",
|
|
2182
|
+
},
|
|
2183
|
+
{
|
|
2184
|
+
entryId: "pithy.router",
|
|
2185
|
+
name: "router test",
|
|
2186
|
+
code: 'router code',
|
|
2187
|
+
location: { file: "router.test.ts", line: 5 },
|
|
2188
|
+
testFile: "router.test.ts",
|
|
2189
|
+
status: "passing",
|
|
2190
|
+
},
|
|
2191
|
+
];
|
|
2192
|
+
const content = generateExamplesContent([comp], undefined, testExamples, "pithy.signals");
|
|
2193
|
+
content; // → "signal code"
|
|
2194
|
+
content; // → "router code"
|
|
2195
|
+
});
|
|
2196
|
+
});
|
|
2197
|
+
`````
|
|
2198
|
+
|
|
2199
|
+
**generateApiContent**
|
|
2200
|
+
|
|
2201
|
+
```typescript
|
|
2202
|
+
describe("generateApiContent", () => {
|
|
2203
|
+
it("generates markdown table with correct columns", () => {
|
|
2204
|
+
const comp = makeComponentWithApis();
|
|
2205
|
+
const content = generateApiContent([comp]);
|
|
2206
|
+
content; // → "| API |"
|
|
2207
|
+
content; // → "| Signature |"
|
|
2208
|
+
content; // → "| Stability |"
|
|
2209
|
+
});
|
|
2210
|
+
|
|
2211
|
+
it("renders all APIs from matched components", () => {
|
|
2212
|
+
const comp = makeComponentWithApis();
|
|
2213
|
+
const content = generateApiContent([comp]);
|
|
2214
|
+
content; // → "signal"
|
|
2215
|
+
content; // → "effect"
|
|
2216
|
+
content; // → "computed"
|
|
2217
|
+
});
|
|
2218
|
+
|
|
2219
|
+
it("preserves pipe characters in signatures (inside backticks)", () => {
|
|
2220
|
+
const comp = makeComponent({
|
|
2221
|
+
apis: [{
|
|
2222
|
+
id: "test.api",
|
|
2223
|
+
name: "fn",
|
|
2224
|
+
parent: "test",
|
|
2225
|
+
signature: "(a: A | B) => C",
|
|
2226
|
+
stability: "stable",
|
|
2227
|
+
examples: [],
|
|
2228
|
+
testRefs: [],
|
|
2229
|
+
sourceLocation: { file: "f.ts", line: 1 },
|
|
2230
|
+
}],
|
|
2231
|
+
});
|
|
2232
|
+
const content = generateApiContent([comp]);
|
|
2233
|
+
// Signature is inside backticks — pipes don't need escaping in GFM inline code
|
|
2234
|
+
content; // → "`(a: A | B) => C`"
|
|
2235
|
+
});
|
|
2236
|
+
|
|
2237
|
+
it("renders list format when format='list'", () => {
|
|
2238
|
+
const comp = makeComponentWithApis();
|
|
2239
|
+
const content = generateApiContent([comp], "list");
|
|
2240
|
+
content; // → "- **signal**"
|
|
2241
|
+
content; // → "- **effect**"
|
|
2242
|
+
content; // → "[stable]"
|
|
2243
|
+
content; // → "[experimental]"
|
|
2244
|
+
// Should NOT have table syntax
|
|
2245
|
+
content; // → "| API |"
|
|
2246
|
+
});
|
|
2247
|
+
|
|
2248
|
+
it("includes descriptions in list format", () => {
|
|
2249
|
+
const comp = makeComponentWithApis();
|
|
2250
|
+
const content = generateApiContent([comp], "list");
|
|
2251
|
+
content; // → "Creates a reactive signal"
|
|
2252
|
+
});
|
|
2253
|
+
|
|
2254
|
+
it("returns placeholder when no APIs found", () => {
|
|
2255
|
+
const content = generateApiContent([]);
|
|
2256
|
+
content; // → "No API"
|
|
2257
|
+
});
|
|
2258
|
+
});
|
|
2259
|
+
```
|
|
2260
|
+
|
|
2261
|
+
**syncReadmeContent**
|
|
2262
|
+
|
|
2263
|
+
```typescript
|
|
2264
|
+
describe("syncReadmeContent", () => {
|
|
2265
|
+
it("replaces install marker content", () => {
|
|
2266
|
+
const readme = `# Package
|
|
2267
|
+
|
|
2268
|
+
<!-- @codex:auto install="@pithyjs/signals" -->
|
|
2269
|
+
```bash
|
|
2270
|
+
npm install @pithyjs/signals
|
|
2271
|
+
```
|
|
2272
|
+
|
|
2273
|
+
```bash
|
|
2274
|
+
pnpm add @pithyjs/signals
|
|
2275
|
+
```
|
|
2276
|
+
|
|
2277
|
+
```bash
|
|
2278
|
+
yarn add @pithyjs/signals
|
|
2279
|
+
```
|
|
2280
|
+
<!-- @codex:end -->`;
|
|
2281
|
+
|
|
2282
|
+
const result = syncReadmeContent(readme, "README.md", []);
|
|
2283
|
+
result.changed; // → true
|
|
2284
|
+
result.markersProcessed; // → 1
|
|
2285
|
+
result.updatedContent; // → "npm install @pithyjs/signals"
|
|
2286
|
+
});
|
|
2287
|
+
|
|
2288
|
+
it("replaces API marker content", () => {
|
|
2289
|
+
const comp = makeComponentWithApis();
|
|
2290
|
+
const readme = `# API
|
|
2291
|
+
|
|
2292
|
+
<!-- @codex:auto api="pithy.signals.*" format="table" -->
|
|
2293
|
+
_No API entries found._
|
|
2294
|
+
<!-- @codex:end -->`;
|
|
2295
|
+
|
|
2296
|
+
const result = syncReadmeContent(readme, "README.md", [comp]);
|
|
2297
|
+
result.changed; // → true
|
|
2298
|
+
result.updatedContent; // → "signal"
|
|
2299
|
+
result.updatedContent; // → "effect"
|
|
2300
|
+
});
|
|
2301
|
+
|
|
2302
|
+
it("handles multiple markers in one file", () => {
|
|
2303
|
+
const comp = makeComponentWithApis();
|
|
2304
|
+
const readme = `<!-- @codex:auto install="@pithyjs/signals" -->
|
|
2305
|
+
```bash
|
|
2306
|
+
npm install @pithyjs/signals
|
|
2307
|
+
```
|
|
2308
|
+
|
|
2309
|
+
```bash
|
|
2310
|
+
pnpm add @pithyjs/signals
|
|
2311
|
+
```
|
|
2312
|
+
|
|
2313
|
+
```bash
|
|
2314
|
+
yarn add @pithyjs/signals
|
|
2315
|
+
```
|
|
2316
|
+
<!-- @codex:end -->
|
|
2317
|
+
|
|
2318
|
+
<!-- @codex:auto api="pithy.signals.*" -->
|
|
2319
|
+
_No API entries found._
|
|
2320
|
+
<!-- @codex:end -->`;
|
|
2321
|
+
|
|
2322
|
+
const result = syncReadmeContent(readme, "README.md", [comp]);
|
|
2323
|
+
result.markersProcessed; // → 2
|
|
2324
|
+
result.markersUpdated; // → 2
|
|
2325
|
+
});
|
|
2326
|
+
|
|
2327
|
+
it("preserves content outside markers", () => {
|
|
2328
|
+
const readme = `# Title
|
|
2329
|
+
|
|
2330
|
+
Manual content here.
|
|
2331
|
+
|
|
2332
|
+
<!-- @codex:auto install="@pithyjs/signals" -->
|
|
2333
|
+
```bash
|
|
2334
|
+
npm install @pithyjs/signals
|
|
2335
|
+
```
|
|
2336
|
+
|
|
2337
|
+
```bash
|
|
2338
|
+
pnpm add @pithyjs/signals
|
|
2339
|
+
```
|
|
2340
|
+
|
|
2341
|
+
```bash
|
|
2342
|
+
yarn add @pithyjs/signals
|
|
2343
|
+
```
|
|
2344
|
+
<!-- @codex:end -->
|
|
2345
|
+
|
|
2346
|
+
More manual content.`;
|
|
2347
|
+
|
|
2348
|
+
const result = syncReadmeContent(readme, "README.md", []);
|
|
2349
|
+
result.updatedContent; // → "# Title"
|
|
2350
|
+
result.updatedContent; // → "Manual content here."
|
|
2351
|
+
result.updatedContent; // → "More manual content."
|
|
2352
|
+
});
|
|
2353
|
+
|
|
2354
|
+
it("reports changed: false when content is identical after sync", () => {
|
|
2355
|
+
// First sync to get the generated content
|
|
2356
|
+
const readme = `<!-- @codex:auto install="@pithyjs/signals" -->
|
|
2357
|
+
```bash
|
|
2358
|
+
npm install @pithyjs/signals
|
|
2359
|
+
```
|
|
2360
|
+
|
|
2361
|
+
```bash
|
|
2362
|
+
pnpm add @pithyjs/signals
|
|
2363
|
+
```
|
|
2364
|
+
|
|
2365
|
+
```bash
|
|
2366
|
+
yarn add @pithyjs/signals
|
|
2367
|
+
```
|
|
2368
|
+
<!-- @codex:end -->`;
|
|
2369
|
+
const first = syncReadmeContent(readme, "README.md", []);
|
|
2370
|
+
|
|
2371
|
+
// Second sync with the already-synced content
|
|
2372
|
+
const second = syncReadmeContent(first.updatedContent!, "README.md", []);
|
|
2373
|
+
second.changed; // → false
|
|
2374
|
+
});
|
|
2375
|
+
|
|
2376
|
+
it("reports warnings for unsupported marker types", () => {
|
|
2377
|
+
const readme = `<!-- @codex:auto bundle="@pithyjs/signals" -->
|
|
2378
|
+
<!-- Bundle size tracking not yet available -->
|
|
2379
|
+
<!-- @codex:end -->`;
|
|
2380
|
+
|
|
2381
|
+
const result = syncReadmeContent(readme, "README.md", []);
|
|
2382
|
+
expect(result.warnings.length).toBeGreaterThan(0);
|
|
2383
|
+
result.warnings[0]; // → "bundle"
|
|
2384
|
+
});
|
|
2385
|
+
|
|
2386
|
+
it("uses test examples for examples markers when no API examples exist", () => {
|
|
2387
|
+
const comp = makeComponent({
|
|
2388
|
+
id: "pithy.feature.signals",
|
|
2389
|
+
apis: [
|
|
2390
|
+
{
|
|
2391
|
+
id: "pithy.feature.signals.signal",
|
|
2392
|
+
name: "signal",
|
|
2393
|
+
parent: "pithy.feature.signals",
|
|
2394
|
+
stability: "stable",
|
|
2395
|
+
examples: [],
|
|
2396
|
+
testRefs: [],
|
|
2397
|
+
sourceLocation: { file: "signal.ts", line: 1 },
|
|
2398
|
+
},
|
|
2399
|
+
],
|
|
2400
|
+
});
|
|
2401
|
+
const testExamples: TestExample[] = [
|
|
2402
|
+
{
|
|
2403
|
+
entryId: "pithy.feature.signals",
|
|
2404
|
+
name: "creates a reactive signal",
|
|
2405
|
+
code: 'const count = signal(0);\nexpect(count()).toBe(0);',
|
|
2406
|
+
location: { file: "signal.test.ts", line: 5 },
|
|
2407
|
+
testFile: "signal.test.ts",
|
|
2408
|
+
status: "passing",
|
|
2409
|
+
},
|
|
2410
|
+
];
|
|
2411
|
+
const readme = `<!-- @codex:auto examples="pithy.feature.signals" -->
|
|
2412
|
+
**creates a reactive signal with initial value**
|
|
2413
|
+
|
|
2414
|
+
```typescript
|
|
2415
|
+
const count = signal(0);
|
|
2416
|
+
count(); // → 0
|
|
2417
|
+
count.set(1);
|
|
2418
|
+
count(); // → 1
|
|
2419
|
+
```
|
|
2420
|
+
|
|
2421
|
+
**supports functional updates via .set()**
|
|
2422
|
+
|
|
2423
|
+
```typescript
|
|
2424
|
+
const count = signal(0);
|
|
2425
|
+
count.set(n => n + 1);
|
|
2426
|
+
count(); // → 1
|
|
2427
|
+
count.set(n => n * 10);
|
|
2428
|
+
count(); // → 10
|
|
2429
|
+
```
|
|
2430
|
+
|
|
2431
|
+
**tracks signal dependencies and re-runs**
|
|
2432
|
+
|
|
2433
|
+
```typescript
|
|
2434
|
+
const count = signal(0);
|
|
2435
|
+
let observed = -1;
|
|
2436
|
+
effect(() => {
|
|
2437
|
+
observed = count();
|
|
2438
|
+
});
|
|
2439
|
+
observed; // → 0
|
|
2440
|
+
count.set(5);
|
|
2441
|
+
observed; // → 5
|
|
2442
|
+
```
|
|
2443
|
+
|
|
2444
|
+
**derives a value from other signals**
|
|
2445
|
+
|
|
2446
|
+
```typescript
|
|
2447
|
+
const a = signal(2);
|
|
2448
|
+
const b = signal(3);
|
|
2449
|
+
const sum = computed(() => a() + b());
|
|
2450
|
+
sum(); // → 5
|
|
2451
|
+
a.set(10);
|
|
2452
|
+
sum(); // → 13
|
|
2453
|
+
```
|
|
2454
|
+
|
|
2455
|
+
**defers effect execution until batch completes**
|
|
2456
|
+
|
|
2457
|
+
```typescript
|
|
2458
|
+
const a = signal(1);
|
|
2459
|
+
const b = signal(2);
|
|
2460
|
+
let runCount = 0;
|
|
2461
|
+
effect(() => {
|
|
2462
|
+
a();
|
|
2463
|
+
b();
|
|
2464
|
+
runCount++;
|
|
2465
|
+
});
|
|
2466
|
+
runCount; // → 1
|
|
2467
|
+
|
|
2468
|
+
batch(() => {
|
|
2469
|
+
a.set(10);
|
|
2470
|
+
b.set(20);
|
|
2471
|
+
});
|
|
2472
|
+
// Effect should have run only once after batch, not twice
|
|
2473
|
+
runCount; // → 2
|
|
2474
|
+
```
|
|
2475
|
+
|
|
2476
|
+
**runs cleanup on disposal**
|
|
2477
|
+
|
|
2478
|
+
```typescript
|
|
2479
|
+
const count = signal(0);
|
|
2480
|
+
let cleaned = false;
|
|
2481
|
+
|
|
2482
|
+
const dispose = effect(() => {
|
|
2483
|
+
count();
|
|
2484
|
+
onCleanup(() => {
|
|
2485
|
+
cleaned = true;
|
|
2486
|
+
});
|
|
2487
|
+
});
|
|
2488
|
+
|
|
2489
|
+
cleaned; // → false
|
|
2490
|
+
dispose();
|
|
2491
|
+
cleaned; // → true
|
|
2492
|
+
```
|
|
2493
|
+
|
|
2494
|
+
**detects signal values at runtime**
|
|
2495
|
+
|
|
2496
|
+
```typescript
|
|
2497
|
+
const count = signal(0);
|
|
2498
|
+
isSignal(count); // → true
|
|
2499
|
+
isSignal(42); // → false
|
|
2500
|
+
isSignal(() => {}); // → false
|
|
2501
|
+
isSignal(null); // → false
|
|
2502
|
+
```
|
|
2503
|
+
|
|
2504
|
+
**unsubscribes from a dependency it stopped reading**
|
|
2505
|
+
|
|
2506
|
+
```typescript
|
|
2507
|
+
const useB = signal(true);
|
|
2508
|
+
const b = signal(2);
|
|
2509
|
+
|
|
2510
|
+
let runs = 0;
|
|
2511
|
+
const dispose = effect(() => {
|
|
2512
|
+
runs += 1;
|
|
2513
|
+
if (useB()) b();
|
|
2514
|
+
});
|
|
2515
|
+
|
|
2516
|
+
useB.set(false);
|
|
2517
|
+
const afterSwitch = runs;
|
|
2518
|
+
|
|
2519
|
+
b.set(99);
|
|
2520
|
+
expect(runs, 'live effect re-ran for a dependency it stopped reading').toBe(
|
|
2521
|
+
afterSwitch
|
|
2522
|
+
);
|
|
2523
|
+
|
|
2524
|
+
dispose();
|
|
2525
|
+
```
|
|
2526
|
+
<!-- @codex:end -->`;
|
|
2527
|
+
|
|
2528
|
+
const result = syncReadmeContent(readme, "README.md", [comp], testExamples);
|
|
2529
|
+
result.changed; // → true
|
|
2530
|
+
result.updatedContent; // → "creates a reactive signal"
|
|
2531
|
+
result.updatedContent; // → "const count = signal(0)"
|
|
2532
|
+
// Verify test boilerplate was stripped
|
|
2533
|
+
result.updatedContent; // → "expect("
|
|
2534
|
+
result.updatedContent; // → "count(); // → 0"
|
|
2535
|
+
});
|
|
2536
|
+
|
|
2537
|
+
it("skips outer marker when nested markers are detected", () => {
|
|
2538
|
+
const readme = `<!-- @codex:auto install="@pithyjs/signals" -->
|
|
2539
|
+
<!-- @codex:auto api="pithy.signals.*" -->
|
|
2540
|
+
_No API entries found._
|
|
2541
|
+
<!-- @codex:end -->
|
|
2542
|
+
<!-- @codex:end -->`;
|
|
2543
|
+
|
|
2544
|
+
const result = syncReadmeContent(readme, "README.md", []);
|
|
2545
|
+
// The parser skips the outer marker (its inner content contains another @codex:auto).
|
|
2546
|
+
// Only the inner marker is processed — the outer is treated as malformed.
|
|
2547
|
+
result.markersProcessed; // → 1
|
|
2548
|
+
// The extra <!-- @codex:end --> remains in the file as-is (not corrupted)
|
|
2549
|
+
result.updatedContent ?? readme; // → "<!-- @codex:end -->"
|
|
2550
|
+
});
|
|
2551
|
+
|
|
2552
|
+
it("preserves original open tag attributes (no reordering)", () => {
|
|
2553
|
+
const readme = `<!-- @codex:auto examples="pithy.signals.*" limit="3" -->
|
|
2554
|
+
**creates an effect**
|
|
2555
|
+
|
|
2556
|
+
```typescript
|
|
2557
|
+
const count = signal(0);
|
|
2558
|
+
count(); // → 0
|
|
2559
|
+
```
|
|
2560
|
+
<!-- @codex:end -->`;
|
|
2561
|
+
|
|
2562
|
+
const first = syncReadmeContent(readme, "README.md", []);
|
|
2563
|
+
// The open tag should be preserved exactly as written
|
|
2564
|
+
first.updatedContent; // → `<!-- @codex:auto examples="pithy.signals.*" limit="3" -->`
|
|
2565
|
+
});
|
|
2566
|
+
|
|
2567
|
+
it("warns on empty pattern value", () => {
|
|
2568
|
+
const readme = `<!-- @codex:auto api="" -->
|
|
2569
|
+
_No API entries found._
|
|
2570
|
+
<!-- @codex:end -->`;
|
|
2571
|
+
|
|
2572
|
+
const result = syncReadmeContent(readme, "README.md", []);
|
|
2573
|
+
result.warnings.some((w) => w.includes("Empty pattern")); // → true
|
|
2574
|
+
});
|
|
2575
|
+
|
|
2576
|
+
it("warns on unknown api format", () => {
|
|
2577
|
+
const comp = makeComponentWithApis();
|
|
2578
|
+
const readme = `<!-- @codex:auto api="pithy.signals.*" format="csv" -->
|
|
2579
|
+
_No API entries found._
|
|
2580
|
+
<!-- @codex:end -->`;
|
|
2581
|
+
|
|
2582
|
+
const result = syncReadmeContent(readme, "README.md", [comp]);
|
|
2583
|
+
result.warnings.some((w) => w.includes('Unknown format "csv"')); // → true
|
|
2584
|
+
});
|
|
2585
|
+
|
|
2586
|
+
it("warns on negative limit", () => {
|
|
2587
|
+
const comp = makeComponentWithApis();
|
|
2588
|
+
const readme = `<!-- @codex:auto examples="pithy.signals.*" limit="-1" -->
|
|
2589
|
+
|
|
2590
|
+
<!-- @codex:end -->`;
|
|
2591
|
+
|
|
2592
|
+
const result = syncReadmeContent(readme, "README.md", [comp]);
|
|
2593
|
+
result.warnings.some((w) => w.includes("Negative")); // → true
|
|
2594
|
+
});
|
|
2595
|
+
|
|
2596
|
+
it("returns correct markersProcessed and markersUpdated counts", () => {
|
|
2597
|
+
const readme = `<!-- @codex:auto install="@pithyjs/signals" -->
|
|
2598
|
+
```bash
|
|
2599
|
+
npm install @pithyjs/signals
|
|
2600
|
+
```
|
|
2601
|
+
|
|
2602
|
+
```bash
|
|
2603
|
+
pnpm add @pithyjs/signals
|
|
2604
|
+
```
|
|
2605
|
+
|
|
2606
|
+
```bash
|
|
2607
|
+
yarn add @pithyjs/signals
|
|
2608
|
+
```
|
|
2609
|
+
<!-- @codex:end -->
|
|
2610
|
+
|
|
2611
|
+
<!-- @codex:auto bundle="@pithyjs/signals" -->
|
|
2612
|
+
<!-- Bundle size tracking not yet available -->
|
|
2613
|
+
<!-- @codex:end -->`;
|
|
2614
|
+
|
|
2615
|
+
const result = syncReadmeContent(readme, "README.md", []);
|
|
2616
|
+
result.markersProcessed; // → 2
|
|
2617
|
+
// Both markers generate content (bundle generates a placeholder)
|
|
2618
|
+
result.markersUpdated; // → 2
|
|
2619
|
+
});
|
|
2620
|
+
|
|
2621
|
+
it("replaces testing marker content", () => {
|
|
2622
|
+
const comp = makeComponentWithApis();
|
|
2623
|
+
const readme = `# Testing
|
|
2624
|
+
|
|
2625
|
+
<!-- @codex:auto testing="pithy.signals.*" -->
|
|
2626
|
+
_No test data available._
|
|
2627
|
+
<!-- @codex:end -->`;
|
|
2628
|
+
|
|
2629
|
+
const result = syncReadmeContent(readme, "README.md", [comp]);
|
|
2630
|
+
result.changed; // → true
|
|
2631
|
+
result.markersProcessed; // → 1
|
|
2632
|
+
result.updatedContent; // → "signal"
|
|
2633
|
+
result.updatedContent; // → "effect"
|
|
2634
|
+
// Should contain a markdown table
|
|
2635
|
+
result.updatedContent; // → "|"
|
|
2636
|
+
});
|
|
2637
|
+
|
|
2638
|
+
it("warns on non-numeric limit value", () => {
|
|
2639
|
+
const comp = makeComponentWithApis();
|
|
2640
|
+
const readme = `<!-- @codex:auto examples="pithy.signals.*" limit="abc" -->
|
|
2641
|
+
**creates an effect**
|
|
2642
|
+
|
|
2643
|
+
```typescript
|
|
2644
|
+
const count = signal(0);
|
|
2645
|
+
count(); // → 0
|
|
2646
|
+
```
|
|
2647
|
+
<!-- @codex:end -->`;
|
|
2648
|
+
|
|
2649
|
+
const result = syncReadmeContent(readme, "README.md", [comp]);
|
|
2650
|
+
result.warnings.some((w) => w.toLowerCase().includes("limit")); // → true
|
|
2651
|
+
});
|
|
2652
|
+
});
|
|
2653
|
+
```
|
|
2654
|
+
|
|
2655
|
+
**syncAllReadmes**
|
|
2656
|
+
|
|
2657
|
+
```typescript
|
|
2658
|
+
describe("syncAllReadmes", () => {
|
|
2659
|
+
beforeEach(() => {
|
|
2660
|
+
vi.clearAllMocks();
|
|
2661
|
+
});
|
|
2662
|
+
|
|
2663
|
+
it("returns empty results when no README files match", async () => {
|
|
2664
|
+
const { globby } = await import("globby");
|
|
2665
|
+
vi.mocked(globby).mockResolvedValue([]);
|
|
2666
|
+
|
|
2667
|
+
const extractionResult = makeExtractionResult();
|
|
2668
|
+
const results = await syncAllReadmes(extractionResult, { root: "/project" });
|
|
2669
|
+
results; // → []
|
|
2670
|
+
});
|
|
2671
|
+
|
|
2672
|
+
it("processes matched README files and returns results", async () => {
|
|
2673
|
+
const { globby } = await import("globby");
|
|
2674
|
+
const { readFile } = await import("node:fs/promises");
|
|
2675
|
+
|
|
2676
|
+
vi.mocked(globby).mockResolvedValue(["/project/README.md"]);
|
|
2677
|
+
vi.mocked(readFile).mockResolvedValue(
|
|
2678
|
+
`<!-- @codex:auto install="@pithyjs/signals" -->
|
|
2679
|
+
```bash
|
|
2680
|
+
npm install @pithyjs/signals
|
|
2681
|
+
```
|
|
2682
|
+
|
|
2683
|
+
```bash
|
|
2684
|
+
pnpm add @pithyjs/signals
|
|
2685
|
+
```
|
|
2686
|
+
|
|
2687
|
+
```bash
|
|
2688
|
+
yarn add @pithyjs/signals
|
|
2689
|
+
```
|
|
2690
|
+
<!-- @codex:end -->`
|
|
2691
|
+
);
|
|
2692
|
+
|
|
2693
|
+
const extractionResult = makeExtractionResult();
|
|
2694
|
+
const results = await syncAllReadmes(extractionResult, { root: "/project", dryRun: true });
|
|
2695
|
+
|
|
2696
|
+
results; // → 1
|
|
2697
|
+
results[0].filePath; // → "/project/README.md"
|
|
2698
|
+
results[0].changed; // → true
|
|
2699
|
+
results[0].markersProcessed; // → 1
|
|
2700
|
+
});
|
|
2701
|
+
|
|
2702
|
+
it("does not write files in dry-run mode", async () => {
|
|
2703
|
+
const { globby } = await import("globby");
|
|
2704
|
+
const { readFile, writeFile } = await import("node:fs/promises");
|
|
2705
|
+
|
|
2706
|
+
vi.mocked(globby).mockResolvedValue(["/project/README.md"]);
|
|
2707
|
+
vi.mocked(readFile).mockResolvedValue(
|
|
2708
|
+
`<!-- @codex:auto install="@pithyjs/signals" -->
|
|
2709
|
+
```bash
|
|
2710
|
+
npm install @pithyjs/signals
|
|
2711
|
+
```
|
|
2712
|
+
|
|
2713
|
+
```bash
|
|
2714
|
+
pnpm add @pithyjs/signals
|
|
2715
|
+
```
|
|
2716
|
+
|
|
2717
|
+
```bash
|
|
2718
|
+
yarn add @pithyjs/signals
|
|
2719
|
+
```
|
|
2720
|
+
<!-- @codex:end -->`
|
|
2721
|
+
);
|
|
2722
|
+
|
|
2723
|
+
const extractionResult = makeExtractionResult();
|
|
2724
|
+
await syncAllReadmes(extractionResult, { root: "/project", dryRun: true });
|
|
2725
|
+
|
|
2726
|
+
expect(writeFile).not.toHaveBeenCalled();
|
|
2727
|
+
});
|
|
2728
|
+
|
|
2729
|
+
it("writes files when not in dry-run mode", async () => {
|
|
2730
|
+
const { globby } = await import("globby");
|
|
2731
|
+
const { readFile, writeFile } = await import("node:fs/promises");
|
|
2732
|
+
|
|
2733
|
+
vi.mocked(globby).mockResolvedValue(["/project/README.md"]);
|
|
2734
|
+
vi.mocked(readFile).mockResolvedValue(
|
|
2735
|
+
`<!-- @codex:auto install="@pithyjs/signals" -->
|
|
2736
|
+
```bash
|
|
2737
|
+
npm install @pithyjs/signals
|
|
2738
|
+
```
|
|
2739
|
+
|
|
2740
|
+
```bash
|
|
2741
|
+
pnpm add @pithyjs/signals
|
|
2742
|
+
```
|
|
2743
|
+
|
|
2744
|
+
```bash
|
|
2745
|
+
yarn add @pithyjs/signals
|
|
2746
|
+
```
|
|
2747
|
+
<!-- @codex:end -->`
|
|
2748
|
+
);
|
|
2749
|
+
vi.mocked(writeFile).mockResolvedValue(undefined);
|
|
2750
|
+
|
|
2751
|
+
const extractionResult = makeExtractionResult();
|
|
2752
|
+
await syncAllReadmes(extractionResult, { root: "/project", dryRun: false });
|
|
2753
|
+
|
|
2754
|
+
expect(writeFile).toHaveBeenCalledWith(
|
|
2755
|
+
"/project/README.md",
|
|
2756
|
+
expect.stringContaining("npm install @pithyjs/signals"),
|
|
2757
|
+
"utf-8"
|
|
2758
|
+
);
|
|
2759
|
+
});
|
|
2760
|
+
|
|
2761
|
+
it("passes test examples from extraction result to syncReadmeContent", async () => {
|
|
2762
|
+
const { globby } = await import("globby");
|
|
2763
|
+
const { readFile } = await import("node:fs/promises");
|
|
2764
|
+
|
|
2765
|
+
const comp = makeComponent({
|
|
2766
|
+
id: "pithy.feature.signals",
|
|
2767
|
+
apis: [{
|
|
2768
|
+
id: "pithy.feature.signals.signal",
|
|
2769
|
+
name: "signal",
|
|
2770
|
+
parent: "pithy.feature.signals",
|
|
2771
|
+
stability: "stable",
|
|
2772
|
+
examples: [],
|
|
2773
|
+
testRefs: [],
|
|
2774
|
+
sourceLocation: { file: "signal.ts", line: 1 },
|
|
2775
|
+
}],
|
|
2776
|
+
});
|
|
2777
|
+
|
|
2778
|
+
vi.mocked(globby).mockResolvedValue(["/project/README.md"]);
|
|
2779
|
+
vi.mocked(readFile).mockResolvedValue(
|
|
2780
|
+
`<!-- @codex:auto examples="pithy.feature.signals" -->
|
|
2781
|
+
**creates a reactive signal with initial value**
|
|
2782
|
+
|
|
2783
|
+
```typescript
|
|
2784
|
+
const count = signal(0);
|
|
2785
|
+
count(); // → 0
|
|
2786
|
+
count.set(1);
|
|
2787
|
+
count(); // → 1
|
|
2788
|
+
```
|
|
2789
|
+
|
|
2790
|
+
**supports functional updates via .set()**
|
|
2791
|
+
|
|
2792
|
+
```typescript
|
|
2793
|
+
const count = signal(0);
|
|
2794
|
+
count.set(n => n + 1);
|
|
2795
|
+
count(); // → 1
|
|
2796
|
+
count.set(n => n * 10);
|
|
2797
|
+
count(); // → 10
|
|
2798
|
+
```
|
|
2799
|
+
|
|
2800
|
+
**tracks signal dependencies and re-runs**
|
|
2801
|
+
|
|
2802
|
+
```typescript
|
|
2803
|
+
const count = signal(0);
|
|
2804
|
+
let observed = -1;
|
|
2805
|
+
effect(() => {
|
|
2806
|
+
observed = count();
|
|
2807
|
+
});
|
|
2808
|
+
observed; // → 0
|
|
2809
|
+
count.set(5);
|
|
2810
|
+
observed; // → 5
|
|
2811
|
+
```
|
|
2812
|
+
|
|
2813
|
+
**derives a value from other signals**
|
|
2814
|
+
|
|
2815
|
+
```typescript
|
|
2816
|
+
const a = signal(2);
|
|
2817
|
+
const b = signal(3);
|
|
2818
|
+
const sum = computed(() => a() + b());
|
|
2819
|
+
sum(); // → 5
|
|
2820
|
+
a.set(10);
|
|
2821
|
+
sum(); // → 13
|
|
2822
|
+
```
|
|
2823
|
+
|
|
2824
|
+
**defers effect execution until batch completes**
|
|
2825
|
+
|
|
2826
|
+
```typescript
|
|
2827
|
+
const a = signal(1);
|
|
2828
|
+
const b = signal(2);
|
|
2829
|
+
let runCount = 0;
|
|
2830
|
+
effect(() => {
|
|
2831
|
+
a();
|
|
2832
|
+
b();
|
|
2833
|
+
runCount++;
|
|
2834
|
+
});
|
|
2835
|
+
runCount; // → 1
|
|
2836
|
+
|
|
2837
|
+
batch(() => {
|
|
2838
|
+
a.set(10);
|
|
2839
|
+
b.set(20);
|
|
2840
|
+
});
|
|
2841
|
+
// Effect should have run only once after batch, not twice
|
|
2842
|
+
runCount; // → 2
|
|
2843
|
+
```
|
|
2844
|
+
|
|
2845
|
+
**runs cleanup on disposal**
|
|
2846
|
+
|
|
2847
|
+
```typescript
|
|
2848
|
+
const count = signal(0);
|
|
2849
|
+
let cleaned = false;
|
|
2850
|
+
|
|
2851
|
+
const dispose = effect(() => {
|
|
2852
|
+
count();
|
|
2853
|
+
onCleanup(() => {
|
|
2854
|
+
cleaned = true;
|
|
2855
|
+
});
|
|
2856
|
+
});
|
|
2857
|
+
|
|
2858
|
+
cleaned; // → false
|
|
2859
|
+
dispose();
|
|
2860
|
+
cleaned; // → true
|
|
2861
|
+
```
|
|
2862
|
+
|
|
2863
|
+
**detects signal values at runtime**
|
|
2864
|
+
|
|
2865
|
+
```typescript
|
|
2866
|
+
const count = signal(0);
|
|
2867
|
+
isSignal(count); // → true
|
|
2868
|
+
isSignal(42); // → false
|
|
2869
|
+
isSignal(() => {}); // → false
|
|
2870
|
+
isSignal(null); // → false
|
|
2871
|
+
```
|
|
2872
|
+
|
|
2873
|
+
**unsubscribes from a dependency it stopped reading**
|
|
2874
|
+
|
|
2875
|
+
```typescript
|
|
2876
|
+
const useB = signal(true);
|
|
2877
|
+
const b = signal(2);
|
|
2878
|
+
|
|
2879
|
+
let runs = 0;
|
|
2880
|
+
const dispose = effect(() => {
|
|
2881
|
+
runs += 1;
|
|
2882
|
+
if (useB()) b();
|
|
2883
|
+
});
|
|
2884
|
+
|
|
2885
|
+
useB.set(false);
|
|
2886
|
+
const afterSwitch = runs;
|
|
2887
|
+
|
|
2888
|
+
b.set(99);
|
|
2889
|
+
expect(runs, 'live effect re-ran for a dependency it stopped reading').toBe(
|
|
2890
|
+
afterSwitch
|
|
2891
|
+
);
|
|
2892
|
+
|
|
2893
|
+
dispose();
|
|
2894
|
+
```
|
|
2895
|
+
<!-- @codex:end -->`
|
|
2896
|
+
);
|
|
2897
|
+
|
|
2898
|
+
const extractionResult = makeExtractionResult({
|
|
2899
|
+
components: [comp],
|
|
2900
|
+
testExamples: [{
|
|
2901
|
+
entryId: "pithy.feature.signals",
|
|
2902
|
+
name: "basic usage",
|
|
2903
|
+
code: 'const s = signal(1);\nexpect(s()).toBe(1);',
|
|
2904
|
+
location: { file: "signal.test.ts", line: 5 },
|
|
2905
|
+
testFile: "signal.test.ts",
|
|
2906
|
+
status: "passing",
|
|
2907
|
+
}],
|
|
2908
|
+
});
|
|
2909
|
+
|
|
2910
|
+
const results = await syncAllReadmes(extractionResult, { root: "/project", dryRun: true });
|
|
2911
|
+
results[0].updatedContent; // → "basic usage"
|
|
2912
|
+
results[0].updatedContent; // → "s(); // → 1"
|
|
2913
|
+
});
|
|
2914
|
+
});
|
|
2915
|
+
```
|
|
2916
|
+
|
|
2917
|
+
**should escape single quotes in entryId**
|
|
2918
|
+
|
|
2919
|
+
```typescript
|
|
2920
|
+
const example = createExample('console.log("test")');
|
|
2921
|
+
const entryId = "test'injection";
|
|
2922
|
+
|
|
2923
|
+
const result = wrapDoctestInHarness(example, entryId);
|
|
2924
|
+
|
|
2925
|
+
// Should contain escaped single quote
|
|
2926
|
+
result; // → "test\\'injection"
|
|
2927
|
+
```
|
|
2928
|
+
|
|
2929
|
+
**should escape regex metacharacters in API names**
|
|
2930
|
+
|
|
2931
|
+
```typescript
|
|
2932
|
+
const content = 'import { set } from "./store"; set(value);';
|
|
2933
|
+
const apisWithMetachars = ['set(value)', 'get.*', 'test[0]', 'func$'];
|
|
2934
|
+
|
|
2935
|
+
// Should not throw and should handle metacharacters safely
|
|
2936
|
+
expect(() => identifyCoveredApis(content, apisWithMetachars)).not.toThrow();
|
|
2937
|
+
```
|
|
2938
|
+
|
|
2939
|
+
**getSnapshotPath**
|
|
2940
|
+
|
|
2941
|
+
```typescript
|
|
2942
|
+
describe("getSnapshotPath", () => {
|
|
2943
|
+
it("generates path for a simple version", () => {
|
|
2944
|
+
const path = getSnapshotPath(TEST_DIR, "1.0.0");
|
|
2945
|
+
path; // → join(TEST_DIR, "1.0.0.json")
|
|
2946
|
+
});
|
|
2947
|
+
|
|
2948
|
+
it("generates path for a prerelease version", () => {
|
|
2949
|
+
const path = getSnapshotPath(TEST_DIR, "2.0.0-beta.1");
|
|
2950
|
+
path; // → join(TEST_DIR, "2.0.0-beta.1.json")
|
|
2951
|
+
});
|
|
2952
|
+
|
|
2953
|
+
it("rejects version strings with path separators", () => {
|
|
2954
|
+
expect(() => getSnapshotPath(TEST_DIR, "../../etc/passwd")).toThrow(/Invalid version/);
|
|
2955
|
+
});
|
|
2956
|
+
|
|
2957
|
+
it("rejects version strings with slashes", () => {
|
|
2958
|
+
expect(() => getSnapshotPath(TEST_DIR, "v1/exploit")).toThrow(/Invalid version/);
|
|
2959
|
+
});
|
|
2960
|
+
|
|
2961
|
+
it("rejects version strings with backslashes", () => {
|
|
2962
|
+
expect(() => getSnapshotPath(TEST_DIR, "v1\\exploit")).toThrow(/Invalid version/);
|
|
2963
|
+
});
|
|
2964
|
+
|
|
2965
|
+
it("rejects empty version string", () => {
|
|
2966
|
+
expect(() => getSnapshotPath(TEST_DIR, "")).toThrow(/Invalid version/);
|
|
2967
|
+
});
|
|
2968
|
+
});
|
|
2969
|
+
```
|
|
2970
|
+
|
|
2971
|
+
**saveSnapshot**
|
|
2972
|
+
|
|
2973
|
+
```typescript
|
|
2974
|
+
describe("saveSnapshot", () => {
|
|
2975
|
+
it("saves a snapshot to disk as JSON", async () => {
|
|
2976
|
+
const snapshot = makeTestSnapshot("1.0.0");
|
|
2977
|
+
await saveSnapshot(snapshot, TEST_DIR);
|
|
2978
|
+
|
|
2979
|
+
const filePath = join(TEST_DIR, "1.0.0.json");
|
|
2980
|
+
const content = await readFile(filePath, "utf8");
|
|
2981
|
+
const parsed = JSON.parse(content);
|
|
2982
|
+
|
|
2983
|
+
parsed.version; // → "1.0.0"
|
|
2984
|
+
parsed.entries; // → 2
|
|
2985
|
+
parsed.timestamp; // → "2025-01-01T00:00:00.000Z"
|
|
2986
|
+
});
|
|
2987
|
+
|
|
2988
|
+
it("creates the directory if it does not exist", async () => {
|
|
2989
|
+
const nestedDir = join(TEST_DIR, "nested", "versions");
|
|
2990
|
+
const snapshot = makeTestSnapshot("1.0.0");
|
|
2991
|
+
await saveSnapshot(snapshot, nestedDir);
|
|
2992
|
+
|
|
2993
|
+
const filePath = join(nestedDir, "1.0.0.json");
|
|
2994
|
+
const content = await readFile(filePath, "utf8");
|
|
2995
|
+
JSON.parse(content).version; // → "1.0.0"
|
|
2996
|
+
});
|
|
2997
|
+
|
|
2998
|
+
it("overwrites an existing snapshot for the same version", async () => {
|
|
2999
|
+
const v1 = makeTestSnapshot("1.0.0");
|
|
3000
|
+
await saveSnapshot(v1, TEST_DIR);
|
|
3001
|
+
|
|
3002
|
+
const v1Updated: ApiSnapshot = {
|
|
3003
|
+
...v1,
|
|
3004
|
+
entries: [
|
|
3005
|
+
...v1.entries,
|
|
3006
|
+
{
|
|
3007
|
+
name: "computed",
|
|
3008
|
+
parent: "pithy.signals",
|
|
3009
|
+
qualifiedName: "pithy.signals.computed",
|
|
3010
|
+
signature: "<T>(fn: () => T) => () => T",
|
|
3011
|
+
stability: "stable",
|
|
3012
|
+
},
|
|
3013
|
+
],
|
|
3014
|
+
};
|
|
3015
|
+
await saveSnapshot(v1Updated, TEST_DIR);
|
|
3016
|
+
|
|
3017
|
+
const content = await readFile(join(TEST_DIR, "1.0.0.json"), "utf8");
|
|
3018
|
+
JSON.parse(content).entries; // → 3
|
|
3019
|
+
});
|
|
3020
|
+
|
|
3021
|
+
it("handles prerelease version strings in filenames", async () => {
|
|
3022
|
+
const snapshot = makeTestSnapshot("2.0.0-beta.1");
|
|
3023
|
+
await saveSnapshot(snapshot, TEST_DIR);
|
|
3024
|
+
|
|
3025
|
+
const filePath = join(TEST_DIR, "2.0.0-beta.1.json");
|
|
3026
|
+
const content = await readFile(filePath, "utf8");
|
|
3027
|
+
JSON.parse(content).version; // → "2.0.0-beta.1"
|
|
3028
|
+
});
|
|
3029
|
+
|
|
3030
|
+
it("produces valid JSON with 2-space indentation", async () => {
|
|
3031
|
+
const snapshot = makeTestSnapshot("1.0.0");
|
|
3032
|
+
await saveSnapshot(snapshot, TEST_DIR);
|
|
3033
|
+
|
|
3034
|
+
const content = await readFile(join(TEST_DIR, "1.0.0.json"), "utf8");
|
|
3035
|
+
// Check indentation (2 spaces)
|
|
3036
|
+
content; // → ' "version"'
|
|
3037
|
+
// Should end with a newline
|
|
3038
|
+
content.endsWith("\n"); // → true
|
|
3039
|
+
});
|
|
3040
|
+
});
|
|
3041
|
+
```
|
|
3042
|
+
|
|
3043
|
+
**loadSnapshot**
|
|
3044
|
+
|
|
3045
|
+
```typescript
|
|
3046
|
+
describe("loadSnapshot", () => {
|
|
3047
|
+
it("loads a saved snapshot by version", async () => {
|
|
3048
|
+
const original = makeTestSnapshot("1.0.0");
|
|
3049
|
+
await saveSnapshot(original, TEST_DIR);
|
|
3050
|
+
|
|
3051
|
+
const loaded = await loadSnapshot("1.0.0", TEST_DIR);
|
|
3052
|
+
loaded; // → null
|
|
3053
|
+
loaded!.version; // → "1.0.0"
|
|
3054
|
+
loaded!.entries; // → 2
|
|
3055
|
+
loaded!.entries[0].name; // → "signal"
|
|
3056
|
+
});
|
|
3057
|
+
|
|
3058
|
+
it("returns null when snapshot does not exist", async () => {
|
|
3059
|
+
const loaded = await loadSnapshot("99.99.99", TEST_DIR);
|
|
3060
|
+
loaded; // → null
|
|
3061
|
+
});
|
|
3062
|
+
|
|
3063
|
+
it("returns null when directory does not exist", async () => {
|
|
3064
|
+
const loaded = await loadSnapshot("1.0.0", join(TEST_DIR, "nonexistent"));
|
|
3065
|
+
loaded; // → null
|
|
3066
|
+
});
|
|
3067
|
+
|
|
3068
|
+
it("preserves all snapshot fields through save/load cycle", async () => {
|
|
3069
|
+
const original = makeTestSnapshot("1.0.0");
|
|
3070
|
+
await saveSnapshot(original, TEST_DIR);
|
|
3071
|
+
const loaded = await loadSnapshot("1.0.0", TEST_DIR);
|
|
3072
|
+
|
|
3073
|
+
loaded; // → original
|
|
3074
|
+
});
|
|
3075
|
+
|
|
3076
|
+
it("loads snapshot with type members correctly", async () => {
|
|
3077
|
+
const snapshot: ApiSnapshot = {
|
|
3078
|
+
version: "1.0.0",
|
|
3079
|
+
timestamp: "2025-01-01T00:00:00.000Z",
|
|
3080
|
+
entries: [
|
|
3081
|
+
{
|
|
3082
|
+
name: "Signal",
|
|
3083
|
+
parent: "pithy.signals",
|
|
3084
|
+
qualifiedName: "pithy.signals.Signal",
|
|
3085
|
+
stability: "stable",
|
|
3086
|
+
kind: "interface",
|
|
3087
|
+
members: [
|
|
3088
|
+
{ name: "set", type: "method", optional: false, readonly: false },
|
|
3089
|
+
{ name: "subscribe", type: "method", optional: false, readonly: false },
|
|
3090
|
+
],
|
|
3091
|
+
},
|
|
3092
|
+
],
|
|
3093
|
+
};
|
|
3094
|
+
await saveSnapshot(snapshot, TEST_DIR);
|
|
3095
|
+
const loaded = await loadSnapshot("1.0.0", TEST_DIR);
|
|
3096
|
+
|
|
3097
|
+
loaded!.entries[0].kind; // → "interface"
|
|
3098
|
+
loaded!.entries[0].members; // → 2
|
|
3099
|
+
});
|
|
3100
|
+
|
|
3101
|
+
it("throws on corrupted JSON file instead of returning null", async () => {
|
|
3102
|
+
await mkdir(TEST_DIR, { recursive: true });
|
|
3103
|
+
await writeFile(join(TEST_DIR, "corrupt.json"), "not valid json{{{", "utf8");
|
|
3104
|
+
await expect(loadSnapshot("corrupt", TEST_DIR)).rejects.toThrow(/Failed to parse snapshot/);
|
|
3105
|
+
});
|
|
3106
|
+
|
|
3107
|
+
it("throws on non-ENOENT filesystem errors instead of returning null", async () => {
|
|
3108
|
+
// Trying to read a version that resolves to a directory path triggers a non-ENOENT error
|
|
3109
|
+
const dirAsFile = join(TEST_DIR, "not-a-file");
|
|
3110
|
+
await mkdir(join(dirAsFile + ".json"), { recursive: true }); // Create dir where file expected
|
|
3111
|
+
await expect(loadSnapshot("not-a-file", TEST_DIR)).rejects.toThrow();
|
|
3112
|
+
});
|
|
3113
|
+
});
|
|
3114
|
+
```
|
|
3115
|
+
|
|
3116
|
+
**listSnapshots**
|
|
3117
|
+
|
|
3118
|
+
```typescript
|
|
3119
|
+
describe("listSnapshots", () => {
|
|
3120
|
+
it("lists all saved snapshot versions sorted by version", async () => {
|
|
3121
|
+
await saveSnapshot(makeTestSnapshot("1.0.0"), TEST_DIR);
|
|
3122
|
+
await saveSnapshot(makeTestSnapshot("2.0.0"), TEST_DIR);
|
|
3123
|
+
await saveSnapshot(makeTestSnapshot("1.1.0"), TEST_DIR);
|
|
3124
|
+
|
|
3125
|
+
const versions = await listSnapshots(TEST_DIR);
|
|
3126
|
+
versions; // → ["1.0.0", "1.1.0", "2.0.0"]
|
|
3127
|
+
});
|
|
3128
|
+
|
|
3129
|
+
it("sorts by semver, not lexicographically (1.2.0 before 1.10.0)", async () => {
|
|
3130
|
+
await saveSnapshot(makeTestSnapshot("1.10.0"), TEST_DIR);
|
|
3131
|
+
await saveSnapshot(makeTestSnapshot("1.2.0"), TEST_DIR);
|
|
3132
|
+
await saveSnapshot(makeTestSnapshot("1.1.0"), TEST_DIR);
|
|
3133
|
+
|
|
3134
|
+
const versions = await listSnapshots(TEST_DIR);
|
|
3135
|
+
versions; // → ["1.1.0", "1.2.0", "1.10.0"]
|
|
3136
|
+
});
|
|
3137
|
+
|
|
3138
|
+
it("returns empty array when no snapshots exist", async () => {
|
|
3139
|
+
const versions = await listSnapshots(TEST_DIR);
|
|
3140
|
+
versions; // → []
|
|
3141
|
+
});
|
|
3142
|
+
|
|
3143
|
+
it("returns empty array when directory does not exist", async () => {
|
|
3144
|
+
const versions = await listSnapshots(join(TEST_DIR, "nonexistent"));
|
|
3145
|
+
versions; // → []
|
|
3146
|
+
});
|
|
3147
|
+
|
|
3148
|
+
it("ignores non-JSON files", async () => {
|
|
3149
|
+
await saveSnapshot(makeTestSnapshot("1.0.0"), TEST_DIR);
|
|
3150
|
+
await writeFile(join(TEST_DIR, "README.md"), "# Versions\n");
|
|
3151
|
+
|
|
3152
|
+
const versions = await listSnapshots(TEST_DIR);
|
|
3153
|
+
versions; // → ["1.0.0"]
|
|
3154
|
+
});
|
|
3155
|
+
|
|
3156
|
+
it("handles prerelease versions", async () => {
|
|
3157
|
+
await saveSnapshot(makeTestSnapshot("1.0.0"), TEST_DIR);
|
|
3158
|
+
await saveSnapshot(makeTestSnapshot("2.0.0-beta.1"), TEST_DIR);
|
|
3159
|
+
|
|
3160
|
+
const versions = await listSnapshots(TEST_DIR);
|
|
3161
|
+
versions; // → "1.0.0"
|
|
3162
|
+
versions; // → "2.0.0-beta.1"
|
|
3163
|
+
});
|
|
3164
|
+
|
|
3165
|
+
it("sorts prereleases before their release (semver precedence)", async () => {
|
|
3166
|
+
await saveSnapshot(makeTestSnapshot("1.0.0"), TEST_DIR);
|
|
3167
|
+
await saveSnapshot(makeTestSnapshot("1.0.0-beta.1"), TEST_DIR);
|
|
3168
|
+
await saveSnapshot(makeTestSnapshot("1.0.0-alpha.1"), TEST_DIR);
|
|
3169
|
+
|
|
3170
|
+
const versions = await listSnapshots(TEST_DIR);
|
|
3171
|
+
versions; // → ["1.0.0-alpha.1", "1.0.0-beta.1", "1.0.0"]
|
|
3172
|
+
});
|
|
3173
|
+
|
|
3174
|
+
it("sorts prerelease numeric identifiers numerically", async () => {
|
|
3175
|
+
await saveSnapshot(makeTestSnapshot("1.0.0-beta.2"), TEST_DIR);
|
|
3176
|
+
await saveSnapshot(makeTestSnapshot("1.0.0-beta.10"), TEST_DIR);
|
|
3177
|
+
await saveSnapshot(makeTestSnapshot("1.0.0-beta.1"), TEST_DIR);
|
|
3178
|
+
|
|
3179
|
+
const versions = await listSnapshots(TEST_DIR);
|
|
3180
|
+
versions; // → ["1.0.0-beta.1", "1.0.0-beta.2", "1.0.0-beta.10"]
|
|
3181
|
+
});
|
|
3182
|
+
});
|
|
3183
|
+
```
|
|
3184
|
+
|
|
3185
|
+
**extractTestExamples**
|
|
3186
|
+
|
|
3187
|
+
```typescript
|
|
3188
|
+
describe("extractTestExamples", () => {
|
|
3189
|
+
it("extracts examples with plain marker format", () => {
|
|
3190
|
+
const content = `
|
|
3191
|
+
// @codex:example pithy.signals.effect
|
|
3192
|
+
it("creates an effect", () => {
|
|
3193
|
+
const count = signal(0);
|
|
3194
|
+
count(); // → 0
|
|
3195
|
+
});
|
|
3196
|
+
`;
|
|
3197
|
+
const examples = extractTestExamples(content, "/test/signal.test.ts");
|
|
3198
|
+
examples; // → 1
|
|
3199
|
+
examples[0].entryId; // → "pithy.signals.effect"
|
|
3200
|
+
examples[0].name; // → "creates an effect"
|
|
3201
|
+
examples[0].code; // → "signal(0)"
|
|
3202
|
+
examples[0].testFile; // → "/test/signal.test.ts"
|
|
3203
|
+
examples[0].status; // → "unknown"
|
|
3204
|
+
});
|
|
3205
|
+
|
|
3206
|
+
it("extracts examples with parenthesized marker format", () => {
|
|
3207
|
+
const content = `
|
|
3208
|
+
// @codex:example("pithy.core.compile")
|
|
3209
|
+
test("compiles template", () => {
|
|
3210
|
+
compile("<div>hello</div>");
|
|
3211
|
+
});
|
|
3212
|
+
`;
|
|
3213
|
+
const examples = extractTestExamples(content, "test.ts");
|
|
3214
|
+
examples; // → 1
|
|
3215
|
+
examples[0].entryId; // → "pithy.core.compile"
|
|
3216
|
+
});
|
|
3217
|
+
|
|
3218
|
+
it("extracts examples with object marker format", () => {
|
|
3219
|
+
const content = `
|
|
3220
|
+
// @codex:example { entry: "pithy.router.navigate" }
|
|
3221
|
+
it("navigates to route", () => {
|
|
3222
|
+
router.navigate("/home");
|
|
3223
|
+
});
|
|
3224
|
+
`;
|
|
3225
|
+
const examples = extractTestExamples(content, "test.ts");
|
|
3226
|
+
examples; // → 1
|
|
3227
|
+
examples[0].entryId; // → "pithy.router.navigate"
|
|
3228
|
+
});
|
|
3229
|
+
|
|
3230
|
+
it("extracts multiple examples from one file", () => {
|
|
3231
|
+
const content = `
|
|
3232
|
+
// @codex:example pithy.a
|
|
3233
|
+
it("first", () => { expect(1).toBe(1); });
|
|
3234
|
+
|
|
3235
|
+
// @codex:example pithy.b
|
|
3236
|
+
it("second", () => { expect(2).toBe(2); });
|
|
3237
|
+
`;
|
|
3238
|
+
const examples = extractTestExamples(content, "test.ts");
|
|
3239
|
+
examples; // → 2
|
|
3240
|
+
examples[0].entryId; // → "pithy.a"
|
|
3241
|
+
examples[1].entryId; // → "pithy.b"
|
|
3242
|
+
});
|
|
3243
|
+
|
|
3244
|
+
it("skips markers not followed by a test block", () => {
|
|
3245
|
+
const content = `
|
|
3246
|
+
// @codex:example pithy.orphan
|
|
3247
|
+
const helper = () => {};
|
|
3248
|
+
`;
|
|
3249
|
+
const examples = extractTestExamples(content, "test.ts");
|
|
3250
|
+
examples; // → 0
|
|
3251
|
+
});
|
|
3252
|
+
|
|
3253
|
+
it("handles braces inside strings correctly", () => {
|
|
3254
|
+
const content = `
|
|
3255
|
+
// @codex:example pithy.strings
|
|
3256
|
+
it("handles object { with braces }", () => {
|
|
3257
|
+
const obj = { key: "value with }" };
|
|
3258
|
+
obj.key; // → defined
|
|
3259
|
+
});
|
|
3260
|
+
`;
|
|
3261
|
+
const examples = extractTestExamples(content, "test.ts");
|
|
3262
|
+
examples; // → 1
|
|
3263
|
+
// Should capture the full test block including the closing });
|
|
3264
|
+
examples[0].code; // → "expect(obj.key).toBeDefined()"
|
|
3265
|
+
});
|
|
3266
|
+
|
|
3267
|
+
it("handles braces inside comments", () => {
|
|
3268
|
+
const content = `
|
|
3269
|
+
// @codex:example pithy.comments
|
|
3270
|
+
it("works with comments", () => {
|
|
3271
|
+
// This { brace should be ignored }
|
|
3272
|
+
const x = 1;
|
|
3273
|
+
x; // → 1
|
|
3274
|
+
});
|
|
3275
|
+
`;
|
|
3276
|
+
const examples = extractTestExamples(content, "test.ts");
|
|
3277
|
+
examples; // → 1
|
|
3278
|
+
examples[0].code; // → "expect(x).toBe(1)"
|
|
3279
|
+
});
|
|
3280
|
+
|
|
3281
|
+
it("handles braces inside multi-line block comments", () => {
|
|
3282
|
+
const content = `
|
|
3283
|
+
// @codex:example pithy.multiline
|
|
3284
|
+
it("multi-line comment", () => {
|
|
3285
|
+
/*
|
|
3286
|
+
* This { brace } should be ignored
|
|
3287
|
+
* and this one too {
|
|
3288
|
+
*/
|
|
3289
|
+
const x = 1;
|
|
3290
|
+
x; // → 1
|
|
3291
|
+
});
|
|
3292
|
+
`;
|
|
3293
|
+
const examples = extractTestExamples(content, "test.ts");
|
|
3294
|
+
examples; // → 1
|
|
3295
|
+
examples[0].code; // → "expect(x).toBe(1)"
|
|
3296
|
+
examples[0].code; // → "});"
|
|
3297
|
+
});
|
|
3298
|
+
|
|
3299
|
+
it("provides correct 1-indexed line numbers", () => {
|
|
3300
|
+
const content = `line1
|
|
3301
|
+
line2
|
|
3302
|
+
// @codex:example pithy.loc
|
|
3303
|
+
it("has location", () => {
|
|
3304
|
+
true; // → true
|
|
3305
|
+
});`;
|
|
3306
|
+
const examples = extractTestExamples(content, "test.ts");
|
|
3307
|
+
examples[0].location.line; // → 4); // it() is on line 4 (1-indexed
|
|
3308
|
+
});
|
|
3309
|
+
|
|
3310
|
+
it("handles escaped quotes in test names", () => {
|
|
3311
|
+
const content = `
|
|
3312
|
+
// @codex:example pithy.escape
|
|
3313
|
+
it("handles \\"quoted\\" text", () => {
|
|
3314
|
+
true; // → true
|
|
3315
|
+
});
|
|
3316
|
+
`;
|
|
3317
|
+
const examples = extractTestExamples(content, "test.ts");
|
|
3318
|
+
examples; // → 1
|
|
3319
|
+
examples[0].name; // → 'handles \\"quoted\\" text'
|
|
3320
|
+
});
|
|
3321
|
+
|
|
3322
|
+
it("handles describe blocks", () => {
|
|
3323
|
+
const content = `
|
|
3324
|
+
// @codex:example pithy.describe
|
|
3325
|
+
describe("my suite", () => {
|
|
3326
|
+
it("inner test", () => {
|
|
3327
|
+
1; // → 1
|
|
3328
|
+
});
|
|
3329
|
+
});
|
|
3330
|
+
`;
|
|
3331
|
+
const examples = extractTestExamples(content, "test.ts");
|
|
3332
|
+
examples; // → 1
|
|
3333
|
+
examples[0].name; // → "my suite"
|
|
3334
|
+
examples[0].code; // → "inner test"
|
|
3335
|
+
});
|
|
3336
|
+
|
|
3337
|
+
it("does not match marker-like text that isn't a dotted identifier", () => {
|
|
3338
|
+
const content = `
|
|
3339
|
+
// This text mentions @codex:example but has no id after it
|
|
3340
|
+
it("test", () => { expect(1).toBe(1); });
|
|
3341
|
+
`;
|
|
3342
|
+
const examples = extractTestExamples(content, "test.ts");
|
|
3343
|
+
examples; // → 0
|
|
3344
|
+
});
|
|
3345
|
+
});
|
|
3346
|
+
```
|
|
3347
|
+
|
|
3348
|
+
**parseVitestOutput**
|
|
3349
|
+
|
|
3350
|
+
```typescript
|
|
3351
|
+
describe("parseVitestOutput", () => {
|
|
3352
|
+
it("parses testResults format (jest-like)", () => {
|
|
3353
|
+
const output = JSON.stringify({
|
|
3354
|
+
testResults: [
|
|
3355
|
+
{
|
|
3356
|
+
name: "packages/signals/src/signal.test.ts",
|
|
3357
|
+
assertionResults: [
|
|
3358
|
+
{ fullName: "creates a signal", status: "passed", duration: 5 },
|
|
3359
|
+
{
|
|
3360
|
+
fullName: "updates signal",
|
|
3361
|
+
status: "failed",
|
|
3362
|
+
duration: 10,
|
|
3363
|
+
failureMessages: ["Expected 1 to be 2"],
|
|
3364
|
+
},
|
|
3365
|
+
],
|
|
3366
|
+
},
|
|
3367
|
+
],
|
|
3368
|
+
});
|
|
3369
|
+
|
|
3370
|
+
const statuses = parseVitestOutput(output);
|
|
3371
|
+
statuses; // → 2
|
|
3372
|
+
expect(statuses[0]).toMatchObject({
|
|
3373
|
+
file: "packages/signals/src/signal.test.ts",
|
|
3374
|
+
testName: "creates a signal",
|
|
3375
|
+
status: "passed",
|
|
3376
|
+
});
|
|
3377
|
+
expect(statuses[1]).toMatchObject({
|
|
3378
|
+
status: "failed",
|
|
3379
|
+
error: "Expected 1 to be 2",
|
|
3380
|
+
});
|
|
3381
|
+
});
|
|
3382
|
+
|
|
3383
|
+
it("parses results/tasks format (vitest native)", () => {
|
|
3384
|
+
const output = JSON.stringify({
|
|
3385
|
+
success: true,
|
|
3386
|
+
results: [
|
|
3387
|
+
{
|
|
3388
|
+
file: "src/foo.test.ts",
|
|
3389
|
+
tasks: [
|
|
3390
|
+
{
|
|
3391
|
+
name: "suite",
|
|
3392
|
+
type: "suite",
|
|
3393
|
+
tasks: [
|
|
3394
|
+
{
|
|
3395
|
+
name: "test one",
|
|
3396
|
+
type: "test",
|
|
3397
|
+
result: { state: "pass", duration: 3 },
|
|
3398
|
+
},
|
|
3399
|
+
],
|
|
3400
|
+
},
|
|
3401
|
+
],
|
|
3402
|
+
},
|
|
3403
|
+
],
|
|
3404
|
+
});
|
|
3405
|
+
|
|
3406
|
+
const statuses = parseVitestOutput(output);
|
|
3407
|
+
statuses; // → 1
|
|
3408
|
+
expect(statuses[0]).toMatchObject({
|
|
3409
|
+
file: "src/foo.test.ts",
|
|
3410
|
+
testName: "suite > test one",
|
|
3411
|
+
status: "passed",
|
|
3412
|
+
});
|
|
3413
|
+
});
|
|
3414
|
+
|
|
3415
|
+
it("returns empty array for invalid JSON", () => {
|
|
3416
|
+
parseVitestOutput("not json"); // → []
|
|
3417
|
+
});
|
|
3418
|
+
|
|
3419
|
+
it("returns empty array for empty object", () => {
|
|
3420
|
+
parseVitestOutput("{}"); // → []
|
|
3421
|
+
});
|
|
3422
|
+
|
|
3423
|
+
it("maps pending/skipped/todo to skipped", () => {
|
|
3424
|
+
const output = JSON.stringify({
|
|
3425
|
+
testResults: [
|
|
3426
|
+
{
|
|
3427
|
+
name: "test.ts",
|
|
3428
|
+
assertionResults: [
|
|
3429
|
+
{ title: "pending", status: "pending" },
|
|
3430
|
+
{ title: "skipped", status: "skipped" },
|
|
3431
|
+
{ title: "todo", status: "todo" },
|
|
3432
|
+
],
|
|
3433
|
+
},
|
|
3434
|
+
],
|
|
3435
|
+
});
|
|
3436
|
+
|
|
3437
|
+
const statuses = parseVitestOutput(output);
|
|
3438
|
+
statuses.every((s) => s.status === "skipped"); // → true
|
|
3439
|
+
});
|
|
3440
|
+
});
|
|
3441
|
+
```
|
|
3442
|
+
|
|
3443
|
+
**matchExamplesToStatuses**
|
|
3444
|
+
|
|
3445
|
+
```typescript
|
|
3446
|
+
describe("matchExamplesToStatuses", () => {
|
|
3447
|
+
function makeExample(overrides: Partial<TestExample> = {}): TestExample {
|
|
3448
|
+
return {
|
|
3449
|
+
entryId: "pithy.test",
|
|
3450
|
+
name: "my test",
|
|
3451
|
+
code: 'it("my test", () => {})',
|
|
3452
|
+
location: { file: "test.ts", line: 1 },
|
|
3453
|
+
testFile: "packages/signals/src/signal.test.ts",
|
|
3454
|
+
status: "unknown",
|
|
3455
|
+
...overrides,
|
|
3456
|
+
};
|
|
3457
|
+
}
|
|
3458
|
+
|
|
3459
|
+
it("matches by exact name and file path suffix", () => {
|
|
3460
|
+
const examples = [makeExample()];
|
|
3461
|
+
const statuses: TestStatus[] = [
|
|
3462
|
+
{
|
|
3463
|
+
file: "packages/signals/src/signal.test.ts",
|
|
3464
|
+
testName: "my test",
|
|
3465
|
+
status: "passed",
|
|
3466
|
+
},
|
|
3467
|
+
];
|
|
3468
|
+
|
|
3469
|
+
const result = matchExamplesToStatuses(examples, statuses);
|
|
3470
|
+
result[0].status; // → "passing"
|
|
3471
|
+
});
|
|
3472
|
+
|
|
3473
|
+
it("matches when status testName includes describe prefix", () => {
|
|
3474
|
+
const examples = [makeExample({ name: "creates signal" })];
|
|
3475
|
+
const statuses: TestStatus[] = [
|
|
3476
|
+
{
|
|
3477
|
+
file: "packages/signals/src/signal.test.ts",
|
|
3478
|
+
testName: "signal > creates signal",
|
|
3479
|
+
status: "passed",
|
|
3480
|
+
},
|
|
3481
|
+
];
|
|
3482
|
+
|
|
3483
|
+
const result = matchExamplesToStatuses(examples, statuses);
|
|
3484
|
+
result[0].status; // → "passing"
|
|
3485
|
+
});
|
|
3486
|
+
|
|
3487
|
+
it("does not false-match short names across different files", () => {
|
|
3488
|
+
const examples = [
|
|
3489
|
+
makeExample({
|
|
3490
|
+
name: "works",
|
|
3491
|
+
testFile: "packages/foo/src/utils.test.ts",
|
|
3492
|
+
}),
|
|
3493
|
+
];
|
|
3494
|
+
const statuses: TestStatus[] = [
|
|
3495
|
+
{
|
|
3496
|
+
file: "packages/bar/src/utils.test.ts",
|
|
3497
|
+
testName: "works",
|
|
3498
|
+
status: "passed",
|
|
3499
|
+
},
|
|
3500
|
+
];
|
|
3501
|
+
|
|
3502
|
+
// Different packages — should NOT match
|
|
3503
|
+
const result = matchExamplesToStatuses(examples, statuses);
|
|
3504
|
+
result[0].status; // → "unknown"
|
|
3505
|
+
});
|
|
3506
|
+
|
|
3507
|
+
it("maps failed status correctly", () => {
|
|
3508
|
+
const examples = [makeExample()];
|
|
3509
|
+
const statuses: TestStatus[] = [
|
|
3510
|
+
{
|
|
3511
|
+
file: "packages/signals/src/signal.test.ts",
|
|
3512
|
+
testName: "my test",
|
|
3513
|
+
status: "failed",
|
|
3514
|
+
error: "Assertion failed",
|
|
3515
|
+
},
|
|
3516
|
+
];
|
|
3517
|
+
|
|
3518
|
+
const result = matchExamplesToStatuses(examples, statuses);
|
|
3519
|
+
result[0].status; // → "failing"
|
|
3520
|
+
result[0].error; // → "Assertion failed"
|
|
3521
|
+
});
|
|
3522
|
+
|
|
3523
|
+
it("maps skipped status correctly", () => {
|
|
3524
|
+
const examples = [makeExample()];
|
|
3525
|
+
const statuses: TestStatus[] = [
|
|
3526
|
+
{
|
|
3527
|
+
file: "packages/signals/src/signal.test.ts",
|
|
3528
|
+
testName: "my test",
|
|
3529
|
+
status: "skipped",
|
|
3530
|
+
},
|
|
3531
|
+
];
|
|
3532
|
+
|
|
3533
|
+
const result = matchExamplesToStatuses(examples, statuses);
|
|
3534
|
+
result[0].status; // → "skipped"
|
|
3535
|
+
});
|
|
3536
|
+
|
|
3537
|
+
it("leaves unmatched examples as unknown", () => {
|
|
3538
|
+
const examples = [makeExample({ name: "no match here" })];
|
|
3539
|
+
const statuses: TestStatus[] = [];
|
|
3540
|
+
|
|
3541
|
+
const result = matchExamplesToStatuses(examples, statuses);
|
|
3542
|
+
result[0].status; // → "unknown"
|
|
3543
|
+
});
|
|
3544
|
+
});
|
|
3545
|
+
```
|
|
3546
|
+
|
|
3547
|
+
**getTestingDefaults**
|
|
3548
|
+
|
|
3549
|
+
```typescript
|
|
3550
|
+
describe("getTestingDefaults", () => {
|
|
3551
|
+
it("returns directive defaults", () => {
|
|
3552
|
+
const defaults = getTestingDefaults("directive");
|
|
3553
|
+
defaults.unit; // → { required: true, coverage: 90 }
|
|
3554
|
+
defaults.integration; // → { required: true, coverage: 80 }
|
|
3555
|
+
defaults.e2e; // → { required: false }
|
|
3556
|
+
});
|
|
3557
|
+
|
|
3558
|
+
it("returns feature defaults", () => {
|
|
3559
|
+
const defaults = getTestingDefaults("feature");
|
|
3560
|
+
defaults.unit; // → { required: true, coverage: 85 }
|
|
3561
|
+
defaults.integration; // → { required: true, coverage: 70 }
|
|
3562
|
+
defaults.e2e; // → { required: false }
|
|
3563
|
+
});
|
|
3564
|
+
|
|
3565
|
+
it("returns runtime defaults", () => {
|
|
3566
|
+
const defaults = getTestingDefaults("runtime");
|
|
3567
|
+
defaults.unit; // → { required: true, coverage: 90 }
|
|
3568
|
+
defaults.integration; // → { required: true, coverage: 80 }
|
|
3569
|
+
defaults.e2e; // → { required: true }
|
|
3570
|
+
});
|
|
3571
|
+
|
|
3572
|
+
it("returns compiler defaults", () => {
|
|
3573
|
+
const defaults = getTestingDefaults("compiler");
|
|
3574
|
+
defaults.unit; // → { required: true, coverage: 95 }
|
|
3575
|
+
defaults.integration; // → { required: true, coverage: 70 }
|
|
3576
|
+
defaults.e2e; // → { required: false }
|
|
3577
|
+
});
|
|
3578
|
+
|
|
3579
|
+
it("returns plugin defaults", () => {
|
|
3580
|
+
const defaults = getTestingDefaults("plugin");
|
|
3581
|
+
defaults.unit; // → { required: true, coverage: 70 }
|
|
3582
|
+
defaults.integration; // → { required: true, coverage: 85 }
|
|
3583
|
+
defaults.e2e; // → { required: true }
|
|
3584
|
+
});
|
|
3585
|
+
|
|
3586
|
+
it("returns empty requirements for types category", () => {
|
|
3587
|
+
const defaults = getTestingDefaults("types");
|
|
3588
|
+
defaults.unit; // → undefined
|
|
3589
|
+
defaults.integration; // → undefined
|
|
3590
|
+
defaults.e2e; // → undefined
|
|
3591
|
+
});
|
|
3592
|
+
|
|
3593
|
+
it("returns feature defaults for unknown category", () => {
|
|
3594
|
+
const defaults = getTestingDefaults("unknown-category");
|
|
3595
|
+
defaults; // → getTestingDefaults("feature")
|
|
3596
|
+
});
|
|
3597
|
+
|
|
3598
|
+
it("returns independent copies (mutations don't affect defaults)", () => {
|
|
3599
|
+
const first = getTestingDefaults("feature");
|
|
3600
|
+
first.unit!.coverage = 999;
|
|
3601
|
+
const second = getTestingDefaults("feature");
|
|
3602
|
+
second.unit!.coverage; // → 85
|
|
3603
|
+
});
|
|
3604
|
+
});
|
|
3605
|
+
```
|
|
3606
|
+
|
|
3607
|
+
**applyRiskModifiers**
|
|
3608
|
+
|
|
3609
|
+
```typescript
|
|
3610
|
+
describe("applyRiskModifiers", () => {
|
|
3611
|
+
it("applies critical risk modifiers", () => {
|
|
3612
|
+
const base: TestingRequirements = {
|
|
3613
|
+
unit: { required: true, coverage: 85 },
|
|
3614
|
+
integration: { required: true, coverage: 70 },
|
|
3615
|
+
e2e: { required: false },
|
|
3616
|
+
};
|
|
3617
|
+
|
|
3618
|
+
const result = applyRiskModifiers(base, "critical");
|
|
3619
|
+
expect(result.unit!.coverage).toBe(90); // +5
|
|
3620
|
+
expect(result.integration!.coverage).toBe(80); // +10
|
|
3621
|
+
expect(result.e2e!.required).toBe(true); // Required
|
|
3622
|
+
});
|
|
3623
|
+
|
|
3624
|
+
it("applies high risk modifiers", () => {
|
|
3625
|
+
const base: TestingRequirements = {
|
|
3626
|
+
unit: { required: true, coverage: 85 },
|
|
3627
|
+
integration: { required: true, coverage: 70 },
|
|
3628
|
+
e2e: { required: false },
|
|
3629
|
+
};
|
|
3630
|
+
|
|
3631
|
+
const result = applyRiskModifiers(base, "high");
|
|
3632
|
+
expect(result.unit!.coverage).toBe(85); // +0
|
|
3633
|
+
expect(result.integration!.coverage).toBe(75); // +5
|
|
3634
|
+
expect(result.e2e!.required).toBe(false); // Unchanged
|
|
3635
|
+
});
|
|
3636
|
+
|
|
3637
|
+
it("applies medium risk modifiers", () => {
|
|
3638
|
+
const base: TestingRequirements = {
|
|
3639
|
+
unit: { required: true, coverage: 85 },
|
|
3640
|
+
integration: { required: true, coverage: 70 },
|
|
3641
|
+
e2e: { required: false },
|
|
3642
|
+
};
|
|
3643
|
+
|
|
3644
|
+
const result = applyRiskModifiers(base, "medium");
|
|
3645
|
+
expect(result.unit!.coverage).toBe(80); // -5
|
|
3646
|
+
expect(result.integration!.coverage).toBe(70); // +0
|
|
3647
|
+
result.e2e!.required; // → false
|
|
3648
|
+
});
|
|
3649
|
+
|
|
3650
|
+
it("applies low risk modifiers", () => {
|
|
3651
|
+
const base: TestingRequirements = {
|
|
3652
|
+
unit: { required: true, coverage: 85 },
|
|
3653
|
+
integration: { required: true, coverage: 70 },
|
|
3654
|
+
e2e: { required: false },
|
|
3655
|
+
};
|
|
3656
|
+
|
|
3657
|
+
const result = applyRiskModifiers(base, "low");
|
|
3658
|
+
expect(result.unit!.coverage).toBe(75); // -10
|
|
3659
|
+
expect(result.integration!.coverage).toBe(60); // -10
|
|
3660
|
+
result.e2e!.required; // → false
|
|
3661
|
+
});
|
|
3662
|
+
|
|
3663
|
+
it("does not modify base object (returns new object)", () => {
|
|
3664
|
+
const base: TestingRequirements = {
|
|
3665
|
+
unit: { required: true, coverage: 85 },
|
|
3666
|
+
integration: { required: true, coverage: 70 },
|
|
3667
|
+
e2e: { required: false },
|
|
3668
|
+
};
|
|
3669
|
+
const original = JSON.parse(JSON.stringify(base));
|
|
3670
|
+
|
|
3671
|
+
applyRiskModifiers(base, "critical");
|
|
3672
|
+
base; // → original
|
|
3673
|
+
});
|
|
3674
|
+
|
|
3675
|
+
it("handles undefined test levels gracefully", () => {
|
|
3676
|
+
const base: TestingRequirements = {};
|
|
3677
|
+
const result = applyRiskModifiers(base, "critical");
|
|
3678
|
+
result; // → {}
|
|
3679
|
+
});
|
|
3680
|
+
|
|
3681
|
+
it("clamps coverage to minimum 0", () => {
|
|
3682
|
+
const base: TestingRequirements = {
|
|
3683
|
+
unit: { required: true, coverage: 5 },
|
|
3684
|
+
};
|
|
3685
|
+
const result = applyRiskModifiers(base, "low");
|
|
3686
|
+
result.unit!.coverage; // → 0
|
|
3687
|
+
});
|
|
3688
|
+
|
|
3689
|
+
it("clamps coverage to maximum 100", () => {
|
|
3690
|
+
const base: TestingRequirements = {
|
|
3691
|
+
unit: { required: true, coverage: 98 },
|
|
3692
|
+
};
|
|
3693
|
+
const result = applyRiskModifiers(base, "critical");
|
|
3694
|
+
result.unit!.coverage; // → 100
|
|
3695
|
+
});
|
|
3696
|
+
|
|
3697
|
+
it("returns base unchanged when no risk level provided", () => {
|
|
3698
|
+
const base: TestingRequirements = {
|
|
3699
|
+
unit: { required: true, coverage: 85 },
|
|
3700
|
+
};
|
|
3701
|
+
const result = applyRiskModifiers(base, undefined);
|
|
3702
|
+
result; // → base
|
|
3703
|
+
});
|
|
3704
|
+
|
|
3705
|
+
it("returns a deep copy when no risk level provided (no shared references)", () => {
|
|
3706
|
+
const base: TestingRequirements = {
|
|
3707
|
+
unit: { required: true, coverage: 85 },
|
|
3708
|
+
};
|
|
3709
|
+
const result = applyRiskModifiers(base, undefined);
|
|
3710
|
+
result.unit!.coverage = 999;
|
|
3711
|
+
base.unit!.coverage; // → 85
|
|
3712
|
+
});
|
|
3713
|
+
|
|
3714
|
+
it("returns clone for unknown risk string (fallback path)", () => {
|
|
3715
|
+
const base: TestingRequirements = {
|
|
3716
|
+
unit: { required: true, coverage: 85 },
|
|
3717
|
+
};
|
|
3718
|
+
const result = applyRiskModifiers(base, "unknown" as RiskLevel);
|
|
3719
|
+
result; // → base
|
|
3720
|
+
// Verify it's a clone, not the same object
|
|
3721
|
+
result.unit!.coverage = 999;
|
|
3722
|
+
base.unit!.coverage; // → 85
|
|
3723
|
+
});
|
|
3724
|
+
|
|
3725
|
+
it("handles level with coverage undefined", () => {
|
|
3726
|
+
const base: TestingRequirements = {
|
|
3727
|
+
unit: { required: true },
|
|
3728
|
+
};
|
|
3729
|
+
const result = applyRiskModifiers(base, "critical");
|
|
3730
|
+
result.unit!.required; // → true
|
|
3731
|
+
result.unit!.coverage; // → undefined
|
|
3732
|
+
});
|
|
3733
|
+
|
|
3734
|
+
it("handles e2e-only base with critical risk", () => {
|
|
3735
|
+
const base: TestingRequirements = {
|
|
3736
|
+
e2e: { required: false },
|
|
3737
|
+
};
|
|
3738
|
+
const result = applyRiskModifiers(base, "critical");
|
|
3739
|
+
result.e2e!.required; // → true
|
|
3740
|
+
result.unit; // → undefined
|
|
3741
|
+
result.integration; // → undefined
|
|
3742
|
+
});
|
|
3743
|
+
});
|
|
3744
|
+
```
|
|
3745
|
+
|
|
3746
|
+
**resolveTestingRequirements**
|
|
3747
|
+
|
|
3748
|
+
```typescript
|
|
3749
|
+
describe("resolveTestingRequirements", () => {
|
|
3750
|
+
it("uses category defaults when no annotation testing exists", () => {
|
|
3751
|
+
const result = resolveTestingRequirements("feature");
|
|
3752
|
+
result.unit; // → defined
|
|
3753
|
+
result.unit!.required; // → true
|
|
3754
|
+
result.unit!.coverage; // → 85
|
|
3755
|
+
});
|
|
3756
|
+
|
|
3757
|
+
it("applies risk modifier on top of category defaults", () => {
|
|
3758
|
+
const result = resolveTestingRequirements("feature", "critical");
|
|
3759
|
+
expect(result.unit!.coverage).toBe(90); // 85 + 5
|
|
3760
|
+
expect(result.integration!.coverage).toBe(80); // 70 + 10
|
|
3761
|
+
result.e2e!.required; // → true
|
|
3762
|
+
});
|
|
3763
|
+
|
|
3764
|
+
it("annotation testing overrides defaults", () => {
|
|
3765
|
+
const override: TestingRequirements = {
|
|
3766
|
+
unit: { required: true, coverage: 100 },
|
|
3767
|
+
};
|
|
3768
|
+
const result = resolveTestingRequirements("feature", undefined, override);
|
|
3769
|
+
result.unit!.coverage; // → 100
|
|
3770
|
+
// Non-overridden levels use defaults
|
|
3771
|
+
result.integration; // → defined
|
|
3772
|
+
result.integration!.coverage; // → 70
|
|
3773
|
+
});
|
|
3774
|
+
|
|
3775
|
+
it("annotation testing with risk modifier", () => {
|
|
3776
|
+
const override: TestingRequirements = {
|
|
3777
|
+
unit: { required: true, coverage: 80 },
|
|
3778
|
+
};
|
|
3779
|
+
const result = resolveTestingRequirements("feature", "high", override);
|
|
3780
|
+
// Override unit is used as base, then risk applied: 80 + 0 = 80
|
|
3781
|
+
result.unit!.coverage; // → 80
|
|
3782
|
+
// Integration from defaults + risk: 70 + 5 = 75
|
|
3783
|
+
result.integration!.coverage; // → 75
|
|
3784
|
+
});
|
|
3785
|
+
|
|
3786
|
+
it("returns empty requirements for types category", () => {
|
|
3787
|
+
const result = resolveTestingRequirements("types");
|
|
3788
|
+
result.unit; // → undefined
|
|
3789
|
+
result.integration; // → undefined
|
|
3790
|
+
result.e2e; // → undefined
|
|
3791
|
+
});
|
|
3792
|
+
|
|
3793
|
+
it("full override replaces all levels", () => {
|
|
3794
|
+
const override: TestingRequirements = {
|
|
3795
|
+
unit: { required: true, coverage: 100 },
|
|
3796
|
+
integration: { required: false, coverage: 50 },
|
|
3797
|
+
e2e: { required: true },
|
|
3798
|
+
};
|
|
3799
|
+
const result = resolveTestingRequirements("feature", undefined, override);
|
|
3800
|
+
result.unit!.coverage; // → 100
|
|
3801
|
+
result.integration!.coverage; // → 50
|
|
3802
|
+
result.integration!.required; // → false
|
|
3803
|
+
result.e2e!.required; // → true
|
|
3804
|
+
});
|
|
3805
|
+
|
|
3806
|
+
it("unknown category with risk modifier uses feature fallback", () => {
|
|
3807
|
+
const result = resolveTestingRequirements("unknown", "critical");
|
|
3808
|
+
// feature defaults: unit 85, integration 70, e2e false
|
|
3809
|
+
// critical: +5 unit, +10 integration, e2e required
|
|
3810
|
+
result.unit!.coverage; // → 90
|
|
3811
|
+
result.integration!.coverage; // → 80
|
|
3812
|
+
result.e2e!.required; // → true
|
|
3813
|
+
});
|
|
3814
|
+
|
|
3815
|
+
it("deep-merges partial override so defaults are preserved", () => {
|
|
3816
|
+
// Override only sets covered/file, should retain required/coverage from defaults
|
|
3817
|
+
const override: TestingRequirements = {
|
|
3818
|
+
unit: { required: true, covered: true, file: "signal.test.ts" },
|
|
3819
|
+
};
|
|
3820
|
+
const result = resolveTestingRequirements("feature", undefined, override);
|
|
3821
|
+
// Default feature unit: required=true, coverage=85
|
|
3822
|
+
result.unit!.required; // → true
|
|
3823
|
+
result.unit!.covered; // → true
|
|
3824
|
+
result.unit!.file; // → "signal.test.ts"
|
|
3825
|
+
expect(result.unit!.coverage).toBe(85); // retained from defaults
|
|
3826
|
+
// Integration should still have defaults
|
|
3827
|
+
result.integration!.required; // → true
|
|
3828
|
+
result.integration!.coverage; // → 70
|
|
3829
|
+
});
|
|
3830
|
+
});
|
|
3831
|
+
```
|
|
3832
|
+
|
|
3833
|
+
**computeTestingStatus**
|
|
3834
|
+
|
|
3835
|
+
```typescript
|
|
3836
|
+
describe("computeTestingStatus", () => {
|
|
3837
|
+
it("returns 'Complete' when all required levels are covered", () => {
|
|
3838
|
+
const reqs: TestingRequirements = {
|
|
3839
|
+
unit: { required: true, covered: true },
|
|
3840
|
+
integration: { required: true, covered: true },
|
|
3841
|
+
e2e: { required: false },
|
|
3842
|
+
};
|
|
3843
|
+
computeTestingStatus(reqs); // → "Complete"
|
|
3844
|
+
});
|
|
3845
|
+
|
|
3846
|
+
it("returns 'Missing unit' when unit is required but not covered", () => {
|
|
3847
|
+
const reqs: TestingRequirements = {
|
|
3848
|
+
unit: { required: true, covered: false },
|
|
3849
|
+
integration: { required: true, covered: true },
|
|
3850
|
+
e2e: { required: false },
|
|
3851
|
+
};
|
|
3852
|
+
computeTestingStatus(reqs); // → "Missing unit"
|
|
3853
|
+
});
|
|
3854
|
+
|
|
3855
|
+
it("returns 'Missing integration' when integration is required but not covered", () => {
|
|
3856
|
+
const reqs: TestingRequirements = {
|
|
3857
|
+
unit: { required: true, covered: true },
|
|
3858
|
+
integration: { required: true, covered: false },
|
|
3859
|
+
e2e: { required: false },
|
|
3860
|
+
};
|
|
3861
|
+
computeTestingStatus(reqs); // → "Missing integration"
|
|
3862
|
+
});
|
|
3863
|
+
|
|
3864
|
+
it("returns 'Missing e2e' when e2e is required but not covered", () => {
|
|
3865
|
+
const reqs: TestingRequirements = {
|
|
3866
|
+
unit: { required: true, covered: true },
|
|
3867
|
+
integration: { required: true, covered: true },
|
|
3868
|
+
e2e: { required: true, covered: false },
|
|
3869
|
+
};
|
|
3870
|
+
computeTestingStatus(reqs); // → "Missing e2e"
|
|
3871
|
+
});
|
|
3872
|
+
|
|
3873
|
+
it("lists multiple missing levels", () => {
|
|
3874
|
+
const reqs: TestingRequirements = {
|
|
3875
|
+
unit: { required: true, covered: false },
|
|
3876
|
+
integration: { required: true, covered: false },
|
|
3877
|
+
e2e: { required: false },
|
|
3878
|
+
};
|
|
3879
|
+
const status = computeTestingStatus(reqs);
|
|
3880
|
+
status; // → "unit"
|
|
3881
|
+
status; // → "integration"
|
|
3882
|
+
});
|
|
3883
|
+
|
|
3884
|
+
it("returns 'N/A' when no levels are defined", () => {
|
|
3885
|
+
const reqs: TestingRequirements = {};
|
|
3886
|
+
computeTestingStatus(reqs); // → "N/A"
|
|
3887
|
+
});
|
|
3888
|
+
|
|
3889
|
+
it("returns 'Complete' when required levels are covered and e2e is optional", () => {
|
|
3890
|
+
const reqs: TestingRequirements = {
|
|
3891
|
+
unit: { required: true, covered: true },
|
|
3892
|
+
e2e: { required: false, covered: false },
|
|
3893
|
+
};
|
|
3894
|
+
computeTestingStatus(reqs); // → "Complete"
|
|
3895
|
+
});
|
|
3896
|
+
|
|
3897
|
+
it("lists all three levels when all missing", () => {
|
|
3898
|
+
const reqs: TestingRequirements = {
|
|
3899
|
+
unit: { required: true, covered: false },
|
|
3900
|
+
integration: { required: true, covered: false },
|
|
3901
|
+
e2e: { required: true, covered: false },
|
|
3902
|
+
};
|
|
3903
|
+
computeTestingStatus(reqs); // → "Missing unit, integration, e2e"
|
|
3904
|
+
});
|
|
3905
|
+
|
|
3906
|
+
it("ignores levels with required: false even when not covered", () => {
|
|
3907
|
+
const reqs: TestingRequirements = {
|
|
3908
|
+
unit: { required: false, covered: false },
|
|
3909
|
+
integration: { required: false, covered: false },
|
|
3910
|
+
};
|
|
3911
|
+
computeTestingStatus(reqs); // → "Complete"
|
|
3912
|
+
});
|
|
3913
|
+
});
|
|
3914
|
+
```
|
|
3915
|
+
|
|
3916
|
+
**extractTypesFromFile**
|
|
3917
|
+
|
|
3918
|
+
```typescript
|
|
3919
|
+
describe("extractTypesFromFile", () => {
|
|
3920
|
+
it("extracts an exported interface", () => {
|
|
3921
|
+
const code = `
|
|
3922
|
+
export interface Foo {
|
|
3923
|
+
bar: string;
|
|
3924
|
+
baz?: number;
|
|
3925
|
+
}`;
|
|
3926
|
+
const types = extractTypesFromFile("test.ts", code);
|
|
3927
|
+
types; // → 1
|
|
3928
|
+
types[0].name; // → "Foo"
|
|
3929
|
+
types[0].kind; // → "interface"
|
|
3930
|
+
types[0].exported; // → true
|
|
3931
|
+
types[0].members; // → 2
|
|
3932
|
+
expect(types[0].members[0]).toMatchObject({
|
|
3933
|
+
name: "bar",
|
|
3934
|
+
type: "string",
|
|
3935
|
+
optional: false,
|
|
3936
|
+
});
|
|
3937
|
+
expect(types[0].members[1]).toMatchObject({
|
|
3938
|
+
name: "baz",
|
|
3939
|
+
type: "number",
|
|
3940
|
+
optional: true,
|
|
3941
|
+
});
|
|
3942
|
+
});
|
|
3943
|
+
|
|
3944
|
+
it("extracts a non-exported interface", () => {
|
|
3945
|
+
const code = `interface Internal { x: number; }`;
|
|
3946
|
+
const types = extractTypesFromFile("test.ts", code);
|
|
3947
|
+
types[0].exported; // → false
|
|
3948
|
+
});
|
|
3949
|
+
|
|
3950
|
+
it("extracts interface with generics and extends", () => {
|
|
3951
|
+
const code = `
|
|
3952
|
+
export interface Container<T extends Base> extends Parent {
|
|
3953
|
+
value: T;
|
|
3954
|
+
}`;
|
|
3955
|
+
const types = extractTypesFromFile("test.ts", code);
|
|
3956
|
+
types[0].generics; // → ["T extends Base"]
|
|
3957
|
+
types[0].extends; // → ["Parent"]
|
|
3958
|
+
});
|
|
3959
|
+
|
|
3960
|
+
it("extracts interface with method signatures", () => {
|
|
3961
|
+
const code = `
|
|
3962
|
+
export interface Handler {
|
|
3963
|
+
handle(req: Request): Response;
|
|
3964
|
+
optional?(): void;
|
|
3965
|
+
}`;
|
|
3966
|
+
const types = extractTypesFromFile("test.ts", code);
|
|
3967
|
+
types[0].members; // → 2
|
|
3968
|
+
types[0].members[0].name; // → "handle"
|
|
3969
|
+
types[0].members[0].type; // → "method"
|
|
3970
|
+
types[0].members[1].optional; // → true
|
|
3971
|
+
});
|
|
3972
|
+
|
|
3973
|
+
it("extracts interface with readonly and index signatures", () => {
|
|
3974
|
+
const code = `
|
|
3975
|
+
export interface Config {
|
|
3976
|
+
readonly name: string;
|
|
3977
|
+
[key: string]: unknown;
|
|
3978
|
+
}`;
|
|
3979
|
+
const types = extractTypesFromFile("test.ts", code);
|
|
3980
|
+
types[0].members[0].readonly; // → true
|
|
3981
|
+
types[0].members[1].name; // → "[index]"
|
|
3982
|
+
});
|
|
3983
|
+
|
|
3984
|
+
it("extracts a type alias with object literal", () => {
|
|
3985
|
+
const code = `
|
|
3986
|
+
export type Options = {
|
|
3987
|
+
verbose: boolean;
|
|
3988
|
+
count?: number;
|
|
3989
|
+
};`;
|
|
3990
|
+
const types = extractTypesFromFile("test.ts", code);
|
|
3991
|
+
types[0].kind; // → "type"
|
|
3992
|
+
types[0].name; // → "Options"
|
|
3993
|
+
types[0].members; // → 2
|
|
3994
|
+
});
|
|
3995
|
+
|
|
3996
|
+
it("extracts a union type alias as __type member", () => {
|
|
3997
|
+
const code = `export type Status = "active" | "inactive";`;
|
|
3998
|
+
const types = extractTypesFromFile("test.ts", code);
|
|
3999
|
+
types[0].kind; // → "type"
|
|
4000
|
+
types[0].members; // → 1
|
|
4001
|
+
types[0].members[0].name; // → "__type"
|
|
4002
|
+
types[0].members[0].type; // → "active"
|
|
4003
|
+
});
|
|
4004
|
+
|
|
4005
|
+
it("extracts a type alias with generics", () => {
|
|
4006
|
+
const code = `export type Wrapper<T> = { inner: T };`;
|
|
4007
|
+
const types = extractTypesFromFile("test.ts", code);
|
|
4008
|
+
types[0].generics; // → ["T"]
|
|
4009
|
+
});
|
|
4010
|
+
|
|
4011
|
+
it("extracts an enum", () => {
|
|
4012
|
+
const code = `
|
|
4013
|
+
export enum Direction {
|
|
4014
|
+
Up = "UP",
|
|
4015
|
+
Down = "DOWN",
|
|
4016
|
+
Left,
|
|
4017
|
+
}`;
|
|
4018
|
+
const types = extractTypesFromFile("test.ts", code);
|
|
4019
|
+
types[0].kind; // → "enum"
|
|
4020
|
+
types[0].name; // → "Direction"
|
|
4021
|
+
types[0].members; // → 3
|
|
4022
|
+
expect(types[0].members[0]).toMatchObject({
|
|
4023
|
+
name: "Up",
|
|
4024
|
+
type: '"UP"',
|
|
4025
|
+
readonly: true,
|
|
4026
|
+
});
|
|
4027
|
+
expect(types[0].members[2].type).toBe("auto"); // no initializer
|
|
4028
|
+
});
|
|
4029
|
+
|
|
4030
|
+
it("extracts a class with public members only", () => {
|
|
4031
|
+
const code = `
|
|
4032
|
+
export class MyService {
|
|
4033
|
+
public name: string = "";
|
|
4034
|
+
private secret: string = "";
|
|
4035
|
+
protected internal: number = 0;
|
|
4036
|
+
|
|
4037
|
+
constructor() {}
|
|
4038
|
+
|
|
4039
|
+
public greet(): string { return this.name; }
|
|
4040
|
+
private hash(): void {}
|
|
4041
|
+
protected compute(): void {}
|
|
4042
|
+
|
|
4043
|
+
get value(): number { return 1; }
|
|
4044
|
+
set value(v: number) {}
|
|
4045
|
+
}`;
|
|
4046
|
+
const types = extractTypesFromFile("test.ts", code);
|
|
4047
|
+
types[0].kind; // → "class"
|
|
4048
|
+
types[0].exported; // → true
|
|
4049
|
+
|
|
4050
|
+
const memberNames = types[0].members.map((m) => m.name);
|
|
4051
|
+
// Should include: name, greet, value (getter), value (setter)
|
|
4052
|
+
memberNames; // → "name"
|
|
4053
|
+
memberNames; // → "greet"
|
|
4054
|
+
memberNames; // → "value"
|
|
4055
|
+
// Should NOT include: secret, internal, hash, compute
|
|
4056
|
+
memberNames; // → "secret"
|
|
4057
|
+
memberNames; // → "internal"
|
|
4058
|
+
memberNames; // → "hash"
|
|
4059
|
+
memberNames; // → "compute"
|
|
4060
|
+
|
|
4061
|
+
// Method signatures should not include the body
|
|
4062
|
+
const greet = types[0].members.find((m) => m.name === "greet");
|
|
4063
|
+
greet?.signature; // → "greet(): string"
|
|
4064
|
+
greet?.signature; // → "return"
|
|
4065
|
+
|
|
4066
|
+
// Getter/setter signatures should be declaration-only
|
|
4067
|
+
const getter = types[0].members.find((m) => m.name === "value" && m.type === "getter");
|
|
4068
|
+
getter?.signature; // → "get value(): number"
|
|
4069
|
+
const setter = types[0].members.find((m) => m.name === "value" && m.type === "setter");
|
|
4070
|
+
setter?.signature; // → "set value(v: number)"
|
|
4071
|
+
});
|
|
4072
|
+
|
|
4073
|
+
it("skips ECMAScript #private fields", () => {
|
|
4074
|
+
const code = `
|
|
4075
|
+
export class Vault {
|
|
4076
|
+
#data: string = "";
|
|
4077
|
+
public label: string = "";
|
|
4078
|
+
#compute(): void {}
|
|
4079
|
+
}`;
|
|
4080
|
+
const types = extractTypesFromFile("test.ts", code);
|
|
4081
|
+
const memberNames = types[0].members.map((m) => m.name);
|
|
4082
|
+
memberNames; // → ["label"]
|
|
4083
|
+
});
|
|
4084
|
+
|
|
4085
|
+
it("extracts class with generics, extends, and implements", () => {
|
|
4086
|
+
const code = `
|
|
4087
|
+
export class Store<T> extends BaseStore implements Disposable {
|
|
4088
|
+
public items: T[] = [];
|
|
4089
|
+
}`;
|
|
4090
|
+
const types = extractTypesFromFile("test.ts", code);
|
|
4091
|
+
types[0].generics; // → ["T"]
|
|
4092
|
+
types[0].extends; // → ["BaseStore"]
|
|
4093
|
+
types[0].implements; // → ["Disposable"]
|
|
4094
|
+
});
|
|
4095
|
+
|
|
4096
|
+
it("extracts JSDoc description from types", () => {
|
|
4097
|
+
const code = `
|
|
4098
|
+
/** A documented interface */
|
|
4099
|
+
export interface Documented {
|
|
4100
|
+
value: string;
|
|
4101
|
+
}`;
|
|
4102
|
+
const types = extractTypesFromFile("test.ts", code);
|
|
4103
|
+
types[0].jsdoc; // → defined
|
|
4104
|
+
types[0].jsdoc!.description; // → "A documented interface"
|
|
4105
|
+
});
|
|
4106
|
+
|
|
4107
|
+
it("extracts JSDoc @param and @returns tags", () => {
|
|
4108
|
+
const code = `
|
|
4109
|
+
/**
|
|
4110
|
+
* A service class
|
|
4111
|
+
* @param config - The configuration
|
|
4112
|
+
* @returns The instance
|
|
4113
|
+
* @since 1.0.0
|
|
4114
|
+
* @deprecated Use NewService instead
|
|
4115
|
+
*/
|
|
4116
|
+
export interface Service {
|
|
4117
|
+
run(): void;
|
|
4118
|
+
}`;
|
|
4119
|
+
const types = extractTypesFromFile("test.ts", code);
|
|
4120
|
+
const jsdoc = types[0].jsdoc!;
|
|
4121
|
+
jsdoc.params; // → 1
|
|
4122
|
+
jsdoc.params[0].name; // → "config"
|
|
4123
|
+
jsdoc.returns?.description; // → "The instance"
|
|
4124
|
+
jsdoc.since; // → "1.0.0"
|
|
4125
|
+
jsdoc.deprecated; // → truthy
|
|
4126
|
+
});
|
|
4127
|
+
|
|
4128
|
+
it("extracts multiple types from a single file", () => {
|
|
4129
|
+
const code = `
|
|
4130
|
+
export interface A { x: number; }
|
|
4131
|
+
export type B = string;
|
|
4132
|
+
export enum C { X, Y }
|
|
4133
|
+
export class D { public z = 1; }`;
|
|
4134
|
+
const types = extractTypesFromFile("test.ts", code);
|
|
4135
|
+
types; // → 4
|
|
4136
|
+
types.map((t) => t.name); // → ["A", "B", "C", "D"]
|
|
4137
|
+
types.map((t) => t.kind); // → ["interface", "type", "enum", "class"]
|
|
4138
|
+
});
|
|
4139
|
+
|
|
4140
|
+
it("returns empty array for files with no type definitions", () => {
|
|
4141
|
+
const code = `const x = 5; function foo() { return x; }`;
|
|
4142
|
+
const types = extractTypesFromFile("test.ts", code);
|
|
4143
|
+
types; // → []
|
|
4144
|
+
});
|
|
4145
|
+
|
|
4146
|
+
it("provides correct source locations", () => {
|
|
4147
|
+
const code = `\nexport interface Foo {\n bar: string;\n}\n`;
|
|
4148
|
+
const types = extractTypesFromFile("test.ts", code);
|
|
4149
|
+
types[0].location.file; // → "test.ts"
|
|
4150
|
+
types[0].location.line; // → 2
|
|
4151
|
+
});
|
|
4152
|
+
});
|
|
4153
|
+
```
|
|
4154
|
+
|
|
4155
|
+
**getTypeSignature**
|
|
4156
|
+
|
|
4157
|
+
```typescript
|
|
4158
|
+
describe("getTypeSignature", () => {
|
|
4159
|
+
it("generates interface signature", () => {
|
|
4160
|
+
const sig = getTypeSignature({
|
|
4161
|
+
name: "Foo",
|
|
4162
|
+
kind: "interface",
|
|
4163
|
+
exported: true,
|
|
4164
|
+
generics: ["T"],
|
|
4165
|
+
extends: ["Bar"],
|
|
4166
|
+
members: [],
|
|
4167
|
+
location: { file: "test.ts", line: 1 },
|
|
4168
|
+
});
|
|
4169
|
+
sig; // → "export interface Foo<T> extends Bar"
|
|
4170
|
+
});
|
|
4171
|
+
|
|
4172
|
+
it("generates type signature", () => {
|
|
4173
|
+
const sig = getTypeSignature({
|
|
4174
|
+
name: "Options",
|
|
4175
|
+
kind: "type",
|
|
4176
|
+
exported: true,
|
|
4177
|
+
generics: ["K", "V"],
|
|
4178
|
+
members: [],
|
|
4179
|
+
location: { file: "test.ts", line: 1 },
|
|
4180
|
+
});
|
|
4181
|
+
sig; // → "export type Options<K, V>"
|
|
4182
|
+
});
|
|
4183
|
+
|
|
4184
|
+
it("generates enum signature", () => {
|
|
4185
|
+
const sig = getTypeSignature({
|
|
4186
|
+
name: "Status",
|
|
4187
|
+
kind: "enum",
|
|
4188
|
+
exported: false,
|
|
4189
|
+
members: [],
|
|
4190
|
+
location: { file: "test.ts", line: 1 },
|
|
4191
|
+
});
|
|
4192
|
+
sig; // → "enum Status"
|
|
4193
|
+
});
|
|
4194
|
+
|
|
4195
|
+
it("generates class signature with implements", () => {
|
|
4196
|
+
const sig = getTypeSignature({
|
|
4197
|
+
name: "Service",
|
|
4198
|
+
kind: "class",
|
|
4199
|
+
exported: true,
|
|
4200
|
+
extends: ["Base"],
|
|
4201
|
+
implements: ["Disposable", "Serializable"],
|
|
4202
|
+
members: [],
|
|
4203
|
+
location: { file: "test.ts", line: 1 },
|
|
4204
|
+
});
|
|
4205
|
+
expect(sig).toBe(
|
|
4206
|
+
"export class Service extends Base implements Disposable, Serializable"
|
|
4207
|
+
);
|
|
4208
|
+
});
|
|
4209
|
+
});
|
|
4210
|
+
```
|
|
4211
|
+
|
|
4212
|
+
**generateMethodTable**
|
|
4213
|
+
|
|
4214
|
+
```typescript
|
|
4215
|
+
describe("generateMethodTable", () => {
|
|
4216
|
+
it("returns empty string for no members", () => {
|
|
4217
|
+
expect(
|
|
4218
|
+
generateMethodTable({
|
|
4219
|
+
name: "Empty",
|
|
4220
|
+
kind: "interface",
|
|
4221
|
+
exported: true,
|
|
4222
|
+
members: [],
|
|
4223
|
+
location: { file: "test.ts", line: 1 },
|
|
4224
|
+
})
|
|
4225
|
+
).toBe("");
|
|
4226
|
+
});
|
|
4227
|
+
|
|
4228
|
+
it("generates markdown table", () => {
|
|
4229
|
+
const table = generateMethodTable({
|
|
4230
|
+
name: "Foo",
|
|
4231
|
+
kind: "interface",
|
|
4232
|
+
exported: true,
|
|
4233
|
+
members: [
|
|
4234
|
+
{ name: "bar", type: "string", optional: false, readonly: false, description: "The bar" },
|
|
4235
|
+
{ name: "baz", type: "number", optional: true, readonly: false },
|
|
4236
|
+
{ name: "id", type: "string", optional: false, readonly: true },
|
|
4237
|
+
],
|
|
4238
|
+
location: { file: "test.ts", line: 1 },
|
|
4239
|
+
});
|
|
4240
|
+
|
|
4241
|
+
table; // → "| Member | Type | Description |"
|
|
4242
|
+
table; // → "| `bar` | `string` | The bar |"
|
|
4243
|
+
table; // → "| `baz?` | `number` | |"
|
|
4244
|
+
table; // → "| `readonly id` | `string` | |"
|
|
4245
|
+
});
|
|
4246
|
+
|
|
4247
|
+
it("renders both readonly and optional when both are set", () => {
|
|
4248
|
+
const table = generateMethodTable({
|
|
4249
|
+
name: "Foo",
|
|
4250
|
+
kind: "interface",
|
|
4251
|
+
exported: true,
|
|
4252
|
+
members: [
|
|
4253
|
+
{ name: "version", type: "string", optional: true, readonly: true },
|
|
4254
|
+
],
|
|
4255
|
+
location: { file: "test.ts", line: 1 },
|
|
4256
|
+
});
|
|
4257
|
+
|
|
4258
|
+
table; // → "| `readonly version?` | `string` | |"
|
|
4259
|
+
});
|
|
4260
|
+
|
|
4261
|
+
it("escapes pipes and newlines in descriptions", () => {
|
|
4262
|
+
const table = generateMethodTable({
|
|
4263
|
+
name: "Foo",
|
|
4264
|
+
kind: "interface",
|
|
4265
|
+
exported: true,
|
|
4266
|
+
members: [
|
|
4267
|
+
{ name: "bar", type: "string", optional: false, readonly: false, description: "A | B choice" },
|
|
4268
|
+
{ name: "baz", type: "number", optional: false, readonly: false, description: "Line1\nLine2" },
|
|
4269
|
+
],
|
|
4270
|
+
location: { file: "test.ts", line: 1 },
|
|
4271
|
+
});
|
|
4272
|
+
|
|
4273
|
+
table; // → "A \\| B choice"
|
|
4274
|
+
table; // → "Line1\n"
|
|
4275
|
+
table; // → "Line1<br/>Line2"
|
|
4276
|
+
});
|
|
4277
|
+
|
|
4278
|
+
it("uses signature over type when available", () => {
|
|
4279
|
+
const table = generateMethodTable({
|
|
4280
|
+
name: "Foo",
|
|
4281
|
+
kind: "interface",
|
|
4282
|
+
exported: true,
|
|
4283
|
+
members: [
|
|
4284
|
+
{
|
|
4285
|
+
name: "handle",
|
|
4286
|
+
type: "method",
|
|
4287
|
+
optional: false,
|
|
4288
|
+
readonly: false,
|
|
4289
|
+
signature: "handle(req: Request): Response",
|
|
4290
|
+
},
|
|
4291
|
+
],
|
|
4292
|
+
location: { file: "test.ts", line: 1 },
|
|
4293
|
+
});
|
|
4294
|
+
|
|
4295
|
+
table; // → "handle(req: Request): Response"
|
|
4296
|
+
});
|
|
4297
|
+
});
|
|
4298
|
+
```
|
|
4299
|
+
<!-- @codex:end -->
|
|
4300
|
+
|
|
4301
|
+
## Testing
|
|
4302
|
+
|
|
4303
|
+
<!-- @codex:auto testing="pithy.codex.**" -->
|
|
4304
|
+
| Feature | Unit | Integration | E2E | Status |
|
|
4305
|
+
| --- | --- | --- | --- | --- |
|
|
4306
|
+
| parseCodexAnnotations | ✓ | - | - | Missing integration |
|
|
4307
|
+
| parseCodexApiAnnotations | ✓ | - | - | Missing integration |
|
|
4308
|
+
| createSourceDigest | ✓ | - | - | Missing integration |
|
|
4309
|
+
| createCanonicalFingerprint | ✓ | - | - | Missing integration |
|
|
4310
|
+
| discoverComponentsFromFile | ✓ | - | - | Missing integration |
|
|
4311
|
+
| validateAnnotation | ✓ | - | - | Missing integration |
|
|
4312
|
+
| CHANGED_ONLY_PATHSPEC | - | - | - | N/A |
|
|
4313
|
+
| loadEnv | - | - | - | Missing unit, integration |
|
|
4314
|
+
| askLLM | - | - | - | Missing unit, integration |
|
|
4315
|
+
| scan | - | - | - | Missing unit, integration |
|
|
4316
|
+
| combineFingerprints | - | - | - | Missing unit, integration |
|
|
4317
|
+
| ApiSig | - | - | - | Missing unit, integration |
|
|
4318
|
+
| codexSchema | - | - | - | Missing unit, integration |
|
|
4319
|
+
| CodexEntry | - | - | - | Missing unit, integration |
|
|
4320
|
+
| consumeValue | ✓ | - | - | Missing integration, e2e |
|
|
4321
|
+
| parseArgs | ✓ | - | - | Missing integration, e2e |
|
|
4322
|
+
| validatePath | ✓ | - | - | Missing integration, e2e |
|
|
4323
|
+
| validatePath | ✓ | - | - | Missing integration |
|
|
4324
|
+
| runSyncPipeline | - | - | - | Missing unit, integration |
|
|
4325
|
+
| validateExampleSyntax | - | - | - | Missing unit, integration |
|
|
4326
|
+
| validateExtractionResult | - | - | - | Missing unit, integration |
|
|
4327
|
+
| formatValidationReport | - | - | - | Missing unit, integration |
|
|
4328
|
+
| createApiSnapshot | ✓ | - | - | Missing integration |
|
|
4329
|
+
| snapshotFromTypeDefinitions | ✓ | - | - | Missing integration |
|
|
4330
|
+
| diffSnapshots | ✓ | - | - | Missing integration |
|
|
4331
|
+
| classifyChange | ✓ | - | - | Missing integration |
|
|
4332
|
+
| detectBreakingChanges | ✓ | - | - | Missing integration |
|
|
4333
|
+
| generateMigrationGuide | ✓ | - | - | Missing integration |
|
|
4334
|
+
| generateChangelog | ✓ | - | - | Missing integration |
|
|
4335
|
+
| extractExamples | - | - | - | Missing unit, integration |
|
|
4336
|
+
| validateExample | - | - | - | Missing unit, integration |
|
|
4337
|
+
| wrapDoctestInHarness | - | - | - | Missing unit, integration |
|
|
4338
|
+
| extractExamplesFromSource | - | - | - | Missing unit, integration |
|
|
4339
|
+
| generateRunnableExampleFile | - | - | - | Missing unit, integration |
|
|
4340
|
+
| parseJSDocComment | - | - | - | Missing unit, integration |
|
|
4341
|
+
| extractJSDocBlocks | - | - | - | Missing unit, integration |
|
|
4342
|
+
| linkJSDocToDeclarations | - | - | - | Missing unit, integration |
|
|
4343
|
+
| extractSignature | - | - | - | Missing unit, integration |
|
|
4344
|
+
| runExtractionPipeline | - | - | - | Missing unit, integration |
|
|
4345
|
+
| extractSingleFile | - | - | - | Missing unit, integration |
|
|
4346
|
+
| generateExtractionReport | - | - | - | Missing unit, integration |
|
|
4347
|
+
| exportToCodexFormat | - | - | - | Missing unit, integration |
|
|
4348
|
+
| parseMarkerAttributes | - | - | - | Missing unit, integration |
|
|
4349
|
+
| resolveMarkerType | - | - | - | Missing unit, integration |
|
|
4350
|
+
| parseReadmeMarkers | - | - | - | Missing unit, integration |
|
|
4351
|
+
| matchIdPattern | - | - | - | Missing unit, integration |
|
|
4352
|
+
| filterByPattern | - | - | - | Missing unit, integration |
|
|
4353
|
+
| stripTestBoilerplate | - | - | - | Missing unit, integration |
|
|
4354
|
+
| generateInstallContent | - | - | - | Missing unit, integration |
|
|
4355
|
+
| generateExamplesContent | - | - | - | Missing unit, integration |
|
|
4356
|
+
| escapeTableCell | - | - | - | Missing unit, integration |
|
|
4357
|
+
| escapeMd | - | - | - | Missing unit, integration |
|
|
4358
|
+
| sanitizeForInlineCode | - | - | - | Missing unit, integration |
|
|
4359
|
+
| longestBacktickRun | - | - | - | Missing unit, integration |
|
|
4360
|
+
| sanitizeLanguage | - | - | - | Missing unit, integration |
|
|
4361
|
+
| generateApiContent | - | - | - | Missing unit, integration |
|
|
4362
|
+
| generateTestingContent | - | - | - | Missing unit, integration |
|
|
4363
|
+
| generateBundleContent | - | - | - | Missing unit, integration |
|
|
4364
|
+
| syncReadmeContent | - | - | - | Missing unit, integration |
|
|
4365
|
+
| syncAllReadmes | - | - | - | Missing unit, integration |
|
|
4366
|
+
| getSnapshotPath | ✓ | - | - | Missing integration |
|
|
4367
|
+
| saveSnapshot | ✓ | - | - | Missing integration |
|
|
4368
|
+
| loadSnapshot | ✓ | - | - | Missing integration |
|
|
4369
|
+
| listSnapshots | ✓ | - | - | Missing integration |
|
|
4370
|
+
| DeclarationLocation | - | - | - | Missing unit, integration |
|
|
4371
|
+
| findDeclarationLocation | - | - | - | Missing unit, integration |
|
|
4372
|
+
| extractAllDeclarations | - | - | - | Missing unit, integration |
|
|
4373
|
+
| parseReadmeSections | - | - | - | Missing unit, integration |
|
|
4374
|
+
| linkReadmeToEntries | - | - | - | Missing unit, integration |
|
|
4375
|
+
| generateSourceLink | - | - | - | Missing unit, integration |
|
|
4376
|
+
| toRelativePath | - | - | - | Missing unit, integration |
|
|
4377
|
+
| enrichSourceLocation | - | - | - | Missing unit, integration |
|
|
4378
|
+
| extractTestExamples | ✓ | - | - | Missing integration |
|
|
4379
|
+
| parseVitestOutput | ✓ | - | - | Missing integration |
|
|
4380
|
+
| matchExamplesToStatuses | ✓ | - | - | Missing integration |
|
|
4381
|
+
| generateExampleReport | ✓ | - | - | Missing integration |
|
|
4382
|
+
| validateTestExamples | ✓ | - | - | Missing integration |
|
|
4383
|
+
| extractTestCases | - | - | - | Missing unit, integration |
|
|
4384
|
+
| extractTestImports | - | - | - | Missing unit, integration |
|
|
4385
|
+
| identifyCoveredApis | - | - | - | Missing unit, integration |
|
|
4386
|
+
| analyzeTestFile | - | - | - | Missing unit, integration |
|
|
4387
|
+
| testCasesToReferences | - | - | - | Missing unit, integration |
|
|
4388
|
+
| matchTestToEntry | - | - | - | Missing unit, integration |
|
|
4389
|
+
| generateCoverageSummary | - | - | - | Missing unit, integration |
|
|
4390
|
+
| getTestingDefaults | ✓ | - | - | Missing integration |
|
|
4391
|
+
| applyRiskModifiers | ✓ | - | - | Missing integration |
|
|
4392
|
+
| resolveTestingRequirements | ✓ | - | - | Missing integration |
|
|
4393
|
+
| computeTestingStatus | ✓ | - | - | Missing integration |
|
|
4394
|
+
| extractTypesFromFile | ✓ | - | - | Missing integration |
|
|
4395
|
+
| getTypeSignature | ✓ | - | - | Missing integration |
|
|
4396
|
+
| generateMethodTable | ✓ | - | - | Missing integration |
|
|
4397
|
+
<!-- @codex:end -->
|
|
4398
|
+
|
|
4399
|
+
## License
|
|
4400
|
+
|
|
4401
|
+
MIT
|