@elevasis/core 0.42.1 → 0.44.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 (44) hide show
  1. package/dist/auth/index.d.ts +8 -3
  2. package/dist/auth/index.js +6 -0
  3. package/dist/business/entities-published.d.ts +1 -1
  4. package/dist/index.d.ts +12 -13
  5. package/dist/index.js +48 -29
  6. package/dist/knowledge/index.d.ts +94 -6
  7. package/dist/knowledge/index.js +172 -8
  8. package/dist/organization-model/index.d.ts +12 -13
  9. package/dist/organization-model/index.js +48 -29
  10. package/dist/test-utils/index.d.ts +5 -6
  11. package/dist/test-utils/index.js +21 -18
  12. package/package.json +3 -3
  13. package/src/auth/access-keys.ts +6 -0
  14. package/src/business/acquisition/api-schemas.ts +1 -1
  15. package/src/business/base-entities.ts +1 -1
  16. package/src/knowledge/cli-helpers.ts +211 -0
  17. package/src/knowledge/index.ts +13 -0
  18. package/src/knowledge/published.ts +18 -5
  19. package/src/knowledge/queries.ts +5 -5
  20. package/src/organization-model/__tests__/cross-ref.test.ts +11 -1
  21. package/src/organization-model/__tests__/domains/systems.test.ts +34 -8
  22. package/src/organization-model/__tests__/scaffolders.test.ts +30 -1
  23. package/src/organization-model/__tests__/schema-refinements.test.ts +178 -0
  24. package/src/organization-model/cross-ref.ts +43 -7
  25. package/src/organization-model/defaults.ts +2 -2
  26. package/src/organization-model/domains/actions.ts +1 -1
  27. package/src/organization-model/domains/resources.ts +1 -1
  28. package/src/organization-model/domains/systems.ts +0 -4
  29. package/src/organization-model/ontology.ts +13 -18
  30. package/src/organization-model/organization-graph.mdx +9 -8
  31. package/src/organization-model/published.ts +9 -3
  32. package/src/organization-model/resolve.ts +9 -7
  33. package/src/organization-model/scaffolders/helpers.ts +1 -1
  34. package/src/organization-model/scaffolders/scaffoldKnowledgeNode.ts +1 -0
  35. package/src/organization-model/scaffolders/scaffoldOntologyRecord.ts +28 -6
  36. package/src/organization-model/scaffolders/scaffoldResource.ts +1 -0
  37. package/src/organization-model/scaffolders/scaffoldSystem.ts +2 -1
  38. package/src/organization-model/schema-refinements.ts +3 -5
  39. package/src/platform/registry/__tests__/validation.test.ts +28 -0
  40. package/src/platform/registry/validation.ts +20 -2
  41. package/src/scaffold-registry/__tests__/index.test.ts +380 -206
  42. package/src/scaffold-registry/index.ts +392 -381
  43. package/src/test-utils/mocks/supabase.ts +1 -1
  44. package/src/test-utils/mocks/workos.ts +2 -2
