@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.
Files changed (69) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +4401 -0
  3. package/dist/annotations.d.ts +106 -0
  4. package/dist/annotations.js +306 -0
  5. package/dist/apply.d.ts +2 -0
  6. package/dist/apply.js +80 -0
  7. package/dist/changed-scope.d.ts +23 -0
  8. package/dist/changed-scope.js +31 -0
  9. package/dist/check.d.ts +2 -0
  10. package/dist/check.js +117 -0
  11. package/dist/cli.d.ts +2 -0
  12. package/dist/cli.js +67 -0
  13. package/dist/env.d.ts +5 -0
  14. package/dist/env.js +35 -0
  15. package/dist/extract-cli.d.ts +23 -0
  16. package/dist/extract-cli.js +192 -0
  17. package/dist/extraction/breaking-changes.d.ts +66 -0
  18. package/dist/extraction/breaking-changes.js +352 -0
  19. package/dist/extraction/example-extractor.d.ts +55 -0
  20. package/dist/extraction/example-extractor.js +272 -0
  21. package/dist/extraction/index.d.ts +27 -0
  22. package/dist/extraction/index.js +31 -0
  23. package/dist/extraction/jsdoc-parser.d.ts +43 -0
  24. package/dist/extraction/jsdoc-parser.js +274 -0
  25. package/dist/extraction/pipeline.d.ts +54 -0
  26. package/dist/extraction/pipeline.js +526 -0
  27. package/dist/extraction/readme-sync.d.ts +108 -0
  28. package/dist/extraction/readme-sync.js +592 -0
  29. package/dist/extraction/snapshot-store.d.ts +40 -0
  30. package/dist/extraction/snapshot-store.js +153 -0
  31. package/dist/extraction/source-linker.d.ts +80 -0
  32. package/dist/extraction/source-linker.js +316 -0
  33. package/dist/extraction/test-example-extractor.d.ts +69 -0
  34. package/dist/extraction/test-example-extractor.js +400 -0
  35. package/dist/extraction/test-pattern-extractor.d.ts +68 -0
  36. package/dist/extraction/test-pattern-extractor.js +261 -0
  37. package/dist/extraction/testing-pyramid.d.ts +44 -0
  38. package/dist/extraction/testing-pyramid.js +163 -0
  39. package/dist/extraction/type-extractor.d.ts +34 -0
  40. package/dist/extraction/type-extractor.js +494 -0
  41. package/dist/extraction/types.d.ts +401 -0
  42. package/dist/extraction/types.js +34 -0
  43. package/dist/indexer.d.ts +1 -0
  44. package/dist/indexer.js +107 -0
  45. package/dist/llm.d.ts +8 -0
  46. package/dist/llm.js +75 -0
  47. package/dist/readme-sync-cli.d.ts +20 -0
  48. package/dist/readme-sync-cli.js +167 -0
  49. package/dist/review.d.ts +2 -0
  50. package/dist/review.js +93 -0
  51. package/dist/scan.d.ts +29 -0
  52. package/dist/scan.js +221 -0
  53. package/dist/schema.d.ts +169 -0
  54. package/dist/schema.js +70 -0
  55. package/dist/snapshot-cli.d.ts +45 -0
  56. package/dist/snapshot-cli.js +217 -0
  57. package/dist/sync-cli.d.ts +22 -0
  58. package/dist/sync-cli.js +154 -0
  59. package/dist/sync-pipeline.d.ts +58 -0
  60. package/dist/sync-pipeline.js +104 -0
  61. package/dist/sync.d.ts +2 -0
  62. package/dist/sync.js +318 -0
  63. package/dist/validate-cli.d.ts +20 -0
  64. package/dist/validate-cli.js +144 -0
  65. package/dist/validate.d.ts +76 -0
  66. package/dist/validate.js +183 -0
  67. package/dist/watch-cli.d.ts +21 -0
  68. package/dist/watch-cli.js +220 -0
  69. package/package.json +62 -0
