diff --git a/README.md b/README.md index 383ec89..911caf1 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,48 @@ HTTP 402 ("Payment Required") has been reserved since 1999 — waiting for a pay **s402 is Sui-native by design.** These advantages come from Sui's object model, PTBs, and sub-second finality. They can't be replicated on EVM — and they don't need to be. x402 already handles EVM well. s402 handles Sui better. +## Who This Is For + +**Use s402 if** you are charging for an HTTP endpoint that AI agents call, and either you settle +on Sui, or you need a payment model x402 does not have — metered draw-down, streaming with rate +enforcement, escrow with an arbiter, or pay-to-decrypt. Also use it if you are implementing the +protocol in another language: the wire format is specified independently of this codebase and +ships with conformance vectors to check yourself against. + +**Do not use s402 if** you are EVM-only and `exact` covers you — x402 is the better fit and we +say so. Also skip it if you want a payments *product*: s402 is a wire format and a set of types +with zero runtime dependencies. It does not move money, custody funds, or run a facilitator for +you. Fiat and card rails are out of scope and will stay that way. + +## What Would Prove This Should Stop + +s402's bet is narrow enough to be wrong in public. Any of the following would falsify it, and we +would rather retire the project than defend it past its evidence: + +1. **x402 absorbs the superset.** This README claims x402's two-scheme governance envelope + cannot take on prepaid, stream, escrow and unlock without re-ratification. If x402 ratifies + equivalents, the reason to run a second protocol is gone — the correct response is to fold + the schemes upstream and delete this repo, not to compete. + +2. **The prepaid economics do not survive contact with production.** The case for micropayments + rests on batching 1,000 payments into 2 on-chain transactions. If real deployments do not land + near the claimed ~$0.014 per 1,000 calls — because of contention on shared objects, gas-price + regimes, or settlement patterns we have not modelled — then the central advantage over + per-call settlement is arithmetic that only works on paper. + +3. **No independent implementation ever passes the vectors.** s402 claims to be a protocol, not a + library. A protocol with exactly one implementation is a library wearing a specification. If + the conformance vectors go unclaimed by any second-language implementation, the honest + description is "the wire format of the s402 npm package," and the spec should be demoted to + internal documentation. + +4. **Agent commerce settles somewhere other than HTTP 402.** If agent-to-service payment + converges on mandate-passing over a non-402 channel, then the status code this protocol is + built around is the wrong integration point, and being excellent at it does not matter. + +Points 1 and 3 are the ones to watch: both are observable from outside this repo, and neither +depends on our own judgement of our own work. + ## Which Scheme Should I Use? | Your situation | Scheme | Gas per 1K calls | Latency | @@ -349,16 +391,21 @@ const requirements: s402PaymentRequirements = { ## Conformance Testing -s402 ships 133 machine-readable JSON test vectors for cross-language conformance. If you're implementing s402 in Go, Python, Rust, or any other language, use these vectors to verify your implementation matches the spec. +s402 ships machine-readable JSON test vectors for cross-language conformance — 167 vectors across 14 files. If you're implementing s402 in Go, Python, Rust, or any other language, use these vectors to verify your implementation matches the spec. ```bash -# Vectors are in the npm package +# From the npm package ls node_modules/s402/test/conformance/vectors/ -# Or clone the repo -ls test/conformance/vectors/ +# Or from a clone of this repo +ls spec/vectors/ ``` +The two paths hold the same files. `spec/vectors/` is the canonical, version-controlled +location; `scripts/prepare-publish.sh` copies it into `test/conformance/vectors/` at publish +time so it lands inside the npm tarball. **In a fresh clone only `spec/vectors/` exists** — +`test/conformance/vectors/` is generated and git-ignored. + See [`test/conformance/README.md`](./typescript/test/conformance/README.md) for the vector format, encoding scheme, and implementation guide. ## Related diff --git a/docs/adr/001-protocol-boundaries.md b/docs/adr/001-protocol-boundaries.md index 096c8b0..001df05 100644 --- a/docs/adr/001-protocol-boundaries.md +++ b/docs/adr/001-protocol-boundaries.md @@ -1,6 +1,7 @@ # ADR-001: Protocol Boundaries — Facilitator Trust, Receipt Cardinality, Scheme Cap, and Extension Hygiene **Status:** Accepted +**Implementation:** shipped **Date:** 2026-04-11 **Supersedes:** (none — first ADR) diff --git a/docs/adr/002-s402-is-pure-protocol.md b/docs/adr/002-s402-is-pure-protocol.md index 6a8f263..0491c7f 100644 --- a/docs/adr/002-s402-is-pure-protocol.md +++ b/docs/adr/002-s402-is-pure-protocol.md @@ -1,6 +1,7 @@ # ADR-002: s402 is a Pure Protocol Repo — mcp-server Moves to SweeFi **Status:** Accepted +**Implementation:** shipped **Date:** 2026-04-11 **Supersedes:** (none — refines the boundary that ADR-001 § Decision 1 named but did not enforce at the repo level) diff --git a/docs/adr/003-upto-scheme.md b/docs/adr/003-upto-scheme.md index 9bd9512..9b6482e 100644 --- a/docs/adr/003-upto-scheme.md +++ b/docs/adr/003-upto-scheme.md @@ -1,6 +1,7 @@ # ADR-003: `upto` Scheme — Usage-Based Payments with Settlement Overrides **Status:** Accepted (implemented — see note) +**Implementation:** shipped **Date:** 2026-04-12 **Supersedes:** (none) **Linear:** DAN-284 diff --git a/docs/adr/004-extensions-architecture.md b/docs/adr/004-extensions-architecture.md index 8e55efe..f501e36 100644 --- a/docs/adr/004-extensions-architecture.md +++ b/docs/adr/004-extensions-architecture.md @@ -1,6 +1,8 @@ # ADR-004: Extensions Architecture — Typed, Lifecycle-Aware Plugin System **Status:** Proposed +**Implementation:** shipped — `src/extensions.ts` (10KB), exported as the `s402/extensions` subpath in `package.json`. +⚠️ **`Status: Proposed` above is stale and is left as-is deliberately** (found 2026-08-16, DAN-855): this was built without the ADR ever being moved to Accepted. Flipping a governance field is a decision, not a cleanup, so it is surfaced here rather than changed unilaterally. It is also the exact gap the `Implementation:` field exists to expose — under `Status` alone, "proposed and never built" and "proposed and shipped" were the same page. **Date:** 2026-04-12 **Supersedes:** (none — implements the framework that ADR-001 Decision 4 described as hygiene rules) **Linear:** DAN-285 diff --git a/docs/adr/005-interop-superset-principle.md b/docs/adr/005-interop-superset-principle.md index 3907404..bd339ad 100644 --- a/docs/adr/005-interop-superset-principle.md +++ b/docs/adr/005-interop-superset-principle.md @@ -1,6 +1,7 @@ # ADR-005: Interop When Possible, Superset When Wise **Status:** Accepted +**Implementation:** upheld **Date:** 2026-04-14 **Supersedes:** (none — formalizes informal positioning against x402 and MPP) **Linear:** DAN-313 diff --git a/docs/adr/006-version-negotiation-and-scheme-digests.md b/docs/adr/006-version-negotiation-and-scheme-digests.md index 5efe549..6d4371a 100644 --- a/docs/adr/006-version-negotiation-and-scheme-digests.md +++ b/docs/adr/006-version-negotiation-and-scheme-digests.md @@ -1,6 +1,7 @@ # ADR-006: Version Negotiation and Scheme Content-Hashing **Status:** Draft +**Implementation:** not-started **Date:** 2026-04-19 **Supersedes:** implicit version string in `s402Version` field **Related:** ADR-001 (Protocol Boundaries), ADR-002 (Protocol-Pure), ADR-004 (Extensions), ADR-005 (Interop-Superset), INVARIANTS S3 @@ -95,7 +96,18 @@ advertised = "sha256-" || base64url_no_pad(digest) **Target is the prose spec, not derived artifacts.** The markdown spec at `docs/schemes/.md` is normative — it is the human-readable law of the scheme. JSON Schema files and BCS type definitions are derivative; their content-hashes are advertised separately in `spec/scheme-digests.json` under a `derived` key but do not drive version negotiation. -**Implementation:** `scripts/compute-scheme-digests.mjs` runs in CI and writes `spec/scheme-digests.json`. Any PR that modifies `docs/schemes/**/*.md` without updating the digests file fails CI. +**Implementation plan — NOT BUILT (verified 2026-08-16, DAN-855).** The intent is that +`scripts/compute-scheme-digests.mjs` runs in CI and writes `spec/scheme-digests.json`, so any PR +modifying `docs/schemes/**/*.md` without updating the digests file fails CI. + +⚠️ **None of that exists.** Neither file appears in any commit on any branch (`git log --all -- +'**/compute-scheme-digests.mjs' '**/scheme-digests.json'` returns nothing), and CI's five jobs +contain no such gate. This paragraph previously asserted the mechanism in the present tense, which +made an unbuilt plan read as a shipped guarantee — in the one field the ADR convention reserves for +answering exactly that question. The header now carries `Implementation: not-started`. + +`docs/schemes/` itself is real (`escrow.md`, `exact.md`, `prepaid.md`), so the thing this would +guard exists; only the guard is missing. ### Amendment chain diff --git a/docs/adr/007-settlement-response-envelope.md b/docs/adr/007-settlement-response-envelope.md index 12c893e..13bd685 100644 --- a/docs/adr/007-settlement-response-envelope.md +++ b/docs/adr/007-settlement-response-envelope.md @@ -1,6 +1,7 @@ # ADR-007: Settlement Response Envelope **Status:** Draft (v2 — post /vet wave review) +**Implementation:** shipped **Date:** 2026-04-19 **Related:** ADR-001 (Protocol Boundaries), ADR-006 (Version Negotiation), ADR-008 (Safety Invariants S9-S13), ADR-009 (Open Gaps), INVARIANTS S7, S8 **Supersedes:** `s402SettleResponse` (legacy flat shape in `typescript/src/scheme.ts`) diff --git a/docs/adr/008-safety-invariants-s9-s13.md b/docs/adr/008-safety-invariants-s9-s13.md index e894782..651dbe2 100644 --- a/docs/adr/008-safety-invariants-s9-s13.md +++ b/docs/adr/008-safety-invariants-s9-s13.md @@ -1,6 +1,7 @@ # ADR-008: Safety Invariants S9–S14 **Status:** Draft (v2 — post /vet wave review; S14 added, S11 hardened) +**Implementation:** in-progress — **S11** and **S14** are built (`src/envelope.ts:77`, `:430`, `:443`; S14's constant-time comparison has its own test at `test/envelope.test.ts:429`). **S9, S10, S12 and S13** have no enforcement site found. Determined 2026-08-16 (DAN-855) by searching for the mechanisms, not only the labels — but treat the negatives as *no witness found*, not *proven absent*. **Date:** 2026-04-19 **Related:** INVARIANTS.md (S1–S8), ADR-004 (Extensions), ADR-007 (Settlement Envelope), ADR-009 (Open Gaps) diff --git a/docs/adr/009-open-gaps-rotation-reorg.md b/docs/adr/009-open-gaps-rotation-reorg.md index d124a6f..92b2adf 100644 --- a/docs/adr/009-open-gaps-rotation-reorg.md +++ b/docs/adr/009-open-gaps-rotation-reorg.md @@ -1,6 +1,7 @@ # ADR-009: Open Gaps — Facilitator Key Rotation, Chain Reorg Tolerance, Scheme Acceptance Process **Status:** Placeholder — tracked gaps, not yet resolved +**Implementation:** not-started **Date:** 2026-04-19 **Related:** ADR-006, ADR-007, ADR-008, INVARIANTS S8, S11 diff --git a/docs/adr/010-safety-invariants-s15-s16.md b/docs/adr/010-safety-invariants-s15-s16.md index aac5239..725121b 100644 --- a/docs/adr/010-safety-invariants-s15-s16.md +++ b/docs/adr/010-safety-invariants-s15-s16.md @@ -1,6 +1,7 @@ # ADR-010: Safety Invariants S15–S16 — Session Binding and Version Binding **Status:** Draft +**Implementation:** in-progress — **S16 is half-built**: the protocol version *is* bound into the signed envelope rather than only into transport headers (`src/envelope.ts:54`, `:173`, `:219`, and required at `:268`). Its *scheme-digest* half is blocked on ADR-006, which is `not-started`. **S15** (binding long-running scheme state to the mandate/capability object rather than the signer's key) has no enforcement site in this repo — consistent with ADR-002, since mandates live upstack. Determined 2026-08-16 (DAN-855) by searching for the mechanisms rather than the labels; a label-only search finds where someone wrote "S16", not where it is enforced. **Date:** 2026-04-21 **Related:** INVARIANTS.md (S1–S8), ADR-006 (Version Negotiation), ADR-007 (Settlement Envelope), ADR-008 (Safety Invariants S9–S14), ADR-009 (Open Gaps) diff --git a/docs/adr/011-transport-abstraction.md b/docs/adr/011-transport-abstraction.md index c799646..e38a747 100644 --- a/docs/adr/011-transport-abstraction.md +++ b/docs/adr/011-transport-abstraction.md @@ -1,6 +1,7 @@ # ADR-011: Transport Abstraction — Payment Rides Any Carrier **Status:** Accepted (2026-06-28; revised after blind-spot review; Chunks 1a-i, 1a-ii, 1a-iii, and 2 landed + verified — HTTP + MCP + A2A all behind one seam, 1075 tests green) +**Implementation:** shipped **Date:** 2026-06-28 **Supersedes:** (none) **Related:** ADR-005 (interop/superset), ADR-002 (s402 is pure protocol), S7 (chain-agnostic boundary) diff --git a/docs/guide/tutorial.md b/docs/guide/tutorial.md index 98c033d..b9ef977 100644 --- a/docs/guide/tutorial.md +++ b/docs/guide/tutorial.md @@ -233,6 +233,16 @@ This is the complete s402 protocol flow. In production, step 3 uses the Sui SDK ## Making It Real +> 🛑 **The snippet below will not connect today.** Sui has deprecated JSON-RPC on public +> fullnodes — `https://fullnode.{testnet,mainnet}.sui.io` returns `-32601` for every core method +> (verified 2026-08-16). A `SuiClient` pointed at a public fullnode is dead on arrival, so treat +> this section as illustrating the *shape* of the integration, not as runnable today. Reaching a +> live network requires the gRPC or GraphQL transport; migration is tracked separately. +> +> Everything **above** this section is unaffected: the s402 protocol layer has no Sui dependency +> (ADR-002, invariant S7), so the encoding, validation and error handling you just worked through +> all still run offline. + To connect this to Sui, replace the mock payment in `client.ts` with real Sui SDK code: ```typescript diff --git a/docs/specification.md b/docs/specification.md index 92dc0a1..e04e29f 100644 --- a/docs/specification.md +++ b/docs/specification.md @@ -8,7 +8,7 @@ description: s402 Wire Format Specification v1 — the formal, field-by-field de This document defines the s402 wire format — the exact encoding, field definitions, validation rules, and error semantics for the s402 HTTP 402 payment protocol. It is the authoritative reference for any implementation in any language. -The TypeScript reference implementation lives at [github.com/s402-protocol/core](https://github.com/s402-protocol/core). Machine-readable conformance test vectors ship in the npm package (161 vectors across 13 files). +The TypeScript reference implementation lives at [github.com/s402-protocol/core](https://github.com/s402-protocol/core). Machine-readable conformance test vectors ship in the npm package (167 vectors across 14 files). ## 1. Terminology @@ -510,7 +510,7 @@ An implementation is **s402-conformant** if it: 2. Validates all required fields per §4.1, §5.1, and §6 3. Rejects malformed input with the appropriate error code from §8 4. Strips unknown keys on decode per §10 -5. Passes the 161 machine-readable conformance test vectors shipped in the `s402` npm package +5. Passes **every** machine-readable conformance test vector shipped in the `s402` npm package (167 vectors across 14 files as of v0.9.0) The conformance vectors cover: encode, decode, body transport, x402 compat normalization, receipt format/parse, settlement verification, validation rejection, key stripping, and roundtrip identity. See the [Conformance Vectors guide](/guide/conformance) for the vector format and implementation instructions. diff --git a/mcp-demo/README.md b/mcp-demo/README.md index 0fb1454..0915c21 100644 --- a/mcp-demo/README.md +++ b/mcp-demo/README.md @@ -2,6 +2,30 @@ A minimal Sui-native MCP server that advertises **three coexisting payment protocols** (x402, s402, stripe-mpp) inside a single `payment[]` envelope. Built as a reference implementation for [MCP SEP-2007](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2007). +> ## 🛑 This demo cannot currently reach the network +> +> **Sui has deprecated JSON-RPC on public fullnodes.** Every core method now returns `-32601` +> on **both testnet and mainnet** (verified 2026-08-16): +> +> ``` +> curl -s -X POST https://fullnode.testnet.sui.io:443 -H 'Content-Type: application/json' \ +> -d '{"jsonrpc":"2.0","id":1,"method":"sui_getChainIdentifier","params":[]}' +> # {"error":{"code":-32601,"message":"Method not found. JSON-RPC on public fullnodes has been +> # deprecated. Please migrate to gRPC or GraphQL endpoints."}} +> ``` +> +> `src/server.ts` and `src/agent.ts` both construct a `SuiClient` over that transport, so the +> end-to-end run does not work today. The **protocol layer is unaffected** — `s402` itself has +> no Sui dependency and never had one (ADR-002, invariant S7), so the envelope shapes, encoding +> and conformance vectors this demo illustrates are all still correct and all still tested. +> +> **What still works offline:** reading the `tools/list` envelope below, and the `s402` package's +> own suite (`cd ../typescript && pnpm vitest run`). +> +> Migration to gRPC/GraphQL is tracked separately — it is a migration, not a doc fix, and it is +> not being attempted inside a documentation pass. **This warning is removed when that lands**; +> a stale blocker notice is its own defect. + ## The headline artifact The `tools/list` response advertises three payment options per tool: diff --git a/typescript/CHANGELOG.md b/typescript/CHANGELOG.md index 306ad6d..e739504 100644 --- a/typescript/CHANGELOG.md +++ b/typescript/CHANGELOG.md @@ -7,6 +7,51 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **The documented size of the conformance suite was wrong everywhere it appeared.** The repo + stated it three different ways — `README.md` said 133 vectors, `docs/specification.md` said + 161 in two places — while `spec/vectors/` holds **167 vectors across 14 files**. All three now + report the real number. The consequential one was §13 *Conformance*, criterion 5: that clause + defines what makes an implementation s402-conformant, so an implementer in Go, Python or Rust + reading the spec was told they conformed at 161 vectors, leaving six shipped vectors outside + the definition of conformance. +- **The README told you to look for the conformance vectors in a directory that does not exist + in a clone.** It pointed at `test/conformance/vectors/`, which is generated by + `scripts/prepare-publish.sh` at publish time and is git-ignored. Cloning the repo and following + the instructions produced *No such file or directory*. The README now points clones at + `spec/vectors/` — the canonical, version-controlled location — and explains how the two paths + relate, so the npm path stays correct for package consumers. + +- **An architecture decision record claimed a CI check that has never existed.** ADR-006 stated, + in the present tense, that `scripts/compute-scheme-digests.mjs` "runs in CI and writes + `spec/scheme-digests.json`", failing any PR that changed a scheme doc without updating digests. + Neither file appears in any commit on any branch, and CI has no such job. It was the only ADR + carrying an `Implementation:` field, so the project's single answer to "was this built?" was + false. The paragraph is now marked as an unbuilt plan and the record reads + `Implementation: not-started`. + +### Added + +- **Every ADR now records whether it was actually built.** All twelve carry an `Implementation:` + field (`shipped` · `in-progress` · `not-started` · `upheld`), each determined against the code + rather than the prose. `Status: Accepted` only ever meant *decided* — so a decision that shipped + and one that was ratified and quietly never built were indistinguishable on the page. The + conceived-to-shipped ratio is now countable: **7 of 12 shipped.** Two findings fell straight out + of the exercise: ADR-004's extensions framework ships as the `s402/extensions` subpath while its + `Status` still reads *Proposed*, and ADR-010's S16 turns out to be half-built — version binding + is enforced in the envelope, its scheme-digest half blocked on ADR-006. +- **A regression test that fails when a documented vector count drifts from reality** + (`test/spec-doc-counts.test.ts`). It counts `spec/vectors/` and compares against every + `" vectors across files"` claim in `README.md` and `docs/specification.md`, and it + refuses to pass vacuously: if the wording changes so no claim is found, the test fails rather + than silently verifying nothing. Counts written into prose are derived values maintained by + hand, and three independent wrong numbers in one repo is what that looks like after a while. +- **The README now says who s402 is for, who it is not for, and what would prove it should + stop.** The last of those is a falsification section naming four observable conditions that + would retire the project — chiefly x402 absorbing the superset schemes, and no independent + implementation ever passing the conformance vectors. + ## [0.8.0] - 2026-06-28 The transport-abstraction release (ADR-011): **one seam, three carriers — HTTP, MCP, and A2A.** diff --git a/typescript/test/spec-doc-counts.test.ts b/typescript/test/spec-doc-counts.test.ts new file mode 100644 index 0000000..0f8e6f7 --- /dev/null +++ b/typescript/test/spec-doc-counts.test.ts @@ -0,0 +1,141 @@ +/** + * Doc consistency — every conformance-vector count printed in the repo's prose + * must match what spec/vectors/ actually contains. + * + * ── WHY THIS EXISTS ───────────────────────────────────────────────────────── + * + * On 2026-08-16 (DAN-855) this repo stated the size of its own conformance + * suite THREE different ways, and all three were wrong: + * + * README.md "133 machine-readable JSON test vectors" + * specification.md §0 "161 vectors across 13 files" + * specification.md §13 "the 161 machine-readable conformance test vectors" + * spec/vectors/ 167 vectors across 14 files <- the truth + * + * The first two are cosmetic. The third is NORMATIVE: §13 "Conformance" + * criterion 5 defines what makes an implementation s402-conformant, so a + * third-party implementer in Go or Rust was told they were conformant at 161 + * vectors, leaving six shipped vectors outside the definition of conformance. + * + * A count written into prose is a derived value stored by hand. It rots + * silently every time a vector is added, and nothing fails when it does. Three + * independent numbers in one repo is what that rot looks like once it has been + * running for a while. This test is the thing that fails. + * + * ── WHY THESE PATHS AND NOT THE OTHER ONES ────────────────────────────────── + * + * There are two vector directories and only one of them exists everywhere: + * + * spec/vectors/ tracked, canonical, in every clone + * typescript/test/conformance/vectors/ GITIGNORED — a copy that + * scripts/prepare-publish.sh makes for + * `npm pack` + * + * The prose says the vectors "ship in the npm package", which points at the + * copy — so the naive reading is to count the copy. A check keyed there passes + * on any machine that has ever packed and is red on its first CI run, because a + * fresh checkout correctly does not have that directory. Every path read below + * is tracked and present in every checkout. conformance.test.ts resolves + * spec/vectors/ the same way, for the same reason. + */ + +import { describe, it, expect } from 'vitest'; +import { readFileSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; + +const REPO_ROOT = join(import.meta.dirname, '..', '..'); +const VECTORS_DIR = join(REPO_ROOT, 'spec', 'vectors'); + +/** + * The canonical claim phrasing, held as a SOURCE STRING rather than a shared + * RegExp object. A regex carrying /g is stateful (`lastIndex`), and + * `expect(...).toMatch(re)` calls `.test()` internally, which advances that + * state — sharing one global regex across `matchAll` and `toMatch` makes + * results depend on assertion order, which is an intermittent false green. + * Every use below builds a fresh regex. + * + * Keeping the phrasing identical in both documents is deliberate: it is what + * lets one narrow pattern cover them, instead of a loose pattern that would + * match numbers it was never meant to. + */ +const CLAIM_PATTERN = String.raw`(\d+)\s+vectors\s+across\s+(\d+)\s+files`; +const claimsIn = (text: string) => [...text.matchAll(new RegExp(CLAIM_PATTERN, 'g'))]; + +/** + * Documents that state the count, and the minimum number of times each must + * state it. The minimum is the anti-vacuum guard: without it, rewording a + * sentence yields zero matches, zero comparisons, and a green test that has + * verified nothing. + */ +const DOCS = [ + { label: 'docs/specification.md', rel: ['docs', 'specification.md'], minClaims: 2 }, + { label: 'README.md', rel: ['README.md'], minClaims: 1 }, +] as const; + +function actualCounts(): { files: number; vectors: number } { + const files = readdirSync(VECTORS_DIR) + .filter((f) => f.endsWith('.json')) + .sort(); + + let vectors = 0; + for (const f of files) { + const parsed: unknown = JSON.parse(readFileSync(join(VECTORS_DIR, f), 'utf8')); + if (!Array.isArray(parsed)) { + throw new Error( + `spec/vectors/${f} is not a top-level JSON array — this test counts array ` + + `entries and would silently undercount a different shape.`, + ); + } + vectors += parsed.length; + } + + return { files: files.length, vectors }; +} + +describe('conformance-vector counts stated in prose', () => { + const actual = actualCounts(); + + it('spec/vectors/ is non-empty (guards against counting nothing)', () => { + expect(actual.files).toBeGreaterThan(0); + expect(actual.vectors).toBeGreaterThan(0); + }); + + for (const doc of DOCS) { + const path = join(REPO_ROOT, ...doc.rel); + + it(`${doc.label} states the count at least ${doc.minClaims}x`, () => { + const claims = claimsIn(readFileSync(path, 'utf8')); + expect( + claims.length, + `expected the " vectors across files" claim at least ${doc.minClaims} ` + + `time(s) in ${doc.label}. If the wording changed deliberately, update ` + + `CLAIM_PATTERN in this file — do not delete the assertion, or this check ` + + `silently stops checking.`, + ).toBeGreaterThanOrEqual(doc.minClaims); + }); + + it(`${doc.label} counts match spec/vectors/ on disk`, () => { + const claims = claimsIn(readFileSync(path, 'utf8')).map((m) => ({ + vectors: Number(m[1]), + files: Number(m[2]), + })); + for (const claim of claims) { + expect(claim).toEqual({ vectors: actual.vectors, files: actual.files }); + } + }); + } + + it('specification.md §13 still binds conformance to the vector set', () => { + const doc = readFileSync(join(REPO_ROOT, 'docs', 'specification.md'), 'utf8'); + + // indexOf returning -1 must fail loudly. `slice(-1)` hands back the + // document's last character, which is a non-empty string, so a "not empty" + // assertion would PASS on a missing section. + const start = doc.indexOf('## 13. Conformance'); + expect(start, 'the "## 13. Conformance" heading is gone from the spec').toBeGreaterThan(-1); + + const section = doc.slice(start); + expect(section).toMatch(/conformance test vector/i); + expect(section).toMatch(new RegExp(CLAIM_PATTERN)); + }); +});