@elevasis/core 0.6.0 → 0.7.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.
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Snapshot test for the scaffold-contracts generator wrapper.
3
+ *
4
+ * Calls generate() (which runs the real script) and asserts:
5
+ * 1. The output file exists and has content.
6
+ * 2. The content matches the stored snapshot (drift detection).
7
+ *
8
+ * If the snapshot is missing it is created on first run. Subsequent runs fail
9
+ * if the output changes without an intentional snapshot update.
10
+ */
11
+
12
+ import { readFileSync } from 'node:fs'
13
+ import { resolve } from 'node:path'
14
+ import { describe, it, expect } from 'vitest'
15
+
16
+ /** Monorepo root relative to packages/core/src/_gen/__tests__/ */
17
+ const ROOT = resolve(import.meta.dirname, '..', '..', '..', '..', '..')
18
+
19
+ const OUTPUT_PATH = resolve(ROOT, 'packages/core/src/reference/_generated/contracts.md')
20
+
21
+ describe('scaffold-contracts generator', () => {
22
+ it('output file exists and has content', () => {
23
+ // The generator must have been run (either manually or by CI gen step).
24
+ // This test validates the committed artifact — it does NOT re-run the generator
25
+ // so the test suite stays fast and deterministic.
26
+ let content: string
27
+ try {
28
+ content = readFileSync(OUTPUT_PATH, 'utf8')
29
+ } catch {
30
+ throw new Error(
31
+ `Generated file not found: ${OUTPUT_PATH}\n` +
32
+ `Run "pnpm scaffold:generate" or "node scripts/monorepo/generate-scaffold-contracts.js" first.`
33
+ )
34
+ }
35
+
36
+ expect(content.length).toBeGreaterThan(0)
37
+ })
38
+
39
+ it('output file matches stored snapshot', async () => {
40
+ let content: string
41
+ try {
42
+ content = readFileSync(OUTPUT_PATH, 'utf8')
43
+ } catch {
44
+ throw new Error(
45
+ `Generated file not found: ${OUTPUT_PATH}\n` +
46
+ `Run "pnpm scaffold:generate" first to produce the artifact before snapshotting.`
47
+ )
48
+ }
49
+
50
+ // Snapshot stored alongside test file in __snapshots__/
51
+ await expect(content).toMatchFileSnapshot(resolve(import.meta.dirname, '__snapshots__', 'contracts.md.snap'))
52
+ })
53
+ })
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Thin _gen/ wrapper for the scaffold-contracts generator.
3
+ *
4
+ * The actual generation logic lives in scripts/monorepo/generate-scaffold-contracts.js.
5
+ * This wrapper provides the canonical generate() entry point so the generator
6
+ * participates in the @repo/gen-utils pattern (snapshot tests, drift detection,
7
+ * Turbo gen task).
8
+ *
9
+ * DO NOT move logic here — edit the script instead, then re-run generate().
10
+ */
11
+
12
+ import { spawnSync } from 'node:child_process'
13
+ import { fileURLToPath } from 'node:url'
14
+ import { resolve, dirname } from 'node:path'
15
+
16
+ const __filename = fileURLToPath(import.meta.url)
17
+ const __dirname = dirname(__filename)
18
+
19
+ /** Monorepo root — four levels up from packages/core/src/_gen/ */
20
+ const ROOT = resolve(__dirname, '..', '..', '..', '..')
21
+
22
+ /**
23
+ * Run the scaffold-contracts generator.
24
+ * Delegates to scripts/monorepo/generate-scaffold-contracts.js via child process
25
+ * so the existing script is the single source of implementation.
26
+ */
27
+ export function generate(): void {
28
+ const result = spawnSync('node', ['scripts/monorepo/generate-scaffold-contracts.js'], {
29
+ stdio: 'inherit',
30
+ cwd: ROOT
31
+ })
32
+
33
+ if (result.error) {
34
+ throw new Error(`scaffold-contracts generator failed to spawn: ${result.error.message}`)
35
+ }
36
+
37
+ if (result.status !== 0) {
38
+ throw new Error(`scaffold-contracts generator exited with status ${result.status}`)
39
+ }
40
+ }
41
+
42
+ // Allow direct invocation: node packages/core/src/_gen/scaffold-contracts.ts
43
+ if (process.argv[1] && resolve(process.argv[1]) === __filename) {
44
+ generate()
45
+ }
package/src/index.ts CHANGED
@@ -52,3 +52,13 @@ export * from './integrations/oauth/index'
52
52
 
