From 6cf12acafb178cfcc311e467eb2c078cb08060ed Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 15:12:45 +0000 Subject: [PATCH] docs(kernel): name the registry slot on every runtime-services page, and gate it (#9630) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `services.storage` was the only documented runtime accessor whose registry slot has a different name (`file-storage`), and the page never said so. The chapter's binding note tells the reader that no literal `services.*` object is injected and that plugin code resolves through `ctx.getService(...)`; `storage-service.mdx` then spelled its whole surface `services.storage.upload(...)`. Following both together produces `ctx.getService('storage')`, which throws `[Kernel] Service 'storage' not found` — the name they need appears nowhere on the page. Seven of the eight pages were fine only because their accessor and their slot happen to be the same word. The slot is not what is wrong. `file-storage` is canonical: it is the `CoreServiceName` member, `CORE_SERVICE_PROVIDER` maps it to `@objectstack/service-storage`, and the CLI, plugin-email and the HTTP dispatcher all resolve it. So this names the real key on the page rather than renaming the slot or adding a `registerService('storage', ...)` alias. - every chapter page gains a `- **Registry slot:** ` bullet, matching the `Service name:` convention the `kernel/contracts/` pages already use - `storage-service.mdx` gains an "Accessor name vs registry slot" section, and a warning separating the server-side `IStorageService` from the client SDK's differently-shaped `ObjectStackClient.storage` - the chapter binding note states the accessor/slot distinction chapter-wide check-runtime-services-index gains a fifth check so the class cannot recur: the declared slot must be a key some production `registerService` call really registers. Its four existing enumerations are untouched — they read page existence and order, never a line of `packages/`, which is why this shipped green. The sweep is multiline-aware because `plugin-audit` puts its key on the line after the `(`; the self-test pins that shape, and narrowing the sweep to one line makes `audit` report a false absence on the real tree. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XqDQYVU5smx29ts9pAErja --- .../kernel/runtime-services/audit-service.mdx | 1 + .../kernel/runtime-services/data-service.mdx | 1 + .../kernel/runtime-services/email-service.mdx | 1 + .../docs/kernel/runtime-services/index.mdx | 9 + .../kernel/runtime-services/queue-service.mdx | 1 + .../runtime-services/settings-service.mdx | 1 + .../runtime-services/sharing-service.mdx | 1 + .../kernel/runtime-services/sms-service.mdx | 1 + .../runtime-services/storage-service.mdx | 40 ++++ scripts/check-runtime-services-index.mjs | 206 ++++++++++++++++-- 10 files changed, 247 insertions(+), 15 deletions(-) diff --git a/content/docs/kernel/runtime-services/audit-service.mdx b/content/docs/kernel/runtime-services/audit-service.mdx index d7445a5e6c..88841bc725 100644 --- a/content/docs/kernel/runtime-services/audit-service.mdx +++ b/content/docs/kernel/runtime-services/audit-service.mdx @@ -7,6 +7,7 @@ description: Write ingress for audit events that are not CRUD — today, auth se - **Stability:** `experimental` - **Canonical source:** `packages/plugins/plugin-audit/src/auth-event-audit.ts` +- **Registry slot:** `audit` — resolve with `ctx.getService('audit')`. `services.audit` is the kernel service slot that `@objectstack/plugin-audit` registers during `init()`. It is the **write ingress for audit events that are not CRUD** — today, diff --git a/content/docs/kernel/runtime-services/data-service.mdx b/content/docs/kernel/runtime-services/data-service.mdx index 6fdff27be2..97d7bf031a 100644 --- a/content/docs/kernel/runtime-services/data-service.mdx +++ b/content/docs/kernel/runtime-services/data-service.mdx @@ -9,6 +9,7 @@ description: CRUD runtime helper API for records (`query`, `get`, `find`, `creat - **Canonical source:** `packages/client/src/index.ts` — the `ObjectStackClient.data` surface (see [Canonical source](#canonical-source) for why this page names the SDK rather than a `contracts/*-service.ts` interface) +- **Registry slot:** `data` — resolve with `ctx.getService('data')`. diff --git a/content/docs/kernel/runtime-services/email-service.mdx b/content/docs/kernel/runtime-services/email-service.mdx index 111994c822..e1200cb603 100644 --- a/content/docs/kernel/runtime-services/email-service.mdx +++ b/content/docs/kernel/runtime-services/email-service.mdx @@ -7,6 +7,7 @@ description: Outbound email delivery and template rendering APIs. - **Stability:** `stable` - **Canonical source:** `packages/spec/src/contracts/email-service.ts` +- **Registry slot:** `email` — resolve with `ctx.getService('email')`. ## Methods diff --git a/content/docs/kernel/runtime-services/index.mdx b/content/docs/kernel/runtime-services/index.mdx index fbabe9c299..e2387fce74 100644 --- a/content/docs/kernel/runtime-services/index.mdx +++ b/content/docs/kernel/runtime-services/index.mdx @@ -12,6 +12,15 @@ repo's runtime, hook bodies reach scoped data operations through `ctx.api`, while plugin code resolves any registered service through `ctx.getService(...)` — a literal `services.*` object is not injected into hook contexts by the open framework today. Managed runtimes provide the `services.*` binding directly. + +**`services.*` is the accessor spelling, not necessarily the registry slot.** The name +after `services.` is what this chapter documents each surface under; the string you pass +to `ctx.getService(...)` is the slot the implementation is registered by. They are the +same word for every service below **except storage**, whose slot is `file-storage` +(there is no `storage` alias — see +[`services.storage`](/docs/kernel/runtime-services/storage-service)). Each page states +its own slot in a **Registry slot** bullet; `scripts/check-runtime-services-index.mjs` +holds those to a real `registerService` call. This chapter documents the runtime `services.*` APIs used in hook/action/flow/plugin code: diff --git a/content/docs/kernel/runtime-services/queue-service.mdx b/content/docs/kernel/runtime-services/queue-service.mdx index dbd1d43ac1..82d3213c68 100644 --- a/content/docs/kernel/runtime-services/queue-service.mdx +++ b/content/docs/kernel/runtime-services/queue-service.mdx @@ -7,6 +7,7 @@ description: Async queue publish/subscribe and DLQ operations. - **Stability:** `stable` - **Canonical source:** `packages/spec/src/contracts/queue-service.ts` +- **Registry slot:** `queue` — resolve with `ctx.getService('queue')`. ## Methods diff --git a/content/docs/kernel/runtime-services/settings-service.mdx b/content/docs/kernel/runtime-services/settings-service.mdx index 0d0eadebb3..e75d0c23a6 100644 --- a/content/docs/kernel/runtime-services/settings-service.mdx +++ b/content/docs/kernel/runtime-services/settings-service.mdx @@ -7,6 +7,7 @@ description: Namespace-based configuration service with OS env/global/tenant/use - **Stability:** `stable` - **Canonical source:** `packages/services/service-settings/src/settings-service.ts` +- **Registry slot:** `settings` — resolve with `ctx.getService('settings')`. ## Key Methods diff --git a/content/docs/kernel/runtime-services/sharing-service.mdx b/content/docs/kernel/runtime-services/sharing-service.mdx index b6cde6390b..e043ff9094 100644 --- a/content/docs/kernel/runtime-services/sharing-service.mdx +++ b/content/docs/kernel/runtime-services/sharing-service.mdx @@ -7,6 +7,7 @@ description: Record-level sharing and editability checks. - **Stability:** `stable` - **Canonical source:** `packages/spec/src/contracts/sharing-service.ts` +- **Registry slot:** `sharing` — resolve with `ctx.getService('sharing')`. ## Methods diff --git a/content/docs/kernel/runtime-services/sms-service.mdx b/content/docs/kernel/runtime-services/sms-service.mdx index 8992e07fc0..66d78a5796 100644 --- a/content/docs/kernel/runtime-services/sms-service.mdx +++ b/content/docs/kernel/runtime-services/sms-service.mdx @@ -7,6 +7,7 @@ description: Outbound SMS delivery through pluggable providers (Aliyun SMS, Twil - **Stability:** `stable` - **Canonical source:** `packages/spec/src/contracts/sms-service.ts` +- **Registry slot:** `sms` — resolve with `ctx.getService('sms')`. ## Methods diff --git a/content/docs/kernel/runtime-services/storage-service.mdx b/content/docs/kernel/runtime-services/storage-service.mdx index 6c71dfb783..deaa75867c 100644 --- a/content/docs/kernel/runtime-services/storage-service.mdx +++ b/content/docs/kernel/runtime-services/storage-service.mdx @@ -7,6 +7,46 @@ description: File/object storage contract for upload/download and presigned URL - **Stability:** `stable` - **Canonical source:** `packages/spec/src/contracts/storage-service.ts` +- **Registry slot:** `file-storage` — **not** `storage`; resolve with + `ctx.getService('file-storage')`. See [Accessor name vs registry + slot](#accessor-name-vs-registry-slot). + +## Accessor name vs registry slot + +`services.storage` is this chapter's **accessor spelling** — the name the contract surface +is documented under. The **registry slot** is the string the kernel actually keys the +implementation by, and for storage the two differ: + +| | value | +|:--|:--| +| Documented accessor | `services.storage` | +| Registry slot | `file-storage` | +| Resolve with | `ctx.getService('file-storage')` | + +This is the **only** service in this chapter where they differ; for the other seven the +accessor and the slot are the same word. There is no `storage` alias — nothing calls +`registerService('storage', ...)` anywhere in the platform — so resolving the accessor +spelling throws rather than returning an empty value: + +```ts +ctx.getService('storage'); // ✗ throws: [Kernel] Service 'storage' not found +ctx.getService('file-storage'); // ✓ the IStorageService documented below +``` + +`file-storage` is the canonical spelling, not an implementation detail: it is the member +listed in `CoreServiceName` (`packages/spec/src/system/core-services.zod.ts`), +`CORE_SERVICE_PROVIDER` maps it to `@objectstack/service-storage`, and it is the key the +`/api/v1/discovery` document reports this service's availability under. + + +A second `storage` accessor exists and is easy to reach for by mistake. +`ObjectStackClient.storage` (`packages/client/src/index.ts`) is the **browser/HTTP client** +surface — `upload(file, scope)`, `getDownloadUrl(fileId)`, `getPresignedUrl(req)`, +`initChunkedUpload(req)` — which calls `/api/v1/storage` over the wire. It is a different +shape from the server-side `IStorageService` documented on this page, which takes storage +**keys** and returns `Buffer`s. Neither one is reachable through +`ctx.getService('storage')`. + ## Core Methods diff --git a/scripts/check-runtime-services-index.mjs b/scripts/check-runtime-services-index.mjs index 689dc761b7..facfee0243 100644 --- a/scripts/check-runtime-services-index.mjs +++ b/scripts/check-runtime-services-index.mjs @@ -1,8 +1,9 @@ #!/usr/bin/env node // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. // -// check-runtime-services-index (#9604) -- hold the runtime-services chapter's -// two INDEX lists to the pages that actually exist. +// check-runtime-services-index (#9604, #9630) -- hold the runtime-services +// chapter's two INDEX lists to the pages that actually exist, and each page's +// documented registry slot to a real `registerService` call. // // node scripts/check-runtime-services-index.mjs // node scripts/check-runtime-services-index.mjs --self-test # verify the checker itself @@ -17,6 +18,9 @@ // 2. `runtime-services/index.mdx` -> "This chapter documents ..." bullets // 3. `kernel/index.mdx` -> the `services.*` table // +// and each page additionally claims the registry slot it is resolved by (see +// "The fourth claim" below). +// // None of the three is generated, so each drifts from the tree one edit at a // time, and nothing reads them: `check:docs-audit-scope` derives WHICH pages the // docs-accuracy audit covers, never whether an index enumerates them. #9604 @@ -31,6 +35,40 @@ // copy from, so a short list is read as a fact about the platform's surface. // Declared = enforced. // +// ## The fourth claim: which key `ctx.getService()` actually takes (#9630) +// +// The three enumerations above are all about page EXISTENCE and ORDER. None of +// them reads a line of `packages/`, so every one of them stayed green over a +// page that documented an accessor the kernel cannot resolve -- and one did: +// +// * `storage-service.mdx` spelled its whole surface `services.storage.*`; +// * the chapter's own binding note tells the reader that no literal +// `services.*` object is injected and that plugin code goes through +// `ctx.getService(...)`; +// * the registered slot is `file-storage` (`storage-service-plugin.ts:237`), +// and NOTHING registers `storage`. +// +// Following both instructions together produced `ctx.getService('storage')`, +// which throws `[Kernel] Service 'storage' not found`. Seven of the eight pages +// were fine only because their accessor and their slot happened to be the same +// word, so the chapter had exactly one unannounced exception and no way to +// notice a second one. +// +// So each page now declares `- **Registry slot:** \`\``, and this gate holds +// that declaration to a production `registerService`/`registerServiceFactory` +// call under `packages/`. Note what is deliberately NOT required: the slot does +// not have to equal the accessor. `file-storage` is canonical -- it is the +// `CoreServiceName` member, `CORE_SERVICE_PROVIDER` maps it, and the CLI, email +// plugin and HTTP dispatcher all resolve it -- so a docs defect must never be +// "fixed" by renaming the slot or adding a `registerService('storage', ...)` +// alias. The rule is only: say which key you mean, and be right. +// +// Test files are excluded from the sweep on purpose: a slot only a fixture +// registers is not a platform surface a reader can resolve. The sweep is also +// multiline-aware, because `plugin-audit` puts its key on the line after the +// `(` -- a line-at-a-time grep reports a confident zero for it, and a wrongly +// measured absence is precisely the failure this check exists to rule out. +// // ## What "derived" means here // // The pages on disk are the source of truth -- they are the thing a reader can @@ -65,8 +103,8 @@ // natural follow-up; until then a green here means "the three enumerations agree // with the tree", which is exactly what the summary line says. -import { readFileSync, readdirSync, existsSync, mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; -import { join, dirname } from 'node:path'; +import { readFileSync, readdirSync, statSync, existsSync, mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { join, dirname, relative, sep } from 'node:path'; import { tmpdir } from 'node:os'; import { fileURLToPath } from 'node:url'; @@ -75,13 +113,29 @@ const repoRoot = () => join(HERE, '..'); const CHAPTER_DIR = 'content/docs/kernel/runtime-services'; const KERNEL_INDEX = 'content/docs/kernel/index.mdx'; +const PACKAGES_DIR = 'packages'; const PAGE_SUFFIX = '-service.mdx'; const META_SUFFIX = '-service'; +/** Directories that never hold a production registration. */ +const SKIP_DIRS = new Set(['node_modules', 'dist', 'build', '.turbo', '.next', 'coverage']); +/** A registration in one of these is a fixture, not a platform surface. */ +const IS_TEST_PATH = (rel) => + /\.(test|spec|conformance|e2e)\./.test(rel) || /(^|\/)(test|tests|__tests__|__mocks__|mocks|fixtures)\//.test(rel); + +// MULTILINE-AWARE ON PURPOSE. `\s*` spans newlines, so a registration whose key +// sits on the line after the `(` is still found -- `plugin-audit` really is +// written that way (`audit-plugin.ts:117`), and a `^.*registerService\('x'` style +// line-at-a-time grep reports a confident ZERO for it. An absence measured with +// the wrong tool is the failure mode this whole check exists to rule out, so the +// self-test pins the split-line form explicitly. +const REGISTER_RE = /register(?:Service|ServiceFactory)\s*(?:<[^>]*>)?\s*\(\s*['"`]([^'"`]+)['"`]/g; + // --------------------------------------------------------------------------- // Derivation -/** Accessor names from the pages that exist, plus each page's declared title. */ +/** Accessor names from the pages that exist, plus each page's declared title + * and its declared registry slot. */ export function readPages(chapterDir) { return readdirSync(chapterDir) .filter((f) => f.endsWith(PAGE_SUFFIX)) @@ -90,10 +144,37 @@ export function readPages(chapterDir) { const name = file.slice(0, -PAGE_SUFFIX.length); const text = readFileSync(join(chapterDir, file), 'utf8'); const m = /^title:\s*(.+?)\s*$/m.exec(text); - return { name, file, title: m ? m[1] : null }; + const slot = /^-\s+\*\*Registry slot:\*\*\s+`([^`]+)`/m.exec(text); + return { name, file, title: m ? m[1] : null, slot: slot ? slot[1] : null }; }); } +/** Every service key a PRODUCTION `registerService` / `registerServiceFactory` + * call registers under `packages/`, mapped to its call sites. */ +export function readRegisteredSlots(root) { + const found = new Map(); + const pkgRoot = join(root, PACKAGES_DIR); + if (!existsSync(pkgRoot)) return found; + const walk = (dir) => { + for (const entry of readdirSync(dir)) { + if (SKIP_DIRS.has(entry)) continue; + const full = join(dir, entry); + if (statSync(full).isDirectory()) { walk(full); continue; } + if (!/\.(ts|tsx|mts|js|mjs)$/.test(entry) || entry.endsWith('.d.ts')) continue; + const rel = relative(root, full).split(sep).join('/'); + if (IS_TEST_PATH(rel)) continue; + const text = readFileSync(full, 'utf8'); + for (const m of text.matchAll(REGISTER_RE)) { + const line = text.slice(0, m.index).split('\n').length; + if (!found.has(m[1])) found.set(m[1], []); + found.get(m[1]).push(`${rel}:${line}`); + } + } + }; + walk(pkgRoot); + return found; +} + /** `pages` entries that name a service page, in nav order. */ export function readMetaOrder(chapterDir) { const raw = JSON.parse(readFileSync(join(chapterDir, 'meta.json'), 'utf8')); @@ -118,7 +199,7 @@ export function readKernelTable(kernelText) { const missing = (expected, actual) => expected.filter((n) => !actual.includes(n)); const extra = (expected, actual) => actual.filter((n) => !expected.includes(n)); -export function check({ pages, metaOrder, chapterList, kernelTable }) { +export function check({ pages, metaOrder, chapterList, kernelTable, registeredSlots }) { const findings = []; const add = (where, msg) => findings.push({ where, msg }); @@ -159,11 +240,46 @@ export function check({ pages, metaOrder, chapterList, kernelTable }) { } } + // 5. Each page names the registry slot it is really resolved by, and that + // slot is a key something actually registers (#9630). The four checks + // above hold page EXISTENCE and ORDER to each other; none of them reads a + // single line of `packages/`, so a page could document a slot nothing has + // ever registered and stay green -- which is exactly what shipped: + // `services.storage` was resolvable only as `file-storage`, the accessor + // and the slot differed on that one page alone, and the page never said + // so. A reader following the chapter's own binding note wrote + // `ctx.getService('storage')` and got a throw. + // + // The accessor is NOT required to equal the slot -- `file-storage` is + // canonical (`CoreServiceName`, `CORE_SERVICE_PROVIDER`, three internal + // consumers) and renaming it to satisfy a docs page would be backwards. + // What is required is that the page SAYS which one it is, and that what it + // says is true. + if (registeredSlots) { + for (const p of pages) { + if (!p.slot) { + add(`${CHAPTER_DIR}/${p.file}`, `no \`- **Registry slot:** \`\`\` bullet -- state the key \`ctx.getService()\` resolves this surface by (it is \`${p.name}\` unless the page says otherwise)`); + continue; + } + if (!registeredSlots.has(p.slot)) { + const near = [...registeredSlots.keys()].filter((k) => k.includes(p.name) || p.name.includes(k)); + add( + `${CHAPTER_DIR}/${p.file}`, + `declares registry slot \`${p.slot}\`, but no production registerService() call under ${PACKAGES_DIR}/ registers that key` + + (near.length ? ` -- did you mean ${near.map((k) => `\`${k}\``).join(' / ')}?` : ''), + ); + } + } + } + return findings; } -export function summarise({ pages, chapterList, kernelTable }) { - return `${pages.length} chapter page(s) vs meta.json "pages", ${chapterList.length} chapter-list bullet(s) and ${kernelTable.length} kernel/index.mdx table row(s)`; +export function summarise({ pages, chapterList, kernelTable, registeredSlots }) { + const slots = registeredSlots + ? `, and ${pages.filter((p) => p.slot).length} declared registry slot(s) vs ${registeredSlots.size} registered key(s)` + : ''; + return `${pages.length} chapter page(s) vs meta.json "pages", ${chapterList.length} chapter-list bullet(s) and ${kernelTable.length} kernel/index.mdx table row(s)${slots}`; } // --------------------------------------------------------------------------- @@ -174,8 +290,9 @@ function run(root) { const metaOrder = readMetaOrder(chapterDir); const chapterList = readChapterList(readFileSync(join(chapterDir, 'index.mdx'), 'utf8')); const kernelTable = readKernelTable(readFileSync(join(root, KERNEL_INDEX), 'utf8')); + const registeredSlots = readRegisteredSlots(root); if (pages.length === 0) throw new Error(`no ${PAGE_SUFFIX} pages found under ${CHAPTER_DIR} -- refusing to report OK over an empty set`); - const input = { pages, metaOrder, chapterList, kernelTable }; + const input = { pages, metaOrder, chapterList, kernelTable, registeredSlots }; return { findings: check(input), summary: summarise(input) }; } @@ -191,10 +308,12 @@ function main() { for (const f of findings) console.error(` • ${f.where}: ${f.msg}`); console.error('\n The pages on disk are the source of truth. Add the missing entry (or delete the stale'); console.error(' one) so all three enumerations agree; the chapter list follows meta.json "pages" order.'); - console.error(' The "Source of Truth" canonical-source list is deliberately not checked -- see the header.'); + console.error(' A "Registry slot" finding is different: the page must name the key ctx.getService() really'); + console.error(' resolves it by. Fix the PAGE, never the slot -- the registered key is the contract (#9630).'); + console.error(' The "Source of Truth" canonical-source list is deliberately not checked -- see the header.'); process.exit(1); } - console.log(`✓ check-runtime-services-index: ${summary} -- all three enumerations agree (Source-of-Truth list not in scope).`); + console.log(`✓ check-runtime-services-index: ${summary} -- all enumerations agree and every declared slot is really registered (Source-of-Truth list not in scope).`); } // --------------------------------------------------------------------------- @@ -212,9 +331,34 @@ function selfTest() { mkdirSync(join(dir, 'content/docs/kernel'), { recursive: true }); const names = ['data', 'email', 'sms']; - const writeTree = ({ pages = names, meta = names, list = names, table = names, titleFor = (n) => `services.${n}`, hrefFor = (n) => `${n}-service` } = {}) => { + // A synthetic `packages/` tree so check 5 has a registry to compare against. + // `email` is registered on the SAME line; `sms` is registered with its key on + // the line AFTER the `(` -- the `plugin-audit` shape that defeats a + // line-at-a-time grep. Both must be found, or the "no such slot" finding + // below would be a false positive rather than a measurement. + const pkg = join(dir, PACKAGES_DIR, 'svc/src'); + mkdirSync(pkg, { recursive: true }); + writeFileSync(join(pkg, 'plugin.ts'), [ + "ctx.registerService('data', engine);", + "ctx.registerService('email', mailer);", + 'ctx.registerService(', + " 'sms',", + ' texter,', + ');', + "ctx.registerServiceFactory('file-storage', async () => adapter);", + ].join('\n')); + // Registered ONLY by a test file: must NOT count as a platform surface. + mkdirSync(join(dir, PACKAGES_DIR, 'svc/src/__tests__'), { recursive: true }); + writeFileSync(join(dir, PACKAGES_DIR, 'svc/src/__tests__/fake.ts'), "ctx.registerService('testonly', {});"); + + const slotFor = (n) => n; + const writeTree = ({ pages = names, meta = names, list = names, table = names, titleFor = (n) => `services.${n}`, hrefFor = (n) => `${n}-service`, slot = slotFor } = {}) => { for (const f of readdirSync(chapter)) rmSync(join(chapter, f), { force: true }); - for (const n of pages) writeFileSync(join(chapter, `${n}${PAGE_SUFFIX}`), `---\ntitle: ${titleFor(n)}\n---\n`); + for (const n of pages) { + const declared = slot(n); + const bullet = declared === null ? '' : `\n- **Registry slot:** \`${declared}\` — resolve with \`ctx.getService('${declared}')\`.\n`; + writeFileSync(join(chapter, `${n}${PAGE_SUFFIX}`), `---\ntitle: ${titleFor(n)}\n---\n${bullet}`); + } writeFileSync(join(chapter, 'meta.json'), JSON.stringify({ pages: ['index', ...meta.map((n) => `${n}${META_SUFFIX}`), 'examples'] })); writeFileSync(join(chapter, 'index.mdx'), `# x\n\n${list.map((n) => `- \`services.${n}\``).join('\n')}\n\n## Source of Truth\n\n- Security: \`packages/spec/src/contracts/security-service.ts\`\n`); writeFileSync(join(dir, KERNEL_INDEX), `# k\n\n${table.map((n) => `| [\`services.${n}\`](/docs/kernel/runtime-services/${hrefFor(n)}) | stable | d |`).join('\n')}\n`); @@ -241,6 +385,38 @@ function selfTest() { assert(lying.some((f) => f.msg.includes('expected "services.sms"')), 'a page whose title contradicts its filename is caught'); assert(lying.every((f) => f.where.endsWith(`sms${PAGE_SUFFIX}`)), 'the title premise short-circuits: no set comparison runs over a page that lies about its name'); + // ── Check 5: declared registry slot vs the real registry (#9630) ──────── + // The defect itself: a page documenting an accessor that resolves to nothing. + const ghost = findingsFor({ slot: (n) => (n === 'sms' ? 'storage' : n) }); + assert( + ghost.some((f) => f.where.endsWith(`sms${PAGE_SUFFIX}`) && f.msg.includes('no production registerService() call')), + `a page declaring a slot nothing registers is caught -- got ${JSON.stringify(ghost)}`, + ); + assert(ghost.some((f) => f.msg.includes('did you mean')), 'the finding suggests the near-miss key rather than only rejecting'); + + // An accessor that legitimately differs from its slot is ACCEPTED -- the rule + // is "say which key you mean and be right", never "the two must match". + assert( + findingsFor({ slot: (n) => (n === 'sms' ? 'file-storage' : n) }).length === 0, + 'a page whose slot deliberately differs from its accessor is silent when that slot is really registered', + ); + + // The split-line registration is REACHED, not merely tolerated: `sms` is only + // registered in the multiline form, so a line-at-a-time scan would flag it. + assert(findingsFor().every((f) => !f.where.endsWith(`sms${PAGE_SUFFIX}`)), 'a registration whose key sits on the next line is found (the plugin-audit shape)'); + + // A test-only registration does not make a slot real. + assert( + findingsFor({ slot: (n) => (n === 'sms' ? 'testonly' : n) }).some((f) => f.msg.includes('no production registerService() call')), + 'a slot registered only under a test path is not accepted as a platform surface', + ); + + // A page that declares no slot at all is caught -- silence is not a pass. + assert( + findingsFor({ slot: (n) => (n === 'sms' ? null : n) }).some((f) => f.where.endsWith(`sms${PAGE_SUFFIX}`) && f.msg.includes('Registry slot')), + 'a page with no Registry slot bullet is caught', + ); + // ── Refuses to report OK over nothing ─────────────────────────────────── let threw = false; try { findingsFor({ pages: [], meta: [], list: [], table: [] }); } catch { threw = true; } @@ -254,7 +430,7 @@ function selfTest() { for (const f of failures) console.error(` • ${f}`); process.exit(1); } - console.log(`✓ check-runtime-services-index --self-test: ${checked} assertions over a temp fixture (real run() path); every limb -- chapter list, kernel table, meta.json, order, href, title premise, empty tree -- observed FAILING and observed silent.`); + console.log(`✓ check-runtime-services-index --self-test: ${checked} assertions over a temp fixture (real run() path); every limb -- chapter list, kernel table, meta.json, order, href, title premise, registry slot (incl. the split-line registration), empty tree -- observed FAILING and observed silent.`); } if (process.argv.includes('--self-test')) selfTest();