@@ -0,0 +1,592 @@
1
+ /**
2
+ * @codex
3
+ * {
4
+ * "id": "pithy.codex.extraction.readme-sync",
5
+ * "title": "README Marker Sync",
6
+ * "category": "feature"
7
+ * }
8
+ *
9
+ * Parses README files for @codex:auto markers and replaces their content
10
+ * with generated documentation from extraction pipeline data.
11
+ */
12
+ import { readFile, writeFile } from 'node:fs/promises';
13
+ import { globby } from 'globby';
14
+ import { resolveTestingRequirements, computeTestingStatus, } from './testing-pyramid.js';
15
+ // ── Marker Parsing ──────────────────────────────────────────────
16
+ /**
17
+ * Parses HTML-style attributes from a marker opening tag string.
18
+ * Only quoted attribute values are supported (double or single quotes).
19
+ * Unquoted values like `install=pkg` are ignored by design.
20
+ * @codexApi {"parent":"pithy.codex.extraction.readme-sync","name":"parseMarkerAttributes","stability":"stable","signature":"(attrString: string) => Record<string, string>"}
21
+ */
22
+ export function parseMarkerAttributes(attrString) {
23
+ const attrs = {};
24
+ const regex = /(\w+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
25
+ let match;
26
+ while ((match = regex.exec(attrString)) !== null) {
27
+ attrs[match[1]] = match[2] ?? match[3];
28
+ }
29
+ return attrs;
30
+ }
31
+ const MARKER_TYPES = [
32
+ 'install',
33
+ 'examples',
34
+ 'api',
35
+ 'bundle',
36
+ 'testing',
37
+ ];
38
+ /**
39
+ * Determines marker type from its attributes.
40
+ * @codexApi {"parent":"pithy.codex.extraction.readme-sync","name":"resolveMarkerType","stability":"stable","signature":"(attributes: Record<string, string>) => ReadmeMarkerType | null"}
41
+ */
42
+ export function resolveMarkerType(attributes) {
43
+ for (const type of MARKER_TYPES) {
44
+ if (type in attributes)
45
+ return type;
46
+ }
47
+ return null;
48
+ }
49
+ const MARKER_OPEN_REGEX = /<!--\s*@codex:auto\s+(.*?)\s*-->/g;
50
+ const MARKER_CLOSE = '<!-- @codex:end -->';
51
+ /**
52
+ * Parses all @codex:auto markers from README content.
53
+ * @codexApi {"parent":"pithy.codex.extraction.readme-sync","name":"parseReadmeMarkers","stability":"stable","signature":"(content: string) => ReadmeMarker[]"}
54
+ */
55
+ export function parseReadmeMarkers(content) {
56
+ const markers = [];
57
+ MARKER_OPEN_REGEX.lastIndex = 0;
58
+ let match;
59
+ while ((match = MARKER_OPEN_REGEX.exec(content)) !== null) {
60
+ const openTag = match[0];
61
+ const attrString = match[1];
62
+ const startIndex = match.index;
63
+ const afterOpen = startIndex + openTag.length;
64
+ // Find closing tag
65
+ const closeIndex = content.indexOf(MARKER_CLOSE, afterOpen);
66
+ if (closeIndex === -1) {
67
+ // Malformed marker — skip
68
+ continue;
69
+ }
70
+ const endIndex = closeIndex + MARKER_CLOSE.length;
71
+ const innerContent = content.slice(afterOpen, closeIndex);
72
+ // If the inner content contains another @codex:auto tag, the close tag we found
73
+ // likely belongs to the nested marker — skip this outer match as malformed.
74
+ if (/<!--\s*@codex:auto\s/.test(innerContent)) {
75
+ continue;
76
+ }
77
+ const attributes = parseMarkerAttributes(attrString);
78
+ const type = resolveMarkerType(attributes);
79
+ if (type === null)
80
+ continue;
81
+ const originalContent = content.slice(startIndex, endIndex);
82
+ markers.push({
83
+ type,
84
+ attributes,
85
+ openTag,
86
+ startIndex,
87
+ endIndex,
88
+ originalContent,
89
+ innerContent,
90
+ });
91
+ }
92
+ return markers;
93
+ }
94
+ // ── ID Pattern Matching ─────────────────────────────────────────
95
+ /**
96
+ * Matches codex entry IDs against a glob-style pattern.
97
+ * Supports: exact match, trailing `*` (one segment), trailing `**` (any depth).
98
+ * @codexApi {"parent":"pithy.codex.extraction.readme-sync","name":"matchIdPattern","stability":"stable","signature":"(pattern: string, entryId: string) => boolean"}
99
+ */
100
+ export function matchIdPattern(pattern, entryId) {
101
+ if (pattern === entryId)
102
+ return true;
103
+ // pithy.signals.** → match anything under pithy.signals
104
+ if (pattern.endsWith('.**')) {
105
+ const prefix = pattern.slice(0, -3); // remove .**
106
+ return (entryId.startsWith(prefix + '.') && entryId.length > prefix.length + 1);
107
+ }
108
+ // pithy.signals.* → match exactly one segment after pithy.signals.
109
+ if (pattern.endsWith('.*')) {
110
+ const prefix = pattern.slice(0, -2); // remove .*
111
+ if (!entryId.startsWith(prefix + '.'))
112
+ return false;
113
+ const remainder = entryId.slice(prefix.length + 1);
114
+ return remainder.length > 0 && !remainder.includes('.');
115
+ }
116
+ return false;
117
+ }
118
+ /**
119
+ * Filters components by an ID pattern.
120
+ * @codexApi {"parent":"pithy.codex.extraction.readme-sync","name":"filterByPattern","stability":"stable","signature":"(components: EnrichedComponent[], pattern: string) => EnrichedComponent[]"}
121
+ */
122
+ export function filterByPattern(components, pattern) {
123
+ return components.filter(c => {
124
+ if (matchIdPattern(pattern, c.id))
125
+ return true;
126
+ if (c.apis.some(api => matchIdPattern(pattern, api.id)))
127
+ return true;
128
+ // Match parent component when pattern uses wildcards
129
+ // e.g., component "pithy.signals" matches pattern "pithy.signals.*"
130
+ if (pattern.endsWith('.*') || pattern.endsWith('.**')) {
131
+ const prefix = pattern.replace(/\.\*+$/, '');
132
+ if (c.id === prefix)
133
+ return true;
134
+ }
135
+ return false;
136
+ });
137
+ }
138
+ // ── Test Boilerplate Stripping ──────────────────────────────────
139
+ /**
140
+ * Strips test framework boilerplate from example code extracted from tests.
141
+ * Removes it()/test() wrapper, dedents, converts expect() to value comments.
142
+ * @codexApi {"parent":"pithy.codex.extraction.readme-sync","name":"stripTestBoilerplate","stability":"stable","signature":"(code: string) => string"}
143
+ */
144
+ export function stripTestBoilerplate(code) {
145
+ let lines = code.split('\n');
146
+ // Strip it('...', () => { ... }); or test('...', () => { ... }); wrapper
147
+ if (lines.length > 1 && /^\s*(it|test)\s*\(/.test(lines[0])) {
148
+ lines = lines.slice(1);
149
+ // Remove trailing });
150
+ for (let i = lines.length - 1; i >= 0; i--) {
151
+ if (/^\s*\}\s*\);?\s*$/.test(lines[i])) {
152
+ lines = lines.slice(0, i);
153
+ break;
154
+ }
155
+ }
156
+ }
157
+ // Dedent by common leading whitespace
158
+ const nonEmpty = lines.filter(l => l.trim().length > 0);
159
+ if (nonEmpty.length > 0) {
160
+ const minIndent = Math.min(...nonEmpty.map(l => (l.match(/^(\s*)/) || [''])[1].length));
161
+ if (minIndent > 0) {
162
+ lines = lines.map(l => (l.length >= minIndent ? l.slice(minIndent) : l));
163
+ }
164
+ }
165
+ // Convert expect(expr).toBe(val) / .toEqual(val) → expr; // → val
166
+ lines = lines.map(line => {
167
+ const trimmed = line.trimStart();
168
+ const indent = line.slice(0, line.length - trimmed.length);
169
+ if (!trimmed.startsWith('expect('))
170
+ return line;
171
+ // Find the closing paren of expect() by counting parens (handles nested calls)
172
+ let depth = 0;
173
+ let expectCloseIndex = -1;
174
+ for (let j = 7; j < trimmed.length; j++) {
175
+ if (trimmed[j] === '(')
176
+ depth++;
177
+ else if (trimmed[j] === ')') {
178
+ if (depth === 0) {
179
+ expectCloseIndex = j;
180
+ break;
181
+ }
182
+ depth--;
183
+ }
184
+ }
185
+ if (expectCloseIndex === -1)
186
+ return line;
187
+ const expr = trimmed.slice(7, expectCloseIndex); // between expect( and )
188
+ const rest = trimmed.slice(expectCloseIndex + 1); // after the closing )
189
+ // Matchers with a value argument
190
+ const valueMatch = rest.match(/\.(toBe|toEqual|toStrictEqual|toContain|toMatch|toHaveLength)\((.+)\);?\s*$/);
191
+ if (valueMatch) {
192
+ const value = valueMatch[2];
193
+ return `${indent}${expr}; // → ${value}`;
194
+ }
195
+ // No-argument matchers
196
+ const noArgMatch = rest.match(/\.(toBeTruthy|toBeFalsy|toBeNull|toBeUndefined|toBeDefined)\(\);?\s*$/);
197
+ if (noArgMatch) {
198
+ const labels = {
199
+ toBeTruthy: 'truthy',
200
+ toBeFalsy: 'falsy',
201
+ toBeNull: 'null',
202
+ toBeUndefined: 'undefined',
203
+ toBeDefined: 'defined',
204
+ };
205
+ return `${indent}${expr}; // → ${labels[noArgMatch[1]]}`;
206
+ }
207
+ return line;
208
+ });
209
+ return lines.join('\n').trim();
210
+ }
211
+ // ── Content Generators ──────────────────────────────────────────
212
+ /**
213
+ * Generates install command content for a package.
214
+ * @codexApi {"parent":"pithy.codex.extraction.readme-sync","name":"generateInstallContent","stability":"stable","signature":"(packageName: string) => string"}
215
+ */
216
+ export function generateInstallContent(packageName) {
217
+ if (!NPM_NAME_RE.test(packageName)) {
218
+ return `_Invalid package name: \`${sanitizeForInlineCode(packageName)}\`_`;
219
+ }
220
+ return [
221
+ '```bash',
222
+ `npm install ${packageName}`,
223
+ '```',
224
+ '',
225
+ '```bash',
226
+ `pnpm add ${packageName}`,
227
+ '```',
228
+ '',
229
+ '```bash',
230
+ `yarn add ${packageName}`,
231
+ '```',
232
+ ].join('\n');
233
+ }
234
+ /**
235
+ * Generates examples content from extraction data.
236
+ * Prefers API-level JSDoc examples; falls back to @codex:example test examples
237
+ * when no API examples are found (test examples are safe from feedback loops).
238
+ * @codexApi {"parent":"pithy.codex.extraction.readme-sync","name":"generateExamplesContent","stability":"stable","signature":"(components: EnrichedComponent[], limit?: number, testExamples?: TestExample[], pattern?: string) => string"}
239
+ */
240
+ export function generateExamplesContent(components, limit, testExamples, pattern) {
241
+ // Only collect examples from API-level JSDoc (not component-level, which may
242
+ // include README-sourced examples that would create a feedback loop)
243
+ const allExamples = [];
244
+ for (const comp of components) {
245
+ for (const api of comp.apis) {
246
+ for (const ex of api.examples) {
247
+ allExamples.push(ex);
248
+ }
249
+ }
250
+ }
251
+ // Fall back to test examples when no API JSDoc examples exist
252
+ if (allExamples.length === 0 && testExamples && pattern) {
253
+ const matchingTests = testExamples.filter(te => te.entryId === pattern || matchIdPattern(pattern, te.entryId));
254
+ for (const te of matchingTests) {
255
+ allExamples.push({
256
+ description: te.name,
257
+ code: stripTestBoilerplate(te.code),
258
+ language: 'typescript',
259
+ });
260
+ }
261
+ }
262
+ // Deduplicate by normalized code + language (collapse whitespace differences
263
+ // but keep distinct examples that share code with different languages)
264
+ const seen = new Set();
265
+ const uniqueExamples = allExamples.filter(ex => {
266
+ const key = `${ex.language}:${ex.code.replace(/\s+/g, ' ').trim()}`;
267
+ if (seen.has(key))
268
+ return false;
269
+ seen.add(key);
270
+ return true;
271
+ });
272
+ if (uniqueExamples.length === 0) {
273
+ return '_No examples found._';
274
+ }
275
+ const selected = limit !== undefined
276
+ ? uniqueExamples.slice(0, Math.max(0, limit))
277
+ : uniqueExamples;
278
+ const lines = [];
279
+ for (const ex of selected) {
280
+ if (ex.description) {
281
+ lines.push(`**${escapeMd(ex.description)}**`);
282
+ lines.push('');
283
+ }
284
+ // Use enough backticks to avoid collision if content contains triple backticks
285
+ const fenceLen = Math.max(3, longestBacktickRun(ex.code) + 1);
286
+ const fence = '`'.repeat(fenceLen);
287
+ lines.push(`${fence}${sanitizeLanguage(ex.language)}`);
288
+ lines.push(ex.code);
289
+ lines.push(fence);
290
+ lines.push('');
291
+ }
292
+ return lines.join('\n').trimEnd();
293
+ }
294
+ /**
295
+ * Escapes pipe characters and collapses newlines for use in markdown table cells.
296
+ * @codexApi {"parent":"pithy.codex.extraction.readme-sync","name":"escapeTableCell","stability":"stable","signature":"(str: string) => string"}
297
+ */
298
+ export function escapeTableCell(str) {
299
+ return str.replace(/\r?\n/g, ' ').replace(/\|/g, '\\|');
300
+ }
301
+ /**
302
+ * Escapes markdown special characters in inline text (bold markers, brackets, pipes, backticks).
303
+ * @codexApi {"parent":"pithy.codex.extraction.readme-sync","name":"escapeMd","stability":"stable","signature":"(str: string) => string"}
304
+ */
305
+ export function escapeMd(str) {
306
+ return str.replace(/([*[\]|\\`_~<>])/g, '\\$1');
307
+ }
308
+ /**
309
+ * Strips backticks from a string so it can safely be wrapped in inline code.
310
+ * @codexApi {"parent":"pithy.codex.extraction.readme-sync","name":"sanitizeForInlineCode","stability":"stable","signature":"(str: string) => string"}
311
+ */
312
+ export function sanitizeForInlineCode(str) {
313
+ return str.replace(/`/g, "'");
314
+ }
315
+ /** Returns the length of the longest consecutive run of backticks in a string.
316
+ * @codexApi {"parent":"pithy.codex.extraction.readme-sync","name":"longestBacktickRun","stability":"stable","signature":"(str: string) => number"} */
317
+ export function longestBacktickRun(str) {
318
+ let max = 0;
319
+ let current = 0;
320
+ for (const ch of str) {
321
+ if (ch === '`') {
322
+ current++;
323
+ if (current > max)
324
+ max = current;
325
+ }
326
+ else {
327
+ current = 0;
328
+ }
329
+ }
330
+ return max;
331
+ }
332
+ /** Sanitizes a language identifier for use in fenced code blocks.
333
+ * @codexApi {"parent":"pithy.codex.extraction.readme-sync","name":"sanitizeLanguage","stability":"stable","signature":"(lang: string) => string"} */
334
+ export function sanitizeLanguage(lang) {
335
+ return lang.replace(/[^a-zA-Z0-9_+-]/g, '');
336
+ }
337
+ /** Matches valid npm package names (scoped and unscoped, lowercase-only per npm rules). */
338
+ const NPM_NAME_RE = /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/;
339
+ /**
340
+ * Generates API reference table from extraction data.
341
+ * @codexApi {"parent":"pithy.codex.extraction.readme-sync","name":"generateApiContent","stability":"stable","signature":"(components: EnrichedComponent[], format?: string) => string"}
342
+ */
343
+ export function generateApiContent(components, format) {
344
+ const allApis = components.flatMap(c => c.apis);
345
+ if (allApis.length === 0) {
346
+ return '_No API entries found._';
347
+ }
348
+ if (format === 'list') {
349
+ return allApis
350
+ .map(api => {
351
+ const name = escapeMd(api.name);
352
+ const sig = api.signature
353
+ ? ` - \`${sanitizeForInlineCode(api.signature)}\``
354
+ : '';
355
+ const desc = api.jsdoc?.description
356
+ ? ` — ${escapeMd(api.jsdoc.description)}`
357
+ : '';
358
+ return `- **${name}**${sig}${desc} [${api.stability}]`;
359
+ })
360
+ .join('\n');
361
+ }
362
+ // Default: table format
363
+ // Include a Component column when APIs come from multiple components
364
+ // to disambiguate entries with the same name (e.g. validatePath)
365
+ const parentIds = new Set(components.map(c => c.id));
366
+ const multiComponent = parentIds.size > 1;
367
+ const lines = multiComponent
368
+ ? [
369
+ '| API | Component | Signature | Stability | Description |',
370
+ '| --- | --- | --- | --- | --- |',
371
+ ]
372
+ : [
373
+ '| API | Signature | Stability | Description |',
374
+ '| --- | --- | --- | --- |',
375
+ ];
376
+ for (const comp of components) {
377
+ for (const api of comp.apis) {
378
+ const name = escapeTableCell(api.name);
379
+ const sig = api.signature ? sanitizeForInlineCode(api.signature) : '-';
380
+ const stability = escapeTableCell(api.stability);
381
+ const desc = api.jsdoc?.description
382
+ ? escapeTableCell(api.jsdoc.description)
383
+ : '-';
384
+ // Signature is wrapped in backticks — pipes inside inline code don't need escaping in GFM
385
+ if (multiComponent) {
386
+ const shortParent = escapeTableCell(comp.id.split('.').slice(-1)[0]);
387
+ lines.push(`| ${name} | ${shortParent} | \`${sig}\` | ${stability} | ${desc} |`);
388
+ }
389
+ else {
390
+ lines.push(`| ${name} | \`${sig}\` | ${stability} | ${desc} |`);
391
+ }
392
+ }
393
+ }
394
+ return lines.join('\n');
395
+ }
396
+ /**
397
+ * Generates testing pyramid table from extraction data.
398
+ * Includes a Status column showing coverage completeness based on
399
+ * test references and category-aware testing pyramid requirements.
400
+ * @codexApi {"parent":"pithy.codex.extraction.readme-sync","name":"generateTestingContent","stability":"stable","signature":"(components: EnrichedComponent[]) => string"}
401
+ */
402
+ export function generateTestingContent(components) {
403
+ const allApis = components.flatMap(c => c.apis);
404
+ if (allApis.length === 0) {
405
+ return '_No test data available._';
406
+ }
407
+ // Build lookups from API parent id → component metadata
408
+ const componentByParent = new Map();
409
+ for (const comp of components) {
410
+ componentByParent.set(comp.id, comp);
411
+ }
412
+ const lines = [
413
+ '| Feature | Unit | Integration | E2E | Status |',
414
+ '| --- | --- | --- | --- | --- |',
415
+ ];
416
+ for (const api of allApis) {
417
+ const hasUnit = api.testRefs.some(r => r.type === 'unit');
418
+ const hasIntegration = api.testRefs.some(r => r.type === 'integration');
419
+ const hasE2e = api.testRefs.some(r => r.type === 'e2e');
420
+ const name = escapeTableCell(api.name);
421
+ // Resolve requirements using the shared testing pyramid API
422
+ // with risk and per-entry testing overrides from annotations
423
+ const comp = componentByParent.get(api.parent);
424
+ const category = comp?.category ?? 'feature';
425
+ const reqs = resolveTestingRequirements(category, comp?.risk, comp?.testing);
426
+ if (reqs.unit)
427
+ reqs.unit = { ...reqs.unit, covered: hasUnit };
428
+ if (reqs.integration)
429
+ reqs.integration = { ...reqs.integration, covered: hasIntegration };
430
+ if (reqs.e2e)
431
+ reqs.e2e = { ...reqs.e2e, covered: hasE2e };
432
+ const status = computeTestingStatus(reqs);
433
+ lines.push(`| ${name} | ${hasUnit ? '✓' : '-'} | ${hasIntegration ? '✓' : '-'} | ${hasE2e ? '✓' : '-'} | ${status} |`);
434
+ }
435
+ return lines.join('\n');
436
+ }
437
+ /**
438
+ * Placeholder for bundle content generation (not yet implemented).
439
+ * @codexApi {"parent":"pithy.codex.extraction.readme-sync","name":"generateBundleContent","stability":"stable","signature":"(_packageName: string) => string"}
440
+ */
441
+ export function generateBundleContent(_packageName) {
442
+ return '<!-- Bundle size tracking not yet available -->';
443
+ }
444
+ // ── Sync Engine ─────────────────────────────────────────────────
445
+ /**
446
+ * Generates replacement content for a single marker.
447
+ */
448
+ function generateMarkerContent(marker, components, testExamples) {
449
+ const pattern = marker.attributes[marker.type] || '';
450
+ const warnings = [];
451
+ // Warn when pattern value is empty (likely a typo in the marker)
452
+ if (!pattern && marker.type !== 'install') {
453
+ warnings.push(`Empty pattern value for "${marker.type}" marker; no components will match.`);
454
+ }
455
+ switch (marker.type) {
456
+ case 'install':
457
+ return { content: generateInstallContent(pattern), warning: warnings[0] };
458
+ case 'examples': {
459
+ let limit;
460
+ if (marker.attributes.limit) {
461
+ const parsed = parseInt(marker.attributes.limit, 10);
462
+ if (Number.isNaN(parsed)) {
463
+ warnings.push(`Invalid "limit" value "${marker.attributes.limit}" for examples marker; ignoring limit.`);
464
+ }
465
+ else if (parsed < 0) {
466
+ warnings.push(`Negative "limit" value "${marker.attributes.limit}" for examples marker; clamping to 0.`);
467
+ limit = 0;
468
+ }
469
+ else {
470
+ limit = parsed;
471
+ }
472
+ }
473
+ const matched = filterByPattern(components, pattern);
474
+ return {
475
+ content: generateExamplesContent(matched, limit, testExamples, pattern),
476
+ warning: warnings.join(' ') || undefined,
477
+ };
478
+ }
479
+ case 'api': {
480
+ const format = marker.attributes.format;
481
+ if (format && format !== 'table' && format !== 'list') {
482
+ warnings.push(`Unknown format "${format}" for api marker; falling back to table format.`);
483
+ }
484
+ const matched = filterByPattern(components, pattern);
485
+ return {
486
+ content: generateApiContent(matched, format),
487
+ warning: warnings.join(' ') || undefined,
488
+ };
489
+ }
490
+ case 'testing': {
491
+ const matched = filterByPattern(components, pattern);
492
+ return {
493
+ content: generateTestingContent(matched),
494
+ warning: warnings.join(' ') || undefined,
495
+ };
496
+ }
497
+ case 'bundle': {
498
+ warnings.push(`bundle marker not yet fully supported for "${pattern}", generated placeholder content instead`);
499
+ return {
500
+ content: generateBundleContent(pattern),
501
+ warning: warnings.join(' ') || undefined,
502
+ };
503
+ }
504
+ default:
505
+ return { content: '', warning: `Unknown marker type: ${marker.type}` };
506
+ }
507
+ }
508
+ /**
509
+ * Syncs a single README's content: parses markers, generates content, replaces.
510
+ *
511
+ * @codexApi {"parent":"pithy.codex.extraction.readme-sync","name":"syncReadmeContent","stability":"stable","signature":"(content: string, filePath: string, components: EnrichedComponent[], testExamples?: TestExample[]) => ReadmeSyncResult"}
512
+ */
513
+ export function syncReadmeContent(content, filePath, components, testExamples) {
514
+ const allMarkers = parseReadmeMarkers(content);
515
+ const warnings = [];
516
+ let markersUpdated = 0;
517
+ let result = content;
518
+ // Detect overlapping/nested markers and skip inner ones to avoid corruption
519
+ const markers = [];
520
+ for (const marker of allMarkers) {
521
+ const prev = markers[markers.length - 1];
522
+ if (prev && marker.startIndex < prev.endIndex) {
523
+ warnings.push(`Nested @codex:auto marker detected at offset ${marker.startIndex}; skipping to avoid output corruption.`);
524
+ continue;
525
+ }
526
+ markers.push(marker);
527
+ }
528
+ // Process markers back-to-front to preserve indices
529
+ for (let i = markers.length - 1; i >= 0; i--) {
530
+ const marker = markers[i];
531
+ const generated = generateMarkerContent(marker, components, testExamples);
532
+ if (generated.warning) {
533
+ warnings.push(generated.warning);
534
+ }
535
+ // Preserve original open tag to avoid reordering attributes
536
+ const replacement = `${marker.openTag}\n${generated.content}\n${MARKER_CLOSE}`;
537
+ if (replacement !== marker.originalContent) {
538
+ result =
539
+ result.slice(0, marker.startIndex) +
540
+ replacement +
541
+ result.slice(marker.endIndex);
542
+ markersUpdated++;
543
+ }
544
+ }
545
+ const changed = result !== content;
546
+ return {
547
+ filePath,
548
+ changed,
549
+ markersProcessed: markers.length,
550
+ markersUpdated,
551
+ warnings,
552
+ updatedContent: changed ? result : undefined,
553
+ };
554
+ }
555
+ /**
556
+ * Syncs all README files matching configured patterns.
557
+ *
558
+ * @codexApi {"parent":"pithy.codex.extraction.readme-sync","name":"syncAllReadmes","stability":"stable","signature":"(extractionResult: ExtendedExtractionResult, config?: ReadmeSyncConfig) => Promise<ReadmeSyncResult[]>"}
559
+ */
560
+ export async function syncAllReadmes(extractionResult, config = {}) {
561
+ const root = config.root || process.cwd();
562
+ const readmePatterns = config.readmePatterns || [
563
+ 'packages/*/README.md',
564
+ 'README.md',
565
+ ];
566
+ const dryRun = config.dryRun ?? false;
567
+ const verbose = config.verbose ?? false;
568
+ const readmePaths = await globby(readmePatterns, {
569
+ cwd: root,
570
+ absolute: true,
571
+ gitignore: true,
572
+ });
573
+ const results = [];
574
+ for (const readmePath of readmePaths) {
575
+ const content = await readFile(readmePath, 'utf-8');
576
+ const result = syncReadmeContent(content, readmePath, extractionResult.components, extractionResult.testExamples);
577
+ if (verbose) {
578
+ const status = result.changed
579
+ ? `updated (${result.markersUpdated}/${result.markersProcessed} markers)`
580
+ : `unchanged (${result.markersProcessed} markers)`;
581
+ console.log(` ${readmePath}: ${status}`);
582
+ for (const w of result.warnings) {
583
+ console.log(` ⚠ ${w}`);
584
+ }
585
+ }
586
+ if (result.changed && !dryRun && result.updatedContent) {
587
+ await writeFile(readmePath, result.updatedContent, 'utf-8');
588
+ }
589
+ results.push(result);
590
+ }
591
+ return results;
592
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * @codex
3
+ * {
4
+ * "id": "pithy.codex.snapshot-store",
5
+ * "title": "API Snapshot Store",
6
+ * "category": "feature"
7
+ * }
8
+ *
9
+ * Persists and retrieves API snapshots per version in `codex.versions/`.
10
+ * Enables historical API tracking and versioned breaking-change detection.
11
+ */
12
+ import type { ApiSnapshot } from './types.js';
13
+ /**
14
+ * Get the file path for a snapshot of a given version.
15
+ *
16
+ * @codexApi {"parent":"pithy.codex.snapshot-store","name":"getSnapshotPath","stability":"stable","signature":"(dir: string, version: string) => string"}
17
+ */
18
+ export declare function getSnapshotPath(dir: string, version: string): string;
19
+ /**
20
+ * Save an API snapshot to disk as a JSON file named `{version}.json`.
21
+ * Creates the target directory if it doesn't exist.
22
+ *
23
+ * @codexApi {"parent":"pithy.codex.snapshot-store","name":"saveSnapshot","stability":"stable","signature":"(snapshot: ApiSnapshot, dir: string) => Promise<void>"}
24
+ */
25
+ export declare function saveSnapshot(snapshot: ApiSnapshot, dir: string): Promise<void>;
26
+ /**
27
+ * Load an API snapshot from disk by version.
28
+ * Returns `null` if the snapshot file does not exist.
29
+ * Throws if the file exists but cannot be parsed.
30
+ *
31
+ * @codexApi {"parent":"pithy.codex.snapshot-store","name":"loadSnapshot","stability":"stable","signature":"(version: string, dir: string) => Promise<ApiSnapshot | null>"}
32
+ */
33
+ export declare function loadSnapshot(version: string, dir: string): Promise<ApiSnapshot | null>;
34
+ /**
35
+ * List all stored snapshot versions, sorted by semver (with lexicographic fallback).
36
+ * Returns an empty array if the directory does not exist.
37
+ *
38
+ * @codexApi {"parent":"pithy.codex.snapshot-store","name":"listSnapshots","stability":"stable","signature":"(dir: string) => Promise<string[]>"}
39
+ */
40
+ export declare function listSnapshots(dir: string): Promise<string[]>;