@vegastack/skills 0.1.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 (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +46 -0
  3. package/dist/index.js +512 -0
  4. package/package.json +35 -0
  5. package/skill/vegastack-arch-guardian/SKILL.md +96 -0
  6. package/skill/vegastack-arch-guardian/agents/openai.yaml +4 -0
  7. package/skill/vegastack-arch-guardian/assets/adr-template.md +40 -0
  8. package/skill/vegastack-arch-guardian/assets/answers-example.json +20 -0
  9. package/skill/vegastack-arch-guardian/assets/architecture-profile.json +25 -0
  10. package/skill/vegastack-arch-guardian/assets/architecture-profile.schema.json +213 -0
  11. package/skill/vegastack-arch-guardian/assets/deployment-review-template.md +24 -0
  12. package/skill/vegastack-arch-guardian/assets/service-design-template.md +33 -0
  13. package/skill/vegastack-arch-guardian/assets/threat-model-template.md +34 -0
  14. package/skill/vegastack-arch-guardian/references/architecture/agent-product.md +22 -0
  15. package/skill/vegastack-arch-guardian/references/architecture/ai-cost.md +22 -0
  16. package/skill/vegastack-arch-guardian/references/architecture/ai-data-boundaries.md +19 -0
  17. package/skill/vegastack-arch-guardian/references/architecture/ai-evals.md +26 -0
  18. package/skill/vegastack-arch-guardian/references/architecture/connectors-sandbox.md +39 -0
  19. package/skill/vegastack-arch-guardian/references/architecture/data-memory.md +25 -0
  20. package/skill/vegastack-arch-guardian/references/architecture/delivery-operations.md +34 -0
  21. package/skill/vegastack-arch-guardian/references/architecture/durable-execution.md +43 -0
  22. package/skill/vegastack-arch-guardian/references/architecture/flutter.md +26 -0
  23. package/skill/vegastack-arch-guardian/references/architecture/foundation.md +33 -0
  24. package/skill/vegastack-arch-guardian/references/architecture/hosting-reliability.md +37 -0
  25. package/skill/vegastack-arch-guardian/references/architecture/identity-tenancy.md +37 -0
  26. package/skill/vegastack-arch-guardian/references/architecture/model-lifecycle.md +18 -0
  27. package/skill/vegastack-arch-guardian/references/architecture/models-observability.md +23 -0
  28. package/skill/vegastack-arch-guardian/references/architecture/realtime-channels.md +16 -0
  29. package/skill/vegastack-arch-guardian/references/architecture/security-privacy.md +23 -0
  30. package/skill/vegastack-arch-guardian/references/architecture/topology-monorepo.md +47 -0
  31. package/skill/vegastack-arch-guardian/references/architecture/web.md +29 -0
  32. package/skill/vegastack-arch-guardian/references/control-catalog.json +55 -0
  33. package/skill/vegastack-arch-guardian/references/foundation-compatibility.json +44 -0
  34. package/skill/vegastack-arch-guardian/references/golden-patterns.md +43 -0
  35. package/skill/vegastack-arch-guardian/references/profile-governance.md +54 -0
  36. package/skill/vegastack-arch-guardian/references/rule-model.json +36 -0
  37. package/skill/vegastack-arch-guardian/references/workflows.md +45 -0
  38. package/skill/vegastack-arch-guardian/refresh/REFRESH.md +40 -0
  39. package/skill/vegastack-arch-guardian/refresh/sources.json +1159 -0
  40. package/skill/vegastack-arch-guardian/scripts/architecture-check.mjs +323 -0
  41. package/skill/vegastack-arch-guardian/scripts/lib.mjs +57 -0
  42. package/skill/vegastack-arch-guardian/scripts/profile-tool.mjs +223 -0
  43. package/skill/vegastack-arch-guardian/scripts/refresh-evidence.mjs +325 -0
  44. package/skill/vegastack-arch-guardian/scripts/schema-validate.mjs +63 -0
  45. package/skill/vegastack-arch-guardian/scripts/validate-profile.mjs +241 -0
  46. package/skill/vegastack-arch-guardian/scripts/verify-corpus.mjs +148 -0
  47. package/skill-integrity.json +48 -0
@@ -0,0 +1,325 @@
1
+ #!/usr/bin/env node
2
+ import { randomUUID } from 'node:crypto'
3
+ import { lookup } from 'node:dns/promises'
4
+ import { lstat, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
5
+ import { isIP } from 'node:net'
6
+ import { dirname, join, parse, relative, resolve, sep } from 'node:path'
7
+ import { spawnSync } from 'node:child_process'
8
+ import { fileURLToPath, pathToFileURL } from 'node:url'
9
+ import { pathExists, readJsonYaml, sha256 } from './lib.mjs'
10
+
11
+ const skillRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
12
+ const defaultRegistry = join(skillRoot, 'refresh', 'sources.json')
13
+ const maximumBytes = 5 * 1024 * 1024
14
+ // Every host referenced by refresh/sources.json must appear here; loadRegistry enforces the
15
+ // consistency so the allowlist cannot silently drift from the registry.
16
+ const approvedHosts = new Set(['ai-sdk.dev', 'ai.google.dev', 'api.flutter.dev', 'aws.amazon.com', 'better-auth.com', 'bun.sh', 'developer.apple.com', 'developers.cloudflare.com', 'developers.openai.com', 'docs.aws.amazon.com', 'docs.flutter.dev', 'firebase.google.com', 'git.postgresql.org', 'github.com', 'modal.com', 'modelcontextprotocol.io', 'nextjs.org', 'openbao.org', 'openid.github.io', 'opennext.js.org', 'opentelemetry.io', 'platform.claude.com', 'pub.dev', 'pypi.org', 'raw.githubusercontent.com', 'registry.npmjs.org', 'riverpod.dev', 'turborepo.dev', 'workflow-sdk.dev', 'www.cloudflare.com', 'www.npmjs.com', 'www.postgresql.org'])
17
+
18
+ function flagValue(argv, flag) {
19
+ const value = argv.shift()
20
+ if (value === undefined || value.startsWith('-')) throw new Error(`${flag} requires a value`)
21
+ return value
22
+ }
23
+
24
+ function args(argv) {
25
+ const result = { registry: defaultRegistry, cache: '.vegastack/evidence-cache.json', report: '.vegastack/evidence-drift.json', topics: [], offline: false, acceptBaselines: false, now: new Date().toISOString() }
26
+ while (argv.length) {
27
+ const flag = argv.shift()
28
+ if (flag === '--registry') result.registry = resolve(flagValue(argv, flag))
29
+ else if (flag === '--cache') result.cache = resolve(flagValue(argv, flag))
30
+ else if (flag === '--report') result.report = resolve(flagValue(argv, flag))
31
+ else if (flag === '--topics') result.topics = flagValue(argv, flag).split(',').filter(Boolean)
32
+ else if (flag === '--offline') result.offline = true
33
+ else if (flag === '--accept-baselines') result.acceptBaselines = true
34
+ else if (flag === '--verify-baselines') { /* explicit alias for the default online verification run */ }
35
+ else if (flag === '--now') result.now = flagValue(argv, flag)
36
+ else throw new Error(`Unknown option: ${flag}`)
37
+ }
38
+ return result
39
+ }
40
+
41
+ function privateAddress(address) {
42
+ if (address === '::1' || address === '::' || address.startsWith('fc') || address.startsWith('fd') || address.startsWith('fe80:')) return true
43
+ if (!isIP(address)) return true
44
+ // IPv4-mapped IPv6 must be evaluated as its IPv4 payload, not passed as "public IPv6" —
45
+ // in both dotted (::ffff:10.0.0.1) and hex-group (::ffff:a00:1) notations.
46
+ const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(address)
47
+ if (mapped) return privateAddress(mapped[1])
48
+ const hexMapped = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(address)
49
+ if (hexMapped) {
50
+ const high = Number.parseInt(hexMapped[1], 16)
51
+ const low = Number.parseInt(hexMapped[2], 16)
52
+ return privateAddress(`${high >> 8}.${high & 255}.${low >> 8}.${low & 255}`)
53
+ }
54
+ if (address.includes(':')) return false
55
+ const [a, b] = address.split('.').map(Number)
56
+ if (a === 0 || a === 10 || a === 127 || a >= 224) return true
57
+ if (a === 169 && b === 254) return true
58
+ if (a === 172 && b >= 16 && b <= 31) return true
59
+ if (a === 192 && (b === 168 || b === 0)) return true
60
+ if (a === 100 && b >= 64 && b <= 127) return true // CGNAT 100.64/10
61
+ if (a === 198 && (b === 18 || b === 19 || b === 51)) return true // benchmark + TEST-NET-2
62
+ if (a === 203 && b === 0) return true // TEST-NET-3
63
+ return false
64
+ }
65
+
66
+ // NOTE: validation resolves DNS immediately before each fetch (including every redirect hop), but
67
+ // Node fetch re-resolves independently, so a hostile authoritative DNS server could still rebind
68
+ // between the check and the request. The hard host allowlist above is the primary control; this
69
+ // check is defense in depth against allowlisted-host compromise, not a substitute for it.
70
+ async function validateNetworkTarget(input, allowHttpLocalhost = false) {
71
+ const url = new URL(input)
72
+ const loopbackName = ['localhost', '127.0.0.1', '::1', '[::1]'].includes(url.hostname)
73
+ if (url.protocol !== 'https:' && !(allowHttpLocalhost && url.protocol === 'http:' && loopbackName)) throw new Error(`Only HTTPS evidence URLs are allowed: ${url}`)
74
+ if (!approvedHosts.has(url.hostname) && !(allowHttpLocalhost && loopbackName)) throw new Error(`Unapproved evidence host: ${url.hostname}`)
75
+ const addresses = isIP(url.hostname) ? [{ address: url.hostname }] : await lookup(url.hostname, { all: true, verbatim: true })
76
+ if ((!allowHttpLocalhost || !loopbackName) && addresses.some(entry => privateAddress(entry.address))) throw new Error(`Private/reserved evidence target refused: ${url.hostname}`)
77
+ return url
78
+ }
79
+
80
+ async function readBounded(response) {
81
+ const declared = Number(response.headers.get('content-length') ?? 0)
82
+ if (declared > maximumBytes) throw new Error(`Evidence response exceeds ${maximumBytes} bytes`)
83
+ if (!response.body) return new Uint8Array()
84
+ const reader = response.body.getReader()
85
+ const chunks = []
86
+ let total = 0
87
+ while (true) {
88
+ const { done, value } = await reader.read()
89
+ if (done) break
90
+ total += value.byteLength
91
+ if (total > maximumBytes) { await reader.cancel(); throw new Error(`Evidence response exceeds ${maximumBytes} bytes`) }
92
+ chunks.push(value)
93
+ }
94
+ const output = new Uint8Array(total)
95
+ let offset = 0
96
+ for (const chunk of chunks) { output.set(chunk, offset); offset += chunk.byteLength }
97
+ return output
98
+ }
99
+
100
+ async function safeFetch(input, init = {}, allowHttpLocalhost = false) {
101
+ let url = await validateNetworkTarget(input, allowHttpLocalhost)
102
+ for (let redirects = 0; redirects <= 5; redirects += 1) {
103
+ const response = await fetch(url, { ...init, redirect: 'manual', signal: AbortSignal.timeout(20_000) })
104
+ if (![301, 302, 303, 307, 308].includes(response.status)) return response
105
+ const location = response.headers.get('location')
106
+ if (!location || redirects === 5) throw new Error('Invalid or excessive evidence redirect')
107
+ url = await validateNetworkTarget(new URL(location, url).href, allowHttpLocalhost)
108
+ }
109
+ throw new Error('Evidence redirect limit exceeded')
110
+ }
111
+
112
+ async function assertSafeWriteTarget(input) {
113
+ const target = resolve(input)
114
+ const root = parse(target).root
115
+ const segments = relative(root, target).split(sep).filter(Boolean)
116
+ let current = root
117
+ for (const part of segments) {
118
+ current = join(current, part)
119
+ try {
120
+ const entry = await lstat(current)
121
+ if (entry.isSymbolicLink()) throw new Error(`Refusing write through symlink: ${current}`)
122
+ } catch (error) {
123
+ if (error?.code === 'ENOENT') break
124
+ throw error
125
+ }
126
+ }
127
+ await mkdir(dirname(target), { recursive: true })
128
+ current = root
129
+ for (const part of segments) {
130
+ current = join(current, part)
131
+ try {
132
+ if ((await lstat(current)).isSymbolicLink()) throw new Error(`Refusing write through symlink: ${current}`)
133
+ } catch (error) {
134
+ if (error?.code !== 'ENOENT') throw error
135
+ }
136
+ }
137
+ return target
138
+ }
139
+
140
+ async function atomicJson(path, value) {
141
+ const target = await assertSafeWriteTarget(path)
142
+ const temporary = join(dirname(target), `.${randomUUID()}.tmp`)
143
+ try {
144
+ await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { flag: 'wx' })
145
+ await rename(temporary, target)
146
+ } finally {
147
+ await rm(temporary, { force: true }).catch(() => {})
148
+ }
149
+ }
150
+
151
+ const ownerFor = source => source.owner ?? 'maintainers'
152
+
153
+ function item(source, extra = {}) {
154
+ return { id: source.id, critical: Boolean(source.critical), owner: ownerFor(source), affected: source.affected, ...extra }
155
+ }
156
+
157
+ function evidenceChecksum(source, policy, bytes) {
158
+ const scope = source.checksumScope ?? policy?.defaultChecksumScope
159
+ if (scope === 'http-body') return sha256(bytes)
160
+ if (scope === 'html-text-v1') {
161
+ const document = new TextDecoder().decode(bytes)
162
+ const claimSurface = document.match(/<main\b[^>]*>([\s\S]*?)<\/main>/i)?.[1] ?? document
163
+ const normalized = claimSurface
164
+ .replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, '')
165
+ .replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, '')
166
+ .replace(/<[^>]+>/g, ' ')
167
+ .replace(/&(?:nbsp|#x27|quot|amp|lt|gt);/g, ' ')
168
+ .replace(/\s+/g, ' ')
169
+ .trim()
170
+ return sha256(normalized)
171
+ }
172
+ throw new Error(`Unsupported checksum scope for ${source.id}: ${scope}`)
173
+ }
174
+
175
+ async function detectVersion(source, allowHttpLocalhost) {
176
+ const detection = source.versionDetection ?? {}
177
+ if (detection.type === 'npm' || detection.type === 'npm-suite') {
178
+ const packages = detection.type === 'npm-suite' ? detection.packages : [detection.package]
179
+ const tag = detection.tag ?? 'latest'
180
+ const versions = []
181
+ for (const packageName of packages) {
182
+ const response = await safeFetch(`https://registry.npmjs.org/${encodeURIComponent(packageName)}/${encodeURIComponent(tag)}`, {}, allowHttpLocalhost)
183
+ if (!response.ok) throw new Error(`npm version detection HTTP ${response.status} for ${packageName}`)
184
+ const metadata = JSON.parse(new TextDecoder().decode(await readBounded(response)))
185
+ versions.push(metadata.version ?? null)
186
+ }
187
+ const unique = [...new Set(versions)]
188
+ return unique.length === 1 ? unique[0] : versions.map((version, index) => `${packages[index]}@${version}`).join(',')
189
+ }
190
+ if (detection.type === 'pypi') {
191
+ const response = await safeFetch(`https://pypi.org/pypi/${encodeURIComponent(detection.package)}/json`, {}, allowHttpLocalhost)
192
+ if (!response.ok) throw new Error(`PyPI version detection HTTP ${response.status}`)
193
+ return JSON.parse(new TextDecoder().decode(await readBounded(response))).info?.version ?? null
194
+ }
195
+ return null
196
+ }
197
+
198
+ function validAgeDays(prior, now) {
199
+ const retrieved = prior?.retrievedAt ? new Date(prior.retrievedAt) : null
200
+ if (!retrieved || Number.isNaN(retrieved.getTime()) || retrieved.getTime() > now.getTime()) return Infinity
201
+ return (now.getTime() - retrieved.getTime()) / 86_400_000
202
+ }
203
+
204
+ async function verifyLocalIntegrity(source) {
205
+ if (!source.path) return null
206
+ if (source.treeChecksumAlgorithm === 'git-sha1-tree' || source.checksumAlgorithm === 'git-sha1-tree') {
207
+ const expected = source.treeChecksum ?? source.checksum
208
+ const result = spawnSync('git', ['-C', source.path, 'rev-parse', 'HEAD^{tree}'], { encoding: 'utf8' })
209
+ if (result.status !== 0) throw new Error(result.stderr.trim() || 'git tree checksum failed')
210
+ return { expected, actual: result.stdout.trim(), algorithm: 'git-sha1-tree' }
211
+ }
212
+ if (source.refreshable === false) return { expected: source.checksum, actual: sha256(await readFile(source.path)), algorithm: 'sha256' }
213
+ return null
214
+ }
215
+
216
+ function validateRegistry(registry, options) {
217
+ if (registry.schemaVersion !== 1 || !Array.isArray(registry.sources)) throw new Error('Invalid evidence registry schema')
218
+ const sourceIds = new Set()
219
+ for (const source of registry.sources) {
220
+ if (!source.id || sourceIds.has(source.id) || !Array.isArray(source.topics) || !Number.isFinite(source.thresholdDays) || source.thresholdDays < 1) throw new Error(`Invalid evidence registry entry: ${source.id ?? 'unknown'}`)
221
+ sourceIds.add(source.id)
222
+ if (source.refreshable !== false) {
223
+ const scope = source.checksumScope ?? registry.policy?.defaultChecksumScope
224
+ if (!['http-body', 'html-text-v1'].includes(scope)) throw new Error(`Refreshable source ${source.id} has unsupported checksum scope ${scope}`)
225
+ // --accept-baselines seeds missing baselines for newly added sources; every other mode requires them.
226
+ if (!/^[a-f0-9]{64}$/.test(source.checksum ?? '') && !options.acceptBaselines) throw new Error(`Refreshable source ${source.id} requires an explicit supported SHA-256 baseline`)
227
+ for (const url of Object.values(source.urls ?? {})) {
228
+ const hostname = new URL(url).hostname
229
+ const loopback = ['localhost', '127.0.0.1', '::1', '[::1]'].includes(hostname)
230
+ if (!approvedHosts.has(hostname) && !(options.allowHttpLocalhost && loopback)) throw new Error(`Registry source ${source.id} references unapproved host ${hostname}; update approvedHosts in refresh-evidence.mjs deliberately`)
231
+ }
232
+ }
233
+ }
234
+ }
235
+
236
+ export async function refreshEvidence(options) {
237
+ const registry = await readJsonYaml(options.registry)
238
+ validateRegistry(registry, options)
239
+ const cache = await pathExists(options.cache) ? JSON.parse(await readFile(options.cache, 'utf8')) : { schemaVersion: 1, sources: {} }
240
+ const now = new Date(options.now)
241
+ if (Number.isNaN(now.getTime())) throw new Error(`Invalid --now timestamp: ${options.now}`)
242
+ const selected = registry.sources.filter(source => !options.topics.length || source.topics.some(topic => options.topics.includes(topic)))
243
+ const report = { schemaVersion: 1, generatedAt: now.toISOString(), offline: options.offline, acceptBaselines: options.acceptBaselines, selected: selected.map(source => source.id), drift: [], versionDrift: [], stale: [], unavailable: [], unaffected: [], manualVersionReview: [], acceptedBaselines: [] }
244
+ const baselineUpdates = new Map()
245
+ for (const source of selected) {
246
+ const prior = cache.sources[source.id]
247
+ const ageDays = validAgeDays(prior, now)
248
+ try {
249
+ const local = await verifyLocalIntegrity(source)
250
+ if (local && local.actual !== local.expected) {
251
+ report.drift.push(item(source, { from: local.expected, to: local.actual, integrityFailure: true, scope: local.algorithm }))
252
+ continue
253
+ }
254
+ if (source.refreshable === false) {
255
+ report.unaffected.push(source.id)
256
+ cache.sources[source.id] = { retrievedAt: now.toISOString(), checksum: local.actual, local: true, path: source.path }
257
+ continue
258
+ }
259
+ if (options.offline) {
260
+ if (!prior || !Number.isFinite(ageDays) || ageDays > source.thresholdDays) report.stale.push(item(source, { ageDays: Number.isFinite(ageDays) ? Math.floor(ageDays) : null }))
261
+ else report.unaffected.push(source.id)
262
+ continue
263
+ }
264
+ const detectedVersion = await detectVersion(source, options.allowHttpLocalhost)
265
+ if (detectedVersion && detectedVersion !== source.currentVersion) {
266
+ // Under --accept-baselines a verified version change is accepted, not re-reported as drift —
267
+ // the same single-code-path rule the checksum branch follows.
268
+ if (options.acceptBaselines) baselineUpdates.set(source.id, { ...(baselineUpdates.get(source.id) ?? {}), currentVersion: detectedVersion, versionCheckedAt: now.toISOString() })
269
+ else report.versionDrift.push(item(source, { from: source.currentVersion, to: detectedVersion, detection: source.versionDetection.type }))
270
+ } else if (!detectedVersion && !['parent', 'git-commit'].includes(source.versionDetection?.type)) {
271
+ const reviewedAgeDays = validAgeDays({ retrievedAt: source.retrievedAt }, now)
272
+ report.manualVersionReview.push({ id: source.id, critical: Boolean(source.critical), owner: ownerFor(source), mechanism: source.versionDetection?.type ?? 'unspecified', due: !Number.isFinite(reviewedAgeDays) || reviewedAgeDays > source.thresholdDays, ageDays: Number.isFinite(reviewedAgeDays) ? Math.floor(reviewedAgeDays) : null })
273
+ }
274
+ const headers = {}
275
+ if (prior?.etag) headers['If-None-Match'] = prior.etag
276
+ if (prior?.lastModified) headers['If-Modified-Since'] = prior.lastModified
277
+ const response = await safeFetch(source.urls.primary, { headers }, options.allowHttpLocalhost)
278
+ if (response.status === 304 && prior) {
279
+ if (prior.checksum !== source.checksum) {
280
+ if (!options.acceptBaselines) {
281
+ report.drift.push(item(source, { from: source.checksum, to: prior.checksum, baseline: 'registry-vs-304-cache' }))
282
+ continue
283
+ }
284
+ baselineUpdates.set(source.id, { ...(baselineUpdates.get(source.id) ?? {}), checksum: prior.checksum, retrievedAt: now.toISOString() })
285
+ }
286
+ prior.retrievedAt = now.toISOString()
287
+ report.unaffected.push(source.id)
288
+ continue
289
+ }
290
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
291
+ const checksum = evidenceChecksum(source, registry.policy, await readBounded(response))
292
+ const comparison = prior?.checksum ?? source.checksum
293
+ if (comparison && comparison !== checksum && !options.acceptBaselines) report.drift.push(item(source, { from: comparison, to: checksum, baseline: prior?.checksum ? 'cache' : 'registry' }))
294
+ else report.unaffected.push(source.id)
295
+ if (options.acceptBaselines && source.checksum !== checksum) {
296
+ baselineUpdates.set(source.id, { ...(baselineUpdates.get(source.id) ?? {}), checksum, retrievedAt: now.toISOString() })
297
+ }
298
+ cache.sources[source.id] = { retrievedAt: now.toISOString(), checksum, etag: response.headers.get('etag'), lastModified: response.headers.get('last-modified'), url: response.url, detectedVersion }
299
+ } catch (error) {
300
+ report.unavailable.push(item(source, { error: error.message }))
301
+ }
302
+ }
303
+ // Accepted baselines are written back to the registry in the same run that produced the report,
304
+ // so the registry snapshot, cache, and drift report can never disagree (single code path).
305
+ if (options.acceptBaselines && baselineUpdates.size) {
306
+ for (const source of registry.sources) {
307
+ const update = baselineUpdates.get(source.id)
308
+ if (!update) continue
309
+ Object.assign(source, update)
310
+ report.acceptedBaselines.push({ id: source.id, ...update })
311
+ }
312
+ await atomicJson(options.registry, registry)
313
+ }
314
+ await atomicJson(options.cache, cache)
315
+ await atomicJson(options.report, report)
316
+ const failClosed = [...report.stale, ...report.unavailable, ...report.drift, ...report.versionDrift, ...report.manualVersionReview.filter(entry => entry.due)].some(entry => entry.critical || entry.integrityFailure)
317
+ return { report, failClosed }
318
+ }
319
+
320
+ if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
321
+ const options = args(process.argv.slice(2))
322
+ const { report, failClosed } = await refreshEvidence(options)
323
+ console.log(`refresh-evidence: selected=${report.selected.length} drift=${report.drift.length} version-drift=${report.versionDrift.length} stale=${report.stale.length} unavailable=${report.unavailable.length}${report.acceptedBaselines.length ? ` accepted=${report.acceptedBaselines.length}` : ''}`)
324
+ if (failClosed) process.exitCode = 1
325
+ }
@@ -0,0 +1,63 @@
1
+ function pointer(root, reference) {
2
+ if (!reference.startsWith('#/')) throw new Error(`Only local JSON Schema references are supported: ${reference}`)
3
+ return reference.slice(2).split('/').reduce((value, key) => value?.[key.replaceAll('~1', '/').replaceAll('~0', '~')], root)
4
+ }
5
+
6
+ const same = (left, right) => JSON.stringify(left) === JSON.stringify(right)
7
+ const typeOf = value => Array.isArray(value) ? 'array' : value === null ? 'null' : typeof value
8
+ const joinPath = (base, key) => `${base}/${String(key).replaceAll('~', '~0').replaceAll('/', '~1')}`
9
+ const validDate = value => {
10
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false
11
+ const [year, month, day] = value.split('-').map(Number)
12
+ const parsed = new Date(Date.UTC(year, month - 1, day))
13
+ return parsed.getUTCFullYear() === year && parsed.getUTCMonth() === month - 1 && parsed.getUTCDate() === day
14
+ }
15
+
16
+ export function validateJsonSchema(schema, value) {
17
+ const errors = []
18
+ function validate(node, data, path = '$', quiet = false) {
19
+ const local = []
20
+ const add = message => local.push(`${path}: ${message}`)
21
+ if (node.$ref) return validate(pointer(schema, node.$ref), data, path, quiet)
22
+ if (node.const !== undefined && !same(data, node.const)) add(`must equal ${JSON.stringify(node.const)}`)
23
+ if (node.enum && !node.enum.some(item => same(item, data))) add(`must be one of ${node.enum.map(item => JSON.stringify(item)).join(', ')}`)
24
+ if (node.type) {
25
+ const allowed = Array.isArray(node.type) ? node.type : [node.type]
26
+ if (!allowed.includes(typeOf(data)) || (typeOf(data) === 'number' && !Number.isFinite(data))) add(`must be ${allowed.join(' or ')}`)
27
+ }
28
+ if (typeof data === 'string') {
29
+ if (node.minLength !== undefined && data.length < node.minLength) add(`must have length >= ${node.minLength}`)
30
+ if (node.pattern && !new RegExp(node.pattern).test(data)) add(`must match ${node.pattern}`)
31
+ if (node.format === 'date' && !validDate(data)) add('must be a real ISO calendar date (YYYY-MM-DD)')
32
+ }
33
+ if (typeof data === 'number' && node.minimum !== undefined && data < node.minimum) add(`must be >= ${node.minimum}`)
34
+ if (Array.isArray(data)) {
35
+ if (node.minItems !== undefined && data.length < node.minItems) add(`must contain at least ${node.minItems} item(s)`)
36
+ if (node.uniqueItems && new Set(data.map(item => JSON.stringify(item))).size !== data.length) add('must contain unique items')
37
+ if (node.items) data.forEach((item, index) => local.push(...validate(node.items, item, joinPath(path, index), true)))
38
+ }
39
+ if (data && typeof data === 'object' && !Array.isArray(data)) {
40
+ const keys = Object.keys(data)
41
+ if (node.minProperties !== undefined && keys.length < node.minProperties) add(`must contain at least ${node.minProperties} properties`)
42
+ for (const required of node.required ?? []) if (!(required in data)) local.push(`${joinPath(path, required)}: is required`)
43
+ for (const [key, child] of Object.entries(node.properties ?? {})) if (key in data) local.push(...validate(child, data[key], joinPath(path, key), true))
44
+ if (node.propertyNames) for (const key of keys) local.push(...validate(node.propertyNames, key, `${path} property ${JSON.stringify(key)}`, true))
45
+ const known = new Set(Object.keys(node.properties ?? {}))
46
+ for (const key of keys.filter(key => !known.has(key))) {
47
+ if (node.additionalProperties === false) local.push(`${joinPath(path, key)}: additional property is not allowed`)
48
+ else if (node.additionalProperties && typeof node.additionalProperties === 'object') local.push(...validate(node.additionalProperties, data[key], joinPath(path, key), true))
49
+ }
50
+ }
51
+ for (const child of node.allOf ?? []) local.push(...validate(child, data, path, true))
52
+ if (node.oneOf) {
53
+ const matches = node.oneOf.map(child => validate(child, data, path, true)).filter(result => result.length === 0).length
54
+ if (matches !== 1) add(`must match exactly one schema branch (matched ${matches})`)
55
+ }
56
+ if (node.not && validate(node.not, data, path, true).length === 0) add('must not match forbidden schema')
57
+ if (node.if && validate(node.if, data, path, true).length === 0 && node.then) local.push(...validate(node.then, data, path, true))
58
+ if (!quiet) errors.push(...local)
59
+ return local
60
+ }
61
+ validate(schema, value)
62
+ return errors
63
+ }
@@ -0,0 +1,241 @@
1
+ #!/usr/bin/env node
2
+ import { lstat, readFile, realpath } from 'node:fs/promises'
3
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
4
+ import { fileURLToPath, pathToFileURL } from 'node:url'
5
+ import { listFiles, pathExists, readJsonYaml, resolveProfile } from './lib.mjs'
6
+ import { validateJsonSchema } from './schema-validate.mjs'
7
+
8
+ const skillRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
9
+ const architectureRoot = join(skillRoot, 'references', 'architecture')
10
+ const schemaPath = join(skillRoot, 'assets', 'architecture-profile.schema.json')
11
+ const compatibilityPath = join(skillRoot, 'references', 'foundation-compatibility.json')
12
+
13
+ export async function canonicalRuleIds() {
14
+ const ids = new Set()
15
+ const duplicates = new Set()
16
+ for (const path of await listFiles(architectureRoot, file => file.endsWith('.md'))) {
17
+ const body = await readFile(path, 'utf8')
18
+ for (const match of body.matchAll(/\*\*([A-Z]+-[0-9]{3}) —/g)) {
19
+ if (ids.has(match[1])) duplicates.add(match[1])
20
+ ids.add(match[1])
21
+ }
22
+ }
23
+ if (duplicates.size) throw new Error(`Duplicate canonical rule IDs: ${[...duplicates].join(', ')}`)
24
+ return ids
25
+ }
26
+
27
+ const enabled = (profile, name) => profile.capabilities?.[name]?.status === 'enabled'
28
+ const control = (profile, capability, name) => profile.capabilities?.[capability]?.controls?.[name]
29
+ const exactVersion = /^\d+\.\d+(?:\.\d+)?(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/
30
+ const diag = (rule, controlId, path, message, exceptionEligible = true) => ({ rule, control: controlId, path, message, exceptionEligible })
31
+
32
+ async function containedFile(projectRoot, targetPath) {
33
+ if (isAbsolute(targetPath) || targetPath.split(/[\\/]/).includes('..')) throw new Error('path must be repository-relative without ..')
34
+ const target = resolve(projectRoot, targetPath)
35
+ let current = resolve(projectRoot)
36
+ for (const part of relative(current, target).split(sep).filter(Boolean)) {
37
+ current = resolve(current, part)
38
+ if ((await lstat(current)).isSymbolicLink()) throw new Error('symlink component')
39
+ }
40
+ const resolvedRoot = await realpath(projectRoot)
41
+ const resolvedTarget = await realpath(target)
42
+ if (relative(resolvedRoot, resolvedTarget).startsWith('..') || !(await lstat(resolvedTarget)).isFile()) throw new Error('outside project or not a file')
43
+ return resolvedTarget
44
+ }
45
+
46
+ function metadata(body, key) {
47
+ return body.match(new RegExp(`^- ${key}:\\s*(.+)$`, 'mi'))?.[1]?.trim()
48
+ }
49
+
50
+ async function validateExceptions(profile, projectRoot, now, knownRules) {
51
+ const states = []
52
+ const seen = new Set()
53
+ for (const exception of profile.exceptions ?? []) {
54
+ const reasons = []
55
+ if (seen.has(exception.id)) reasons.push(`duplicate exception id ${exception.id}`)
56
+ seen.add(exception.id)
57
+ if (!knownRules.has(exception.ruleId)) reasons.push(`unknown canonical rule ${exception.ruleId}`)
58
+ for (const path of exception.paths ?? []) if (path.includes('*')) reasons.push('wildcard paths are forbidden')
59
+ if (exception.review?.date) {
60
+ const [year, month, day] = String(exception.review.date).split('-').map(Number)
61
+ const parsed = new Date(Date.UTC(year, month - 1, day))
62
+ const validCalendarDate = /^\d{4}-\d{2}-\d{2}$/.test(exception.review.date) && parsed.getUTCFullYear() === year && parsed.getUTCMonth() === month - 1 && parsed.getUTCDate() === day
63
+ const expiry = validCalendarDate ? Date.parse(`${exception.review.date}T23:59:59Z`) : Number.NaN
64
+ if (!Number.isFinite(expiry)) reasons.push('review.date is not a real ISO calendar date')
65
+ else if (expiry < now.getTime()) reasons.push(`exception expired on ${exception.review.date}`)
66
+ }
67
+ try {
68
+ const adrPath = await containedFile(projectRoot, String(exception.adr ?? ''))
69
+ const body = await readFile(adrPath, 'utf8')
70
+ const requiredHeadings = ['## Decision and rationale', '## Risks and accepted deviation', '## Compensating controls', '## Verification', '## Rollback or migration', '## Review trigger']
71
+ for (const heading of requiredHeadings) if (!body.includes(heading)) reasons.push(`ADR missing ${heading}`)
72
+ const checks = {
73
+ 'Status': 'accepted',
74
+ 'Project-Owner': exception.projectOwner,
75
+ 'Rule-ID': exception.ruleId,
76
+ 'Exception-ID': exception.id,
77
+ 'Foundation-Deviation-Acknowledged': 'true'
78
+ }
79
+ for (const [key, expected] of Object.entries(checks)) if (metadata(body, key) !== String(expected)) reasons.push(`ADR ${key} must equal ${expected}`)
80
+ const adrControls = new Set((metadata(body, 'Control-IDs') ?? '').split(',').map(item => item.trim()).filter(Boolean))
81
+ const adrPaths = new Set((metadata(body, 'Scope-Paths') ?? '').split(',').map(item => item.trim()).filter(Boolean))
82
+ if (exception.controls) {
83
+ if (exception.controls.some(item => !adrControls.has(item)) || adrControls.size !== exception.controls.length) reasons.push('ADR Control-IDs do not exactly match profile')
84
+ } else if (metadata(body, 'Control-IDs') !== 'all') {
85
+ // A rule-level exception (no controls list) covers every control under its rule; the ADR
86
+ // must acknowledge that breadth explicitly.
87
+ reasons.push('ADR Control-IDs must equal all for a rule-level exception without a controls list')
88
+ }
89
+ if ((exception.paths ?? []).some(item => !adrPaths.has(item)) || adrPaths.size !== (exception.paths ?? []).length) reasons.push('ADR Scope-Paths do not exactly match profile')
90
+ if (exception.review?.date && metadata(body, 'Review-Date') !== exception.review.date) reasons.push('ADR Review-Date does not match profile')
91
+ if (exception.review?.event && metadata(body, 'Review-Event') !== exception.review.event) reasons.push('ADR Review-Event does not match profile')
92
+ } catch (error) { reasons.push(`ADR invalid or not found: ${error.message}`) }
93
+ if (exception.status !== 'active') reasons.push(`exception status is ${exception.status}`)
94
+ states.push({ exception, valid: reasons.length === 0, reasons })
95
+ }
96
+ return states
97
+ }
98
+
99
+ function requiredFamilyVersions(profile, compatibility) {
100
+ const baseline = compatibility.baselines?.[profile.foundation?.baseline]
101
+ if (!baseline) return []
102
+ const requirements = []
103
+ const add = (capability, family) => {
104
+ if (!enabled(profile, capability)) return
105
+ for (const [key, expected] of Object.entries(baseline.families?.[family] ?? {})) requirements.push({ capability, key, expected })
106
+ }
107
+ add('webControlPlane', 'web')
108
+ if (enabled(profile, 'webControlPlane') && profile.environments?.production?.hosting === 'cloudflare-opennext') add('webControlPlane', 'cloudflare-opennext')
109
+ add('agents', 'agents')
110
+ add('jobs', 'jobs')
111
+ if (enabled(profile, 'webControlPlane') && profile.project?.access !== 'public') add('webControlPlane', 'identity')
112
+ if (enabled(profile, 'sandbox') && control(profile, 'sandbox', 'provider') === 'cloudflare-sandbox') add('sandbox', 'sandbox-cloudflare')
113
+ return requirements
114
+ }
115
+
116
+ function profileRelativeName(path, projectRoot) {
117
+ const absolute = resolve(path)
118
+ if (projectRoot) {
119
+ const relativePath = relative(resolve(projectRoot), absolute).split(sep).join('/')
120
+ if (!relativePath.startsWith('..')) return relativePath
121
+ }
122
+ return basename(dirname(absolute)) === '.vegastack' ? `.vegastack/${basename(absolute)}` : basename(absolute)
123
+ }
124
+
125
+ export async function validateProfile(path, options = {}) {
126
+ const diagnostics = []
127
+ const profileRel = profileRelativeName(path, options.projectRoot)
128
+ let profile
129
+ try { profile = await readJsonYaml(path) } catch (error) {
130
+ return { profile: null, errors: [error.message], diagnostics: [diag('FOUND-001', 'profile.parse', profileRel, error.message, false)], exceptions: [] }
131
+ }
132
+ if (profile.schemaVersion === 2) {
133
+ const message = 'schemaVersion 2 is obsolete; run profile-tool.mjs migrate-v2 <profile> for a deterministic read-only v3 draft, then confirm project facts'
134
+ return { profile, errors: [message], diagnostics: [diag('FOUND-001', 'profile.schema-version', profileRel, message, false)], exceptions: [] }
135
+ }
136
+ const schema = JSON.parse(await readFile(schemaPath, 'utf8'))
137
+ for (const message of validateJsonSchema(schema, profile)) diagnostics.push(diag('FOUND-001', 'profile.schema', profileRel, message, false))
138
+ const profileDirectory = dirname(resolve(path))
139
+ const projectRoot = resolve(options.projectRoot ?? (basename(profileDirectory) === '.vegastack' ? dirname(profileDirectory) : process.cwd()))
140
+ const compatibility = JSON.parse(await readFile(compatibilityPath, 'utf8'))
141
+ const knownRules = await canonicalRuleIds()
142
+ const now = options.now ? new Date(options.now) : new Date()
143
+ const exceptionStates = await validateExceptions(profile, projectRoot, now, knownRules)
144
+ for (const state of exceptionStates.filter(item => !item.valid)) diagnostics.push(diag('FOUND-002', 'exception.validity', profileRel, `${state.exception?.id ?? 'unknown'}: ${state.reasons.join('; ')}`, false))
145
+
146
+ if (profile.profileStatus !== 'confirmed') diagnostics.push(diag('FOUND-001', 'profile.confirmed', profileRel, 'profileStatus must be confirmed before CI; the bundled profile is an intentionally incomplete draft', false))
147
+ if (String(profile.project?.name ?? '').startsWith('REQUIRED-')) diagnostics.push(diag('FOUND-001', 'profile.project-name', profileRel, 'replace the REQUIRED project name with a confirmed fact', false))
148
+ const baseline = compatibility.baselines?.[profile.foundation?.baseline]
149
+ if (!baseline) diagnostics.push(diag('PKG-003', 'foundation.baseline', profileRel, `unknown foundation baseline ${profile.foundation?.baseline}`))
150
+ else if (profile.foundation?.adoption !== baseline.state) diagnostics.push(diag('PKG-003', 'foundation.adoption', profileRel, `baseline ${profile.foundation.baseline} has state ${baseline.state}, not ${profile.foundation?.adoption}`))
151
+ else if (baseline.state !== 'supported') diagnostics.push(diag('PKG-003', 'foundation.non-supported-adoption', profileRel, `${baseline.state} baseline adoption requires scoped project-owner ADR and qualification evidence`))
152
+
153
+ for (const [name, capability] of Object.entries(profile.capabilities ?? {})) {
154
+ if (capability?.status !== 'enabled') continue
155
+ for (const [key, version] of Object.entries(capability.versions ?? {})) if (!exactVersion.test(String(version))) diagnostics.push(diag('PKG-003', `version.${name}.${key}`, profileRel, `${name}.versions.${key} must be an exact version`))
156
+ if (capability.ownership === 'owned') for (const root of capability.sourceRoots ?? []) {
157
+ const full = resolve(projectRoot, root)
158
+ try {
159
+ if (relative(projectRoot, full).startsWith('..')) throw new Error('outside project')
160
+ if ((await lstat(full)).isSymbolicLink()) throw new Error('symlink')
161
+ if (!(await lstat(full)).isDirectory()) throw new Error('not a directory')
162
+ } catch (error) { diagnostics.push(diag('FOUND-004', `capability.${name}.source-root`, root, `owned enabled capability ${name} source root is unavailable: ${error.message}`)) }
163
+ }
164
+ }
165
+ const separatedRuntimes = new Set(['webControlPlane', 'agents', 'jobs'])
166
+ const ownedRoots = Object.entries(profile.capabilities ?? {}).flatMap(([name, cap]) => separatedRuntimes.has(name) && cap?.status === 'enabled' && cap?.ownership === 'owned' ? (cap.sourceRoots ?? []).map(root => [name, root]) : [])
167
+ for (let left = 0; left < ownedRoots.length; left += 1) for (let right = left + 1; right < ownedRoots.length; right += 1) {
168
+ const [leftOwner, leftRoot] = ownedRoots[left]
169
+ const [rightOwner, rightRoot] = ownedRoots[right]
170
+ if (leftOwner !== rightOwner && (leftRoot === rightRoot || leftRoot.startsWith(`${rightRoot}/`) || rightRoot.startsWith(`${leftRoot}/`))) diagnostics.push(diag('RUN-003', 'capability.source-root-overlap', profileRel, `owned source roots overlap between ${leftOwner} and ${rightOwner}: ${leftRoot} / ${rightRoot}`))
171
+ }
172
+
173
+ for (const item of requiredFamilyVersions(profile, compatibility)) {
174
+ const actual = profile.capabilities?.[item.capability]?.versions?.[item.key]
175
+ if (actual !== item.expected) diagnostics.push(diag('PKG-003', `version.${item.capability}.${item.key}`, profileRel, `${item.capability}.versions.${item.key} must equal supported baseline ${item.expected}; lag/advance requires a scoped project ADR`))
176
+ }
177
+
178
+ if (enabled(profile, 'flutter')) {
179
+ if (!enabled(profile, 'webControlPlane')) diagnostics.push(diag('MOB-002', 'flutter.api-provider', profileRel, 'Flutter requires an enabled REST/OpenAPI provider capability'))
180
+ if (control(profile, 'flutter', 'delegatedOAuthPkce') !== true) diagnostics.push(diag('MOB-001', 'flutter.oauth-pkce', profileRel, 'Flutter requires delegated OAuth/OIDC code with S256 PKCE'))
181
+ if (control(profile, 'flutter', 'generatedRestClient') !== true || control(profile, 'webControlPlane', 'openapiGenerated') !== true) diagnostics.push(diag('MOB-002', 'flutter.generated-client', profileRel, 'Flutter requires deterministic generated REST/OpenAPI client consumption'))
182
+ }
183
+ if (enabled(profile, 'agents')) {
184
+ if (control(profile, 'agents', 'workflowWorld') !== 'postgres' || control(profile, 'agents', 'agentRun') !== true) diagnostics.push(diag('DUR-001', 'agents.durable-owner', profileRel, 'Agents require qualified EVE/Postgres World and AgentRun'))
185
+ if (profile.capabilities.agents.ownership === 'owned' && (!control(profile, 'agents', 'workflowDatabaseOwner') || !control(profile, 'agents', 'agentRunOwner'))) diagnostics.push(diag('DUR-001', 'agents.storage-ownership', profileRel, 'Owned agents must declare the Workflow PostgreSQL and AgentRun durable owners'))
186
+ const admission = control(profile, 'agents', 'admission')
187
+ if (admission === 'pg-boss' && (!enabled(profile, 'jobs') || profile.capabilities.jobs.ownership !== 'owned' || !(control(profile, 'jobs', 'roles') ?? []).includes('agent-admission'))) diagnostics.push(diag('DUR-003', 'agents.admission', profileRel, 'owned agent admission requires owned pg-boss jobs with agent-admission role'))
188
+ if (admission === 'shared-contract' && !profile.capabilities.agents.contract && !profile.capabilities.jobs?.contract) diagnostics.push(diag('DUR-003', 'agents.admission', profileRel, 'shared admission requires an explicit qualified service contract'))
189
+ if (!['pg-boss', 'shared-contract'].includes(admission)) diagnostics.push(diag('DUR-003', 'agents.admission', profileRel, 'agents must declare pg-boss or shared-contract admission'))
190
+ }
191
+ if (enabled(profile, 'jobs') && profile.capabilities.jobs.ownership === 'owned' && !control(profile, 'jobs', 'databaseOwner')) diagnostics.push(diag('DUR-003', 'jobs.database-owner', profileRel, 'Owned pg-boss jobs must declare their PostgreSQL owner'))
192
+ if (enabled(profile, 'knowledge') && profile.capabilities.knowledge.ownership === 'owned') {
193
+ if (!control(profile, 'knowledge', 'postgresOwner')) diagnostics.push(diag('DATA-002', 'knowledge.storage-owner', profileRel, 'Owned knowledge must declare its PostgreSQL/pgvector owner'))
194
+ if (control(profile, 'knowledge', 'binaryObjects') === true && !control(profile, 'knowledge', 'objectStorageOwner')) diagnostics.push(diag('DATA-002', 'knowledge.object-storage-owner', profileRel, 'Knowledge with binary objects must declare the object-storage owner'))
195
+ }
196
+ for (const name of ['connectors', 'modelRouting']) if (enabled(profile, name) && control(profile, name, 'credentialBearing') === true && !enabled(profile, 'secrets')) diagnostics.push(diag('SEC-002', 'secrets.dependency', profileRel, `Credential-bearing ${name} requires an enabled production secret-custody capability`))
197
+ if (enabled(profile, 'enterpriseIdentity') && control(profile, 'enterpriseIdentity', 'scim') === true && !enabled(profile, 'secrets')) diagnostics.push(diag('SEC-002', 'secrets.dependency', profileRel, 'SCIM requires an enabled production secret-custody capability'))
198
+ if (enabled(profile, 'notifications') && !control(profile, 'notifications', 'durableIntentOwner')) diagnostics.push(diag('RT-005', 'notifications.durable-intent-owner', profileRel, 'Notifications must declare the durable notification-intent owner'))
199
+ if (control(profile, 'agents', 'untrustedExecution') === true) {
200
+ if (!enabled(profile, 'sandbox')) diagnostics.push(diag('SBX-001', 'sandbox.activation', profileRel, 'untrusted execution activates the sandbox capability'))
201
+ if (control(profile, 'sandbox', 'trustedBroker') !== true) diagnostics.push(diag('SBX-002', 'sandbox.trusted-broker', profileRel, 'untrusted execution requires a trusted capability broker'))
202
+ }
203
+ if (enabled(profile, 'sandbox')) {
204
+ if (control(profile, 'sandbox', 'egress') !== 'deny-by-default') diagnostics.push(diag('SBX-003', 'sandbox.egress', profileRel, 'sandbox egress must be deny-by-default'))
205
+ if (control(profile, 'sandbox', 'databaseCredentials') !== false) diagnostics.push(diag('SBX-002', 'sandbox.credentials', profileRel, 'sandboxes must not receive database credentials'))
206
+ }
207
+ if (enabled(profile, 'enterpriseIdentity') && control(profile, 'enterpriseIdentity', 'scim') === true) {
208
+ if (control(profile, 'enterpriseIdentity', 'organizationMapping') !== true || control(profile, 'enterpriseIdentity', 'deprovisioning') !== true) diagnostics.push(diag('AUTH-006', 'identity.scim-deprovisioning', profileRel, 'SCIM requires organization mapping and complete deprovisioning'))
209
+ }
210
+ if (enabled(profile, 'webControlPlane')) {
211
+ if (profile.environments?.production?.hosting === 'cloudflare-opennext' && profile.capabilities.webControlPlane.placement !== 'open-next-worker') diagnostics.push(diag('HOST-004', 'hosting.web-placement', profileRel, 'Cloudflare/OpenNext requires web placement open-next-worker'))
212
+ if (profile.project?.access !== 'public' && control(profile, 'webControlPlane', 'secureCookies') !== true) diagnostics.push(diag('AUTH-003', 'auth.secure-cookies', profileRel, 'authenticated web access requires secure cookies'))
213
+ if (profile.project?.access !== 'public' && profile.capabilities.webControlPlane.ownership === 'owned' && !enabled(profile, 'secrets')) diagnostics.push(diag('SEC-002', 'secrets.activation', profileRel, 'owned authenticated web production activates secret custody'))
214
+ if (enabled(profile, 'flutter') && (control(profile, 'webControlPlane', 'canonicalApi') !== 'rest-openapi' || control(profile, 'webControlPlane', 'openapiGenerated') !== true)) diagnostics.push(diag('API-002', 'api.generated-contract', profileRel, 'Flutter requires canonical generated REST/OpenAPI'))
215
+ }
216
+ if (profile.environments?.production?.hosting === 'cloudflare-opennext') for (const name of ['agents', 'jobs']) {
217
+ const cap = profile.capabilities?.[name]
218
+ if (cap?.status === 'enabled' && cap.ownership === 'owned' && !['node-service', 'oci-container'].includes(cap.placement)) diagnostics.push(diag(name === 'agents' ? 'RUN-001' : 'RUN-002', `hosting.${name}-placement`, profileRel, `owned ${name} must use external long-running Node/OCI placement outside OpenNext`))
219
+ }
220
+ if (enabled(profile, 'secrets') && profile.capabilities.secrets.ownership === 'owned' && control(profile, 'secrets', 'provider') !== 'openbao') diagnostics.push(diag('SEC-002', 'secrets.production-provider', profileRel, 'owned production secret custody requires OpenBao'))
221
+
222
+ const errors = diagnostics.map(item => `${item.rule}/${item.control}: ${item.message}`)
223
+ return { profile, errors, diagnostics, exceptions: exceptionStates }
224
+ }
225
+
226
+ if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
227
+ const argument = process.argv.slice(2).find(value => !value.startsWith('-'))
228
+ let path = argument ? resolve(argument) : null
229
+ if (!path) {
230
+ const discovered = await resolveProfile(process.cwd())
231
+ if (!discovered) { console.error('error: no .vegastack/architecture.json (or legacy .yaml) profile found; pass a path explicitly'); process.exit(2) }
232
+ if (discovered.legacy) console.error(`deprecation: ${discovered.relative} uses the legacy .yaml name for a JSON document; rename to .vegastack/architecture.json`)
233
+ path = discovered.path
234
+ }
235
+ const json = process.argv.includes('--json')
236
+ const result = await validateProfile(path)
237
+ if (json) console.log(JSON.stringify({ path, valid: result.errors.length === 0, diagnostics: result.diagnostics, exceptions: result.exceptions }, null, 2))
238
+ else if (result.errors.length) for (const error of result.errors) console.error(`FAIL profile: ${error}`)
239
+ else console.log(`validate-profile: valid ${path}`)
240
+ if (result.errors.length) process.exitCode = 1
241
+ }