Skip to content

feat(plugin-api): golden .d.ts API snapshot test (epic #470 C1) - #780

Merged
Weegy merged 4 commits into
mainfrom
feat/470-c1-plugin-api-golden-snapshot
Aug 20, 2026
Merged

feat(plugin-api): golden .d.ts API snapshot test (epic #470 C1)#780
Weegy merged 4 commits into
mainfrom
feat/470-c1-plugin-api-golden-snapshot

Conversation

@Weegy

@Weegy Weegy commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

What

Adds a golden .d.ts API snapshot gate to @omadia/plugin-api — epic #470, Phase A item C1 (specs/470-dev-platform-plugin/implementation.md §3).

  • packages/plugin-api/scripts/api-snapshot.mjs — compiles the package's declarations and normalizes them into a deterministic snapshot. --check (default) fails with a unified diff on drift; --update accepts the current surface.
  • packages/plugin-api/api-snapshot/plugin-api.d.ts.snap — the committed snapshot (2,331 lines, 34 declaration files).
  • packages/plugin-api/test/apiSnapshot.test.ts — the node:test gate that makes CI run the check.
  • packages/plugin-api/README.md — what to do when the check goes red, with the SemVer table.
  • packages/plugin-api/tsconfig.test.json — so the new test tree is typechecked at all (see design notes).
  • specs/470-dev-platform-plugin/README.md — C1 recorded under Status.

No publishing. The package stays private: true; D1 is untouched.

Why

This package is the type contract the kernel and every plugin compile against, and a breaking change to it is currently invisible at the moment it is made. Every consumer lives in this repo, so they are all recompiled in the same commit and tsc stays green while a renamed method, an added parameter, or a narrowed type quietly changes what a plugin must build against. Once plugins ship from their own repositories that silence stops being free — it becomes an install-time incident in someone else's repo, discovered by a customer.

The snapshot turns that class of change into a diff in the PR that causes it.

How to update the snapshot

npm run api:check  -w packages/plugin-api   # what CI runs
npm run api:update -w packages/plugin-api   # accept the new surface

A red check is not a request to run api:update and move on — read the diff first. If the change is intended, regenerate and bump the version in the same commit:

Change Bump
Symbol removed or renamed; parameter added; type narrowed; optional field made required MAJOR
Symbol added; required field made optional; type widened MINOR
Nothing (empty diff) none

After the split that version number is the only signal an out-of-repo plugin gets about whether its pinned contract still holds, so a snapshot updated without a bump is the same silent break C1 exists to stop.

Mutation proof

A gate is worth nothing until it has been seen to fail. Both directions were probed against a committed-green snapshot.

Baseline — PASS

$ node scripts/api-snapshot.mjs --check
✓ API snapshot up to date (api-snapshot/plugin-api.d.ts.snap).   EXIT=0

Probe 1 — added export (export type CanaryProbeC1 = { readonly canary: string }; appended to src/index.ts) — FAIL, exit 1

✗ The public type surface of @omadia/plugin-api changed.
--- committed api-snapshot/plugin-api.d.ts.snap
+++ current src/
@@ around snapshot line 393 @@
+export type CanaryProbeC1 = {
+readonly canary: string;
+};

The node:test gate itself was run under the same mutation, so the proof covers the thing CI executes and not only the script:

not ok 1 - public .d.ts surface matches the committed golden snapshot
# pass 0
# fail 1                                                          EXIT=1

Probe 2 — removed export (export * from './pkce.js'; deleted from src/index.ts) — FAIL, exit 1

@@ around snapshot line 361 @@
-export * from './pkce.js';

Reverted — PASS

$ git checkout -- src/index.ts && node scripts/api-snapshot.mjs --check
✓ API snapshot up to date (api-snapshot/plugin-api.d.ts.snap).   EXIT=0

Design notes for review

  • Declarations go to a temp dir, never dist/. Under the root suite this check runs alongside test files that import the package's compiled output; rewriting dist/ in place would race them. It also keeps the check honest — it always measures the current src/, never what a previous build left behind.
  • All emitted .d.ts are snapshotted, not just index.d.ts. The emitted tree is what a consumer's tsc actually reads. Probe 2 confirms this catches a re-export removal via index.d.ts too.
  • The comment stripper is a scanner, not a regex. .d.ts string-literal and template-literal types in this package legitimately contain // and /* (route prefixes, URLs). Six such shapes were verified to survive stripping intact.
  • Anti-no-op guard. assertEmitCoverage derives the expectation from disk: every src/**/*.ts must have produced a matching .d.ts, and an empty source list is a hard error. Without it, a shifted rootDir or a stale include would make tsc emit less than the package while the check reported green forever — the permanently-green-no-op failure this repo has been bitten by before (feat(ci): golden-set regression eval for LLM verifier behaviour (#129) #640, CI never runs any .pg.test.ts — the middleware job has no postgres service #565).
  • The test file is typechecked. tsconfig.json has rootDir: src, so the test tree sat outside every project — a type error there would only have surfaced when tsx hit that line at runtime. Added tsconfig.test.json and folded it into the package's typecheck; verified with --listFilesOnly that the project actually loads the test file.

CI wiring

The middleware (lint + typecheck + test) job runs the middleware root npm run test, whose glob is test/**/*.test.ts — it does not reach workspace package tests. Rather than add a required job, the package test dir was added as a second positional glob to that same script, so the gate runs inside the existing job:

… 'test/**/*.test.ts' 'packages/plugin-api/test/**/*.test.ts'

scripts/check-test-file-durations.mjs parses --test-timeout out of that script; the flag is untouched and still parses. The new file measured 766 ms in the full run, against a 60 s fail threshold.

Verification

Command Result
npm install (middleware) clean — no package-lock.json diff
npm run build exit 0
npm run typecheck exit 0
npm run typecheck:test exit 0 — 406 known errors, no regressions (baseline 406)
npm run lint exit 0
npm run test (full middleware suite) 7147 pass, 0 fail, 0 cancelled, exit 0 — 654 files
npm test -w packages/plugin-api 1 pass, 0 fail, exit 0
npm run test:filetimes exit 0 — slowest file 5,684 ms, 21.1x headroom
node scripts/check-core-decoupling.mjs 3296 → 3296, unchanged

Closes part of #470 (item C1).


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.


Cross-family review (Forge)

Adversarial pass by Forge (GPT-5.4, reasoning_effort=high) against the code rather than the claims. Five questions, five verdicts. Two real defects found and fixed in 08a45d84; the rest verified sound.

# Question Finding Verdict
1 Does the check actually run in middleware (lint + typecheck + test)? Traced end to end. ci.yml → root npm run test → glob now carries 'packages/plugin-api/test/**/*.test.ts'; ran that glob standalone → 1 pass. Root npm run typecheck reaches the package via -w @omadia/plugin-api, which now also runs tsconfig.test.json. check-test-file-durations.mjs still parses --test-timeout=120000 out of the edited script. OK
2 Can it pass while emitting zero declarations? No. Probed by shifting rootDir to the package root: hard error naming all 34 uncovered files, exit non-zero. Empty src/ is a separate hard error. A narrowed exclude cannot shrink the set either — index.ts re-exports every module, so tsc pulls them all transitively. The node:test gate additionally asserts on the success message, so a script that exits 0 without doing work still fails. OK
3 Determinism across machines DEFECT. listFiles ordered with a.localeCompare(b) — snapshot bytes depended on ambient ICU collation. Measured on this package's real files: ICU (en-US, de-DE, sv-SE, C, POSIX all agree) puts routinesIntegration.d.ts before routineTarget.d.ts; code-unit ordering is the reverse, so a Node built without Intl flips two blocks and reds the check with no source change to explain it. Fixed: code-unit comparator at both call sites, snapshot regenerated. Sorted, old and new snapshots are byte-identical — a pure relocation, zero declaration content changed. CRLF was already neutral inside normalizeDeclaration; tsc version drift is bounded by the lockfile and both modes resolve the same binary. FIXED
3b CRLF on the committed snapshot DEFECT (minor). The .snap is compared byte-for-byte against text that always uses \n, and the repo has no * text=auto. A contributor with core.autocrlf=true (Git-for-Windows installer default) gets a CRLF working copy and a permanent red check with an invisible cause. Fixed by pinning *.snap text eol=lf in the root .gitattributes. It is the only .snap in the repo and git add --renormalize . touches nothing else, so the rule has no blast radius. No Windows runner executes middleware tests, so this was contributor-facing, not CI-facing. FIXED
4 Does --update silently accept a failed build? No. Probed with a real type error in src/index.ts: --update exited 1 and left the snapshot byte-identicalexecFileSync throws on tsc's non-zero exit before any write. --check fails the same way. OK
5 Can the root test run race this tsc emit? No. Declarations go to a per-invocation mkdtemp dir, and --composite false --incremental false means no .tsbuildinfo. Verified across three isolated runs plus the node:test gate: nothing is written inside the package — no dist/ touch, no buildinfo, git status clean. src/ is read-only to the check. OK

Re-verified after the fix

Command Result
node scripts/api-snapshot.mjs --check exit 0
Mutation — added export exit 1, diff shows +export type ForgeProbeC1
Mutation — via the node:test gate CI runs exit 1, pass 0 / fail 1
Reverted exit 0
Snapshot block order routineTarget.d.ts (L2027) now precedes routinesIntegration.d.ts (L2060)
npm run typecheck (middleware, all workspaces) exit 0
npm test -w @omadia/plugin-api 1 pass, 0 fail, exit 0
node scripts/check-core-decoupling.mjs 3296 → 3296, unchanged

Notes, not blockers

  • npm run lint covers packages/plugin-api/src/ only, so the new scripts/*.mjs and test/ are unlinted. That is the repo's existing lint scope, not something this PR changed.
  • listFiles(src, '.ts') would map a future src/*.d.ts to *.d.d.ts and hard-fail. No such file exists today, and the failure would be loud rather than silent.
  • An unknown argument falls through to --check. The unsafe direction is unreachable: overwriting requires the exact string --update.

Verdict: MERGE. The gate does what the PR claims — it runs in the required CI job, it cannot pass while measuring nothing, and it fails on drift in both directions. The one substantive defect was a golden artifact whose byte order came from the environment instead of the language; that is now fixed and the snapshot is provably unchanged in content.

Weegy added 4 commits August 20, 2026 16:06
@omadia/plugin-api is the type contract the kernel and every plugin
compile against, and a breaking change to it is currently invisible at
the moment it is made: every consumer lives in this repo, so they are
all recompiled in the same commit and tsc stays green while a renamed
method or a narrowed type quietly changes what a plugin must build
against. Once plugins ship from their own repositories that silence
becomes an install-time incident in another repo.

Compile the package's declarations into a temp dir, normalize them
(comments stripped, blank lines dropped, whitespace collapsed, files in
sorted path order) and compare against a committed snapshot. Drift fails
with a unified diff naming both the regeneration command and the SemVer
bump it implies.

- scripts/api-snapshot.mjs   --check (default) / --update
- api-snapshot/plugin-api.d.ts.snap   34 declaration files, 2331 lines
- test/apiSnapshot.test.ts   the node:test gate CI runs
- README.md   what to do when it goes red, with the SemVer table

Declarations go to a temp dir rather than dist/, so the check cannot
race the test files that import the compiled output and always measures
the current src/ instead of a previous build. assertEmitCoverage derives
its expectation from disk — every src/**/*.ts must produce a matching
.d.ts — so a shifted rootDir cannot leave the check green while it
covers less than the package.

The middleware root test glob only reaches test/**, never workspace
package tests, so the package test dir is added as a second positional
glob to that same script. No new required CI job; the existing
"middleware (lint + typecheck + test)" job runs the gate.

tsconfig.json has rootDir: src, which left the new test tree outside
every project. tsconfig.test.json closes that and is folded into the
package typecheck.

No publishing — the package stays private: true (D1 unchanged).

Mutation proof: an added export and a removed re-export each fail the
check and the node:test gate with exit 1; reverting restores green.
Core-decoupling ratchet unchanged at 3296.
The golden .d.ts snapshot concatenated files in `localeCompare` order, so
its bytes depended on ambient ICU collation rather than on the source.
Measured on this package: ICU (en-US, de-DE, sv-SE, C and POSIX all agree)
orders routinesIntegration.d.ts before routineTarget.d.ts, while code-unit
ordering is the reverse -- a Node built without Intl sorts the other way and
the check goes permanently red for a reason no source diff explains. Sort by
code unit at both call sites and regenerate. The snapshot change is a pure
relocation of those two blocks: sorted, old and new are byte-identical.

Also pin *.snap to LF. The check compares the committed file byte-for-byte
against generated text that always uses \n, and the repo has no `* text=auto`,
so a contributor with core.autocrlf=true (the Git-for-Windows installer
default) would get a CRLF working copy and a red check with an invisible
cause. It is the only .snap in the repo, so the rule renormalizes nothing.
@Weegy
Weegy merged commit 6f6956b into main Aug 20, 2026
9 checks passed
Weegy added a commit that referenced this pull request Aug 20, 2026
…anded

PR #780 (C1) merged to main while this branch was in review, so the
snapshot the gate compares against now exists. Merged origin/main and
regenerated it with `npm run api:update -w packages/plugin-api`.

The diff is exactly this PR's intended surface change and nothing else:
ServiceCaller, PerCallerFactory, perCallerService, isPerCallerService,
resolvePerCallerService and ServiceNotDeclaredError added, and
ServicesAccessor.provide/replace widened to accept a PerCallerFactory.
No removals — the DevJob* types were already gone from `src/` before
either branch, leaving only a tombstone comment that the snapshot
strips. So this PR's own surface change is additive; the 0.1.0 -> 1.0.0
bump is the deliberate departure from 0.x, not a break in this diff.

npm run api:check -w packages/plugin-api  ✓ up to date
npm test -w packages/plugin-api           1/1
Weegy added a commit that referenced this pull request Aug 20, 2026
#783)

* feat(#470): grant-gate ctx.services.get and cut plugin-api 1.0.0 (C2b)

Closes the second half of C2 (epic #470 Phase A): the G8 contract break and
bug B1.

G8 — the contract break, taken once, deliberately.
C2a (#555) already deleted `ctx.devJobs` and moved the `DevJob*` view types
out of `@omadia/plugin-api` into `middleware/src/devplatform/devJobTypes.ts`,
where they travel with the extraction. This records that break and cuts the
package at 1.0.0: there is no installed base, nothing is published to npm, and
every consumer is a repository we control, so the break is cheap now and
expensive later (`implementation.md` §1 row 4). Adds a CHANGELOG naming the
removed types, so a consumer grepping its own source for `DevJobDescriptor`
lands on the migration note. `harness-channel-api` pinned `^0.1.0` and would
have failed to resolve against the bump; repinned to `*` like its siblings.

B1 — `ctx.services.get` was a bare pass-through.
Any installed plugin could resolve any registered service, `graphPool`
included, with no manifest declaration and nothing in the install dialog.
`serviceRegistry.ts`'s own header conceded it: "enforcement lives at the
consumer seam". The seam now exists. `get` resolves only capability names the
plugin declares in `requires:` (or `provides:`, to read back its own
registration) and throws the new typed `ServiceNotDeclaredError` otherwise,
naming the capability and the manifest field that would grant it. `has` stays
ungated — existence is not a capability.

Removing `ctx.devJobs` without this would have converted a permission-gated,
kernel-attributed accessor into an ungated, self-attributed one (§2.2). So
`provide` also accepts `perCallerService(factory)`: the kernel invokes the
factory with the id it activated the consumer under, never with an argument
the consumer supplies. The factory is a symbol-branded object, so a service
that is itself a function cannot be mistaken for one; value providers are
untouched.

A call-site audit across this repo's built-in plugin packages and all ten
standalone plugin repos found 27 (plugin, capability) pairs consumed without
being declared — a fail-closed gate in one step would have broken every
shipped plugin. Those exact pairs sit behind a dated, frozen, per-plugin
allowlist: they warn once and resolve, everything else fails closed. The
allowlist is closed in both directions — a different plugin asking for the
same name still throws, and an allowlisted plugin asking for a new name still
throws.

Counter-proof: reverting the gate to `return serviceRegistry.get<T>(name)`
fails 9 of the 25 new tests; restoring it passes all 25.

Ratchet 3296 → 3300, hand-raised for `middleware/packages` only. All five
lines are the new CHANGELOG documenting a removal; three of them are literal
strings that cannot be reworded (a spec path, a test filename, the future
package name). The first measurement was +31 — the avoidable 26 were reworded
away rather than excused, leaving `middleware/test` at its 1,030 baseline.
Justification recorded in the baseline JSON, README and acceptance.md.

Also corrects plan.md §4.2, which still claimed the `DevJob*` types stay in
core — `implementation.md` §2.5 had already flagged it as contradicting §4.1,
and shipped code now settles it.

* fix(#470 C2b): close three gaps in the ctx.services.get grant gate

Cross-family review of PR #783 found the gate correct in shape but
incomplete in three ways that a passing test suite did not surface.

1. The dated legacy allowlist was missing 21 (plugin, capability) pairs
   that are resolved today, 9 of them on @omadia/orchestrator itself.
   `harness-orchestrator/src/plugin.ts:452` reads 'llmProviderCatalog'
   unconditionally near the top of activate(), so the gate as merged
   would have thrown ServiceNotDeclaredError and killed the chat
   orchestrator at boot. The first audit missed these because the names
   sit behind exported constants (PROCESS_MEMORY_SERVICE_NAME,
   PLUGIN_CAPABILITIES_SERVICE, CHANNEL_RESOLVER_SERVICE, ...) rather
   than string literals, and because some channel plugins resolve
   capabilities through shared @omadia/channel-sdk helpers instead of a
   literal call site in their own source.

2. Nothing derived the allowlist from the repository, so the miss above
   was invisible to CI. test/pluginServiceGrantCoverage.test.ts now
   walks every middleware/packages/*/manifest.yaml, resolves each
   `services.get` argument through the TypeScript checker (literals and
   const identifiers alike, comments excluded), and fails when a name is
   neither declared nor allowlisted. It found three further gaps on
   @omadia/ui-orchestrator that hand analysis had also missed. A second
   case fails on stale built-in rows so the ramp cannot rot.

3. Only ctx.services.get passed the caller. Every other plugin-facing
   surface — ctx.memory, ctx.entities, ctx.mcp, ctx.subAgents, ctx.llm,
   ctx.events — still called serviceRegistry.get(name) and therefore
   silently received KERNEL_SERVICE_CALLER, so a perCallerService
   provider would have handed the plugin the kernel-scoped instance.
   That is the exact self-attribution failure the feature exists to
   prevent. The frozen ServiceCaller built in createPluginContext is now
   threaded through all six.

Also: perCallerService documented "one implementation per consuming
plugin" while invoking the factory on every .get(). Resolution is now
memoized by factory object then by caller.pluginId, which makes the
documented contract true and self-invalidates on provider replacement
because a replaced provider is a different object.

The gate test fixture stored `manifest: {}`, but memoryDeclared reads
permissions off the catalog entry's unparsed manifest — so ctx.memory
was undefined regardless of what the fixture declared and the new
attribution test asserted on nothing. The fixture now carries the raw
manifest document.

Ratchet unchanged at 3300; no banned strings added.

* chore(#470 C2b): regenerate the plugin-api golden snapshot after C1 landed

PR #780 (C1) merged to main while this branch was in review, so the
snapshot the gate compares against now exists. Merged origin/main and
regenerated it with `npm run api:update -w packages/plugin-api`.

The diff is exactly this PR's intended surface change and nothing else:
ServiceCaller, PerCallerFactory, perCallerService, isPerCallerService,
resolvePerCallerService and ServiceNotDeclaredError added, and
ServicesAccessor.provide/replace widened to accept a PerCallerFactory.
No removals — the DevJob* types were already gone from `src/` before
either branch, leaving only a tombstone comment that the snapshot
strips. So this PR's own surface change is additive; the 0.1.0 -> 1.0.0
bump is the deliberate departure from 0.x, not a break in this diff.

npm run api:check -w packages/plugin-api  ✓ up to date
npm test -w packages/plugin-api           1/1
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant