130 lines
5.4 KiB
JavaScript
130 lines
5.4 KiB
JavaScript
|
|
#!/usr/bin/env node
|
||
|
|
/**
|
||
|
|
* Emit this build's API-contract manifest to docs/api-contract.json.
|
||
|
|
*
|
||
|
|
* WHY IT IS GENERATED, NOT WRITTEN: a hand-kept list of doors drifts from the
|
||
|
|
* code the first time somebody adds one. This reads the surface the build
|
||
|
|
* actually exposes — the prototype's own methods and accessors, the exported
|
||
|
|
* error classes, the `where` operator sets, the field-addressing vocabulary,
|
||
|
|
* the health verdicts — so a diff between two engines' manifests is a diff
|
||
|
|
* between two engines, never between two authors.
|
||
|
|
*
|
||
|
|
* Requirement marking (required / optional per door) is NOT derivable from the
|
||
|
|
* surface; it is a commitment, and it lives in docs/contract-1-ratification.md.
|
||
|
|
* This manifest carries the surface; that document carries the promise.
|
||
|
|
*
|
||
|
|
* Usage: node scripts/emit-contract-manifest.mjs [--check]
|
||
|
|
* --check exits non-zero when the committed manifest is stale.
|
||
|
|
*/
|
||
|
|
|
||
|
|
import { writeFileSync, readFileSync, existsSync } from 'node:fs'
|
||
|
|
import { join, dirname } from 'node:path'
|
||
|
|
import { fileURLToPath } from 'node:url'
|
||
|
|
|
||
|
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
|
||
|
|
const OUT = join(ROOT, 'docs', 'api-contract.json')
|
||
|
|
|
||
|
|
const { Brainy } = await import(join(ROOT, 'dist', 'brainy.js'))
|
||
|
|
const errorsModule = await import(join(ROOT, 'dist', 'errors', 'brainyError.js'))
|
||
|
|
const versionModule = await import(join(ROOT, 'dist', 'utils', 'version.js'))
|
||
|
|
const fieldAddressing = await import(join(ROOT, 'dist', 'db', 'fieldAddressing.js'))
|
||
|
|
|
||
|
|
/** Every own method and accessor on the class's prototype, minus the private ones. */
|
||
|
|
function surfaceOf(ctor) {
|
||
|
|
const doors = []
|
||
|
|
for (const name of Object.getOwnPropertyNames(ctor.prototype)) {
|
||
|
|
if (name === 'constructor' || name.startsWith('_')) continue
|
||
|
|
const descriptor = Object.getOwnPropertyDescriptor(ctor.prototype, name)
|
||
|
|
if (!descriptor) continue
|
||
|
|
if (typeof descriptor.value === 'function') {
|
||
|
|
doors.push({ name, kind: 'method', arity: descriptor.value.length })
|
||
|
|
} else if (descriptor.get) {
|
||
|
|
doors.push({ name, kind: 'accessor' })
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return doors.sort((a, b) => a.name.localeCompare(b.name))
|
||
|
|
}
|
||
|
|
|
||
|
|
const errors = Object.entries(errorsModule)
|
||
|
|
.filter(([name, value]) => typeof value === 'function' && /Error$/.test(name))
|
||
|
|
.map(([name]) => name)
|
||
|
|
.sort()
|
||
|
|
|
||
|
|
// The operator sets, read from the engine's own refusal message so the
|
||
|
|
// manifest can never disagree with the validator.
|
||
|
|
const filterSource = readFileSync(join(ROOT, 'src', 'utils', 'metadataFilter.ts'), 'utf-8')
|
||
|
|
const acceptedMatch = filterSource.match(/const VALUE_OPERATORS = new Set<string>\(\[([\s\S]*?)\]\)/)
|
||
|
|
if (!acceptedMatch) throw new Error('VALUE_OPERATORS not found — the manifest refuses to guess')
|
||
|
|
const accepted = [...acceptedMatch[1].matchAll(/'([^']+)'/g)].map((m) => m[1]).sort()
|
||
|
|
|
||
|
|
const indexSource = readFileSync(join(ROOT, 'src', 'utils', 'metadataIndex.ts'), 'utf-8')
|
||
|
|
const refusedByIndex = ['endsWith', 'length', 'matches', 'startsWith'].filter((op) =>
|
||
|
|
// Proven by the refusal path: these are the tokens with no case in the
|
||
|
|
// index's operator switch, so they fall to its default and are refused.
|
||
|
|
!new RegExp(`case '${op}':`).test(indexSource)
|
||
|
|
)
|
||
|
|
const servedOnIndex = accepted.filter((op) => !refusedByIndex.includes(op))
|
||
|
|
|
||
|
|
const manifest = {
|
||
|
|
contractVersion: versionModule.contractVersion(),
|
||
|
|
engine: '@soulcraftlabs/brainy',
|
||
|
|
prose: 'docs/contract-1-ratification.md',
|
||
|
|
compatibility: {
|
||
|
|
minor:
|
||
|
|
'additive — a new optional door, a new served operator, a new error class; every existing implementation still conforms',
|
||
|
|
major:
|
||
|
|
'breaking — a door removed, an answer narrowed, an ordering law changed, an optional door promoted to required, or an operator moved from served to refused'
|
||
|
|
},
|
||
|
|
doors: surfaceOf(Brainy),
|
||
|
|
errors,
|
||
|
|
operators: {
|
||
|
|
accepted,
|
||
|
|
servedOnIndexPath: servedOnIndex,
|
||
|
|
refusedByIndexPath: refusedByIndex,
|
||
|
|
combinators: ['allOf', 'anyOf', 'not']
|
||
|
|
},
|
||
|
|
fieldAddressing: {
|
||
|
|
systemKeyPrefix: 'system.',
|
||
|
|
systemEntityScalars: [...(fieldAddressing.SYSTEM_ENTITY_SCALARS ?? [])].sort(),
|
||
|
|
systemRelationScalars: [...(fieldAddressing.SYSTEM_RELATION_SCALARS ?? [])].sort(),
|
||
|
|
plumbingFields: [...(fieldAddressing.PLUMBING_FIELDS ?? [])].sort()
|
||
|
|
},
|
||
|
|
health: {
|
||
|
|
verdicts: ['pass', 'warn', 'fail'],
|
||
|
|
healKinds: ['none', 'repair', 'rebuild'],
|
||
|
|
servingWithholdingInvariants: [
|
||
|
|
'index-initialized',
|
||
|
|
'durable-state-present',
|
||
|
|
'manifest-residency',
|
||
|
|
'replay-clean',
|
||
|
|
'strand-latch'
|
||
|
|
]
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const rendered = `${JSON.stringify(manifest, null, 2)}\n`
|
||
|
|
|
||
|
|
if (process.argv.includes('--check')) {
|
||
|
|
if (!existsSync(OUT)) {
|
||
|
|
console.error(`docs/api-contract.json is missing — run: node scripts/emit-contract-manifest.mjs`)
|
||
|
|
process.exit(1)
|
||
|
|
}
|
||
|
|
if (readFileSync(OUT, 'utf-8') !== rendered) {
|
||
|
|
console.error(
|
||
|
|
`docs/api-contract.json is STALE — the public surface changed. Re-emit it and announce ` +
|
||
|
|
`the addition (minor = additive; a removal is a contract major).`
|
||
|
|
)
|
||
|
|
process.exit(1)
|
||
|
|
}
|
||
|
|
console.log(`docs/api-contract.json is current (${manifest.doors.length} doors, contract ${manifest.contractVersion}).`)
|
||
|
|
process.exit(0)
|
||
|
|
}
|
||
|
|
|
||
|
|
writeFileSync(OUT, rendered)
|
||
|
|
console.log(
|
||
|
|
`Wrote docs/api-contract.json — contract ${manifest.contractVersion}, ` +
|
||
|
|
`${manifest.doors.length} doors, ${manifest.errors.length} error classes, ` +
|
||
|
|
`${manifest.operators.accepted.length} operators ` +
|
||
|
|
`(${manifest.operators.refusedByIndexPath.length} refused by the index path).`
|
||
|
|
)
|