@elevasis/core 0.5.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.
- package/dist/index.d.ts +428 -42
- package/dist/index.js +596 -47
- package/dist/organization-model/index.d.ts +428 -42
- package/dist/organization-model/index.js +596 -47
- package/package.json +4 -3
- package/src/__tests__/template-foundations-compatibility.test.ts +2 -2
- package/src/_gen/__tests__/__snapshots__/contracts.md.snap +1131 -0
- package/src/_gen/__tests__/scaffold-contracts.test.ts +53 -0
- package/src/_gen/scaffold-contracts.ts +45 -0
- package/src/business/acquisition/types.ts +2 -0
- package/src/commands/queue/types/task.ts +3 -3
- package/src/execution/engine/index.ts +8 -0
- package/src/execution/engine/tools/registry.ts +26 -24
- package/src/execution/engine/tools/tool-maps.ts +13 -9
- package/src/execution/engine/workflow/types.ts +2 -3
- package/src/index.ts +10 -0
- package/src/organization-model/README.md +16 -12
- package/src/organization-model/__tests__/defaults.test.ts +175 -0
- package/src/organization-model/__tests__/domains/customers.test.ts +295 -0
- package/src/organization-model/__tests__/domains/goals.test.ts +479 -0
- package/src/organization-model/__tests__/domains/identity.test.ts +279 -0
- package/src/organization-model/__tests__/domains/navigation.test.ts +212 -0
- package/src/organization-model/__tests__/domains/offerings.test.ts +419 -0
- package/src/organization-model/__tests__/domains/operations.test.ts +203 -0
- package/src/organization-model/__tests__/domains/resource-mappings.test.ts +362 -0
- package/src/organization-model/__tests__/domains/roles.test.ts +347 -0
- package/src/organization-model/__tests__/domains/statuses.test.ts +243 -0
- package/src/organization-model/__tests__/foundation.test.ts +3 -3
- package/src/organization-model/__tests__/resolve.test.ts +447 -3
- package/src/organization-model/__tests__/schema.test.ts +407 -0
- package/src/organization-model/contracts.ts +5 -5
- package/src/organization-model/defaults.ts +39 -16
- package/src/organization-model/domains/customers.ts +75 -0
- package/src/organization-model/domains/goals.ts +80 -0
- package/src/organization-model/domains/identity.ts +94 -0
- package/src/organization-model/domains/navigation.ts +43 -4
- package/src/organization-model/domains/offerings.ts +66 -0
- package/src/organization-model/domains/operations.ts +85 -0
- package/src/organization-model/domains/{delivery.ts → projects.ts} +6 -6
- package/src/organization-model/domains/{lead-gen.ts → prospecting.ts} +5 -5
- package/src/organization-model/domains/roles.ts +55 -0
- package/src/organization-model/domains/sales.ts +94 -0
- package/src/organization-model/domains/shared.ts +30 -1
- package/src/organization-model/domains/statuses.ts +130 -0
- package/src/organization-model/index.ts +3 -3
- package/src/organization-model/organization-graph.mdx +1 -0
- package/src/organization-model/organization-model.mdx +84 -19
- package/src/organization-model/published.ts +53 -8
- package/src/organization-model/schema.ts +67 -7
- package/src/organization-model/types.ts +31 -7
- package/src/platform/constants/versions.ts +1 -1
- package/src/platform/registry/types.ts +1 -1
- package/src/projects/api-schemas.ts +1 -0
- package/src/reference/_generated/contracts.md +116 -8
- package/src/reference/glossary.md +25 -4
- package/src/requests/__tests__/api-schemas.test.ts +277 -0
- package/src/requests/api-schemas.ts +83 -0
- package/src/requests/index.ts +1 -0
- package/src/scaffold-registry/__tests__/schema.test.ts +280 -0
- package/src/scaffold-registry/index.ts +194 -0
- package/src/scaffold-registry/schema.ts +144 -0
- package/src/supabase/database.types.ts +158 -6
- package/src/organization-model/domains/crm.ts +0 -46
- /package/src/business/{delivery → projects}/index.ts +0 -0
- /package/src/business/{delivery → projects}/types.ts +0 -0
- /package/src/business/{crm → sales}/api-schemas.ts +0 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Kind taxonomy
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The kind of scaffold entry:
|
|
9
|
+
*
|
|
10
|
+
* - autogen: fully derivable from source; regen command is the fix
|
|
11
|
+
* - manual-scaffold: hand-authored structure that must be updated on source change
|
|
12
|
+
* - typed-id: typed-ID constants that encode semantic naming decisions
|
|
13
|
+
* - sync-preservation: external-template files with a declared sync tier
|
|
14
|
+
* - vibe-gated: paths protected by the pre-edit-vibe-gate hook
|
|
15
|
+
* - sdk-cli-generator: SDK CLI generators (generate-docs-index, generate-resources, etc.)
|
|
16
|
+
* - validator: drift-detection scripts or CI checks (meta.json pages[] validators, etc.)
|
|
17
|
+
* - other: escape hatch; requires free-form notes
|
|
18
|
+
*/
|
|
19
|
+
export const ScaffoldEntryKindSchema = z.enum([
|
|
20
|
+
'autogen',
|
|
21
|
+
'manual-scaffold',
|
|
22
|
+
'typed-id',
|
|
23
|
+
'sync-preservation',
|
|
24
|
+
'vibe-gated',
|
|
25
|
+
'sdk-cli-generator',
|
|
26
|
+
'validator',
|
|
27
|
+
'other'
|
|
28
|
+
])
|
|
29
|
+
|
|
30
|
+
export type ScaffoldEntryKind = z.infer<typeof ScaffoldEntryKindSchema>
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Scaffold reference (a single downstream artifact that needs attention)
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
export const ScaffoldRefSchema = z.object({
|
|
37
|
+
/**
|
|
38
|
+
* File path, glob, or symbolic target (e.g. "docs: sync-preservation-matrix").
|
|
39
|
+
* Symbolic targets begin with "docs:" or "autogen-target:".
|
|
40
|
+
*/
|
|
41
|
+
path: z.string().min(1),
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Command to regenerate, or "manual" if human judgment is required.
|
|
45
|
+
*/
|
|
46
|
+
regen: z.string().min(1).optional(),
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Optionally narrow the kind of this specific scaffold reference, when it
|
|
50
|
+
* differs from the parent entry's kind.
|
|
51
|
+
*/
|
|
52
|
+
kind: ScaffoldEntryKindSchema.optional(),
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Human-readable hint shown in reminder messages.
|
|
56
|
+
*/
|
|
57
|
+
hint: z.string().optional()
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
export type ScaffoldRef = z.infer<typeof ScaffoldRefSchema>
|
|
61
|
+
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// Registry entry
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
export const ScaffoldRegistryEntrySchema = z.object({
|
|
67
|
+
/**
|
|
68
|
+
* Stable slug identifier for this entry (kebab-case).
|
|
69
|
+
* Referenced by hooks, CI checks, and the /scaffold verify skill.
|
|
70
|
+
*/
|
|
71
|
+
id: z
|
|
72
|
+
.string()
|
|
73
|
+
.min(1)
|
|
74
|
+
.regex(/^[a-z0-9-]+$/, 'id must be kebab-case'),
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The primary kind of this entry.
|
|
78
|
+
*/
|
|
79
|
+
kind: ScaffoldEntryKindSchema,
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Glob patterns (or exact paths) that, when edited, should trigger a
|
|
83
|
+
* reminder for the associated scaffolds. Uses micromatch / glob semantics.
|
|
84
|
+
*/
|
|
85
|
+
sources: z.array(z.string().min(1)).min(1, 'At least one source pattern is required'),
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Downstream artifacts that need attention when a source changes.
|
|
89
|
+
*/
|
|
90
|
+
dependents: z.array(ScaffoldRefSchema).min(1, 'At least one dependent is required'),
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Package or app path that owns this scaffold surface.
|
|
94
|
+
* Examples: "packages/core", "apps/api", "external/_template"
|
|
95
|
+
*/
|
|
96
|
+
owner: z.string().min(1),
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Free-form notes. Required when kind is "other".
|
|
100
|
+
*/
|
|
101
|
+
notes: z.string().optional(),
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The shell command used to regenerate this entry's outputs.
|
|
105
|
+
*
|
|
106
|
+
* Required for `kind: autogen` entries once Step 6 migration lands.
|
|
107
|
+
* Optional now so harness scaffolding (Step 5) can land before migrating
|
|
108
|
+
* existing generators. Validated as a non-empty string when present.
|
|
109
|
+
*
|
|
110
|
+
* Example: `"pnpm navigation:generate"`, `"pnpm scaffold:sync"`
|
|
111
|
+
*/
|
|
112
|
+
regen: z.string().min(1).optional(),
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Per-entry reminder throttle override in milliseconds.
|
|
116
|
+
* Defaults to the hook-level default (5 minutes = 300_000 ms) when omitted.
|
|
117
|
+
*/
|
|
118
|
+
cooldown_ms: z.number().int().positive().optional(),
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* When true, the PostToolUse reminder hook will automatically run the `regen`
|
|
122
|
+
* command after emitting the reminder, rather than just advising the user.
|
|
123
|
+
* Defaults to false (advisory only). Only meaningful when `regen` is also set.
|
|
124
|
+
*/
|
|
125
|
+
auto_regen: z.boolean().optional()
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
export type ScaffoldRegistryEntry = z.infer<typeof ScaffoldRegistryEntrySchema>
|
|
129
|
+
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
// Top-level registry document
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
export const ScaffoldRegistrySchema = z.object({
|
|
135
|
+
/**
|
|
136
|
+
* Schema version for forward-compatibility detection.
|
|
137
|
+
* Bump when the shape changes in a breaking way.
|
|
138
|
+
*/
|
|
139
|
+
version: z.string().default('1'),
|
|
140
|
+
|
|
141
|
+
entries: z.array(ScaffoldRegistryEntrySchema).min(1)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
export type ScaffoldRegistry = z.infer<typeof ScaffoldRegistrySchema>
|