@@ -1,392 +1,403 @@
1
- import { createHash } from 'node:crypto'
2
- import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
3
- import path from 'node:path'
4
- import { parse as parseYaml } from 'yaml'
5
- import { type ScaffoldRegistry, type ScaffoldRegistryEntry, ScaffoldRegistrySchema } from './schema'
6
-
7
- const MODULE_DIR = path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Z]:)/, '$1'))
8
-
1
+ import { createHash } from 'node:crypto'
2
+ import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
3
+ import path from 'node:path'
4
+ import { parse as parseYaml } from 'yaml'
5
+ import { type ScaffoldRegistry, type ScaffoldRegistryEntry, ScaffoldRegistrySchema } from './schema'
6
+
7
+ const MODULE_DIR = path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Z]:)/, '$1'))
8
+
9
9
  export {
10
10
  ExternalSyncCategorySchema,
11
11
  ExternalSyncDeletePolicySchema,
12
12
  ExternalSyncOwnerSchema,
13
13
  ExternalSyncStrategySchema,
14
14
  ScaffoldEntryKindSchema,
15
- ScaffoldRegistrySchema
16
- } from './schema'
15
+ ScaffoldRegistrySchema
16
+ } from './schema'
17
17
  export type {
18
18
  ExternalSyncCategory,
19
19
  ExternalSyncDeletePolicy,
20
20
  ExternalSyncOwner,
21
21
  ExternalSyncStrategy,
22
- ScaffoldEntryKind,
23
- ScaffoldRef,
24
- ScaffoldRegistry,
25
- ScaffoldRegistryEntry
26
- } from './schema'
27
-
28
- // ---------------------------------------------------------------------------
29
- // Paths (resolved relative to the monorepo root, not this file's location)
30
- // ---------------------------------------------------------------------------
31
-
32
- /**
33
- * Resolve a path relative to the monorepo root.
34
- * Works whether this module is running from packages/core/src or from dist/.
35
- */
36
- function monorepoRoot(): string {
37
- // Walk up from __dirname until we find the .claude directory (monorepo marker)
38
- const { dirname } = path
39
- let dir = MODULE_DIR
40
- for (let i = 0; i < 8; i++) {
41
- try {
42
- readFileSync(path.join(dir, '.claude', 'settings.json'))
43
- return dir
44
- } catch {
45
- dir = dirname(dir)
46
- }
47
- }
48
- throw new Error(
49
- 'scaffold-registry: could not locate monorepo root (no .claude/settings.json found in ancestor directories)'
50
- )
51
- }
52
-
53
- const YAML_FILENAME = '.claude/registries/scaffold-registry.yml'
54
- const JSON_FILENAME = '.claude/registries/scaffold-registry.compiled.json'
55
-
56
- // ---------------------------------------------------------------------------
57
- // Load + validate
58
- // ---------------------------------------------------------------------------
59
-
60
- /**
61
- * Load and Zod-validate the scaffold registry from `.claude/registries/scaffold-registry.yml`.
62
- *
63
- * Throws if:
64
- * - The YAML file is missing or unreadable
65
- * - The YAML fails Zod validation
66
- * - The compiled JSON is present but its `entries` count differs from the YAML
67
- * (drift detection — regenerate with `compileScaffoldRegistry()` to fix)
68
- */
69
- export function loadScaffoldRegistry(): ScaffoldRegistry {
70
- const root = monorepoRoot()
71
- const yamlPath = path.join(root, YAML_FILENAME)
72
-
73
- let raw: string
74
- try {
75
- raw = readFileSync(yamlPath, 'utf-8')
76
- } catch (err) {
77
- throw new Error(`scaffold-registry: could not read ${YAML_FILENAME} — ${String(err)}`)
78
- }
79
-
80
- const parsed = parseYaml(raw) as unknown
81
- const result = ScaffoldRegistrySchema.safeParse(parsed)
82
-
83
- if (!result.success) {
84
- const issues = result.error.issues.map((i) => ` [${i.path.join('.')}] ${i.message}`).join('\n')
85
- throw new Error(`scaffold-registry: YAML validation failed:\n${issues}`)
86
- }
87
-
88
- const registry = result.data
89
-
90
- // Drift check: if compiled JSON exists, verify the full normalized content matches.
91
- const jsonPath = path.join(root, JSON_FILENAME)
92
- try {
93
- const compiledRaw = readFileSync(jsonPath, 'utf-8')
94
- const compiledParsed = JSON.parse(compiledRaw) as unknown
95
- const compiledResult = ScaffoldRegistrySchema.safeParse(compiledParsed)
96
- if (!compiledResult.success) {
97
- const issues = compiledResult.error.issues.map((i) => ` [${i.path.join('.')}] ${i.message}`).join('\n')
98
- throw new Error(`scaffold-registry: compiled JSON validation failed:\n${issues}`)
99
- }
100
-
101
- const yamlHash = stableRegistryHash(registry)
102
- const compiledHash = stableRegistryHash(compiledResult.data)
103
- if (compiledHash !== yamlHash) {
104
- throw new Error(
105
- `scaffold-registry: compiled JSON is out of sync with YAML ` +
106
- `(YAML hash ${yamlHash.slice(0, 12)}, JSON hash ${compiledHash.slice(0, 12)}). ` +
107
- `Run compileScaffoldRegistry() to regenerate.`
108
- )
109
- }
110
- } catch (err) {
111
- // If the file doesn't exist, skip drift check silently (first-run scenario)
112
- if ((err as { code?: string }).code !== 'ENOENT') {
113
- throw err
114
- }
115
- }
116
-
117
- return registry
118
- }
119
-
120
- // ---------------------------------------------------------------------------
121
- // Compile
122
- // ---------------------------------------------------------------------------
123
-
124
- /**
125
- * Load, validate, and write the pre-compiled JSON lookup file.
126
- * Run this whenever `.claude/registries/scaffold-registry.yml` changes.
127
- *
128
- * Called by the `pnpm scaffold:compile-registry` script (wired in Step 2 CI).
129
- */
130
- export function compileScaffoldRegistry(): ScaffoldRegistry {
131
- const root = monorepoRoot()
132
- const registry = loadScaffoldRegistryNoSyncCheck(root)
133
-
134
- const missing = findMissingDependentPaths(registry, root)
135
- if (missing.length > 0) {
136
- const formatted = missing.map((m) => ` [${m.entryId}] ${m.path}`).join('\n')
137
- throw new Error(
138
- `scaffold-registry: ${missing.length} dependent path(s) do not exist on disk:\n${formatted}\n` +
139
- `Fix the typo, create the file, or convert the path to a glob/symbolic target.`
140
- )
141
- }
142
-
143
- const emptySources = findEmptySourcePatterns(registry, root)
144
- if (emptySources.length > 0) {
145
- const formatted = emptySources.map((source) => ` [${source.entryId}] ${source.pattern}`).join('\n')
146
- throw new Error(
147
- `scaffold-registry: ${emptySources.length} source pattern(s) match no files or directories:\n${formatted}\n` +
148
- `Fix the stale source glob, create the scaffold surface, or add explicit registry support for intentional empty patterns.`
149
- )
150
- }
151
-
152
- const jsonPath = path.join(root, JSON_FILENAME)
153
- writeFileSync(jsonPath, JSON.stringify(registry, null, 2) + '\n', 'utf-8')
154
-
155
- return registry
156
- }
157
-
158
- /**
159
- * Return dependent paths declared in the registry that don't exist on disk.
160
- * Skips symbolic targets (`docs:`, `autogen-target:`) and glob patterns
161
- * (those containing `*`, `?`, or `[`), which can't be resolved to a single file.
162
- *
163
- * Exported so external scripts (e.g. CI gates) can run the same check.
164
- */
165
- export function findMissingDependentPaths(
166
- registry: ScaffoldRegistry,
167
- monorepoRootDir: string
168
- ): Array<{ entryId: string; path: string }> {
169
- const missing: Array<{ entryId: string; path: string }> = []
170
- for (const entry of registry.entries) {
171
- // sync-preservation dependents describe paths inside derived external
172
- // projects (not files that physically exist in this monorepo), so skip them.
173
- if (entry.kind === 'sync-preservation') continue
174
- for (const dependent of entry.dependents) {
175
- if (isSymbolicTarget(dependent.path) || isGlobPattern(dependent.path) || dependent.path === '(self)') {
176
- continue
177
- }
178
- const absolute = path.join(monorepoRootDir, dependent.path)
179
- if (!existsSync(absolute)) {
180
- missing.push({ entryId: entry.id, path: dependent.path })
181
- }
182
- }
183
- }
184
- return missing
185
- }
186
-
187
- /**
188
- * Return source patterns that do not currently match any file or directory.
189
- * Symbolic sources are skipped because they intentionally do not resolve to
190
- * monorepo paths.
191
- */
192
- export function findEmptySourcePatterns(
193
- registry: ScaffoldRegistry,
194
- monorepoRootDir: string
195
- ): Array<{ entryId: string; pattern: string }> {
196
- const empty: Array<{ entryId: string; pattern: string }> = []
197
-
198
- for (const entry of registry.entries) {
199
- for (const sourcePattern of entry.sources) {
200
- if (isSymbolicTarget(sourcePattern)) continue
201
- if (!sourcePatternMatchesAnyPath(sourcePattern, monorepoRootDir)) {
202
- empty.push({ entryId: entry.id, pattern: sourcePattern })
203
- }
204
- }
205
- }
206
-
207
- return empty
208
- }
209
-
210
- // ---------------------------------------------------------------------------
211
- // Lookup helpers (used by hooks for fast path matching)
212
- // ---------------------------------------------------------------------------
213
-
214
- /**
215
- * Load from the pre-compiled JSON only (fastest path; used by PostToolUse hooks).
216
- * Falls back to YAML if the JSON is missing.
217
- */
218
- export function loadScaffoldRegistryFast(): ScaffoldRegistry {
219
- const root = monorepoRoot()
220
- const jsonPath = path.join(root, JSON_FILENAME)
221
-
222
- try {
223
- const raw = readFileSync(jsonPath, 'utf-8')
224
- const parsed = JSON.parse(raw) as unknown
225
- const result = ScaffoldRegistrySchema.safeParse(parsed)
226
- if (result.success) return result.data
227
- } catch {
228
- // fall through to YAML
229
- }
230
-
231
- return loadScaffoldRegistryNoSyncCheck(root)
232
- }
233
-
234
- /**
235
- * Return all entries whose `sources` contain at least one pattern that matches
236
- * the given file path AND whose `excludes` contain no pattern that matches.
237
- * Pattern matching is a simple substring/glob-prefix check suitable for hook
238
- * use; Step 3 will upgrade to full micromatch when the hook is implemented.
239
- */
240
- export function findMatchingEntries(registry: ScaffoldRegistry, filePath: string): ScaffoldRegistryEntry[] {
241
- return registry.entries.filter((entry) => {
242
- const sourceMatch = entry.sources.some((pattern) => scaffoldPathMatchesPattern(filePath, pattern))
243
- if (!sourceMatch) return false
244
- const excluded = (entry.excludes ?? []).some((pattern) => scaffoldPathMatchesPattern(filePath, pattern))
245
- return !excluded
246
- })
247
- }
248
-
249
- // ---------------------------------------------------------------------------
250
- // Internal helpers
251
- // ---------------------------------------------------------------------------
252
-
253
- function loadScaffoldRegistryNoSyncCheck(root: string): ScaffoldRegistry {
254
- const yamlPath = path.join(root, YAML_FILENAME)
255
- let raw: string
256
- try {
257
- raw = readFileSync(yamlPath, 'utf-8')
258
- } catch (err) {
259
- throw new Error(`scaffold-registry: could not read ${YAML_FILENAME} — ${String(err)}`)
260
- }
261
- const parsed = parseYaml(raw) as unknown
262
- const result = ScaffoldRegistrySchema.safeParse(parsed)
263
- if (!result.success) {
264
- const issues = result.error.issues.map((i) => ` [${i.path.join('.')}] ${i.message}`).join('\n')
265
- throw new Error(`scaffold-registry: YAML validation failed:\n${issues}`)
266
- }
267
- return result.data
268
- }
269
-
270
- export function normalizeScaffoldPath(p: string): string {
271
- return p.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '')
272
- }
273
-
274
- export function scaffoldPathMatchesPattern(filePath: string, pattern: string): boolean {
275
- const normalizedFile = normalizeScaffoldPath(filePath)
276
- const normalizedPattern = normalizeScaffoldPath(pattern)
277
-
278
- if (!normalizedFile || !normalizedPattern) return false
279
- if (normalizedFile === normalizedPattern) return true
280
-
281
- if (!isGlobPattern(normalizedPattern)) {
282
- return normalizedFile.startsWith(normalizedPattern + '/')
283
- }
284
-
285
- return globToRegExp(normalizedPattern).test(normalizedFile)
286
- }
287
-
288
- function isSymbolicTarget(p: string): boolean {
289
- return p.startsWith('docs:') || p.startsWith('autogen-target:')
290
- }
291
-
292
- function isGlobPattern(p: string): boolean {
293
- return /[*?[]/.test(p)
294
- }
295
-
296
- function stableRegistryHash(registry: ScaffoldRegistry): string {
297
- return createHash('sha256').update(JSON.stringify(registry)).digest('hex')
298
- }
299
-
300
- function globToRegExp(pattern: string): RegExp {
301
- const segments = pattern.split('/')
302
- let regex = '^'
303
-
304
- for (let index = 0; index < segments.length; index++) {
305
- const segment = segments[index]
306
- const isLast = index === segments.length - 1
307
-
308
- if (segment === '**') {
309
- regex += isLast ? '(?:.*)?' : '(?:[^/]+/)*'
310
- continue
311
- }
312
-
313
- regex += segmentToRegExpSource(segment)
314
- if (!isLast) regex += '/'
315
- }
316
-
317
- regex += '$'
318
- return new RegExp(regex)
319
- }
320
-
321
- function segmentToRegExpSource(segment: string): string {
322
- let source = ''
323
- for (let index = 0; index < segment.length; index++) {
324
- const char = segment[index]
325
- if (char === '*') {
326
- source += '[^/]*'
327
- } else if (char === '?') {
328
- source += '[^/]'
329
- } else {
330
- source += escapeRegExp(char)
331
- }
332
- }
333
- return source
334
- }
335
-
336
- function escapeRegExp(value: string): string {
337
- return value.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&')
338
- }
339
-
340
- function sourcePatternMatchesAnyPath(sourcePattern: string, monorepoRootDir: string): boolean {
341
- const normalizedPattern = normalizeScaffoldPath(sourcePattern)
342
-
343
- if (!isGlobPattern(normalizedPattern)) {
344
- return existsSync(path.join(monorepoRootDir, normalizedPattern))
345
- }
346
-
347
- const baseDir = findGlobBaseDirectory(normalizedPattern)
348
- const absoluteBase = path.join(monorepoRootDir, baseDir)
349
- if (!existsSync(absoluteBase)) return false
350
-
351
- return walkUntilMatch(absoluteBase, monorepoRootDir, normalizedPattern)
352
- }
353
-
354
- function findGlobBaseDirectory(pattern: string): string {
355
- const segments = pattern.split('/')
356
- const baseSegments: string[] = []
357
- for (const segment of segments) {
358
- if (isGlobPattern(segment)) break
359
- baseSegments.push(segment)
360
- }
361
- return baseSegments.join('/')
362
- }
363
-
364
- function walkUntilMatch(currentPath: string, monorepoRootDir: string, pattern: string): boolean {
365
- const rel = normalizeScaffoldPath(path.relative(monorepoRootDir, currentPath))
366
- if (rel && scaffoldPathMatchesPattern(rel, pattern)) return true
367
-
368
- let entries
369
- try {
370
- entries = readdirSync(currentPath, { withFileTypes: true })
371
- } catch {
372
- return false
373
- }
374
-
375
- for (const entry of entries) {
376
- const absolutePath = path.join(currentPath, entry.name)
377
- const relativePath = normalizeScaffoldPath(path.relative(monorepoRootDir, absolutePath))
378
-
379
- if (scaffoldPathMatchesPattern(relativePath, pattern)) return true
380
- if (entry.isDirectory()) {
381
- try {
382
- if (statSync(absolutePath).isDirectory() && walkUntilMatch(absolutePath, monorepoRootDir, pattern)) {
383
- return true
384
- }
385
- } catch {
386
- continue
387
- }
388
- }
389
- }
390
-
391
- return false
392
- }
22
+ ScaffoldEntryKind,
23
+ ScaffoldRef,
24
+ ScaffoldRegistry,
25
+ ScaffoldRegistryEntry
26
+ } from './schema'
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Paths (resolved relative to the monorepo root, not this file's location)
30
+ // ---------------------------------------------------------------------------
31
+
32
+ /**
33
+ * Resolve a path relative to the monorepo root.
34
+ * Works whether this module is running from packages/core/src or from dist/.
35
+ */
36
+ function monorepoRoot(): string {
37
+ // Walk up from __dirname until we find the .claude directory (monorepo marker)
38
+ const { dirname } = path
39
+ let dir = MODULE_DIR
40
+ for (let i = 0; i < 8; i++) {
41
+ try {
42
+ readFileSync(path.join(dir, '.claude', 'settings.json'))
43
+ return dir
44
+ } catch {
45
+ dir = dirname(dir)
46
+ }
47
+ }
48
+ throw new Error(
49
+ 'scaffold-registry: could not locate monorepo root (no .claude/settings.json found in ancestor directories)'
50
+ )
51
+ }
52
+
53
+ const YAML_FILENAME = '.claude/registries/scaffold-registry.yml'
54
+ const JSON_FILENAME = '.claude/registries/scaffold-registry.compiled.json'
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Load + validate
58
+ // ---------------------------------------------------------------------------
59
+
60
+ /**
61
+ * Load and Zod-validate the scaffold registry from `.claude/registries/scaffold-registry.yml`.
62
+ *
63
+ * Throws if:
64
+ * - The YAML file is missing or unreadable
65
+ * - The YAML fails Zod validation
66
+ * - The compiled JSON is present but its sha256 hash differs from the YAML
67
+ * (drift detection — regenerate with `compileScaffoldRegistry()` to fix)
68
+ *
69
+ * @param rootDir - Optional monorepo root override. Defaults to the auto-detected
70
+ * monorepo root. Pass a fixture directory to test against a local snapshot.
71
+ */
72
+ export function loadScaffoldRegistry(rootDir?: string): ScaffoldRegistry {
73
+ const root = rootDir ?? monorepoRoot()
74
+ const yamlPath = path.join(root, YAML_FILENAME)
75
+
76
+ let raw: string
77
+ try {
78
+ raw = readFileSync(yamlPath, 'utf-8')
79
+ } catch (err) {
80
+ throw new Error(`scaffold-registry: could not read ${YAML_FILENAME} — ${String(err)}`)
81
+ }
82
+
83
+ const parsed = parseYaml(raw) as unknown
84
+ const result = ScaffoldRegistrySchema.safeParse(parsed)
85
+
86
+ if (!result.success) {
87
+ const issues = result.error.issues.map((i) => ` [${i.path.join('.')}] ${i.message}`).join('\n')
88
+ throw new Error(`scaffold-registry: YAML validation failed:\n${issues}`)
89
+ }
90
+
91
+ const registry = result.data
92
+
93
+ // Drift check: if compiled JSON exists, verify the full normalized content matches.
94
+ const jsonPath = path.join(root, JSON_FILENAME)
95
+ try {
96
+ const compiledRaw = readFileSync(jsonPath, 'utf-8')
97
+ const compiledParsed = JSON.parse(compiledRaw) as unknown
98
+ const compiledResult = ScaffoldRegistrySchema.safeParse(compiledParsed)
99
+ if (!compiledResult.success) {
100
+ const issues = compiledResult.error.issues.map((i) => ` [${i.path.join('.')}] ${i.message}`).join('\n')
101
+ throw new Error(`scaffold-registry: compiled JSON validation failed:\n${issues}`)
102
+ }
103
+
104
+ const yamlHash = stableRegistryHash(registry)
105
+ const compiledHash = stableRegistryHash(compiledResult.data)
106
+ if (compiledHash !== yamlHash) {
107
+ throw new Error(
108
+ `scaffold-registry: compiled JSON is out of sync with YAML ` +
109
+ `(YAML hash ${yamlHash.slice(0, 12)}, JSON hash ${compiledHash.slice(0, 12)}). ` +
110
+ `Run compileScaffoldRegistry() to regenerate.`
111
+ )
112
+ }
113
+ } catch (err) {
114
+ // If the file doesn't exist, skip drift check silently (first-run scenario)
115
+ if ((err as { code?: string }).code !== 'ENOENT') {
116
+ throw err
117
+ }
118
+ }
119
+
120
+ return registry
121
+ }
122
+
123
+ // ---------------------------------------------------------------------------
124
+ // Compile
125
+ // ---------------------------------------------------------------------------
126
+
127
+ /**
128
+ * Load, validate, and write the pre-compiled JSON lookup file.
129
+ * Run this whenever `.claude/registries/scaffold-registry.yml` changes.
130
+ *
131
+ * Called by the `pnpm scaffold:compile-registry` script.
132
+ *
133
+ * @param rootDir - Optional monorepo root override. Defaults to the auto-detected
134
+ * monorepo root. Pass a fixture directory to test against a local snapshot.
135
+ */
136
+ export function compileScaffoldRegistry(rootDir?: string): ScaffoldRegistry {
137
+ const root = rootDir ?? monorepoRoot()
138
+ const registry = loadScaffoldRegistryNoSyncCheck(root)
139
+
140
+ const missing = findMissingDependentPaths(registry, root)
141
+ if (missing.length > 0) {
142
+ const formatted = missing.map((m) => ` [${m.entryId}] ${m.path}`).join('\n')
143
+ throw new Error(
144
+ `scaffold-registry: ${missing.length} dependent path(s) do not exist on disk:\n${formatted}\n` +
145
+ `Fix the typo, create the file, or convert the path to a glob/symbolic target.`
146
+ )
147
+ }
148
+
149
+ const emptySources = findEmptySourcePatterns(registry, root)
150
+ if (emptySources.length > 0) {
151
+ const formatted = emptySources.map((source) => ` [${source.entryId}] ${source.pattern}`).join('\n')
152
+ throw new Error(
153
+ `scaffold-registry: ${emptySources.length} source pattern(s) match no files or directories:\n${formatted}\n` +
154
+ `Fix the stale source glob, create the scaffold surface, or add explicit registry support for intentional empty patterns.`
155
+ )
156
+ }
157
+
158
+ const jsonPath = path.join(root, JSON_FILENAME)
159
+ writeFileSync(jsonPath, JSON.stringify(registry, null, 2) + '\n', 'utf-8')
160
+
161
+ return registry
162
+ }
163
+
164
+ /**
165
+ * Return dependent paths declared in the registry that don't exist on disk.
166
+ * Skips symbolic targets (`docs:`, `autogen-target:`) and glob patterns
167
+ * (those containing `*`, `?`, or `[`), which can't be resolved to a single file.
168
+ *
169
+ * Exported so external scripts (e.g. CI gates) can run the same check.
170
+ */
171
+ export function findMissingDependentPaths(
172
+ registry: ScaffoldRegistry,
173
+ monorepoRootDir: string
174
+ ): Array<{ entryId: string; path: string }> {
175
+ const missing: Array<{ entryId: string; path: string }> = []
176
+ for (const entry of registry.entries) {
177
+ // sync-preservation dependents describe paths inside derived external
178
+ // projects (not files that physically exist in this monorepo), so skip them.
179
+ if (entry.kind === 'sync-preservation') continue
180
+ for (const dependent of entry.dependents) {
181
+ if (isSymbolicTarget(dependent.path) || isGlobPattern(dependent.path) || dependent.path === '(self)') {
182
+ continue
183
+ }
184
+ const absolute = path.join(monorepoRootDir, dependent.path)
185
+ if (!existsSync(absolute)) {
186
+ missing.push({ entryId: entry.id, path: dependent.path })
187
+ }
188
+ }
189
+ }
190
+ return missing
191
+ }
192
+
193
+ /**
194
+ * Return source patterns that do not currently match any file or directory.
195
+ * Symbolic sources are skipped because they intentionally do not resolve to
196
+ * monorepo paths.
197
+ */
198
+ export function findEmptySourcePatterns(
199
+ registry: ScaffoldRegistry,
200
+ monorepoRootDir: string
201
+ ): Array<{ entryId: string; pattern: string }> {
202
+ const empty: Array<{ entryId: string; pattern: string }> = []
203
+
204
+ for (const entry of registry.entries) {
205
+ for (const sourcePattern of entry.sources) {
206
+ if (isSymbolicTarget(sourcePattern)) continue
207
+ if (!sourcePatternMatchesAnyPath(sourcePattern, monorepoRootDir)) {
208
+ empty.push({ entryId: entry.id, pattern: sourcePattern })
209
+ }
210
+ }
211
+ }
212
+
213
+ return empty
214
+ }
215
+
216
+ // ---------------------------------------------------------------------------
217
+ // Lookup helpers (used by hooks for fast path matching)
218
+ // ---------------------------------------------------------------------------
219
+
220
+ /**
221
+ * Load from the pre-compiled JSON only (fastest path; used by PostToolUse hooks).
222
+ * Falls back to YAML if the JSON is missing.
223
+ */
224
+ export function loadScaffoldRegistryFast(): ScaffoldRegistry {
225
+ const root = monorepoRoot()
226
+ const jsonPath = path.join(root, JSON_FILENAME)
227
+
228
+ try {
229
+ const raw = readFileSync(jsonPath, 'utf-8')
230
+ const parsed = JSON.parse(raw) as unknown
231
+ const result = ScaffoldRegistrySchema.safeParse(parsed)
232
+ if (result.success) return result.data
233
+ } catch {
234
+ // fall through to YAML
235
+ }
236
+
237
+ return loadScaffoldRegistryNoSyncCheck(root)
238
+ }
239
+
240
+ /**
241
+ * Return all entries whose `sources` contain at least one pattern that matches
242
+ * the given file path AND whose `excludes` contain no pattern that matches.
243
+ * Pattern matching is a simple substring/glob-prefix check suitable for hook
244
+ * use; Step 3 will upgrade to full micromatch when the hook is implemented.
245
+ */
246
+ export function findMatchingEntries(registry: ScaffoldRegistry, filePath: string): ScaffoldRegistryEntry[] {
247
+ return registry.entries.filter((entry) => {
248
+ const sourceMatch = entry.sources.some((pattern) => scaffoldPathMatchesPattern(filePath, pattern))
249
+ if (!sourceMatch) return false
250
+ const excluded = (entry.excludes ?? []).some((pattern) => scaffoldPathMatchesPattern(filePath, pattern))
251
+ return !excluded
252
+ })
253
+ }
254
+
255
+ // ---------------------------------------------------------------------------
256
+ // Internal helpers
257
+ // ---------------------------------------------------------------------------
258
+
259
+ function loadScaffoldRegistryNoSyncCheck(root: string): ScaffoldRegistry {
260
+ const yamlPath = path.join(root, YAML_FILENAME)
261
+ let raw: string
262
+ try {
263
+ raw = readFileSync(yamlPath, 'utf-8')
264
+ } catch (err) {
265
+ throw new Error(`scaffold-registry: could not read ${YAML_FILENAME} — ${String(err)}`)
266
+ }
267
+ const parsed = parseYaml(raw) as unknown
268
+ const result = ScaffoldRegistrySchema.safeParse(parsed)
269
+ if (!result.success) {
270
+ const issues = result.error.issues.map((i) => ` [${i.path.join('.')}] ${i.message}`).join('\n')
271
+ throw new Error(`scaffold-registry: YAML validation failed:\n${issues}`)
272
+ }
273
+ return result.data
274
+ }
275
+
276
+ export function normalizeScaffoldPath(p: string): string {
277
+ return p.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '')
278
+ }
279
+
280
+ export function scaffoldPathMatchesPattern(filePath: string, pattern: string): boolean {
281
+ const normalizedFile = normalizeScaffoldPath(filePath)
282
+ const normalizedPattern = normalizeScaffoldPath(pattern)
283
+
284
+ if (!normalizedFile || !normalizedPattern) return false
285
+ if (normalizedFile === normalizedPattern) return true
286
+
287
+ if (!isGlobPattern(normalizedPattern)) {
288
+ return normalizedFile.startsWith(normalizedPattern + '/')
289
+ }
290
+
291
+ return globToRegExp(normalizedPattern).test(normalizedFile)
292
+ }
293
+
294
+ function isSymbolicTarget(p: string): boolean {
295
+ return p.startsWith('docs:') || p.startsWith('autogen-target:')
296
+ }
297
+
298
+ function isGlobPattern(p: string): boolean {
299
+ return /[*?[]/.test(p)
300
+ }
301
+
302
+ function stableRegistryHash(registry: ScaffoldRegistry): string {
303
+ return createHash('sha256').update(JSON.stringify(registry)).digest('hex')
304
+ }
305
+
306
+ // Published-boundary note: scripts/lib/glob-matcher.mjs carries a behaviorally
307
+ // identical copy of globToRegExp. The duplication is INTENTIONAL and must NOT be
308
+ // collapsed: @repo/core is a published npm package that cannot import from scripts/,
309
+ // and scripts/ cannot import across the published boundary. If you change the
310
+ // algorithm here, mirror it in scripts/lib/glob-matcher.mjs.
311
+ function globToRegExp(pattern: string): RegExp {
312
+ const segments = pattern.split('/')
313
+ let regex = '^'
314
+
315
+ for (let index = 0; index < segments.length; index++) {
316
+ const segment = segments[index]
317
+ const isLast = index === segments.length - 1
318
+
319
+ if (segment === '**') {
320
+ regex += isLast ? '(?:.*)?' : '(?:[^/]+/)*'
321
+ continue
322
+ }
323
+
324
+ regex += segmentToRegExpSource(segment)
325
+ if (!isLast) regex += '/'
326
+ }
327
+
328
+ regex += '$'
329
+ return new RegExp(regex)
330
+ }
331
+
332
+ function segmentToRegExpSource(segment: string): string {
333
+ let source = ''
334
+ for (let index = 0; index < segment.length; index++) {
335
+ const char = segment[index]
336
+ if (char === '*') {
337
+ source += '[^/]*'
338
+ } else if (char === '?') {
339
+ source += '[^/]'
340
+ } else {
341
+ source += escapeRegExp(char)
342
+ }
343
+ }
344
+ return source
345
+ }
346
+
347
+ function escapeRegExp(value: string): string {
348
+ return value.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&')
349
+ }
350
+
351
+ function sourcePatternMatchesAnyPath(sourcePattern: string, monorepoRootDir: string): boolean {
352
+ const normalizedPattern = normalizeScaffoldPath(sourcePattern)
353
+
354
+ if (!isGlobPattern(normalizedPattern)) {
355
+ return existsSync(path.join(monorepoRootDir, normalizedPattern))
356
+ }
357
+
358
+ const baseDir = findGlobBaseDirectory(normalizedPattern)
359
+ const absoluteBase = path.join(monorepoRootDir, baseDir)
360
+ if (!existsSync(absoluteBase)) return false
361
+
362
+ return walkUntilMatch(absoluteBase, monorepoRootDir, normalizedPattern)
363
+ }
364
+
365
+ function findGlobBaseDirectory(pattern: string): string {
366
+ const segments = pattern.split('/')
367
+ const baseSegments: string[] = []
368
+ for (const segment of segments) {
369
+ if (isGlobPattern(segment)) break
370
+ baseSegments.push(segment)
371
+ }
372
+ return baseSegments.join('/')
373
+ }
374
+
375
+ function walkUntilMatch(currentPath: string, monorepoRootDir: string, pattern: string): boolean {
376
+ const rel = normalizeScaffoldPath(path.relative(monorepoRootDir, currentPath))
377
+ if (rel && scaffoldPathMatchesPattern(rel, pattern)) return true
378
+
379
+ let entries
380
+ try {
381
+ entries = readdirSync(currentPath, { withFileTypes: true })
382
+ } catch {
383
+ return false
384
+ }
385
+
386
+ for (const entry of entries) {
387
+ const absolutePath = path.join(currentPath, entry.name)
388
+ const relativePath = normalizeScaffoldPath(path.relative(monorepoRootDir, absolutePath))
389
+
390
+ if (scaffoldPathMatchesPattern(relativePath, pattern)) return true
391
+ if (entry.isDirectory()) {
392
+ try {
393
+ if (statSync(absolutePath).isDirectory() && walkUntilMatch(absolutePath, monorepoRootDir, pattern)) {
394
+ return true
395
+ }
396
+ } catch {
397
+ continue
398
+ }
399
+ }
400
+ }
401
+
402
+ return false
403
+ }