The manifest's prose pointer named a document that answers a confidential specification, and such a document does not belong in a public repository even in summary. The pointer is dropped — the manifest is generated from this engine's own surface and is self-describing — and the requirement marking it deliberately omits is recorded with the contract's owner rather than here. The standard is written down so this is not relitigated per document.
128 lines
5.4 KiB
JavaScript
128 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, recorded with the contract's owner rather than
|
|
* here. This manifest carries the surface; the promise lives with the contract.
|
|
*
|
|
* 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',
|
|
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).`
|
|
)
|