open-brainy/scripts/emit-contract-manifest.mjs

130 lines
5.4 KiB
JavaScript
Raw Normal View History

feat(contract): declare contract 1, serve three operators, refuse four by name Open Brainy's side of the API contract the accelerated engine published. DECLARED: package.json carries "brainyContract": 1 and the engine states its own via contractVersion() / BRAINY_CONTRACT_VERSION — two engines compare an integer instead of probing prototypes, and a tool reads the package field without importing the engine. Pinned so the two can never drift apart. SERVED: hasAll, noneOf and excludes now work on the index path. The defect underneath was worse than the reported divergence — the metadata index's operator switch had NO DEFAULT CASE, so any operator without a case left the field's match set at its initial [] and find() returned an empty page. Documented, validator-accepted, matcher-implemented operators answering silently wrong. hasAll intersects each element's posting set (an empty operand is vacuously true of every row that has the field), noneOf complements their union, excludes complements contains. REFUSED BY NAME: startsWith, endsWith, matches and length raise INVALID_QUERY naming the operator, the field and the reason. An equality/range posting index cannot evaluate a substring, a pattern or an array length without reading every row — which is the cost this path exists to avoid — so it refuses rather than answering an empty page. Both engines now agree on all 25 tokens and contract 1 has no remaining operator divergence. This is a visible change for a consumer calling those four through find({ where }): an empty page becomes a typed refusal. EMITTED: scripts/emit-contract-manifest.mjs generates docs/api-contract.json from the BUILT surface — prototype doors, exported error classes, the operator sets read out of their single definitions, the field-addressing vocabulary, the health verdicts. Nothing hand-maintained, so a diff between two manifests is a diff between two engines. `--check` fails on a stale manifest, which makes the announce-every-addition duty mechanical rather than remembered. RATIFIED in docs/contract-1-ratification.md: the 41-of-57 required split with the promise spelled out (a refusal is part of a door; deprecation is not removal), the serving-withholding list confirmed exhaustive and identical, the minor/major rule adopted with the announcement duty, the 30 storage seam methods committed as supported surface until Stage 2, and a finding filed against the spec — is / isNot / greaterEqual / lessEqual are listed there as served aliases and have never existed in this engine, which throws INVALID_QUERY on all four.
2026-08-28 10:57:43 -07:00
#!/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).`
)