@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,106 @@
1
+ /**
2
+ * @codex
3
+ * {
4
+ * "id": "pithy.codex.annotations",
5
+ * "title": "Codex Annotations",
6
+ * "category": "feature"
7
+ * }
8
+ *
9
+ * Parses @codex and @codexApi annotations from source files to discover
10
+ * components and APIs. Provides content normalization and fingerprinting
11
+ * for change detection.
12
+ */
13
+ import type { RiskLevel, TestingRequirements } from './extraction/types.js';
14
+ /**
15
+ * Component/Feature annotation format
16
+ */
17
+ export interface CodexAnnotation {
18
+ id: string;
19
+ title: string;
20
+ category: 'feature' | 'directive' | 'runtime' | 'compiler' | 'plugin' | 'types';
21
+ /** Risk level — affects testing requirements via pyramid modifiers */
22
+ risk?: RiskLevel;
23
+ /** Explicit testing requirements (overrides category defaults) */
24
+ testing?: TestingRequirements;
25
+ }
26
+ /**
27
+ * Public API annotation format
28
+ */
29
+ export interface CodexApiAnnotation {
30
+ parent: string;
31
+ name: string;
32
+ stability?: 'internal' | 'experimental' | 'stable' | 'deprecated';
33
+ signature?: string;
34
+ }
35
+ /**
36
+ * Discovered component with annotations and metadata
37
+ */
38
+ export interface DiscoveredComponent {
39
+ id: string;
40
+ title: string;
41
+ category: string;
42
+ risk?: RiskLevel;
43
+ testing?: TestingRequirements;
44
+ apis: CodexApiAnnotation[];
45
+ files: string[];
46
+ sourceDigest: string;
47
+ }
48
+ /**
49
+ * Parses @codex annotations from source code
50
+ * @param content - Source file content
51
+ * @returns Array of parsed CodexAnnotation objects
52
+ *
53
+ * @codexApi {"parent":"pithy.codex.annotations","name":"parseCodexAnnotations","stability":"stable","signature":"(content: string) => CodexAnnotation[]"}
54
+ */
55
+ export declare function parseCodexAnnotations(content: string): CodexAnnotation[];
56
+ /**
57
+ * Parses @codexApi annotations from source code
58
+ * @param content - Source file content
59
+ * @returns Array of parsed CodexApiAnnotation objects
60
+ *
61
+ * @codexApi {"parent":"pithy.codex.annotations","name":"parseCodexApiAnnotations","stability":"stable","signature":"(content: string) => CodexApiAnnotation[]"}
62
+ */
63
+ export declare function parseCodexApiAnnotations(content: string): CodexApiAnnotation[];
64
+ /**
65
+ * Normalizes source content for stable hashing by:
66
+ * - Removing comments (except annotations)
67
+ * - Normalizing whitespace
68
+ * - Preserving meaningful structure
69
+ * @param content - Source file content
70
+ * @returns Normalized content string
71
+ */
72
+ export declare function normalizeSourceContent(content: string): string;
73
+ /**
74
+ * Creates a stable content hash for a source file
75
+ * @param content - Source file content
76
+ * @returns SHA-256 hash of normalized content
77
+ *
78
+ * @codexApi {"parent":"pithy.codex.annotations","name":"createSourceDigest","stability":"stable","signature":"(content: string) => string"}
79
+ */
80
+ export declare function createSourceDigest(content: string): string;
81
+ /**
82
+ * Creates a canonical fingerprint for a discovered component
83
+ * @param component - DiscoveredComponent to fingerprint
84
+ * @returns SHA-256 hash of canonical representation
85
+ *
86
+ * @codexApi {"parent":"pithy.codex.annotations","name":"createCanonicalFingerprint","stability":"stable","signature":"(component: DiscoveredComponent) => string"}
87
+ */
88
+ export declare function createCanonicalFingerprint(component: DiscoveredComponent): string;
89
+ /**
90
+ * Discovers components from a single source file using annotations
91
+ * @param filePath - Path to the source file
92
+ * @param content - File content (optional, will read if not provided)
93
+ * @returns Array of discovered components
94
+ *
95
+ * @codexApi {"parent":"pithy.codex.annotations","name":"discoverComponentsFromFile","stability":"stable","signature":"(filePath: string, content?: string) => Promise<DiscoveredComponent[]>"}
96
+ */
97
+ export declare function discoverComponentsFromFile(filePath: string, content?: string): Promise<DiscoveredComponent[]>;
98
+ /**
99
+ * Validates annotation format and logs issues in DEV mode
100
+ * @param annotation - Annotation to validate
101
+ * @param type - Type of annotation for logging
102
+ * @returns True if valid
103
+ *
104
+ * @codexApi {"parent":"pithy.codex.annotations","name":"validateAnnotation","stability":"stable","signature":"(annotation: unknown, type: 'codex' | 'codexApi') => boolean"}
105
+ */
106
+ export declare function validateAnnotation(annotation: unknown, type: 'codex' | 'codexApi'): boolean;
@@ -0,0 +1,306 @@
1
+ /**
2
+ * @codex
3
+ * {
4
+ * "id": "pithy.codex.annotations",
5
+ * "title": "Codex Annotations",
6
+ * "category": "feature"
7
+ * }
8
+ *
9
+ * Parses @codex and @codexApi annotations from source files to discover
10
+ * components and APIs. Provides content normalization and fingerprinting
11
+ * for change detection.
12
+ */
13
+ import fs from 'node:fs/promises';
14
+ import { createHash } from 'node:crypto';
15
+ /**
16
+ * Regex patterns for extracting annotations from source files.
17
+ *
18
+ * CODEX_JSON_BODY supports up to 3 levels of nesting (enough for
19
+ * `{ "testing": { "unit": { "required": true } } }`).
20
+ *
21
+ * Each BRACE_CONTENT level matches: non-brace/non-quote chars, quoted
22
+ * strings, or a deeper nested `{ ... }` group.
23
+ */
24
+ const BRACE_CONTENT_L2 = String.raw `[^{}"]|"(?:[^"\\]|\\.)*"`;
25
+ const BRACE_CONTENT_L1 = String.raw `${BRACE_CONTENT_L2}|\{(?:${BRACE_CONTENT_L2})*\}`;
26
+ const BRACE_CONTENT_L0 = String.raw `${BRACE_CONTENT_L2}|\{(?:${BRACE_CONTENT_L1})*\}`;
27
+ const CODEX_JSON_BODY = `(\\{(?:${BRACE_CONTENT_L0})*\\})`;
28
+ const CODEX_ANNOTATION_REGEX = new RegExp(String.raw `/\*\*[\s*]*@codex[\s*]*` +
29
+ CODEX_JSON_BODY +
30
+ String.raw `[\s\S]*?\*/`, 'g');
31
+ // Matches @codexApi on a starred JSDoc line (e.g. ` * @codexApi {...}`).
32
+ // Used as a second pass inside JSDoc blocks found by JSDOC_BLOCK_REGEX
33
+ // to prevent false positives from regular /* ... */ block comments.
34
+ const CODEX_API_ANNOTATION_REGEX = new RegExp(String.raw `\*\s*@codexApi\s*` + CODEX_JSON_BODY, 'g');
35
+ const JSDOC_BLOCK_REGEX = /\/\*\*[\s\S]*?\*\//g;
36
+ /**
37
+ * Parses @codex annotations from source code
38
+ * @param content - Source file content
39
+ * @returns Array of parsed CodexAnnotation objects
40
+ *
41
+ * @codexApi {"parent":"pithy.codex.annotations","name":"parseCodexAnnotations","stability":"stable","signature":"(content: string) => CodexAnnotation[]"}
42
+ */
43
+ export function parseCodexAnnotations(content) {
44
+ const annotations = [];
45
+ let match;
46
+ while ((match = CODEX_ANNOTATION_REGEX.exec(content)) !== null) {
47
+ try {
48
+ // Strip JSDoc line-prefix stars from multi-line JSON bodies
49
+ const jsonStr = match[1].replace(/^\s*\*\s?/gm, '');
50
+ const parsed = JSON.parse(jsonStr);
51
+ // Validate required fields and optional field shapes
52
+ if (validateAnnotation(parsed, 'codex')) {
53
+ annotations.push(parsed);
54
+ if (process.env.NODE_ENV === 'development') {
55
+ console.log(`[Codex] Found @codex annotation: ${parsed.id}`);
56
+ }
57
+ }
58
+ }
59
+ catch (error) {
60
+ if (process.env.NODE_ENV === 'development') {
61
+ console.warn(`[Codex] Failed to parse @codex annotation:`, match[1], error);
62
+ }
63
+ }
64
+ }
65
+ return annotations;
66
+ }
67
+ /**
68
+ * Parses @codexApi annotations from source code
69
+ * @param content - Source file content
70
+ * @returns Array of parsed CodexApiAnnotation objects
71
+ *
72
+ * @codexApi {"parent":"pithy.codex.annotations","name":"parseCodexApiAnnotations","stability":"stable","signature":"(content: string) => CodexApiAnnotation[]"}
73
+ */
74
+ export function parseCodexApiAnnotations(content) {
75
+ const annotations = [];
76
+ // Two-pass: first find JSDoc blocks (/** ... */), then extract
77
+ // @codexApi within each. This prevents false positives from
78
+ // regular /* ... */ block comments that happen to contain @codexApi.
79
+ JSDOC_BLOCK_REGEX.lastIndex = 0;
80
+ let blockMatch;
81
+ while ((blockMatch = JSDOC_BLOCK_REGEX.exec(content)) !== null) {
82
+ const block = blockMatch[0];
83
+ CODEX_API_ANNOTATION_REGEX.lastIndex = 0;
84
+ let match;
85
+ while ((match = CODEX_API_ANNOTATION_REGEX.exec(block)) !== null) {
86
+ try {
87
+ const jsonStr = match[1];
88
+ const parsed = JSON.parse(jsonStr);
89
+ // Validate required fields
90
+ if (parsed.parent && parsed.name) {
91
+ annotations.push({
92
+ ...parsed,
93
+ stability: parsed.stability || 'stable',
94
+ });
95
+ if (process.env.NODE_ENV === 'development') {
96
+ console.log(`[Codex] Found @codexApi annotation: ${parsed.parent}.${parsed.name}`);
97
+ }
98
+ }
99
+ else {
100
+ if (process.env.NODE_ENV === 'development') {
101
+ console.warn(`[Codex] Invalid @codexApi annotation missing required fields:`, parsed);
102
+ }
103
+ }
104
+ }
105
+ catch (error) {
106
+ if (process.env.NODE_ENV === 'development') {
107
+ console.warn(`[Codex] Failed to parse @codexApi annotation:`, match[1], error);
108
+ }
109
+ }
110
+ }
111
+ }
112
+ return annotations;
113
+ }
114
+ /**
115
+ * Normalizes source content for stable hashing by:
116
+ * - Removing comments (except annotations)
117
+ * - Normalizing whitespace
118
+ * - Preserving meaningful structure
119
+ * @param content - Source file content
120
+ * @returns Normalized content string
121
+ */
122
+ export function normalizeSourceContent(content) {
123
+ return (content
124
+ // Normalize line endings first for cross-platform consistency
125
+ .replace(/\r\n/g, '\n')
126
+ // Preserve JSDoc blocks (/** ... */) containing @codex or @codexApi annotations,
127
+ // remove all other block comments. Only JSDoc blocks are preserved because
128
+ // parseCodexApiAnnotations only extracts from /** ... */ blocks.
129
+ .replace(/\/\*[\s\S]*?\*\//g, match => /^\/\*\*/.test(match) && /@codex(?:Api)?/.test(match)
130
+ ? `ANNOTATION:${createHash('md5').update(match).digest('hex')}`
131
+ : '')
132
+ .replace(/\/\/.*$/gm, '')
133
+ // Normalize whitespace
134
+ .replace(/\s+/g, ' ')
135
+ .trim());
136
+ }
137
+ /**
138
+ * Creates a stable content hash for a source file
139
+ * @param content - Source file content
140
+ * @returns SHA-256 hash of normalized content
141
+ *
142
+ * @codexApi {"parent":"pithy.codex.annotations","name":"createSourceDigest","stability":"stable","signature":"(content: string) => string"}
143
+ */
144
+ export function createSourceDigest(content) {
145
+ const normalized = normalizeSourceContent(content);
146
+ return createHash('sha256').update(normalized, 'utf8').digest('hex');
147
+ }
148
+ /**
149
+ * Creates a canonical fingerprint for a discovered component
150
+ * @param component - DiscoveredComponent to fingerprint
151
+ * @returns SHA-256 hash of canonical representation
152
+ *
153
+ * @codexApi {"parent":"pithy.codex.annotations","name":"createCanonicalFingerprint","stability":"stable","signature":"(component: DiscoveredComponent) => string"}
154
+ */
155
+ export function createCanonicalFingerprint(component) {
156
+ const canonical = JSON.stringify({
157
+ id: component.id,
158
+ title: component.title,
159
+ category: component.category,
160
+ apis: component.apis.sort((a, b) => a.name.localeCompare(b.name)), // deterministic order
161
+ sourceDigest: component.sourceDigest,
162
+ });
163
+ return createHash('sha256').update(canonical, 'utf8').digest('hex');
164
+ }
165
+ /**
166
+ * Discovers components from a single source file using annotations
167
+ * @param filePath - Path to the source file
168
+ * @param content - File content (optional, will read if not provided)
169
+ * @returns Array of discovered components
170
+ *
171
+ * @codexApi {"parent":"pithy.codex.annotations","name":"discoverComponentsFromFile","stability":"stable","signature":"(filePath: string, content?: string) => Promise<DiscoveredComponent[]>"}
172
+ */
173
+ export async function discoverComponentsFromFile(filePath, content) {
174
+ const fileContent = content || (await fs.readFile(filePath, 'utf8').catch(() => ''));
175
+ if (!fileContent)
176
+ return [];
177
+ const codexAnnotations = parseCodexAnnotations(fileContent);
178
+ const apiAnnotations = parseCodexApiAnnotations(fileContent);
179
+ const sourceDigest = createSourceDigest(fileContent);
180
+ const components = [];
181
+ for (const annotation of codexAnnotations) {
182
+ // Find APIs that belong to this component
183
+ const componentApis = apiAnnotations.filter(api => api.parent === annotation.id);
184
+ components.push({
185
+ id: annotation.id,
186
+ title: annotation.title,
187
+ category: annotation.category,
188
+ risk: annotation.risk,
189
+ testing: annotation.testing,
190
+ apis: componentApis,
191
+ files: [filePath],
192
+ sourceDigest,
193
+ });
194
+ if (process.env.NODE_ENV === 'development') {
195
+ console.log(`[Codex] Discovered component: ${annotation.id} with ${componentApis.length} APIs`);
196
+ }
197
+ }
198
+ return components;
199
+ }
200
+ /**
201
+ * Validates annotation format and logs issues in DEV mode
202
+ * @param annotation - Annotation to validate
203
+ * @param type - Type of annotation for logging
204
+ * @returns True if valid
205
+ *
206
+ * @codexApi {"parent":"pithy.codex.annotations","name":"validateAnnotation","stability":"stable","signature":"(annotation: unknown, type: 'codex' | 'codexApi') => boolean"}
207
+ */
208
+ export function validateAnnotation(annotation, type) {
209
+ if (typeof annotation !== 'object' || annotation === null) {
210
+ return false;
211
+ }
212
+ const obj = annotation;
213
+ if (type === 'codex') {
214
+ const required = ['id', 'title', 'category'];
215
+ const missing = required.filter(field => !obj[field]);
216
+ if (missing.length > 0) {
217
+ if (process.env.NODE_ENV === 'development') {
218
+ console.warn(`[Codex] Invalid @codex annotation missing: ${missing.join(', ')}`, obj);
219
+ }
220
+ return false;
221
+ }
222
+ const validCategories = [
223
+ 'feature',
224
+ 'directive',
225
+ 'runtime',
226
+ 'compiler',
227
+ 'plugin',
228
+ 'types',
229
+ ];
230
+ if (!validCategories.includes(obj.category)) {
231
+ if (process.env.NODE_ENV === 'development') {
232
+ console.warn(`[Codex] Invalid @codex category: ${obj.category}. Must be one of: ${validCategories.join(', ')}`);
233
+ }
234
+ return false;
235
+ }
236
+ // Validate optional risk field
237
+ if (obj.risk !== undefined) {
238
+ const validRiskLevels = ['critical', 'high', 'medium', 'low'];
239
+ if (!validRiskLevels.includes(obj.risk)) {
240
+ if (process.env.NODE_ENV === 'development') {
241
+ console.warn(`[Codex] Invalid @codex risk level: ${obj.risk}. Must be one of: ${validRiskLevels.join(', ')}`);
242
+ }
243
+ return false;
244
+ }
245
+ }
246
+ // Validate optional testing field structure
247
+ if (obj.testing !== undefined) {
248
+ if (typeof obj.testing !== 'object' || obj.testing === null) {
249
+ if (process.env.NODE_ENV === 'development') {
250
+ console.warn(`[Codex] Invalid @codex testing field: must be an object`);
251
+ }
252
+ return false;
253
+ }
254
+ const testing = obj.testing;
255
+ const validTestLevels = ['unit', 'integration', 'e2e'];
256
+ for (const key of Object.keys(testing)) {
257
+ if (!validTestLevels.includes(key)) {
258
+ if (process.env.NODE_ENV === 'development') {
259
+ console.warn(`[Codex] Invalid @codex testing level: ${key}. Must be one of: ${validTestLevels.join(', ')}`);
260
+ }
261
+ return false;
262
+ }
263
+ const rawLevel = testing[key];
264
+ if (typeof rawLevel !== 'object' || rawLevel === null) {
265
+ if (process.env.NODE_ENV === 'development') {
266
+ console.warn(`[Codex] Invalid @codex testing.${key}: must be an object`);
267
+ }
268
+ return false;
269
+ }
270
+ const level = rawLevel;
271
+ if ('required' in level && typeof level.required !== 'boolean') {
272
+ if (process.env.NODE_ENV === 'development') {
273
+ console.warn(`[Codex] Invalid @codex testing.${key}.required: must be a boolean`);
274
+ }
275
+ return false;
276
+ }
277
+ if ('coverage' in level &&
278
+ (typeof level.coverage !== 'number' ||
279
+ level.coverage < 0 ||
280
+ level.coverage > 100)) {
281
+ if (process.env.NODE_ENV === 'development') {
282
+ console.warn(`[Codex] Invalid @codex testing.${key}.coverage: must be a number between 0 and 100`);
283
+ }
284
+ return false;
285
+ }
286
+ if ('covered' in level && typeof level.covered !== 'boolean') {
287
+ if (process.env.NODE_ENV === 'development') {
288
+ console.warn(`[Codex] Invalid @codex testing.${key}.covered: must be a boolean`);
289
+ }
290
+ return false;
291
+ }
292
+ }
293
+ }
294
+ }
295
+ if (type === 'codexApi') {
296
+ const required = ['parent', 'name'];
297
+ const missing = required.filter(field => !obj[field]);
298
+ if (missing.length > 0) {
299
+ if (process.env.NODE_ENV === 'development') {
300
+ console.warn(`[Codex] Invalid @codexApi annotation missing: ${missing.join(', ')}`, obj);
301
+ }
302
+ return false;
303
+ }
304
+ }
305
+ return true;
306
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/apply.js ADDED
@@ -0,0 +1,80 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @codex
4
+ * {
5
+ * "id": "pithy.codex.apply",
6
+ * "title": "Entry Applier",
7
+ * "category": "feature"
8
+ * }
9
+ *
10
+ * Applies reviewed codex entries to the knowledge base. Reads review.md for
11
+ * [x]-marked approvals, validates each proposal against the Zod schema, and
12
+ * copies approved JSON entries from .proposals/ into the codex/ directory.
13
+ */
14
+ import fs from 'node:fs/promises';
15
+ import { join, dirname, basename } from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
17
+ import { codexSchema } from './schema.js';
18
+ import { execa } from 'execa';
19
+ const __dirname = dirname(fileURLToPath(import.meta.url));
20
+ async function main() {
21
+ const root = process.argv.includes('--root')
22
+ ? process.argv[process.argv.indexOf('--root') + 1]
23
+ : join(__dirname, '../../..');
24
+ const proposalsDir = join(root, 'codex', '.proposals');
25
+ const reviewPath = join(proposalsDir, 'review.md');
26
+ const review = await fs.readFile(reviewPath, 'utf8').catch(() => '');
27
+ const APPROVED_RE = /^\s*-\s*\[(x|X)\]\s*.*?\*\*([^*]+)\*\*/gmu;
28
+ const matches = Array.from(review.matchAll(APPROVED_RE));
29
+ console.log(`[DEBUG] Found ${matches.length} regex matches`);
30
+ if (matches.length > 0) {
31
+ console.log(`[DEBUG] First match: '${matches[0][2].trim()}'`);
32
+ }
33
+ const approvedIds = matches.map(m => m[2].trim());
34
+ if (approvedIds.length === 0) {
35
+ console.log('No approved proposals found. Edit codex/.proposals/review.md and mark with [x].');
36
+ return;
37
+ }
38
+ const outDir = join(root, 'codex');
39
+ await fs.mkdir(outDir, { recursive: true });
40
+ for (const id of approvedIds) {
41
+ const file = join(proposalsDir, `${id}.json`);
42
+ const raw = await fs.readFile(file, 'utf8');
43
+ const json = JSON.parse(raw);
44
+ json.updated = new Date().toISOString();
45
+ // Preserve hash metadata for future change detection
46
+ if (json.meta?.hash) {
47
+ if (process.env.NODE_ENV === 'development') {
48
+ console.log(`[Codex] Preserving hash for ${id}: ${json.meta.hash.substring(0, 8)}...`);
49
+ }
50
+ }
51
+ const parsed = codexSchema.safeParse(json);
52
+ if (!parsed.success) {
53
+ console.error(`❌ Invalid entry ${id}:`, parsed.error.issues.map(i => i.message).join('; '));
54
+ continue;
55
+ }
56
+ const dest = join(outDir, `${id}.json`);
57
+ try {
58
+ await fs.access(dest);
59
+ console.log(`↷ Updating ${basename(dest)}`);
60
+ }
61
+ catch {
62
+ console.log(`+ Creating ${basename(dest)}`);
63
+ }
64
+ await fs.writeFile(dest, JSON.stringify(parsed.data, null, 2));
65
+ console.log(`✅ Wrote ${basename(dest)}`);
66
+ }
67
+ // Rebuild index via the package script
68
+ try {
69
+ await execa('pnpm', ['-w', '--filter', '@pithyjs/codex', 'index'], {
70
+ stdio: 'inherit',
71
+ });
72
+ }
73
+ catch {
74
+ console.log('Run `pnpm -w --filter @pithyjs/codex index` manually if needed.');
75
+ }
76
+ }
77
+ main().catch(e => {
78
+ console.error(e);
79
+ process.exit(1);
80
+ });
@@ -0,0 +1,23 @@
1
+ /** @codex { "id": "pithy.codex.changed-scope", "title": "Codex Changed-File Scope", "category": "types" } */
2
+ /**
3
+ * Which files `--changed-only` looks at.
4
+ *
5
+ * These are GIT PATHSPECS, not globs, and the difference is not cosmetic: git's
6
+ * `*` already crosses `/`, while its doubled form requires at least one
7
+ * intervening directory. The previous spelling resolved 875 files and dropped
8
+ * every file sitting directly in a `src/` — 114 of them, including
9
+ * `packages/signals/src/signal.ts` and `apps/studio/src/main.ts` — while the
10
+ * same string handed to globby in `scan.ts` matches all 989. One spelling, two
11
+ * engines, two scopes.
12
+ *
13
+ * The test directories are here because `codex-sync.yml` triggers on them: a
14
+ * marker-only change would otherwise reach an empty changed set and return at
15
+ * "Nothing to sync". The marker itself is harvested by the pipeline step, which
16
+ * globs `testPatterns` and is not changed-scoped — this keeps the two filters
17
+ * describing one set rather than gating that.
18
+ *
19
+ * Its own module because `sync.ts` calls `main()` at import: anything importing
20
+ * the constant from there would start a full sync during module evaluation.
21
+ */
22
+ /** @codexApi {"parent":"pithy.codex.changed-scope","name":"CHANGED_ONLY_PATHSPEC","stability":"stable","signature":"readonly string[]"} */
23
+ export declare const CHANGED_ONLY_PATHSPEC: readonly string[];
@@ -0,0 +1,31 @@
1
+ /** @codex { "id": "pithy.codex.changed-scope", "title": "Codex Changed-File Scope", "category": "types" } */
2
+ /**
3
+ * Which files `--changed-only` looks at.
4
+ *
5
+ * These are GIT PATHSPECS, not globs, and the difference is not cosmetic: git's
6
+ * `*` already crosses `/`, while its doubled form requires at least one
7
+ * intervening directory. The previous spelling resolved 875 files and dropped
8
+ * every file sitting directly in a `src/` — 114 of them, including
9
+ * `packages/signals/src/signal.ts` and `apps/studio/src/main.ts` — while the
10
+ * same string handed to globby in `scan.ts` matches all 989. One spelling, two
11
+ * engines, two scopes.
12
+ *
13
+ * The test directories are here because `codex-sync.yml` triggers on them: a
14
+ * marker-only change would otherwise reach an empty changed set and return at
15
+ * "Nothing to sync". The marker itself is harvested by the pipeline step, which
16
+ * globs `testPatterns` and is not changed-scoped — this keeps the two filters
17
+ * describing one set rather than gating that.
18
+ *
19
+ * Its own module because `sync.ts` calls `main()` at import: anything importing
20
+ * the constant from there would start a full sync during module evaluation.
21
+ */
22
+ /** @codexApi {"parent":"pithy.codex.changed-scope","name":"CHANGED_ONLY_PATHSPEC","stability":"stable","signature":"readonly string[]"} */
23
+ export const CHANGED_ONLY_PATHSPEC = [
24
+ 'packages/*/src/*.ts',
25
+ 'packages/*/src/*.js',
26
+ 'apps/*/src/*.ts',
27
+ 'apps/*/src/*.js',
28
+ 'packages/*/tests/*.ts',
29
+ 'apps/*/tests/*.ts',
30
+ 'e2e/tests/*.ts',
31
+ ];
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/check.js ADDED
@@ -0,0 +1,117 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @codex
4
+ * {
5
+ * "id": "pithy.codex.check",
6
+ * "title": "Integrity Checker",
7
+ * "category": "feature"
8
+ * }
9
+ *
10
+ * Validates codex entries against source annotations. Walks all JSON files in
11
+ * codex/, validates each against the Zod schema, checks for duplicate IDs,
12
+ * verifies ISO date formats, and reports errors/warnings.
13
+ */
14
+ import fs from 'node:fs/promises';
15
+ import { join, dirname } from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
17
+ import { codexSchema } from './schema.js';
18
+ const __dirname = dirname(fileURLToPath(import.meta.url));
19
+ const repoRoot = join(__dirname, '../../..');
20
+ const codexDir = join(repoRoot, 'codex');
21
+ async function getJsonFiles(dir) {
22
+ const out = [];
23
+ try {
24
+ const dirents = await fs.readdir(dir, { withFileTypes: true });
25
+ for (const d of dirents) {
26
+ const p = join(dir, d.name);
27
+ // Skip .proposals directory to avoid duplicate ID errors
28
+ if (d.isDirectory() && d.name !== '.proposals') {
29
+ out.push(...(await getJsonFiles(p)));
30
+ }
31
+ else if (d.isFile() && p.endsWith('.json')) {
32
+ out.push(p);
33
+ }
34
+ }
35
+ }
36
+ catch {
37
+ // no codex dir yet
38
+ }
39
+ return out;
40
+ }
41
+ function isIsoDate(s) {
42
+ // Zod .datetime() already checks, but we double-check for clarity
43
+ const d = new Date(s);
44
+ return !Number.isNaN(d.valueOf()) && /^\d{4}-\d{2}-\d{2}T/.test(s);
45
+ }
46
+ async function main() {
47
+ const files = await getJsonFiles(codexDir);
48
+ const issues = [];
49
+ const entriesById = new Map();
50
+ for (const file of files) {
51
+ try {
52
+ const raw = await fs.readFile(file, 'utf8');
53
+ const json = JSON.parse(raw);
54
+ const parsed = codexSchema.safeParse(json);
55
+ if (!parsed.success) {
56
+ issues.push({
57
+ type: 'error',
58
+ file,
59
+ msg: 'Schema validation failed: ' +
60
+ parsed.error.issues.map(i => i.message).join('; '),
61
+ });
62
+ continue;
63
+ }
64
+ const e = parsed.data;
65
+ // Duplicate ID check
66
+ if (entriesById.has(e.id)) {
67
+ const prev = entriesById.get(e.id);
68
+ issues.push({
69
+ type: 'error',
70
+ file,
71
+ msg: `Duplicate id "${e.id}" also used in ${prev.file}`,
72
+ });
73
+ }
74
+ else {
75
+ entriesById.set(e.id, { file, entry: e });
76
+ }
77
+ // ISO date sanity (redundant but explicit)
78
+ if (!isIsoDate(e.updated)) {
79
+ issues.push({
80
+ type: 'error',
81
+ file,
82
+ msg: `updated is not a valid ISO date: "${e.updated}"`,
83
+ });
84
+ }
85
+ }
86
+ catch (err) {
87
+ const msg = err instanceof Error ? err.message : String(err);
88
+ issues.push({ type: 'error', file, msg: `Read/parse failed: ${msg}` });
89
+ }
90
+ }
91
+ // Related reference checks (warn if dangling)
92
+ for (const { file, entry } of entriesById.values()) {
93
+ for (const rel of entry.related ?? []) {
94
+ if (!entriesById.has(rel)) {
95
+ issues.push({
96
+ type: 'warn',
97
+ file,
98
+ msg: `related references missing id "${rel}"`,
99
+ });
100
+ }
101
+ }
102
+ }
103
+ // Output
104
+ const errors = issues.filter(i => i.type === 'error');
105
+ const warns = issues.filter(i => i.type === 'warn');
106
+ for (const i of issues) {
107
+ const tag = i.type === 'error' ? '❌' : '⚠️';
108
+ console.log(`${tag} ${i.type.toUpperCase()} in ${i.file}: ${i.msg}`);
109
+ }
110
+ console.log(`\nCodex check complete: ${entriesById.size} entries, ${errors.length} error(s), ${warns.length} warning(s).`);
111
+ if (errors.length > 0)
112
+ process.exit(1);
113
+ }
114
+ main().catch(err => {
115
+ console.error(err);
116
+ process.exit(1);
117
+ });
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};