53
53
  // Credential types and utilities
54
54
  export * from './integrations/credentials/index'
55
+
56
+ // Scaffold registry — browser-safe schema types and Zod schemas only.
57
+ // For server-side loader functions, use: import { ... } from '@repo/core/scaffold-registry'
58
+ export { ScaffoldRegistrySchema, ScaffoldEntryKindSchema } from './scaffold-registry/schema'
59
+ export type {
60
+ ScaffoldRegistry,
61
+ ScaffoldRegistryEntry,
62
+ ScaffoldRef,
63
+ ScaffoldEntryKind
64
+ } from './scaffold-registry/schema'
@@ -257,6 +257,7 @@ describe('resolveOrganizationModel — identity domain integration', () => {
257
257
  industryCategory: 'Software / SaaS',
258
258
  geographicFocus: 'Global',
259
259
  timeZone: 'America/Chicago',
260
+ clientBrief: '',
260
261
  businessHours: {
261
262
  monday: { open: '09:00', close: '17:00' }
262
263
  }
@@ -67,7 +67,13 @@ export const IdentityDomainSchema = z.object({
67
67
  */
68
68
  timeZone: z.string().trim().max(100).default('UTC'),
69
69
  /** Typical operating hours per day of week. Empty object means not configured. */
70
- businessHours: BusinessHoursSchema
70
+ businessHours: BusinessHoursSchema,
71
+ /**
72
+ * Long-form markdown capturing client context, problem narrative, and domain
73
+ * background. Populated by /setup; surfaced to agents as organizational context.
74
+ * Optional — many projects have no external client.
75
+ */
76
+ clientBrief: z.string().trim().default('')
71
77
  })
72
78
 
73
79
  // ---------------------------------------------------------------------------
@@ -83,5 +89,6 @@ export const DEFAULT_ORGANIZATION_MODEL_IDENTITY: z.infer<typeof IdentityDomainS
83
89
  industryCategory: '',
84
90
  geographicFocus: '',
85
91
  timeZone: 'UTC',
86
- businessHours: {}
92
+ businessHours: {},
93
+ clientBrief: ''
87
94
  }
@@ -1,4 +1,4 @@
1
- <!-- Auto-generated on 2026-04-20T23:27:16.482Z by scripts/monorepo/generate-scaffold-contracts.js -->
1
+ <!-- Auto-generated on 2026-04-21T05:52:18.229Z by scripts/monorepo/generate-scaffold-contracts.js -->
2
2
  ---
3
3
  title: Reference Contracts
4
4
  description: Auto-generated TypeScript contracts for SDK consumers. Do not edit manually.
@@ -0,0 +1,280 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { ScaffoldEntryKindSchema, ScaffoldRegistryEntrySchema, ScaffoldRegistrySchema } from '../schema'
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // Kind enum
6
+ // ---------------------------------------------------------------------------
7
+
8
+ describe('ScaffoldEntryKindSchema', () => {
9
+ it('accepts all declared kinds', () => {
10
+ const validKinds = [
11
+ 'autogen',
12
+ 'manual-scaffold',
13
+ 'typed-id',
14
+ 'sync-preservation',
15
+ 'vibe-gated',
16
+ 'sdk-cli-generator',
17
+ 'validator',
18
+ 'other'
19
+ ]
20
+ for (const kind of validKinds) {
21
+ expect(ScaffoldEntryKindSchema.safeParse(kind).success).toBe(true)
22
+ }
23
+ })
24
+
25
+ it('rejects unknown kinds', () => {
26
+ const result = ScaffoldEntryKindSchema.safeParse('unknown-kind')
27
+ expect(result.success).toBe(false)
28
+ })
29
+
30
+ it('rejects empty string', () => {
31
+ expect(ScaffoldEntryKindSchema.safeParse('').success).toBe(false)
32
+ })
33
+ })
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Valid full entry
37
+ // ---------------------------------------------------------------------------
38
+
39
+ const validEntry = {
40
+ id: 'business-directory-rename',
41
+ kind: 'manual-scaffold',
42
+ owner: 'packages/core',
43
+ sources: ['packages/core/src/business/**'],
44
+ dependents: [
45
+ {
46
+ path: '.navigation/_generated/skeleton.md',
47
+ regen: 'pnpm navigation:generate',
48
+ kind: 'autogen',
49
+ hint: 'Regenerate after rename.'
50
+ }
51
+ ]
52
+ }
53
+
54
+ describe('ScaffoldRegistryEntrySchema — valid entry', () => {
55
+ it('parses a complete valid entry', () => {
56
+ const result = ScaffoldRegistryEntrySchema.safeParse(validEntry)
57
+ expect(result.success).toBe(true)
58
+ if (result.success) {
59
+ expect(result.data.id).toBe('business-directory-rename')
60
+ expect(result.data.kind).toBe('manual-scaffold')
61
+ expect(result.data.sources).toHaveLength(1)
62
+ expect(result.data.dependents).toHaveLength(1)
63
+ }
64
+ })
65
+
66
+ it('accepts optional notes and cooldown_ms', () => {
67
+ const withOptionals = {
68
+ ...validEntry,
69
+ notes: 'Extra context here.',
70
+ cooldown_ms: 60_000
71
+ }
72
+ const result = ScaffoldRegistryEntrySchema.safeParse(withOptionals)
73
+ expect(result.success).toBe(true)
74
+ })
75
+
76
+ it('accepts optional regen command when provided', () => {
77
+ const withRegen = { ...validEntry, regen: 'pnpm navigation:generate' }
78
+ const result = ScaffoldRegistryEntrySchema.safeParse(withRegen)
79
+ expect(result.success).toBe(true)
80
+ if (result.success) {
81
+ expect(result.data.regen).toBe('pnpm navigation:generate')
82
+ }
83
+ })
84
+
85
+ it('accepts entry without regen field (optional)', () => {
86
+ const result = ScaffoldRegistryEntrySchema.safeParse(validEntry)
87
+ expect(result.success).toBe(true)
88
+ if (result.success) {
89
+ expect(result.data.regen).toBeUndefined()
90
+ }
91
+ })
92
+
93
+ it('rejects regen as empty string', () => {
94
+ const withEmptyRegen = { ...validEntry, regen: '' }
95
+ const result = ScaffoldRegistryEntrySchema.safeParse(withEmptyRegen)
96
+ expect(result.success).toBe(false)
97
+ })
98
+
99
+ it('rejects regen as non-string (number)', () => {
100
+ const withBadRegen = { ...validEntry, regen: 42 }
101
+ const result = ScaffoldRegistryEntrySchema.safeParse(withBadRegen)
102
+ expect(result.success).toBe(false)
103
+ })
104
+
105
+ it('accepts entry without optional hint / regen in dependent', () => {
106
+ const minimalDependent = {
107
+ ...validEntry,
108
+ dependents: [{ path: 'some/path' }]
109
+ }
110
+ expect(ScaffoldRegistryEntrySchema.safeParse(minimalDependent).success).toBe(true)
111
+ })
112
+
113
+ it('accepts auto_regen: true', () => {
114
+ const withAutoRegen = { ...validEntry, auto_regen: true }
115
+ expect(ScaffoldRegistryEntrySchema.safeParse(withAutoRegen).success).toBe(true)
116
+ })
117
+
118
+ it('accepts auto_regen: false', () => {
119
+ const withAutoRegen = { ...validEntry, auto_regen: false }
120
+ expect(ScaffoldRegistryEntrySchema.safeParse(withAutoRegen).success).toBe(true)
121
+ })
122
+
123
+ it('accepts entry without auto_regen (optional)', () => {
124
+ expect(ScaffoldRegistryEntrySchema.safeParse(validEntry).success).toBe(true)
125
+ })
126
+
127
+ it('rejects auto_regen as a non-boolean', () => {
128
+ const withBadAutoRegen = { ...validEntry, auto_regen: 'yes' }
129
+ expect(ScaffoldRegistryEntrySchema.safeParse(withBadAutoRegen).success).toBe(false)
130
+ })
131
+
132
+ it('parses all six seed-entry kinds', () => {
133
+ const kinds = ['autogen', 'manual-scaffold', 'typed-id', 'sync-preservation', 'vibe-gated', 'validator']
134
+ for (const kind of kinds) {
135
+ const result = ScaffoldRegistryEntrySchema.safeParse({ ...validEntry, kind })
136
+ expect(result.success, `kind ${kind} should parse`).toBe(true)
137
+ }
138
+ })
139
+ })
140
+
141
+ // ---------------------------------------------------------------------------
142
+ // Invalid entries — required fields missing
143
+ // ---------------------------------------------------------------------------
144
+
145
+ describe('ScaffoldRegistryEntrySchema — invalid entries', () => {
146
+ it('rejects entry missing id', () => {
147
+ const { id: _id, ...withoutId } = validEntry
148
+ expect(ScaffoldRegistryEntrySchema.safeParse(withoutId).success).toBe(false)
149
+ })
150
+
151
+ it('rejects entry missing kind', () => {
152
+ const { kind: _kind, ...withoutKind } = validEntry
153
+ expect(ScaffoldRegistryEntrySchema.safeParse(withoutKind).success).toBe(false)
154
+ })
155
+
156
+ it('rejects entry missing owner', () => {
157
+ const { owner: _owner, ...withoutOwner } = validEntry
158
+ expect(ScaffoldRegistryEntrySchema.safeParse(withoutOwner).success).toBe(false)
159
+ })
160
+
161
+ it('rejects entry missing sources', () => {
162
+ const { sources: _sources, ...withoutSources } = validEntry
163
+ expect(ScaffoldRegistryEntrySchema.safeParse(withoutSources).success).toBe(false)
164
+ })
165
+
166
+ it('rejects entry missing dependents', () => {
167
+ const { dependents: _dependents, ...withoutDependents } = validEntry
168
+ expect(ScaffoldRegistryEntrySchema.safeParse(withoutDependents).success).toBe(false)
169
+ })
170
+
171
+ it('rejects invalid kind', () => {
172
+ const withBadKind = { ...validEntry, kind: 'not-a-real-kind' }
173
+ const result = ScaffoldRegistryEntrySchema.safeParse(withBadKind)
174
+ expect(result.success).toBe(false)
175
+ })
176
+
177
+ it('rejects id with uppercase characters', () => {
178
+ const withBadId = { ...validEntry, id: 'BusinessDirectoryRename' }
179
+ expect(ScaffoldRegistryEntrySchema.safeParse(withBadId).success).toBe(false)
180
+ })
181
+
182
+ it('rejects id with spaces', () => {
183
+ const withBadId = { ...validEntry, id: 'business directory rename' }
184
+ expect(ScaffoldRegistryEntrySchema.safeParse(withBadId).success).toBe(false)
185
+ })
186
+
187
+ it('rejects empty sources array', () => {
188
+ const withEmptySources = { ...validEntry, sources: [] }
189
+ expect(ScaffoldRegistryEntrySchema.safeParse(withEmptySources).success).toBe(false)
190
+ })
191
+
192
+ it('rejects empty dependents array', () => {
193
+ const withEmptyDependents = { ...validEntry, dependents: [] }
194
+ expect(ScaffoldRegistryEntrySchema.safeParse(withEmptyDependents).success).toBe(false)
195
+ })
196
+
197
+ it('rejects dependent missing path', () => {
198
+ const withBadDependent = {
199
+ ...validEntry,
200
+ dependents: [{ regen: 'pnpm nav' }]
201
+ }
202
+ expect(ScaffoldRegistryEntrySchema.safeParse(withBadDependent).success).toBe(false)
203
+ })
204
+
205
+ it('rejects negative cooldown_ms', () => {
206
+ const withBadCooldown = { ...validEntry, cooldown_ms: -1 }
207
+ expect(ScaffoldRegistryEntrySchema.safeParse(withBadCooldown).success).toBe(false)
208
+ })
209
+
210
+ it('rejects non-integer cooldown_ms', () => {
211
+ const withBadCooldown = { ...validEntry, cooldown_ms: 300.5 }
212
+ expect(ScaffoldRegistryEntrySchema.safeParse(withBadCooldown).success).toBe(false)
213
+ })
214
+ })
215
+
216
+ // ---------------------------------------------------------------------------
217
+ // Top-level registry document
218
+ // ---------------------------------------------------------------------------
219
+
220
+ describe('ScaffoldRegistrySchema', () => {
221
+ const validRegistry = {
222
+ version: '1',
223
+ entries: [validEntry]
224
+ }
225
+
226
+ it('parses a valid registry document', () => {
227
+ const result = ScaffoldRegistrySchema.safeParse(validRegistry)
228
+ expect(result.success).toBe(true)
229
+ if (result.success) {
230
+ expect(result.data.version).toBe('1')
231
+ expect(result.data.entries).toHaveLength(1)
232
+ }
233
+ })
234
+
235
+ it('applies default version when omitted', () => {
236
+ const withoutVersion = { entries: [validEntry] }
237
+ const result = ScaffoldRegistrySchema.safeParse(withoutVersion)
238
+ expect(result.success).toBe(true)
239
+ if (result.success) {
240
+ expect(result.data.version).toBe('1')
241
+ }
242
+ })
243
+
244
+ it('rejects registry with no entries', () => {
245
+ const empty = { version: '1', entries: [] }
246
+ expect(ScaffoldRegistrySchema.safeParse(empty).success).toBe(false)
247
+ })
248
+
249
+ it('rejects registry missing entries key', () => {
250
+ expect(ScaffoldRegistrySchema.safeParse({ version: '1' }).success).toBe(false)
251
+ })
252
+
253
+ it('accepts multiple entries of different kinds', () => {
254
+ const multiEntry = {
255
+ version: '1',
256
+ entries: [
257
+ validEntry,
258
+ {
259
+ id: 'meta-json-updated',
260
+ kind: 'validator',
261
+ owner: 'apps/docs',
262
+ sources: ['apps/docs/content/docs/**/meta.json'],
263
+ dependents: [{ path: '(self)', regen: 'pnpm check-docs-meta' }]
264
+ },
265
+ {
266
+ id: 'external-template-hook-added',
267
+ kind: 'sync-preservation',
268
+ owner: 'external/_template',
269
+ sources: ['external/_template/.claude/hooks/*.mjs'],
270
+ dependents: [{ path: 'external/_template/.claude/settings.json', regen: 'manual' }]
271
+ }
272
+ ]
273
+ }
274
+ const result = ScaffoldRegistrySchema.safeParse(multiEntry)
275
+ expect(result.success).toBe(true)
276
+ if (result.success) {
277
+ expect(result.data.entries).toHaveLength(3)
278
+ }
279
+ })
280
+ })
@@ -0,0 +1,194 @@
1
+ import { readFileSync, writeFileSync } from 'node:fs'
2
+ import path from 'node:path'
3
+ import { parse as parseYaml } from 'yaml'
4
+ import { type ScaffoldRegistry, type ScaffoldRegistryEntry, ScaffoldRegistrySchema } from './schema'
5
+
6
+ export { ScaffoldRegistrySchema, ScaffoldEntryKindSchema } from './schema'
7
+ export type { ScaffoldRegistry, ScaffoldRegistryEntry, ScaffoldRef, ScaffoldEntryKind } from './schema'
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Paths (resolved relative to the monorepo root, not this file's location)
11
+ // ---------------------------------------------------------------------------
12
+
13
+ /**
14
+ * Resolve a path relative to the monorepo root.
15
+ * Works whether this module is running from packages/core/src or from dist/.
16
+ */
17
+ function monorepoRoot(): string {
18
+ // Walk up from __dirname until we find the .claude directory (monorepo marker)
19
+ const { dirname } = path
20
+ let dir = __dirname
21
+ for (let i = 0; i < 8; i++) {
22
+ try {
23
+ readFileSync(path.join(dir, '.claude', 'settings.json'))
24
+ return dir
25
+ } catch {
26
+ dir = dirname(dir)
27
+ }
28
+ }
29
+ throw new Error(
30
+ 'scaffold-registry: could not locate monorepo root (no .claude/settings.json found in ancestor directories)'
31
+ )
32
+ }
33
+
34
+ const YAML_FILENAME = '.claude/scaffold-registry.yml'
35
+ const JSON_FILENAME = '.claude/scaffold-registry.compiled.json'
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Load + validate
39
+ // ---------------------------------------------------------------------------
40
+
41
+ /**
42
+ * Load and Zod-validate the scaffold registry from `.claude/scaffold-registry.yml`.
43
+ *
44
+ * Throws if:
45
+ * - The YAML file is missing or unreadable
46
+ * - The YAML fails Zod validation
47
+ * - The compiled JSON is present but its `entries` count differs from the YAML
48
+ * (drift detection — regenerate with `compileScaffoldRegistry()` to fix)
49
+ */
50
+ export function loadScaffoldRegistry(): ScaffoldRegistry {
51
+ const root = monorepoRoot()
52
+ const yamlPath = path.join(root, YAML_FILENAME)
53
+
54
+ let raw: string
55
+ try {
56
+ raw = readFileSync(yamlPath, 'utf-8')
57
+ } catch (err) {
58
+ throw new Error(`scaffold-registry: could not read ${YAML_FILENAME} — ${String(err)}`)
59
+ }
60
+
61
+ const parsed = parseYaml(raw) as unknown
62
+ const result = ScaffoldRegistrySchema.safeParse(parsed)
63
+
64
+ if (!result.success) {
65
+ const issues = result.error.issues.map((i) => ` [${i.path.join('.')}] ${i.message}`).join('\n')
66
+ throw new Error(`scaffold-registry: YAML validation failed:\n${issues}`)
67
+ }
68
+
69
+ const registry = result.data
70
+
71
+ // Drift check: if compiled JSON exists, verify entry count matches
72
+ const jsonPath = path.join(root, JSON_FILENAME)
73
+ try {
74
+ const compiledRaw = readFileSync(jsonPath, 'utf-8')
75
+ const compiled = JSON.parse(compiledRaw) as { entries?: unknown[] }
76
+ if (compiled.entries?.length !== registry.entries.length) {
77
+ throw new Error(
78
+ `scaffold-registry: compiled JSON is out of sync with YAML ` +
79
+ `(YAML has ${registry.entries.length} entries, JSON has ${compiled.entries?.length ?? 0}). ` +
80
+ `Run compileScaffoldRegistry() to regenerate.`
81
+ )
82
+ }
83
+ } catch (err) {
84
+ // If the file doesn't exist, skip drift check silently (first-run scenario)
85
+ if ((err as { code?: string }).code !== 'ENOENT') {
86
+ throw err
87
+ }
88
+ }
89
+
90
+ return registry
91
+ }
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // Compile
95
+ // ---------------------------------------------------------------------------
96
+
97
+ /**
98
+ * Load, validate, and write the pre-compiled JSON lookup file.
99
+ * Run this whenever `.claude/scaffold-registry.yml` changes.
100
+ *
101
+ * Called by the `pnpm scaffold:compile-registry` script (wired in Step 2 CI).
102
+ */
103
+ export function compileScaffoldRegistry(): ScaffoldRegistry {
104
+ const root = monorepoRoot()
105
+ const registry = loadScaffoldRegistryNoSyncCheck(root)
106
+
107
+ const jsonPath = path.join(root, JSON_FILENAME)
108
+ writeFileSync(jsonPath, JSON.stringify(registry, null, 2) + '\n', 'utf-8')
109
+
110
+ return registry
111
+ }
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // Lookup helpers (used by hooks for fast path matching)
115
+ // ---------------------------------------------------------------------------
116
+
117
+ /**
118
+ * Load from the pre-compiled JSON only (fastest path; used by PostToolUse hooks).
119
+ * Falls back to YAML if the JSON is missing.
120
+ */
121
+ export function loadScaffoldRegistryFast(): ScaffoldRegistry {
122
+ const root = monorepoRoot()
123
+ const jsonPath = path.join(root, JSON_FILENAME)
124
+
125
+ try {
126
+ const raw = readFileSync(jsonPath, 'utf-8')
127
+ const parsed = JSON.parse(raw) as unknown
128
+ const result = ScaffoldRegistrySchema.safeParse(parsed)
129
+ if (result.success) return result.data
130
+ } catch {
131
+ // fall through to YAML
132
+ }
133
+
134
+ return loadScaffoldRegistryNoSyncCheck(root)
135
+ }
136
+
137
+ /**
138
+ * Return all entries whose `sources` contain at least one pattern that matches
139
+ * the given file path. Pattern matching is a simple substring/glob-prefix check
140
+ * suitable for hook use; Step 3 will upgrade to full micromatch when the hook
141
+ * is implemented.
142
+ */
143
+ export function findMatchingEntries(registry: ScaffoldRegistry, filePath: string): ScaffoldRegistryEntry[] {
144
+ return registry.entries.filter((entry) => entry.sources.some((pattern) => pathMatchesPattern(filePath, pattern)))
145
+ }
146
+
147
+ // ---------------------------------------------------------------------------
148
+ // Internal helpers
149
+ // ---------------------------------------------------------------------------
150
+
151
+ function loadScaffoldRegistryNoSyncCheck(root: string): ScaffoldRegistry {
152
+ const yamlPath = path.join(root, YAML_FILENAME)
153
+ let raw: string
154
+ try {
155
+ raw = readFileSync(yamlPath, 'utf-8')
156
+ } catch (err) {
157
+ throw new Error(`scaffold-registry: could not read ${YAML_FILENAME} — ${String(err)}`)
158
+ }
159
+ const parsed = parseYaml(raw) as unknown
160
+ const result = ScaffoldRegistrySchema.safeParse(parsed)
161
+ if (!result.success) {
162
+ const issues = result.error.issues.map((i) => ` [${i.path.join('.')}] ${i.message}`).join('\n')
163
+ throw new Error(`scaffold-registry: YAML validation failed:\n${issues}`)
164
+ }
165
+ return result.data
166
+ }
167
+
168
+ /**
169
+ * Lightweight pattern match: handles exact paths, directory prefixes, and
170
+ * simple `*` wildcards at the end of a segment. Full micromatch lands in Step 3.
171
+ */
172
+ function pathMatchesPattern(filePath: string, pattern: string): boolean {
173
+ // Normalize separators
174
+ const normalizedFile = filePath.replace(/\\/g, '/')
175
+ const normalizedPattern = pattern.replace(/\\/g, '/')
176
+
177
+ // Exact match
178
+ if (normalizedFile === normalizedPattern) return true
179
+
180
+ // If pattern ends with `/**` or `/*`, check prefix
181
+ if (normalizedPattern.endsWith('/**') || normalizedPattern.endsWith('/*')) {
182
+ const prefix = normalizedPattern.slice(0, normalizedPattern.lastIndexOf('/*'))
183
+ return normalizedFile.startsWith(prefix + '/')
184
+ }
185
+
186
+ // If pattern contains `*`, convert to simple regex
187
+ if (normalizedPattern.includes('*')) {
188
+ const escaped = normalizedPattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^/]*')
189
+ return new RegExp(`^${escaped}$`).test(normalizedFile)
190
+ }
191
+
192
+ // Directory prefix match (pattern is a directory)
193
+ return normalizedFile.startsWith(normalizedPattern + '/')
194
+ }