From 4ebac563afc9017c407b66eabc46fc4f694fe9de Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 11:52:55 +0000 Subject: [PATCH] feat(spec): gate the shipped llms.txt against the real package surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `packages/spec/llms.txt` ships in the npm tarball as context for AI consumers and is hand-kept with no generator, so nothing ever re-derived what it asserts. It had drifted: eleven advertised symbols existed in no entry point, two advertised packages did not exist, the schema-inventory heading disagreed with the sum of its own table, and the package heading claimed 19 against a real 68. Add `check:llms-txt`, which re-derives every checkable claim: symbols against the checked-in api-surface/ shards, subpaths against the manifest `exports`, per-domain counts against src//**/*.zod.ts, and the package table against the workspace. Symbol claims resolve at the strictness their position earns — namespace bullets and fenced imports name an entry point and must resolve from it, the architecture overview resolves against the union. Repair the claims the gate was otherwise born red on. Deliberately no `gen:llms-txt`: the numbers are not the claim, the prose beside them is. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Rxnd8cyFnoU8V5y21PaTsy --- .changeset/llms-txt-freshness-gate.md | 28 + .github/workflows/lint.yml | 17 + packages/spec/llms.txt | 76 +- packages/spec/package.json | 1 + packages/spec/scripts/check-generated.ts | 13 + packages/spec/scripts/check-llms-txt.ts | 998 +++++++++++++++++++++++ 6 files changed, 1099 insertions(+), 34 deletions(-) create mode 100644 .changeset/llms-txt-freshness-gate.md create mode 100644 packages/spec/scripts/check-llms-txt.ts diff --git a/.changeset/llms-txt-freshness-gate.md b/.changeset/llms-txt-freshness-gate.md new file mode 100644 index 0000000000..bbfccdcb46 --- /dev/null +++ b/.changeset/llms-txt-freshness-gate.md @@ -0,0 +1,28 @@ +--- +'@objectstack/spec': patch +--- + +Gate the shipped `llms.txt` against the real package, and repair the claims that had rotted. + +`packages/spec/llms.txt` ships in the npm tarball as context for AI consumers and is +hand-kept with no generator, so nothing ever re-derived what it asserts. It had drifted +badly: eleven advertised symbols existed in no entry point (`IUIService` — removed in +v11 — plus `ThemeSchema`, `IdentitySchema`, `PolicySchema`, `ContractSchema`, +`EndpointSchema`, `RAGPipelineSchema`, `MCPSchema`, `FilterSchema`, `AnalyticsSchema`, +`FormSchema`), two advertised packages did not exist (`@objectstack/nextjs`, +`@objectstack/nestjs`), the schema-inventory heading disagreed with the sum of its own +table (171 vs 170) and with the tree (207), and the package heading claimed 19 against a +real 68. An agent reading the file wrote imports that do not resolve. + +New gate `check:llms-txt` re-derives every checkable claim on every PR: advertised +symbols against the checked-in `api-surface/` shards, `@objectstack/spec/x` subpaths +against the manifest `exports`, the per-domain schema counts against +`src//**/*.zod.ts`, and the package table against the workspace. Symbol claims +are resolved at the strictness their position earns — namespace bullets and fenced +imports name an entry point and must resolve from it, while the architecture overview +resolves against the union. Prose, code-fence bodies and `N+` lower-bound figures are +out of population and the script header says why. + +There is deliberately no `gen:llms-txt`: the numbers are not the claim, the prose beside +them is, and restamping a count without re-reading its row would turn a loud staleness +into a silent lie. diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index d7a0c718cd..5d03097e4e 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -2973,6 +2973,23 @@ jobs: - name: Check spec pin citations still name the pin this repo builds against run: pnpm --filter @objectstack/spec check:objectui-pin-citations + # #11344. `packages/spec/llms.txt` is hand-kept, has no generator, and + # SHIPS in the published tarball as context for AI consumers — the + # audience least able to notice staleness and most likely to turn a claim + # into code. When this landed the file advertised eleven symbols that + # existed in no entry point (`IUIService` among them, removed in v11), two + # packages that did not exist, and a schema table whose heading disagreed + # with the sum of its own rows. + # + # Reads `src/`, the checked-in `api-surface/` shards, the package manifest + # and pnpm-workspace.yaml — no build — so it belongs in this pre-build + # source-audit group beside its nearest sibling above. Unfiltered on + # purpose: the counts are measured against the whole workspace and against + # every schema domain, so a PR that adds a package or a schema anywhere + # can falsify this file without touching packages/spec/**. + - name: Check the shipped llms.txt still describes this package + run: pnpm --filter @objectstack/spec check:llms-txt + # Lane 2 of 4 behind the required `TypeScript Type Check` context. The # aggregator job at the bottom of this file explains the split, holds the # contract, and is the thing the merge queue actually requires — read it diff --git a/packages/spec/llms.txt b/packages/spec/llms.txt index 7460dd117c..a4f16e8259 100644 --- a/packages/spec/llms.txt +++ b/packages/spec/llms.txt @@ -14,14 +14,15 @@ It is divided into three layers, reflected in the import paths: - **`FieldSchema`**: Defines columns with 46+ types (`text`, `number`, `lookup`, `formula`, `vector`, etc.). - **`QuerySchema`**: A JSON-based AST for querying data (replaces SQL). - **`IDataDriver`**: The authoritative contract for database adapters (SQL, NoSQL, Memory). -- **`AnalyticsSchema`**: OLAP cubes, measures, and dimensions. + Exported from `@objectstack/spec/contracts` — see §6. +- **`CubeSchema`**: OLAP cubes, measures, and dimensions. ### Layer 2: ObjectOS (`@objectstack/spec/system` & `@objectstack/spec/api`) **The Runtime Kernel**. Defines "How System Operates". - **`ManifestSchema`**: `objectstack.config.ts` configuration. -- **`IdentitySchema`**: Users, Roles, Organizations, SCIM. +- **`OrganizationSchema`**: Organizations, members, positions, SCIM provisioning. - **`EventSchema`**: System bus, DLQ, and Webhooks (6 sub-modules). -- **`EndpointSchema`**: API Gateway configuration. +- **`ApiEndpointSchema`**: API Gateway configuration. - **`PluginSchema`**: Module lifecycle, security, registry, loading. ### Layer 3: ObjectUI (`@objectstack/spec/ui`) @@ -30,14 +31,13 @@ It is divided into three layers, reflected in the import paths: - **`ViewSchema`**: Layouts for data (Grid, Kanban, Calendar, Gantt). - **`ActionSchema`**: Buttons and triggers. - **`DashboardSchema`**: Widget composition. -- **`ThemeSchema`**: Design tokens and theming. ### Layer 4: ObjectAI (`@objectstack/spec/ai`) **The Intelligence Layer**. Defines AI Agents and Pipelines. - **`AgentSchema`**: Autonomous actors with tools and permissions. -- **`RAGPipelineSchema`**: Retrieval Augmented Generation pipelines. +- **`KnowledgeSourceSchema`**: Retrieval sources backing RAG grounding. - **`ModelRegistrySchema`**: LLM configuration and routing. -- **`MCPSchema`**: Model Context Protocol integration. +- **`MCPServerRefSchema`**: Model Context Protocol integration. --- @@ -77,23 +77,28 @@ const query = { --- -## 3. Schema Inventory by Domain (171 schemas) +## 3. Schema Inventory by Domain (207 schemas) + +Counted as `*.zod.ts` modules under `packages/spec/src//` — the sources +that ship in this tarball (`files` includes `src/**/*.zod.ts`), so every number +here is verifiable from the installed package. | Domain | Count | Key Schemas | |--------|-------|-------------| +| system | 36 | Auth, Cache, Compliance, Encryption, HTTP Server, License, Logging, Metrics | | kernel | 32 | Plugin, Manifest, Events (6 sub-modules), Feature, Context, Package Registry | -| system | 27 | Auth, Cache, Compliance, Encryption, HTTP Server, License, Logging, Metrics | -| api | 25 | Contract, Endpoint, REST Server, Discovery, GraphQL, OData, Batch, WebSocket | -| data | 19 | Object, Field, Query, Filter, Driver (SQL/NoSQL/Memory/Mongo/Postgres), Hook | -| ui | 18 | View, App, Action, Dashboard, Page, Chart, Component, Theme, Animation | -| ai | 14 | Agent, RAG Pipeline, Model Registry, MCP, Orchestration, NLQ, Predictive | -| automation | 8 | Flow, Workflow, Trigger, Approval, ETL, State Machine, Webhook | -| integration | 7 | Connector (Database, File Storage, GitHub, MQ, SaaS, Vercel) | -| shared | 6 | Enums, HTTP, Identifiers, Mapping, Metadata Types, Connector Auth | -| security | 5 | Permission, Policy, RLS, Sharing, Territory | -| identity | 4 | Identity, Organization, Role, SCIM | -| cloud | 4 | Marketplace, Developer Portal, App Store, Marketplace Admin | -| studio | 1 | Studio Plugin Manifest | +| data | 30 | Object, Field, Query, Filter, Driver (SQL/NoSQL/Memory/Mongo/Postgres), Cube | +| api | 29 | Endpoint, REST Server, Discovery, OData, Batch, WebSocket, Response Envelope | +| ui | 18 | View, App, Action, Dashboard, Page, Chart, Component, Animation | +| automation | 13 | Flow, Approval, BPMN Interop, Control Flow, State Machine, Webhook | +| shared | 13 | Enums, HTTP, Identifiers, Mapping, Metadata Types, Connector Auth, Retry Policy | +| ai | 11 | Agent, Conversation, Knowledge Source/Document, Model Registry, MCP, Skill, Tool | +| cloud | 11 | Marketplace, Developer Portal, App Store, Environment, Package, Tenant | +| identity | 5 | Identity, Organization, Position, SCIM, Eval User | +| security | 4 | Permission, RLS, Sharing, Explain | +| studio | 3 | Flow Builder, Object Designer, Studio Plugin | +| integration | 1 | Connector | +| qa | 1 | Testing | --- @@ -101,29 +106,29 @@ const query = { ### `import * as Data from '@objectstack/spec/data'` - `ObjectSchema`, `FieldSchema`: Logic & Storage definition. -- `QuerySchema`, `FilterSchema`: Data retrieval AST. -- `IDataDriver`, `DatasourceSchema`: Database connectivity. -- `AnalyticsSchema`: OLAP cubes and metrics. +- `QuerySchema`, `FilterArraySchema`: Data retrieval AST. +- `DatasourceSchema`, `DriverInterfaceSchema`: Database connectivity. +- `CubeSchema`: OLAP cubes and metrics. ### `import * as UI from '@objectstack/spec/ui'` - `ViewSchema`: `type: 'grid' | 'kanban' | 'calendar'`. -- `FormSchema`: `layout: 'simple' | 'tabbed'`. +- `FormViewSchema`: Form layout and sections. - `DashboardSchema`: Widget composition. -- `ThemeSchema`: Design tokens. +- `AppSchema`, `ActionSchema`: Navigation and triggers. -### `import * as System from '@objectstack/spec/system'` -- `PluginSchema`: Module lifecycle. +### `import * as Kernel from '@objectstack/spec/kernel'` +- `PluginSchema`, `ManifestSchema`: Module lifecycle and configuration. - `EventSchema`: Pub/Sub definitions. -- `PolicySchema`: Security rules. +- `KernelSecurityPolicySchema`: Plugin security rules. ### `import * as AI from '@objectstack/spec/ai'` - `AgentSchema`: AI agent configuration. -- `RAGPipelineSchema`: Retrieval pipelines. +- `KnowledgeSourceSchema`, `KnowledgeDocumentSchema`: Retrieval sources. - `ModelRegistrySchema`: LLM routing. ### `import * as API from '@objectstack/spec/api'` -- `EndpointSchema`: REST/GraphQL endpoints. -- `ContractSchema`: Request/Response envelopes. +- `ApiEndpointSchema`: REST endpoints. +- `ResponseEnvelopeConfigSchema`, `ApiErrorSchema`: Request/Response envelopes. - `DiscoverySchema`: Service discovery. --- @@ -165,11 +170,14 @@ function registerObject(rawConfig: unknown) { | `IAnalyticsService` | query, aggregate, timeSeries | | `IAuthService` | authenticate, authorize, validateToken | | `IAutomationService` | executeFlow, triggerWorkflow | -| `IUIService` | **DEPRECATED** — use IMetadataService.getView(), .listViews(), .getEffective('view', name, { userId }) | --- -## 7. Package Ecosystem (19 packages) +## 7. Package Ecosystem (68 packages) + +The workspace publishes 68 packages under the `@objectstack` scope. The table +below is a curated entry-point list, not the full set — drivers, connectors, +triggers, plugins and kernel-managed services each form their own family. | Package | Description | |---------|-------------| @@ -183,9 +191,9 @@ function registerObject(rawConfig: unknown) { | `@objectstack/client-react` | React hooks for client | | `@objectstack/cli` | Command-line interface | | `@objectstack/hono` | Hono adapter | -| `@objectstack/nextjs` | Next.js adapter | -| `@objectstack/nestjs` | NestJS adapter | | `@objectstack/driver-memory` | In-memory database driver | +| `@objectstack/driver-sql` | SQL database driver | +| `@objectstack/types` | Shared TypeScript utilities | --- diff --git a/packages/spec/package.json b/packages/spec/package.json index a2f00b28ba..0f41220382 100644 --- a/packages/spec/package.json +++ b/packages/spec/package.json @@ -268,6 +268,7 @@ "check:dual-source-exports": "tsx scripts/check-dual-source-exports.ts --self-test && tsx scripts/check-dual-source-exports.ts", "check:browser-reachable-entries": "tsx scripts/check-browser-reachable-entries.ts --self-test && tsx scripts/check-browser-reachable-entries.ts", "check:objectui-pin-citations": "tsx scripts/check-objectui-pin-citations.ts --self-test && tsx scripts/check-objectui-pin-citations.ts", + "check:llms-txt": "tsx scripts/check-llms-txt.ts --self-test && tsx scripts/check-llms-txt.ts", "check:authorable-surface": "OS_EAGER_SCHEMAS=1 tsx scripts/build-schemas.ts --check", "gen:migration-registry": "tsx scripts/build-migration-registry.ts", "check:migration-registry": "tsx scripts/build-migration-registry.ts --self-test --check", diff --git a/packages/spec/scripts/check-generated.ts b/packages/spec/scripts/check-generated.ts index 7084ca3267..035590a6b9 100644 --- a/packages/spec/scripts/check-generated.ts +++ b/packages/spec/scripts/check-generated.ts @@ -278,6 +278,19 @@ const NO_GENERATOR: ReadonlyArray<{ check: string; why: string }> = [ check: 'check:objectui-pin-citations', why: 'audits spec source prose: a citation in the asserting spelling (`.objectui-sha` = ``) must equal the root pin file — no artifact, and deliberately no `gen:`, because rewriting the sha without re-measuring the anchors beside it is the failure mode (#10274)', }, + // #11344. The nearest sibling to `check:objectui-pin-citations` above, and + // classified NO_GENERATOR for the same reason rather than for a bookkeeping + // one. `llms.txt` is hand-kept prose that SHIPS in the tarball (`files`), and + // the obvious `gen:` — restamp each count, drop each dead symbol — is the one + // operation that must never be offered: the number is not the claim, the + // sentence beside it is. Restamping `| integration | 7 | Connector (Database, + // File Storage, GitHub, MQ, SaaS, Vercel) |` to `1` would leave a freshly + // dated row listing six connectors that do not exist, converting a loud + // staleness into a silent lie. A failure here is a re-read of the section. + { + check: 'check:llms-txt', + why: 'audits the shipped llms.txt: every advertised symbol must resolve against api-surface/, every `@objectstack/spec/x` against the manifest `exports`, and every declared count against src/ and the workspace — no artifact, and deliberately no `gen:`, because restamping a count without re-reading the prose beside it is the failure mode (#11344)', + }, ]; /** diff --git a/packages/spec/scripts/check-llms-txt.ts b/packages/spec/scripts/check-llms-txt.ts new file mode 100644 index 0000000000..4ad15b399a --- /dev/null +++ b/packages/spec/scripts/check-llms-txt.ts @@ -0,0 +1,998 @@ +#!/usr/bin/env tsx +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `llms.txt` FRESHNESS gate — the hand-kept protocol summary we SHIP to AI + * consumers may not advertise a symbol, a subpath, a package or a count that + * this package does not actually have (#11344). + * + * pnpm --filter @objectstack/spec check:llms-txt + * pnpm --filter @objectstack/spec check:llms-txt --self-test + * + * ## WHY THIS EXISTS + * + * `packages/spec/llms.txt` ships inside the published `@objectstack/spec` + * tarball (`files` array; asserted by `scripts/check-published-files.mjs`, + * whose EXTRA_ENTRIES records it as "Protocol summary for LLM consumers"). It + * describes itself as context for AI agents — the audience least able to + * notice staleness, and the audience most likely to turn a claim into code. An + * agent that reads "`FormSchema`: layout" writes + * `import { FormSchema } from '@objectstack/spec/ui'`, which does not resolve. + * The file is not a doc that merely misinforms; it is an input that produces + * broken code. + * + * It has NO generator — none of this package's ~20 `gen:*` scripts emits it — + * so every claim in it is hand-typed and nothing re-derived any of them. The + * measured cost of that, when #11344 counted: + * + * - **eleven** advertised symbols existed nowhere in the published export + * surface (`IUIService`, `ThemeSchema`, `IdentitySchema`, `PolicySchema`, + * `ContractSchema`, `EndpointSchema`, `RAGPipelineSchema`, `MCPSchema`, + * `FilterSchema`, `AnalyticsSchema`, `FormSchema`); + * - **two** advertised packages did not exist (`@objectstack/nextjs`, + * `@objectstack/nestjs`); + * - the schema-inventory table disagreed with the tree in twelve of thirteen + * rows, omitted a domain entirely, and its heading (171) did not even match + * the sum of its own rows (170); + * - the package-ecosystem heading claimed 19 against a real 68. + * + * The `IUIService` row is the one that shows why a gate rather than another + * hand-audit: that contract was REMOVED in v11 ("Remove the deprecated + * `IUIService` contract (use `IMetadataService`)" — this package's own + * CHANGELOG), and the row outlived it by two majors. Worse, the immediately + * preceding repair of this same file — which deleted a phantom + * `IGraphQLService` row — cited the `IUIService` row beside it as a live + * PRECEDENT, never noticing it was itself dead. A hand-audit standing next to + * the defect did not see it. That is the whole argument for re-deriving. + * + * ## WHAT IT CHECKS — five invariants + * + * SUBPATH every `@objectstack/spec/` the file names as an import target + * is a real `exports` key of this package's manifest. + * NAMED every symbol the file advertises in a STRUCTURED CLAIM POSITION + * resolves against the checked-in `api-surface/` shards. Three + * positions, and they are resolved with deliberately different + * strictness — see below. + * COUNTED every declared count is re-derived: the schema-inventory heading + * against the sum of its own table AND against the real per-domain + * `src//**\/*.zod.ts` population; the package-ecosystem + * heading against the real non-private `@objectstack/*` workspace + * set. + * LISTED every row of the package table names a real published package, + * and the schema-inventory table has exactly one row per schema + * domain — no domain silently dropped, none invented. + * STRUCTURE every section this gate reads is present and parses. A file the + * parser stops recognising FAILS; it never passes with zero + * comparisons. + * + * ## WHY `api-surface/` IS THE AUTHORITY FOR NAMES + * + * It is checked in, it is sharded one file per published entry point, and + * `check:api-surface` already keeps it equal to the built `dist/*.d.ts`. So + * this gate needs no build of its own and cannot disagree with the export + * surface a consumer's import actually resolves to. It also ships in the same + * tarball as `llms.txt`, which means a consumer can verify any claim this gate + * makes without leaving the package. + * + * Reading `src/` instead would be the wrong authority twice over: a name can be + * declared in `src/` and never re-exported from an entry point (so the import + * still fails), and a name can be exported from a barrel with no matching + * `export const ` line anywhere (`DriverInterfaceSchema` is exactly this + * — a `src/` grep reports it absent and it is perfectly importable). + * + * ## THE THREE CLAIM POSITIONS, AND WHY THEY ARE NOT EQUALLY STRICT + * + * 1. **Layer bullets** (`### Layer N: ... (\`@objectstack/spec/x\`)` followed + * by `- **\`Name\`**: ...`) resolve against the union of ALL entry points. + * That section is an ARCHITECTURE overview: it groups concepts into + * layers, and a concept legitimately lives in a neighbouring entry point + * (`IDataDriver` is described under the data layer and exported from + * `./contracts`, exactly as this file's own §5 example imports it). + * Demanding entry-point agreement here would manufacture failures for + * claims that are true. + * 2. **Namespace bullets** (`### \`import * as N from '@objectstack/spec/x'\`` + * followed by `- \`A\`, \`B\`: ...`) resolve against THAT entry point + * only. The heading is literally an import statement, so the entry point + * is part of the claim: a name that resolves from a different subpath does + * not make this bullet true, it makes it a broken copy-paste. + * 3. **Contract table rows** (the section whose heading names + * `\`@objectstack/spec/contracts\``) resolve against `./contracts` only, + * for the same reason. This is the position `IGraphQLService` and + * `IUIService` occupied. + * + * Fenced `import ... from '@objectstack/spec/'` statements are read too, at + * strictness 2 — an import in a code block is the most directly copy-pasteable + * claim in the file, and it carries its own entry point. + * + * ## WHAT IS DELIBERATELY OUT OF POPULATION + * + * - **Prose and narrative.** Layer descriptions, coding-pattern text, the + * upgrade advice in the final section. Nothing in them is falsifiable + * against the tree, and a gate that pretended otherwise would be asserting + * taste. + * - **Code-fence BODIES.** Only the `import` specifiers are read. Deciding + * which other identifiers in a snippet are protocol symbols rather than + * locals, fields or illustrative pseudo-code is a judgement this gate would + * have to invent, and inventing it is how a checker starts producing + * confident wrong answers. + * - **`N+` lower-bound figures** — `46+ types`, `7,095+ total` describes, + * `1,470+ JSON Schemas`. These are checkable in the weak sense (real >= + * claimed) and that is precisely the problem: such a check goes red only + * when the protocol SHRINKS, so it would sit green while the figure drifts + * arbitrarily far out of date — the shape `check-quick-reference-counts` + * names when it refuses a comparison that can never fail ("a check that can + * never go red reads as coverage and is not"). Making them honest means + * deciding whether an AI-facing summary should carry an exact, + * every-PR-churning number or no number at all. That is a CONTENT decision + * for the maintainer, not a thing for this gate to settle by picking a + * comparison operator. They are skipped, not excused; #11344's report + * carries the open question. + * + * ## WHY THERE IS NO `gen:llms-txt` + * + * Asked and declined at triage (#11344, verbatim: "a full generator only if it + * falls out of existing `gen:*` vocabulary cheaply. Do not invent a new + * generation pipeline for this card"). The classification is also the safety + * property, exactly as with `check:objectui-pin-citations`: the automation a + * reader reaches for — rewrite each stale number and drop each dead symbol — + * is the operation that must NOT be offered. The numbers are not the claim; the + * PROSE BESIDE THEM is. A row reading `| integration | 7 | Connector (Database, + * File Storage, GitHub, MQ, SaaS, Vercel) |` is wrong in both cells when the + * domain has one schema, and a generator that rewrote `7` to `1` would leave a + * confident, freshly-stamped sentence listing six connectors that are not + * there. That converts a loud "this is stale" into a silent lie, which is + * strictly worse than the rot. Every failure here is a re-read of the section, + * never a command. + */ + +import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; +import { join, resolve } from 'node:path'; + +const HERE = import.meta.dirname; +const PKG = resolve(HERE, '..'); +const ROOT = resolve(PKG, '..', '..'); +const TARGET = 'packages/spec/llms.txt'; +const SELF = 'packages/spec/scripts/check-llms-txt.ts'; +const WORKSPACE_FILE = 'pnpm-workspace.yaml'; + +/** A domain directory with no `*.zod.ts` under it is not a schema domain. */ +const ZOD_SUFFIX = '.zod.ts'; + +export type Finding = { + kind: 'subpath' | 'named' | 'count' | 'listed' | 'structure'; + line: number; + message: string; +}; + +/** + * Everything the gate compares the file against. Injected rather than read + * inside `checkFile` so the self-test can pin behaviour against a known tree + * instead of against whatever this repo happens to hold today. + */ +export type Catalog = { + /** `./data` -> every exported name of that entry point. */ + entryExports: Record>; + /** Declared `exports` keys of packages/spec/package.json, as `./x`. */ + subpaths: Set; + /** `data` -> how many `*.zod.ts` live under `src/data/`, recursively. */ + domainZodCounts: Record; + /** Every non-private `@objectstack/*` workspace package name. */ + workspacePackages: Set; +}; + +/** Every name exported from any entry point — the layer-bullet population. */ +function allExportedNames(catalog: Catalog): Set { + const all = new Set(); + for (const names of Object.values(catalog.entryExports)) for (const n of names) all.add(n); + return all; +} + +/** Split a markdown table line into trimmed cells (an escaped `\|` is not a separator). */ +function splitCells(line: string): string[] { + const parts = line.split(/(? c.trim()); +} + +/** The single backticked token of a cell, or null when it carries none. */ +function backticked(cell: string): string | null { + const m = /`([^`]+)`/.exec(cell); + return m ? m[1]!.trim() : null; +} + +/** + * The part of a bullet BEFORE its first `:` that is outside backticks — the + * claim, as opposed to the description. Backtick-awareness is load-bearing: + * `- \`ViewSchema\`: \`type: 'grid'\`` has a colon inside the description's own + * backticks, and a naive split would swallow the whole line. + */ +function claimSegment(bullet: string): string { + let inTick = false; + for (let i = 0; i < bullet.length; i++) { + const ch = bullet[i]; + if (ch === '`') inTick = !inTick; + else if (ch === ':' && !inTick) return bullet.slice(0, i); + } + return bullet; +} + +/** Backticked identifiers in a claim segment (`**\`A\`**`, `` `A`, `B` ``). */ +function claimedIdentifiers(segment: string): string[] { + const out: string[] = []; + for (const m of segment.matchAll(/`([^`]+)`/g)) { + const tok = m[1]!.trim(); + if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(tok)) out.push(tok); + } + return out; +} + +/** `@objectstack/spec/data` -> `./data`, for a WHOLE token only. */ +function toSubpath(token: string): string | null { + const m = /^@objectstack\/spec\/([a-z][a-z0-9-]*)$/.exec(token); + return m ? `./${m[1]}` : null; +} + +type Section = { heading: string; line: number; body: string[]; bodyStart: number }; + +/** Split the file into `##` sections. Text before the first `##` is dropped. */ +function sections(lines: string[]): Section[] { + const out: Section[] = []; + let cur: Section | null = null; + lines.forEach((raw, i) => { + if (/^##\s+/.test(raw) && !/^###/.test(raw)) { + cur = { heading: raw.replace(/^##\s+/, '').trim(), line: i + 1, body: [], bodyStart: i + 2 }; + out.push(cur); + } else if (cur) cur.body.push(raw); + }); + return out; +} + +/** + * The first markdown table in a body, as rows of cells plus the absolute line + * of each row. A table is recognised by its alignment divider, so a stray + * pipe in prose cannot be mistaken for one. + */ +function firstTable( + body: string[], + bodyStart: number, +): { rows: { cells: string[]; line: number }[]; count: number } { + const tables: { cells: string[]; line: number }[][] = []; + let cur: { cells: string[]; line: number }[] | null = null; + let sawDivider = false; + body.forEach((raw, i) => { + const line = bodyStart + i; + if (/^\|/.test(raw)) { + if (/^\|[\s:|-]+\|\s*$/.test(raw)) { + sawDivider = true; + return; + } + if (!sawDivider) return; + if (!cur) { + cur = []; + tables.push(cur); + } + cur.push({ cells: splitCells(raw), line }); + } else if (raw.trim() === '' ? false : true) { + cur = null; + sawDivider = false; + } else if (raw.trim() === '') { + cur = null; + sawDivider = false; + } + }); + return { rows: tables[0] ?? [], count: tables.length }; +} + +/** + * The whole verdict for one file text against one catalog. Pure: no I/O, so the + * self-test drives it directly. + */ +export function checkFile(text: string, catalog: Catalog): { findings: Finding[]; checked: number } { + const lines = text.split(/\r?\n/); + const findings: Finding[] = []; + const add = (kind: Finding['kind'], line: number, message: string) => + findings.push({ kind, line, message }); + const everywhere = allExportedNames(catalog); + let checked = 0; + + // ---- SUBPATH ----------------------------------------------------------- + // A subpath claim is a WHOLE backticked `@objectstack/spec/` token or a + // fenced import specifier. The whole-token rule is what keeps + // `node_modules/@objectstack/spec/CHANGELOG.md` — a real file path, not an + // import target — out of the population. + const namedSubpaths: { sub: string; line: number }[] = []; + lines.forEach((raw, i) => { + for (const m of raw.matchAll(/`([^`]+)`/g)) { + const sub = toSubpath(m[1]!.trim()); + if (sub) namedSubpaths.push({ sub, line: i + 1 }); + } + for (const m of raw.matchAll(/from\s+'(@objectstack\/spec[^']*)'/g)) { + const sub = toSubpath(m[1]!); + if (sub) namedSubpaths.push({ sub, line: i + 1 }); + } + }); + for (const { sub, line } of namedSubpaths) { + checked++; + if (!catalog.subpaths.has(sub)) + add( + 'subpath', + line, + `names \`@objectstack/spec/${sub.slice(2)}\`, which is not an \`exports\` key of packages/spec/package.json`, + ); + } + + // ---- NAMED: fenced imports (entry-point strict) ------------------------ + lines.forEach((raw, i) => { + const m = /^\s*import\s+(?:type\s+)?\{([^}]*)\}\s*from\s+'(@objectstack\/spec[^']*)'/.exec(raw); + if (!m) return; + const sub = toSubpath(m[2]!); + if (!sub || !catalog.entryExports[sub]) return; + for (const rawName of m[1]!.split(',')) { + const name = rawName.trim().split(/\s+as\s+/)[0]!.trim(); + if (!name) continue; + checked++; + if (!catalog.entryExports[sub]!.has(name)) + add( + 'named', + i + 1, + `example imports \`${name}\` from \`@objectstack/spec/${sub.slice(2)}\`, which does not export it` + + (everywhere.has(name) ? ' (it is exported from another entry point)' : ' (it exists nowhere)'), + ); + } + }); + + // ---- NAMED: layer bullets (union-of-entry-points) ---------------------- + // and NAMED: namespace bullets (entry-point strict). + let layerBlocks = 0; + let namespaceBlocks = 0; + { + type Mode = { kind: 'layer' } | { kind: 'namespace'; sub: string; raw: string } | null; + let mode: Mode = null; + lines.forEach((raw, i) => { + if (/^##\s/.test(raw) && !/^###/.test(raw)) mode = null; + const h3 = /^###\s+(.*)$/.exec(raw); + if (h3) { + const head = h3[1]!; + const asImport = /`import\s+\*\s+as\s+\w+\s+from\s+'(@objectstack\/spec[^']*)'`/.exec(head); + if (asImport) { + const sub = toSubpath(asImport[1]!); + mode = sub ? { kind: 'namespace', sub, raw: asImport[1]! } : null; + if (mode) namespaceBlocks++; + return; + } + if (/^Layer\s+\d+\s*:/.test(head)) { + mode = { kind: 'layer' }; + layerBlocks++; + return; + } + mode = null; + return; + } + if (!mode || !/^\s*-\s+/.test(raw)) return; + const ids = claimedIdentifiers(claimSegment(raw.replace(/^\s*-\s+/, ''))); + for (const id of ids) { + // Only names shaped like a protocol symbol are claims; `camelCase` + // prose tokens in a bullet are not. + if (!/^[A-Z]/.test(id)) continue; + checked++; + if (mode.kind === 'layer') { + if (!everywhere.has(id)) + add('named', i + 1, `layer bullet advertises \`${id}\`, which no entry point exports`); + } else { + const set = catalog.entryExports[mode.sub]; + if (!set) { + add('named', i + 1, `namespace block names unknown entry point \`${mode.raw}\``); + } else if (!set.has(id)) { + add( + 'named', + i + 1, + `\`${id}\` is listed under \`${mode.raw}\`, which does not export it` + + (everywhere.has(id) ? ' (it is exported from another entry point)' : ' (it exists nowhere)'), + ); + } + } + } + }); + } + + const secs = sections(lines); + + // ---- Contract table ---------------------------------------------------- + const contractSecs = secs.filter((s) => /`@objectstack\/spec\/contracts`/.test(s.heading)); + if (contractSecs.length !== 1) { + add( + 'structure', + contractSecs[0]?.line ?? 1, + `expected exactly one section whose heading names \`@objectstack/spec/contracts\`, found ${contractSecs.length}`, + ); + } else { + const sec = contractSecs[0]!; + const { rows, count } = firstTable(sec.body, sec.bodyStart); + if (count !== 1) + add('structure', sec.line, `"${sec.heading}" has ${count} tables under it; expected exactly 1`); + else if (rows.length === 0) + add('structure', sec.line, `"${sec.heading}" has a table with no rows`); + for (const row of rows) { + const name = backticked(row.cells[0] ?? ''); + if (!name) { + add('structure', row.line, `contract row has no backticked contract name in its first cell`); + continue; + } + checked++; + const set = catalog.entryExports['./contracts']; + if (set && !set.has(name)) + add( + 'named', + row.line, + `service-contract table advertises \`${name}\`, which \`@objectstack/spec/contracts\` does not export` + + (everywhere.has(name) ? ' (it is exported from another entry point)' : ' (it exists nowhere)'), + ); + } + } + + // ---- COUNTED / LISTED: schema inventory -------------------------------- + const COUNT_SCHEMAS = /\((\d[\d,]*)\s+schemas?\)/; + const invSecs = secs.filter((s) => COUNT_SCHEMAS.test(s.heading)); + const invNamed = secs.filter((s) => /schema inventory/i.test(s.heading)); + for (const s of invNamed) + if (!COUNT_SCHEMAS.test(s.heading)) + add('structure', s.line, `"${s.heading}" declares no "(N schemas)" count`); + if (invSecs.length !== 1) { + add( + 'structure', + invSecs[0]?.line ?? 1, + `expected exactly one "(N schemas)" section heading, found ${invSecs.length}`, + ); + } else { + const sec = invSecs[0]!; + const declared = Number(COUNT_SCHEMAS.exec(sec.heading)![1]!.replace(/,/g, '')); + const { rows, count } = firstTable(sec.body, sec.bodyStart); + if (count !== 1) { + add('structure', sec.line, `"${sec.heading}" has ${count} tables under it; expected exactly 1`); + } else if (rows.length === 0) { + add('structure', sec.line, `"${sec.heading}" has a table with no rows`); + } else { + let sum = 0; + const seen = new Set(); + for (const row of rows) { + const domain = (backticked(row.cells[0] ?? '') ?? (row.cells[0] ?? '')).replace(/[*`]/g, '').trim(); + const nRaw = (row.cells[1] ?? '').replace(/[,`*]/g, '').trim(); + if (!/^\d+$/.test(nRaw)) { + add('structure', row.line, `domain row \`${domain}\` has no numeric count in its second cell`); + continue; + } + const n = Number(nRaw); + sum += n; + seen.add(domain); + const real = catalog.domainZodCounts[domain]; + checked++; + if (real === undefined) + add( + 'listed', + row.line, + `domain row \`${domain}\` names no schema domain — packages/spec/src/${domain}/ holds no ${ZOD_SUFFIX} file`, + ); + else if (real !== n) + add('count', row.line, `domain \`${domain}\` declares ${n} schemas; src/${domain}/ holds ${real}`); + } + for (const domain of Object.keys(catalog.domainZodCounts).sort()) + if (!seen.has(domain)) + add( + 'listed', + sec.line, + `domain \`${domain}\` has ${catalog.domainZodCounts[domain]} schemas in src/${domain}/ but no row in the inventory table`, + ); + const realTotal = Object.values(catalog.domainZodCounts).reduce((a, b) => a + b, 0); + checked++; + if (declared !== sum) + add( + 'count', + sec.line, + `heading declares ${declared} schemas but its own table sums to ${sum}`, + ); + if (declared !== realTotal) + add( + 'count', + sec.line, + `heading declares ${declared} schemas; packages/spec/src/ holds ${realTotal}`, + ); + } + } + + // ---- COUNTED / LISTED: package ecosystem ------------------------------- + const COUNT_PACKAGES = /\((\d[\d,]*)\s+packages?\)/; + const pkgSecs = secs.filter((s) => COUNT_PACKAGES.test(s.heading)); + const pkgNamed = secs.filter((s) => /package ecosystem/i.test(s.heading)); + for (const s of pkgNamed) + if (!COUNT_PACKAGES.test(s.heading)) + add('structure', s.line, `"${s.heading}" declares no "(N packages)" count`); + if (pkgSecs.length !== 1) { + add( + 'structure', + pkgSecs[0]?.line ?? 1, + `expected exactly one "(N packages)" section heading, found ${pkgSecs.length}`, + ); + } else { + const sec = pkgSecs[0]!; + const declared = Number(COUNT_PACKAGES.exec(sec.heading)![1]!.replace(/,/g, '')); + const { rows, count } = firstTable(sec.body, sec.bodyStart); + if (count !== 1) { + add('structure', sec.line, `"${sec.heading}" has ${count} tables under it; expected exactly 1`); + } else if (rows.length === 0) { + add('structure', sec.line, `"${sec.heading}" has a table with no rows`); + } else { + for (const row of rows) { + const name = backticked(row.cells[0] ?? ''); + if (!name) { + add('structure', row.line, `package row has no backticked package name in its first cell`); + continue; + } + checked++; + if (!catalog.workspacePackages.has(name)) + add('listed', row.line, `package table advertises \`${name}\`, which this workspace does not publish`); + } + checked++; + if (declared !== catalog.workspacePackages.size) + add( + 'count', + sec.line, + `heading declares ${declared} packages; the workspace publishes ${catalog.workspacePackages.size}`, + ); + } + } + + // ---- STRUCTURE: the parser must still recognise the file --------------- + if (layerBlocks === 0) + add('structure', 1, 'no "### Layer N: ..." blocks found at all — the architecture overview is unreadable to this gate'); + if (namespaceBlocks === 0) + add( + 'structure', + 1, + 'no "### `import * as N from \'@objectstack/spec/x\'`" blocks found at all — the namespace exports section is unreadable to this gate', + ); + + return { findings, checked }; +} + +// --------------------------------------------------------------------------- +// Catalog construction (I/O) +// --------------------------------------------------------------------------- + +function countZodFiles(dir: string): number { + let n = 0; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.name === 'node_modules') continue; + const p = join(dir, entry.name); + if (entry.isDirectory()) n += countZodFiles(p); + else if (entry.name.endsWith(ZOD_SUFFIX)) n++; + } + return n; +} + +function readDomainZodCounts(): Record { + const src = join(PKG, 'src'); + if (!existsSync(src)) { + console.error(`\n✗ ${SELF}: packages/spec/src/ not found.\n`); + console.error( + 'The schema-inventory table is measured against that tree. Passing without\n' + + 'it would put this gate exactly where #11344 found the file: green by vacancy.\n', + ); + process.exit(1); + } + const out: Record = {}; + for (const entry of readdirSync(src, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const n = countZodFiles(join(src, entry.name)); + if (n > 0) out[entry.name] = n; + } + return out; +} + +function readApiSurface(): { entryExports: Record> } { + const dir = join(PKG, 'api-surface'); + if (!existsSync(dir)) { + console.error(`\n✗ ${SELF}: packages/spec/api-surface/ not found.\n`); + console.error( + 'Every symbol claim is resolved against those shards. Without them there is\n' + + 'nothing to compare, and a silent pass would re-open the #11344 defect class.\n' + + 'Regenerate with `pnpm --filter @objectstack/spec gen:api-surface` after a build.\n', + ); + process.exit(1); + } + const entryExports: Record> = {}; + for (const f of readdirSync(dir)) { + if (!f.endsWith('.json')) continue; + const shard = JSON.parse(readFileSync(join(dir, f), 'utf8')) as { + entry: string; + exports: string[]; + }; + entryExports[shard.entry] = new Set(shard.exports.map((e) => e.replace(/\s*\(.*$/, '').trim())); + } + if (Object.keys(entryExports).length === 0) { + console.error(`\n✗ ${SELF}: packages/spec/api-surface/ holds no shards.\n`); + process.exit(1); + } + return { entryExports }; +} + +/** + * The `packages:` globs of pnpm-workspace.yaml, restricted to `` and + * `/*` exactly as `scripts/check-published-files.mjs` restricts them. A + * richer pattern is refused rather than approximated: silently matching fewer + * packages would shrink the denominator of the package count and make a stale + * heading look correct. + */ +function readWorkspacePackages(): Set { + const file = join(ROOT, WORKSPACE_FILE); + if (!existsSync(file)) { + console.error(`\n✗ ${SELF}: ${WORKSPACE_FILE} not found at the repo root.\n`); + process.exit(1); + } + const lines = readFileSync(file, 'utf8').split(/\r?\n/); + const start = lines.findIndex((l) => /^packages:\s*$/.test(l)); + if (start === -1) { + console.error(`\n✗ ${SELF}: ${WORKSPACE_FILE} has no top-level \`packages:\` block.\n`); + process.exit(1); + } + const globs: string[] = []; + for (let i = start + 1; i < lines.length; i++) { + const l = lines[i]!; + if (/^\S/.test(l)) break; + const m = /^\s+-\s+'?"?([^'"#]+?)"?'?\s*$/.exec(l); + if (m) globs.push(m[1]!.trim()); + } + if (globs.length === 0) { + console.error(`\n✗ ${SELF}: ${WORKSPACE_FILE} \`packages:\` block is empty.\n`); + process.exit(1); + } + const dirs: string[] = []; + for (const glob of globs) { + if (glob.endsWith('/*')) { + const base = join(ROOT, glob.slice(0, -2)); + if (!existsSync(base)) continue; + for (const e of readdirSync(base, { withFileTypes: true })) + if (e.isDirectory()) dirs.push(join(base, e.name)); + } else if (!glob.includes('*')) { + dirs.push(join(ROOT, glob)); + } else { + console.error( + `\n✗ ${SELF}: ${WORKSPACE_FILE} pattern "${glob}" is richer than or /*; extend ${SELF}.\n`, + ); + process.exit(1); + } + } + const names = new Set(); + for (const dir of dirs) { + const manifest = join(dir, 'package.json'); + if (!existsSync(manifest) || !statSync(dir).isDirectory()) continue; + try { + const j = JSON.parse(readFileSync(manifest, 'utf8')) as { name?: string; private?: boolean }; + if (j.name && j.name.startsWith('@objectstack/') && !j.private) names.add(j.name); + } catch { + // A manifest that does not parse is another gate's problem, not this one's. + } + } + return names; +} + +function readSubpaths(): Set { + const j = JSON.parse(readFileSync(join(PKG, 'package.json'), 'utf8')) as { + exports?: Record; + }; + return new Set(Object.keys(j.exports ?? {})); +} + +function buildCatalog(): Catalog { + return { + ...readApiSurface(), + subpaths: readSubpaths(), + domainZodCounts: readDomainZodCounts(), + workspacePackages: readWorkspacePackages(), + }; +} + +// --------------------------------------------------------------------------- +// Self-test +// --------------------------------------------------------------------------- + +const GOOD_PAGE = [ + '# @objectstack/spec Context for AI Agents', + '', + '## 1. Architecture Overview', + '', + '### Layer 1: ObjectQL (`@objectstack/spec/data`)', + '- **`ObjectSchema`**: Defines database tables.', + '- **`IDataDriver`**: The authoritative contract for database adapters.', + '', + '## 2. Coding Patterns', + '', + '```typescript', + "import { ObjectSchema } from '@objectstack/spec/data';", + '```', + '', + '## 3. Schema Inventory by Domain (5 schemas)', + '', + '| Domain | Count | Key Schemas |', + '|--------|-------|-------------|', + '| data | 3 | Object, Query |', + '| ui | 2 | View |', + '', + '## 4. Key Exports by Namespace', + '', + "### `import * as Data from '@objectstack/spec/data'`", + '- `ObjectSchema`, `QuerySchema`: Data definition.', + '', + '## 6. Service Contracts (`@objectstack/spec/contracts`)', + '', + '| Contract | Methods |', + '|----------|---------|', + '| `IMetadataService` | register, get |', + '', + '## 7. Package Ecosystem (2 packages)', + '', + '| Package | Description |', + '|---------|-------------|', + '| `@objectstack/spec` | Protocol schemas |', + '| `@objectstack/core` | Runtime core |', + '', + '## 9. Upgrading', + '', + '`CHANGELOG.md` ships inside this package (`node_modules/@objectstack/spec/CHANGELOG.md`).', + '', +].join('\n'); + +const GOOD_CATALOG: Catalog = { + entryExports: { + './data': new Set(['ObjectSchema', 'QuerySchema', 'DatasourceSchema']), + './ui': new Set(['ViewSchema']), + './contracts': new Set(['IMetadataService', 'IDataDriver']), + }, + subpaths: new Set(['.', './data', './ui', './contracts']), + domainZodCounts: { data: 3, ui: 2 }, + workspacePackages: new Set(['@objectstack/spec', '@objectstack/core']), +}; + +function selfTest(): void { + const failures: string[] = []; + const expect = (label: string, actual: unknown, expected: unknown) => { + if (actual !== expected) + failures.push(` ${label}\n expected: ${String(expected)}\n actual: ${String(actual)}`); + }; + const has = (findings: Finding[], re: RegExp) => findings.some((f) => re.test(f.message)); + + // 1. NEGATIVE — the known-good page is clean, and it really did compare + // things. Asserting a measured `checked` rather than only "no findings" + // is what stops a parser that silently matches nothing from passing. + { + const { findings, checked } = checkFile(GOOD_PAGE, GOOD_CATALOG); + expect('good page has no findings', findings.map((f) => f.message).join(' | '), ''); + expect('good page actually compared claims', checked > 10, true); + } + + // 2. POSITIVE — the #11344 / #10833 defect: a contract row naming something + // that exists nowhere. + { + const bad = GOOD_PAGE.replace('| `IMetadataService` | register, get |', '| `IUIService` | anything |'); + const { findings } = checkFile(bad, GOOD_CATALOG); + expect('phantom contract is reported', has(findings, /advertises `IUIService`.*does not export \(it exists nowhere\)/), true); + expect('phantom contract is a named finding', findings[0]?.kind, 'named'); + } + + // 3. POSITIVE — a contract row naming a real export of a DIFFERENT entry + // point is still wrong here, and the message says which case it is. + { + const bad = GOOD_PAGE.replace('| `IMetadataService` | register, get |', '| `ObjectSchema` | x |'); + const { findings } = checkFile(bad, GOOD_CATALOG); + expect('cross-entry contract row is reported', has(findings, /`ObjectSchema`.*\(it is exported from another entry point\)/), true); + } + + // 4. POSITIVE — a namespace bullet is entry-point strict: a name that + // resolves only from elsewhere is a broken copy-paste. + { + const bad = GOOD_PAGE.replace('- `ObjectSchema`, `QuerySchema`: Data definition.', '- `ObjectSchema`, `ViewSchema`: Data definition.'); + const { findings } = checkFile(bad, GOOD_CATALOG); + expect('wrong-namespace bullet is reported', has(findings, /`ViewSchema` is listed under `@objectstack\/spec\/data`.*another entry point/), true); + } + + // 5. POSITIVE — a namespace bullet naming a phantom. + { + const bad = GOOD_PAGE.replace('- `ObjectSchema`, `QuerySchema`: Data definition.', '- `ObjectSchema`, `FilterSchema`: Data definition.'); + const { findings } = checkFile(bad, GOOD_CATALOG); + expect('phantom namespace bullet is reported', has(findings, /`FilterSchema` is listed under.*\(it exists nowhere\)/), true); + } + + // 6. NEGATIVE — a LAYER bullet resolves against the union, so `IDataDriver` + // under the data layer is correct. This pins the deliberate asymmetry + // between positions 1 and 2; without it, tightening the layer rule would + // look like a harmless cleanup. + { + const { findings } = checkFile(GOOD_PAGE, GOOD_CATALOG); + expect('layer bullet may name a neighbouring entry point', has(findings, /IDataDriver/), false); + } + + // 7. POSITIVE — but a layer bullet naming something exported NOWHERE is + // still a finding. + { + const bad = GOOD_PAGE.replace('- **`IDataDriver`**: The authoritative', '- **`ThemeSchema`**: The authoritative'); + const { findings } = checkFile(bad, GOOD_CATALOG); + expect('phantom layer bullet is reported', has(findings, /layer bullet advertises `ThemeSchema`, which no entry point exports/), true); + } + + // 8. POSITIVE — a fenced import is the most copy-pasteable claim in the + // file and is checked at entry-point strictness. + { + const bad = GOOD_PAGE.replace("import { ObjectSchema } from '@objectstack/spec/data';", "import { ThemeSchema } from '@objectstack/spec/data';"); + const { findings } = checkFile(bad, GOOD_CATALOG); + expect('bad fenced import is reported', has(findings, /example imports `ThemeSchema`.*exists nowhere/), true); + } + + // 9. POSITIVE — an undeclared subpath. + { + const bad = GOOD_PAGE.replace('### Layer 1: ObjectQL (`@objectstack/spec/data`)', '### Layer 1: ObjectQL (`@objectstack/spec/graphql`)'); + const { findings } = checkFile(bad, GOOD_CATALOG); + expect('unknown subpath is reported', has(findings, /`@objectstack\/spec\/graphql`, which is not an `exports` key/), true); + } + + // 10. NEGATIVE — a package-internal FILE PATH is not a subpath claim. The + // whole-token rule is what keeps §9's `node_modules/...CHANGELOG.md` + // out; losing it would make the gate red on correct prose. + { + const { findings } = checkFile(GOOD_PAGE, GOOD_CATALOG); + expect('a node_modules file path is not a subpath claim', has(findings, /CHANGELOG/), false); + } + + // 11. POSITIVE — the heading disagreeing with its OWN table. This is the + // internal inconsistency #11344 measured (171 declared, 170 summed). + { + const bad = GOOD_PAGE.replace('(5 schemas)', '(6 schemas)'); + const { findings } = checkFile(bad, GOOD_CATALOG); + expect('heading vs table sum is reported', has(findings, /heading declares 6 schemas but its own table sums to 5/), true); + } + + // 12. POSITIVE — a row disagreeing with the tree, which is the freshness + // half: the table can be perfectly self-consistent and still stale. + { + const bad = GOOD_PAGE.replace('| data | 3 | Object, Query |', '| data | 4 | Object, Query |').replace('(5 schemas)', '(6 schemas)'); + const { findings } = checkFile(bad, GOOD_CATALOG); + expect('stale domain row is reported', has(findings, /domain `data` declares 4 schemas; src\/data\/ holds 3/), true); + expect('and the total is reported against the tree', has(findings, /heading declares 6 schemas; packages\/spec\/src\/ holds 5/), true); + } + + // 13. POSITIVE — a domain that exists in the tree but has no row. #11344's + // `qa` domain was missing exactly this way, and every other number on + // the page agreed with every other number. + { + const bad = GOOD_PAGE.replace('| ui | 2 | View |\n', '').replace('(5 schemas)', '(3 schemas)'); + const { findings } = checkFile(bad, GOOD_CATALOG); + expect('dropped domain is reported', has(findings, /domain `ui` has 2 schemas in src\/ui\/ but no row/), true); + expect('dropped domain is a listed finding', findings.find((f) => /no row/.test(f.message))?.kind, 'listed'); + } + + // 14. POSITIVE — a row for a domain that is not a schema domain at all. + { + const bad = GOOD_PAGE.replace('| ui | 2 | View |', '| ui | 2 | View |\n| ghost | 0 | nothing |'); + const { findings } = checkFile(bad, GOOD_CATALOG); + expect('invented domain is reported', has(findings, /domain row `ghost` names no schema domain/), true); + } + + // 15. POSITIVE — the package count against the workspace. + { + const bad = GOOD_PAGE.replace('(2 packages)', '(19 packages)'); + const { findings } = checkFile(bad, GOOD_CATALOG); + expect('stale package count is reported', has(findings, /heading declares 19 packages; the workspace publishes 2/), true); + } + + // 16. POSITIVE — a package row naming something the workspace does not + // publish (#11344's `@objectstack/nextjs` / `@objectstack/nestjs`). + { + const bad = GOOD_PAGE.replace('| `@objectstack/core` | Runtime core |', '| `@objectstack/nextjs` | Next.js adapter |'); + const { findings } = checkFile(bad, GOOD_CATALOG); + expect('phantom package row is reported', has(findings, /advertises `@objectstack\/nextjs`, which this workspace does not publish/), true); + } + + // 17. POSITIVE — reformatting the inventory heading takes it out of the + // count population, so it must be loud rather than silently uncounted. + { + const bad = GOOD_PAGE.replace('## 3. Schema Inventory by Domain (5 schemas)', '## 3. Schema Inventory by Domain - 5 schemas'); + const { findings } = checkFile(bad, GOOD_CATALOG); + expect('uncounted inventory heading is reported', has(findings, /declares no "\(N schemas\)" count/), true); + } + + // 18. POSITIVE — the contract section disappearing is loud. + { + const bad = GOOD_PAGE.replace('## 6. Service Contracts (`@objectstack/spec/contracts`)', '## 6. Service Contracts'); + const { findings } = checkFile(bad, GOOD_CATALOG); + expect('missing contract section is reported', has(findings, /exactly one section whose heading names `@objectstack\/spec\/contracts`, found 0/), true); + } + + // 19. POSITIVE — a page the parser no longer recognises at all fails + // loudly instead of passing with zero comparisons. This is the + // invariant that keeps every other one from retiring silently. + { + const { findings } = checkFile('# llms\n\nnothing here.\n', GOOD_CATALOG); + expect('unrecognised page is reported', findings.length > 0, true); + expect('unrecognised page loses the layer blocks', has(findings, /no "### Layer N: \.\.\." blocks found at all/), true); + expect('unrecognised page loses the namespace blocks', has(findings, /namespace exports section is unreadable/), true); + expect('unrecognised page loses the contract section', has(findings, /found 0/), true); + } + + // 20. POSITIVE — a contract table with no rows is a structure finding, not + // a vacuous pass. + { + const bad = GOOD_PAGE.replace('| `IMetadataService` | register, get |\n', ''); + const { findings } = checkFile(bad, GOOD_CATALOG); + expect('empty contract table is reported', has(findings, /has a table with no rows|has 0 tables/), true); + } + + if (failures.length) { + console.error('\n✗ check-llms-txt self-test failed:\n'); + for (const f of failures) console.error(f); + process.exit(1); + } + console.log('✓ check-llms-txt self-test: 20 cases pass.'); +} + +// --------------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------------- + +function main(): void { + if (process.argv.includes('--self-test')) return selfTest(); + + const full = join(ROOT, TARGET); + if (!existsSync(full)) { + // The file moving is itself a finding: a silent skip would retire the gate. + console.error(`\n✗ ${TARGET} not found.\n`); + console.error( + 'This guard exists because that file ships to AI consumers in the npm\n' + + 'tarball and is hand-kept with no generator. If it moved, point TARGET in\n' + + `${SELF} at its new home; if it is gone, delete this gate,\n` + + "its package.json wiring, its lint.yml step and its check:generated ledger entry.\n", + ); + process.exit(1); + } + + const catalog = buildCatalog(); + const { findings, checked } = checkFile(readFileSync(full, 'utf8'), catalog); + + if (findings.length === 0) { + const domains = Object.keys(catalog.domainZodCounts).length; + const total = Object.values(catalog.domainZodCounts).reduce((a, b) => a + b, 0); + console.log( + `✓ ${TARGET}: ${checked} claim(s) re-derived — every advertised symbol resolves against ` + + `api-surface/ (${Object.keys(catalog.entryExports).length} entry points), every subpath is a real ` + + `\`exports\` key, the inventory matches src/ (${domains} domains, ${total} schemas) and the package ` + + `table matches the workspace (${catalog.workspacePackages.size} published).`, + ); + return; + } + + console.error(`\n✗ ${TARGET} — the file advertises things this package does not have:\n`); + for (const f of findings) console.error(` ${TARGET}:${f.line} [${f.kind}] ${f.message}`); + console.error(` +This file is hand-kept and SHIPS to AI consumers inside the npm tarball, so a +stale claim here becomes generated code that does not compile. Fix the FILE — +there is deliberately no \`gen:llms-txt\` (see the header): the numbers are not +the claim, the prose beside them is, and rewriting a count without re-reading +its row turns a loud staleness into a silent lie. + + - [named] the symbol is not exported where the file says it is. Decide + which half is wrong: re-attribute it to the entry point that + really exports it, rename it to the export that exists, or — when + nothing exports it — DELETE the claim. Do not invent a + replacement: a made-up migration path is worse than a gap. + - [subpath] the file names an \`@objectstack/spec/x\` that is not an \`exports\` + key. Either the subpath was renamed or it never existed. + - [count] a declared number disagrees with the tree or with its own table. + Re-read the section, then correct the number AND the prose. + - [listed] a row names a package or a domain that does not exist, or a real + domain has no row at all. + - [structure] the file no longer looks the way this gate reads it. Reported + rather than skipped on purpose: a checker that quietly stops + recognising its file reports success forever. +`); + process.exit(1); +} + +main();