@remnic/core 9.3.679 → 9.3.681
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/access-boundary.d.ts +178 -0
- package/dist/access-boundary.js +121 -0
- package/dist/access-boundary.js.map +1 -0
- package/dist/access-cli.js +114 -100
- package/dist/access-cli.js.map +1 -1
- package/dist/access-http.d.ts +1 -1
- package/dist/access-http.js +47 -45
- package/dist/access-mcp.d.ts +1 -1
- package/dist/access-mcp.js +43 -41
- package/dist/access-operations.d.ts +127 -0
- package/dist/access-operations.js +115 -0
- package/dist/access-operations.js.map +1 -0
- package/dist/access-schema.d.ts +34 -34
- package/dist/access-schema.js +3 -3
- package/dist/{access-service-S9oGKPZc.d.ts → access-service-DvA6jyHL.d.ts} +1 -1
- package/dist/access-service.d.ts +1 -1
- package/dist/access-service.js +39 -39
- package/dist/access-surface-catalog.d.ts +125 -0
- package/dist/access-surface-catalog.js +162 -0
- package/dist/access-surface-catalog.js.map +1 -0
- package/dist/adapters/index.js +7 -7
- package/dist/adapters/registry.js +3 -3
- package/dist/auto-sync-5CJBJMPZ.js +1 -1
- package/dist/briefing.js +8 -8
- package/dist/causal-behavior.js +5 -5
- package/dist/causal-chain.js +3 -3
- package/dist/causal-consolidation.js +16 -16
- package/dist/causal-retrieval.js +3 -3
- package/dist/causal-trajectory.js +1 -1
- package/dist/{chunk-JBPKEARU.js → chunk-2QSZNTDO.js} +7 -7
- package/dist/{chunk-3OKWZT7F.js → chunk-3IND7N4X.js} +2 -2
- package/dist/{chunk-GYSYLGNE.js → chunk-7MOTEVAA.js} +2 -2
- package/dist/{chunk-6T4LTI2F.js → chunk-7XH7VJN4.js} +4 -4
- package/dist/{chunk-AGNBY3VG.js → chunk-APJQ6UEA.js} +4 -4
- package/dist/{chunk-LZSMQHXC.js → chunk-ARLRTZZZ.js} +5 -5
- package/dist/{chunk-Q2H5U37U.js → chunk-B2B2IHUH.js} +2 -2
- package/dist/{chunk-SECQS4G4.js → chunk-BTVX7ZXZ.js} +5 -5
- package/dist/{chunk-DGEZKYVI.js → chunk-DOCTITOP.js} +4 -4
- package/dist/{chunk-EQYP3HA6.js → chunk-EG4TCVMU.js} +2 -2
- package/dist/{chunk-SLTKP5WJ.js → chunk-EW5KFXHL.js} +4 -4
- package/dist/{chunk-K2JYO6QV.js → chunk-FDSOMA6M.js} +28 -41
- package/dist/chunk-FDSOMA6M.js.map +1 -0
- package/dist/{chunk-CTCPB57O.js → chunk-G7Z3C2X6.js} +2 -2
- package/dist/{chunk-4PPMUNV5.js → chunk-H4BDNIKQ.js} +52 -52
- package/dist/{chunk-MTJ2LFAJ.js → chunk-H6PMGMNP.js} +2 -2
- package/dist/{chunk-7AAKSHDG.js → chunk-I3HSKQT7.js} +136 -136
- package/dist/{chunk-NXBXM7Q6.js → chunk-I75DF4FZ.js} +2 -2
- package/dist/{chunk-RC3AFF6Z.js → chunk-JD4SCARD.js} +1 -1
- package/dist/{chunk-LVTTO3VC.js → chunk-KACIOX42.js} +2 -2
- package/dist/{chunk-ATRB6Q25.js → chunk-KV6CX4ON.js} +2 -2
- package/dist/{chunk-VL5JJOOY.js → chunk-L5MUA6Q7.js} +5 -5
- package/dist/{chunk-PCGCQTU6.js → chunk-M4I3TREG.js} +75 -75
- package/dist/chunk-NHFXF4ZO.js +107 -0
- package/dist/chunk-NHFXF4ZO.js.map +1 -0
- package/dist/{chunk-MNUPGYIV.js → chunk-NQMBSSWW.js} +2 -2
- package/dist/{chunk-V4ZHKCGA.js → chunk-O2WELT5C.js} +5 -5
- package/dist/{chunk-Z6SEG36L.js → chunk-OUWAQVDJ.js} +4 -4
- package/dist/{chunk-57ME5VSI.js → chunk-Q5ZU3RNY.js} +4 -4
- package/dist/{chunk-ACYX37IM.js → chunk-QUA2JPH2.js} +6 -6
- package/dist/{chunk-UNZLU2MX.js → chunk-QVWM4C24.js} +37 -32
- package/dist/chunk-QVWM4C24.js.map +1 -0
- package/dist/{chunk-2AP4QJX5.js → chunk-TOQEZ63C.js} +8 -8
- package/dist/{chunk-EUM7CZFM.js → chunk-TY5NT3T3.js} +17 -17
- package/dist/{chunk-ZCVPFDHB.js → chunk-UAODC6GJ.js} +14 -14
- package/dist/{chunk-YJ4J2JJ2.js → chunk-UJDV2NLT.js} +9 -9
- package/dist/chunk-V254FAT5.js +85 -0
- package/dist/chunk-V254FAT5.js.map +1 -0
- package/dist/{chunk-3IE22DJ2.js → chunk-WEPMT6SC.js} +10 -10
- package/dist/{chunk-EZ25VE3G.js → chunk-YNDLCWXS.js} +4 -4
- package/dist/{cli-B2Ve7R22.d.ts → cli-feUe-x3I.d.ts} +1 -1
- package/dist/cli.d.ts +2 -2
- package/dist/cli.js +74 -72
- package/dist/compounding/engine.js +9 -9
- package/dist/connectors/codex-materialize-runner.js +9 -9
- package/dist/connectors/index.js +9 -9
- package/dist/consolidation-provenance-check.js +2 -2
- package/dist/contradiction/index.js +4 -4
- package/dist/dashboard-runtime.js +2 -2
- package/dist/entity-retrieval.js +7 -7
- package/dist/extraction.js +2 -2
- package/dist/{first-start-migration-PG5HBC3K.js → first-start-migration-FF7YFGRP.js} +4 -4
- package/dist/index.d.ts +2 -2
- package/dist/index.js +209 -207
- package/dist/index.js.map +1 -1
- package/dist/lcm/engine.js +4 -4
- package/dist/lcm/index.js +12 -12
- package/dist/maintenance/memory-governance.js +8 -8
- package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +7 -7
- package/dist/maintenance/rebuild-memory-projection.js +9 -9
- package/dist/mcp-memory-inspector-app.d.ts +1 -1
- package/dist/namespaces/migrate.js +17 -17
- package/dist/namespaces/search.js +8 -8
- package/dist/namespaces/storage.js +8 -8
- package/dist/operator-toolkit.js +22 -22
- package/dist/orchestrator.js +70 -70
- package/dist/resume-bundles.js +1 -1
- package/dist/schemas.d.ts +50 -50
- package/dist/search/factory.js +7 -7
- package/dist/search/index.js +11 -11
- package/dist/search/lancedb-backend.js +3 -3
- package/dist/search/meilisearch-backend.js +3 -3
- package/dist/search/orama-backend.js +3 -3
- package/dist/semantic-consolidation.js +11 -11
- package/dist/semantic-rule-promotion.js +7 -7
- package/dist/semantic-rule-verifier.js +8 -8
- package/dist/storage.js +6 -6
- package/dist/transfer/backup.js +2 -2
- package/dist/transfer/capsule-export.js +2 -2
- package/dist/transfer/capsule-import.js +1 -1
- package/dist/transfer/import-sqlite.js +2 -2
- package/dist/transfer/types.d.ts +38 -38
- package/dist/utils/serialize-mutations.d.ts +122 -0
- package/dist/utils/serialize-mutations.js +287 -0
- package/dist/utils/serialize-mutations.js.map +1 -0
- package/dist/verified-recall.js +8 -8
- package/package.json +12 -2
- package/src/access-boundary.test.ts +212 -0
- package/src/access-boundary.ts +235 -0
- package/src/access-cli.ts +32 -15
- package/src/access-http.ts +38 -28
- package/src/access-mcp.ts +41 -35
- package/src/access-operations.ts +157 -0
- package/src/access-surface-catalog.test.ts +772 -0
- package/src/access-surface-catalog.ts +218 -0
- package/src/utils/serialize-mutations.test.ts +1047 -0
- package/src/utils/serialize-mutations.ts +679 -0
- package/dist/chunk-K2JYO6QV.js.map +0 -1
- package/dist/chunk-UNZLU2MX.js.map +0 -1
- /package/dist/{chunk-JBPKEARU.js.map → chunk-2QSZNTDO.js.map} +0 -0
- /package/dist/{chunk-3OKWZT7F.js.map → chunk-3IND7N4X.js.map} +0 -0
- /package/dist/{chunk-GYSYLGNE.js.map → chunk-7MOTEVAA.js.map} +0 -0
- /package/dist/{chunk-6T4LTI2F.js.map → chunk-7XH7VJN4.js.map} +0 -0
- /package/dist/{chunk-AGNBY3VG.js.map → chunk-APJQ6UEA.js.map} +0 -0
- /package/dist/{chunk-LZSMQHXC.js.map → chunk-ARLRTZZZ.js.map} +0 -0
- /package/dist/{chunk-Q2H5U37U.js.map → chunk-B2B2IHUH.js.map} +0 -0
- /package/dist/{chunk-SECQS4G4.js.map → chunk-BTVX7ZXZ.js.map} +0 -0
- /package/dist/{chunk-DGEZKYVI.js.map → chunk-DOCTITOP.js.map} +0 -0
- /package/dist/{chunk-EQYP3HA6.js.map → chunk-EG4TCVMU.js.map} +0 -0
- /package/dist/{chunk-SLTKP5WJ.js.map → chunk-EW5KFXHL.js.map} +0 -0
- /package/dist/{chunk-CTCPB57O.js.map → chunk-G7Z3C2X6.js.map} +0 -0
- /package/dist/{chunk-4PPMUNV5.js.map → chunk-H4BDNIKQ.js.map} +0 -0
- /package/dist/{chunk-MTJ2LFAJ.js.map → chunk-H6PMGMNP.js.map} +0 -0
- /package/dist/{chunk-7AAKSHDG.js.map → chunk-I3HSKQT7.js.map} +0 -0
- /package/dist/{chunk-NXBXM7Q6.js.map → chunk-I75DF4FZ.js.map} +0 -0
- /package/dist/{chunk-RC3AFF6Z.js.map → chunk-JD4SCARD.js.map} +0 -0
- /package/dist/{chunk-LVTTO3VC.js.map → chunk-KACIOX42.js.map} +0 -0
- /package/dist/{chunk-ATRB6Q25.js.map → chunk-KV6CX4ON.js.map} +0 -0
- /package/dist/{chunk-VL5JJOOY.js.map → chunk-L5MUA6Q7.js.map} +0 -0
- /package/dist/{chunk-PCGCQTU6.js.map → chunk-M4I3TREG.js.map} +0 -0
- /package/dist/{chunk-MNUPGYIV.js.map → chunk-NQMBSSWW.js.map} +0 -0
- /package/dist/{chunk-V4ZHKCGA.js.map → chunk-O2WELT5C.js.map} +0 -0
- /package/dist/{chunk-Z6SEG36L.js.map → chunk-OUWAQVDJ.js.map} +0 -0
- /package/dist/{chunk-57ME5VSI.js.map → chunk-Q5ZU3RNY.js.map} +0 -0
- /package/dist/{chunk-ACYX37IM.js.map → chunk-QUA2JPH2.js.map} +0 -0
- /package/dist/{chunk-2AP4QJX5.js.map → chunk-TOQEZ63C.js.map} +0 -0
- /package/dist/{chunk-EUM7CZFM.js.map → chunk-TY5NT3T3.js.map} +0 -0
- /package/dist/{chunk-ZCVPFDHB.js.map → chunk-UAODC6GJ.js.map} +0 -0
- /package/dist/{chunk-YJ4J2JJ2.js.map → chunk-UJDV2NLT.js.map} +0 -0
- /package/dist/{chunk-3IE22DJ2.js.map → chunk-WEPMT6SC.js.map} +0 -0
- /package/dist/{chunk-EZ25VE3G.js.map → chunk-YNDLCWXS.js.map} +0 -0
- /package/dist/{first-start-migration-PG5HBC3K.js.map → first-start-migration-FF7YFGRP.js.map} +0 -0
|
@@ -0,0 +1,772 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fitness test for the access boundary (issue #1525).
|
|
3
|
+
*
|
|
4
|
+
* Walks the MCP `tools/list` surface and the HTTP route catalog, and asserts:
|
|
5
|
+
* 1. every MCP tool the server actually advertises has a catalog entry
|
|
6
|
+
* (so a new tool cannot ship without either migrating it or
|
|
7
|
+
* acknowledging it as unmigrated);
|
|
8
|
+
* 2. every catalog entry that claims a migration (`operation !== null`)
|
|
9
|
+
* resolves to a registered boundary operation AND dispatches through it
|
|
10
|
+
* — a flipped catalog row without wired dispatch is caught, so the
|
|
11
|
+
* ratchet cannot record a false migration;
|
|
12
|
+
* 3. the unmigrated-handler count equals the ratchet baseline, so the count
|
|
13
|
+
* may only decrease.
|
|
14
|
+
*
|
|
15
|
+
* Prove-fail-before (issue requirement): dedicated tests seed bypasses — an
|
|
16
|
+
* MCP tool the catalog does not know about, and a catalog entry whose
|
|
17
|
+
* operation is registered but never dispatched — and assert the validators
|
|
18
|
+
* report them. That demonstrates the gates catch the regression class before
|
|
19
|
+
* relying on them for the real surface.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import assert from "node:assert/strict";
|
|
23
|
+
import { readFileSync } from "node:fs";
|
|
24
|
+
import test from "node:test";
|
|
25
|
+
|
|
26
|
+
import { EngramMcpServer } from "./access-mcp.js";
|
|
27
|
+
import type { EngramAccessService } from "./access-service.js";
|
|
28
|
+
import { getOperation, listRegisteredOperations, type OperationName } from "./access-boundary.js";
|
|
29
|
+
// Importing access-operations registers the three pilot operations as a side
|
|
30
|
+
// effect — that is the migration state under test here.
|
|
31
|
+
import "./access-operations.js";
|
|
32
|
+
import {
|
|
33
|
+
HTTP_ROUTES,
|
|
34
|
+
MCP_TOOLS,
|
|
35
|
+
countUnmigratedHandlers,
|
|
36
|
+
type HttpRouteEntry,
|
|
37
|
+
type McpToolEntry,
|
|
38
|
+
} from "./access-surface-catalog.js";
|
|
39
|
+
|
|
40
|
+
// The ratchet baseline. Decrease this constant (and run
|
|
41
|
+
// `node scripts/check-ratchets.mjs --update`) whenever a follow-up PR
|
|
42
|
+
// migrates a handler. It may NEVER increase — an increase means a new handler
|
|
43
|
+
// shipped without going through the boundary. The only exception is a
|
|
44
|
+
// catalog-completeness correction: adding routes that were always live but
|
|
45
|
+
// omitted from the catalog (review-caught). Such a bump MUST be accompanied
|
|
46
|
+
// by the newly-cataloged entries; the higher count is the honest baseline.
|
|
47
|
+
const UNMIGRATED_HANDLER_BASELINE = 134;
|
|
48
|
+
|
|
49
|
+
// Keep the import live — `getOperation` is the call surfaces use at dispatch
|
|
50
|
+
// time; referencing it here pins the registry's lookup contract.
|
|
51
|
+
void getOperation;
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// Live MCP surface — short-name extraction
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
const LEGACY_PREFIX = "engram.";
|
|
58
|
+
const CANONICAL_PREFIX = "remnic.";
|
|
59
|
+
|
|
60
|
+
/** Strip the `engram.`/`remnic.` prefix to get the canonical short name. */
|
|
61
|
+
function shortToolName(advertised: string): string {
|
|
62
|
+
if (advertised.startsWith(LEGACY_PREFIX)) return advertised.slice(LEGACY_PREFIX.length);
|
|
63
|
+
if (advertised.startsWith(CANONICAL_PREFIX)) return advertised.slice(CANONICAL_PREFIX.length);
|
|
64
|
+
return advertised;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Spin up a server with emitLegacyTools=true and read the deduped short names. */
|
|
68
|
+
async function liveMcpToolShortNames(): Promise<ReadonlySet<string>> {
|
|
69
|
+
const stub = { briefingEnabled: true } as unknown as EngramAccessService;
|
|
70
|
+
const server = new EngramMcpServer(stub, { emitLegacyTools: true });
|
|
71
|
+
const response = await server.handleRequest({ jsonrpc: "2.0", id: 1, method: "tools/list" });
|
|
72
|
+
const result = (response as { result?: { tools?: Array<{ name: string }> } }).result;
|
|
73
|
+
const names = new Set<string>();
|
|
74
|
+
for (const tool of result?.tools ?? []) {
|
|
75
|
+
names.add(shortToolName(tool.name));
|
|
76
|
+
}
|
|
77
|
+
return names;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// Coverage validator — pure function so the prove-fail-before test can seed a
|
|
82
|
+
// bypass against the SAME logic the real assertion uses.
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
interface CoverageViolation {
|
|
86
|
+
readonly kind:
|
|
87
|
+
| "live-tool-not-in-catalog"
|
|
88
|
+
| "catalog-tool-not-live"
|
|
89
|
+
| "migrated-entry-not-registered"
|
|
90
|
+
| "duplicate-catalog-tool";
|
|
91
|
+
readonly detail: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function validateCoverage(
|
|
95
|
+
catalog: readonly McpToolEntry[],
|
|
96
|
+
liveShortNames: ReadonlySet<string>,
|
|
97
|
+
registered: ReadonlySet<OperationName>,
|
|
98
|
+
): CoverageViolation[] {
|
|
99
|
+
const violations: CoverageViolation[] = [];
|
|
100
|
+
const catalogShortNames = new Set<string>();
|
|
101
|
+
|
|
102
|
+
for (const entry of catalog) {
|
|
103
|
+
if (catalogShortNames.has(entry.tool)) {
|
|
104
|
+
violations.push({ kind: "duplicate-catalog-tool", detail: entry.tool });
|
|
105
|
+
}
|
|
106
|
+
catalogShortNames.add(entry.tool);
|
|
107
|
+
|
|
108
|
+
if (!liveShortNames.has(entry.tool)) {
|
|
109
|
+
violations.push({ kind: "catalog-tool-not-live", detail: entry.tool });
|
|
110
|
+
}
|
|
111
|
+
if (entry.operation !== null && !registered.has(entry.operation)) {
|
|
112
|
+
violations.push({
|
|
113
|
+
kind: "migrated-entry-not-registered",
|
|
114
|
+
detail: `${entry.tool} -> ${entry.operation}`,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
for (const live of liveShortNames) {
|
|
120
|
+
if (!catalogShortNames.has(live)) {
|
|
121
|
+
violations.push({ kind: "live-tool-not-in-catalog", detail: live });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return violations;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function shortNamesOf(catalog: readonly McpToolEntry[]): Set<string> {
|
|
129
|
+
const set = new Set<string>();
|
|
130
|
+
for (const entry of catalog) set.add(entry.tool);
|
|
131
|
+
return set;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function formatViolations(violations: readonly CoverageViolation[]): string {
|
|
135
|
+
return violations.map((v) => ` - [${v.kind}] ${v.detail}`).join("\n");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
// Live dispatch extraction — verify surfaces route through the boundary
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Statically extract the MCP dispatch map (`MCP_MIGRATED_OPERATIONS`) from
|
|
144
|
+
* `access-mcp.ts`. This is the map that routes incoming tool calls to the
|
|
145
|
+
* boundary at dispatch time. Comparing it to the catalog proves a flipped
|
|
146
|
+
* catalog row is not a false migration — the surface code must also wire
|
|
147
|
+
* the dispatch.
|
|
148
|
+
*/
|
|
149
|
+
function extractMcpDispatchMap(): Map<string, string> {
|
|
150
|
+
const source = readFileSync(
|
|
151
|
+
new URL("./access-mcp.ts", import.meta.url),
|
|
152
|
+
"utf-8",
|
|
153
|
+
);
|
|
154
|
+
const blockMatch = source.match(
|
|
155
|
+
/MCP_MIGRATED_OPERATIONS[^{]*\{([\s\S]*?)\}/,
|
|
156
|
+
);
|
|
157
|
+
const body = blockMatch?.[1] ?? "";
|
|
158
|
+
const map = new Map<string, string>();
|
|
159
|
+
for (const m of body.matchAll(/"engram\.(\w+)"\s*:\s*"(\w+)"/g)) {
|
|
160
|
+
map.set(m[1]!, m[2]!);
|
|
161
|
+
}
|
|
162
|
+
return map;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Normalize a regex route body (e.g. `\/engram\/v1\/peers\/([^/]+)`) to the
|
|
167
|
+
* catalog pathname form (`/engram/v1/peers/:id`).
|
|
168
|
+
*/
|
|
169
|
+
function normalizeHttpRegexRoute(src: string): string {
|
|
170
|
+
return src.replace(/\\\//g, "/").replace(/\(\[\^\/\]\+\)/g, ":id");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Statically extract a route-specific dispatch map from `access-http.ts`:
|
|
175
|
+
* `"${method} ${pathname}"` → set of operations dispatched via
|
|
176
|
+
* `getOperation("…")` within that route's handler block. Keying by
|
|
177
|
+
* method+pathname (not just pathname) prevents cross-method contamination
|
|
178
|
+
* when GET and POST share a path — a dispatch in the POST block must not
|
|
179
|
+
* satisfy a GET catalog entry (review P2: key HTTP dispatch coverage by
|
|
180
|
+
* method and path).
|
|
181
|
+
*/
|
|
182
|
+
function extractHttpRouteDispatchMap(): Map<string, Set<string>> {
|
|
183
|
+
const source = readFileSync(
|
|
184
|
+
new URL("./access-http.ts", import.meta.url),
|
|
185
|
+
"utf-8",
|
|
186
|
+
);
|
|
187
|
+
const lines = source.split("\n");
|
|
188
|
+
const routeOps = new Map<string, Set<string>>();
|
|
189
|
+
let currentKey: string | null = null;
|
|
190
|
+
|
|
191
|
+
for (let i = 0; i < lines.length; i++) {
|
|
192
|
+
const line = lines[i]!;
|
|
193
|
+
|
|
194
|
+
// Detect route block start — extract pathname and method.
|
|
195
|
+
let pathname: string | null = null;
|
|
196
|
+
let isExactMatch = false;
|
|
197
|
+
|
|
198
|
+
let m = line.match(/pathname\s*===\s*"((?:\/engram|\/remnic|\/v1)\/[^"]+)"/);
|
|
199
|
+
if (m) {
|
|
200
|
+
pathname = m[1]!.replace(/^\/remnic\//, "/engram/");
|
|
201
|
+
isExactMatch = true;
|
|
202
|
+
} else {
|
|
203
|
+
m = line.match(/\/\^((?:\\\/engram|\\\/remnic|\\\/v1).+?)\$\/g?\.(?:exec|test)\(pathname\)/);
|
|
204
|
+
if (m) {
|
|
205
|
+
pathname = normalizeHttpRegexRoute(m[1]!);
|
|
206
|
+
} else {
|
|
207
|
+
m = line.match(/pathname\.match\(\/\^((?:\\\/engram|\\\/remnic|\\\/v1).+?)\$\/g?\)/);
|
|
208
|
+
if (m) {
|
|
209
|
+
pathname = normalizeHttpRegexRoute(m[1]!);
|
|
210
|
+
} else {
|
|
211
|
+
m = line.match(/pathname\.startsWith\("((?:\/engram)\/v1\/[^"]+)"\)/);
|
|
212
|
+
if (m) {
|
|
213
|
+
pathname = m[1]!.replace(/\/$/, "") + "/:id";
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (pathname) {
|
|
220
|
+
// Determine the HTTP method: same line first, then backward for
|
|
221
|
+
// exact-match routes, forward for dynamic routes. Check both positive
|
|
222
|
+
// (===) and negated (!==) method patterns — `req.method !== "GET"` as
|
|
223
|
+
// a 405 guard means the route IS GET (review P2: require exact method
|
|
224
|
+
// keys; do not fall back to pathname-only when method is undetectable).
|
|
225
|
+
const detectMethod = (text: string): string | null => {
|
|
226
|
+
const pos = text.match(/req\.method\s*===\s*"(\w+)"/);
|
|
227
|
+
if (pos) return pos[1]!;
|
|
228
|
+
const neg = text.match(/req\.method\s*!==\s*"(\w+)"/);
|
|
229
|
+
if (neg) return neg[1]!;
|
|
230
|
+
return null;
|
|
231
|
+
};
|
|
232
|
+
let method = detectMethod(line);
|
|
233
|
+
if (!method && isExactMatch) {
|
|
234
|
+
for (let j = i - 1; j >= Math.max(0, i - 5); j--) {
|
|
235
|
+
method = detectMethod(lines[j]!);
|
|
236
|
+
if (method) break;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
if (!method && !isExactMatch) {
|
|
240
|
+
for (let j = i + 1; j < Math.min(lines.length, i + 5); j++) {
|
|
241
|
+
if (/pathname\s*===\s*"|\/\^.*\$\/g?\.(?:exec|test)\(pathname\)|pathname\.match\(/.test(lines[j]!)) break;
|
|
242
|
+
method = detectMethod(lines[j]!);
|
|
243
|
+
if (method) break;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
// If the method cannot be determined, the route block is ambiguous and
|
|
247
|
+
// must not be keyed — validateDispatchCoverage will flag any catalog
|
|
248
|
+
// entry claiming migration through it (no pathname-only fallback).
|
|
249
|
+
currentKey = method ? `${method} ${pathname}` : null;
|
|
250
|
+
if (currentKey && !routeOps.has(currentKey)) {
|
|
251
|
+
routeOps.set(currentKey, new Set<string>());
|
|
252
|
+
}
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Collect getOperation calls within the current route block.
|
|
257
|
+
if (currentKey) {
|
|
258
|
+
const opMatch = line.match(/getOperation\("(\w+)"\)/);
|
|
259
|
+
if (opMatch) {
|
|
260
|
+
routeOps.get(currentKey)!.add(opMatch[1]!);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return routeOps;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Pure dispatch validator — shared by the prove-fail-before test and the real
|
|
270
|
+
* gate. Flags a catalog entry that claims a migration (`operation !== null`)
|
|
271
|
+
* but whose live surface code does not dispatch through the boundary. A
|
|
272
|
+
* flipped catalog row without wired dispatch is a false migration.
|
|
273
|
+
*/
|
|
274
|
+
interface DispatchViolation {
|
|
275
|
+
readonly kind:
|
|
276
|
+
| "mcp-no-dispatch"
|
|
277
|
+
| "mcp-dispatch-mismatch"
|
|
278
|
+
| "http-no-dispatch";
|
|
279
|
+
readonly detail: string;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function validateDispatchCoverage(
|
|
283
|
+
mcpCatalog: readonly McpToolEntry[],
|
|
284
|
+
httpCatalog: readonly HttpRouteEntry[],
|
|
285
|
+
mcpDispatch: ReadonlyMap<string, string>,
|
|
286
|
+
httpRouteDispatch: ReadonlyMap<string, ReadonlySet<string>>,
|
|
287
|
+
): DispatchViolation[] {
|
|
288
|
+
const violations: DispatchViolation[] = [];
|
|
289
|
+
for (const entry of mcpCatalog) {
|
|
290
|
+
if (entry.operation === null) continue;
|
|
291
|
+
const dispatched = mcpDispatch.get(entry.tool);
|
|
292
|
+
if (dispatched === undefined) {
|
|
293
|
+
violations.push({
|
|
294
|
+
kind: "mcp-no-dispatch",
|
|
295
|
+
detail: `${entry.tool} claims migration to ${entry.operation} but has no entry in MCP_MIGRATED_OPERATIONS`,
|
|
296
|
+
});
|
|
297
|
+
} else if (dispatched !== entry.operation) {
|
|
298
|
+
violations.push({
|
|
299
|
+
kind: "mcp-dispatch-mismatch",
|
|
300
|
+
detail: `${entry.tool}: dispatch routes to "${dispatched}" but catalog claims "${entry.operation}"`,
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
for (const entry of httpCatalog) {
|
|
305
|
+
if (entry.operation === null) continue;
|
|
306
|
+
// Method+path specific: the getOperation("…") call must be in THIS
|
|
307
|
+
// method's handler block for THIS pathname. No pathname-only fallback —
|
|
308
|
+
// if the extractor could not determine the method, the route block is
|
|
309
|
+
// ambiguous and the catalog entry must not be counted as migrated
|
|
310
|
+
// (review P2: require exact method keys; do not fall back).
|
|
311
|
+
const routeKey = `${entry.method} ${entry.pathname}`;
|
|
312
|
+
const routeOps = httpRouteDispatch.get(routeKey);
|
|
313
|
+
if (!routeOps || !routeOps.has(entry.operation)) {
|
|
314
|
+
violations.push({
|
|
315
|
+
kind: "http-no-dispatch",
|
|
316
|
+
detail: `HTTP ${entry.method} ${entry.pathname} claims migration to ${entry.operation} but no getOperation("${entry.operation}") found in its route block in access-http.ts`,
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return violations;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// ---------------------------------------------------------------------------
|
|
324
|
+
// Prove-fail-before: the validator MUST catch a seeded bypass
|
|
325
|
+
// ---------------------------------------------------------------------------
|
|
326
|
+
|
|
327
|
+
test("fitness validator catches an MCP tool the catalog does not know about", () => {
|
|
328
|
+
// Seed: advertise a fake tool that was never added to the catalog. This is
|
|
329
|
+
// exactly the regression the boundary exists to prevent — a new handler
|
|
330
|
+
// shipping with its own ad-hoc validation instead of a registry entry.
|
|
331
|
+
const liveWithBypass = shortNamesOf(MCP_TOOLS);
|
|
332
|
+
liveWithBypass.add("rogue_unregistered_tool");
|
|
333
|
+
|
|
334
|
+
const violations = validateCoverage(
|
|
335
|
+
MCP_TOOLS,
|
|
336
|
+
liveWithBypass,
|
|
337
|
+
new Set(listRegisteredOperations()),
|
|
338
|
+
);
|
|
339
|
+
const rogue = violations.filter((v) => v.kind === "live-tool-not-in-catalog");
|
|
340
|
+
assert.ok(rogue.length > 0, "validator must flag a live tool missing from the catalog");
|
|
341
|
+
assert.equal(rogue[0].detail, "rogue_unregistered_tool");
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test("fitness validator catches a catalog entry claiming a migration the registry does not back", () => {
|
|
345
|
+
// Seed: a catalog entry claims `operation: "memory_get"` but the registry
|
|
346
|
+
// passed in is EMPTY — simulating a handler that flipped its catalog row
|
|
347
|
+
// without adding the `defineOperation` call. Passing an explicit empty set
|
|
348
|
+
// (rather than resetting the real registry) keeps this test isolated from
|
|
349
|
+
// the live pilot registrations.
|
|
350
|
+
const lyingCatalog: readonly McpToolEntry[] = [
|
|
351
|
+
...MCP_TOOLS,
|
|
352
|
+
{ tool: "synthetic_liar", operation: "memory_get" },
|
|
353
|
+
];
|
|
354
|
+
const live = shortNamesOf(lyingCatalog);
|
|
355
|
+
const emptyRegistry = new Set<OperationName>();
|
|
356
|
+
|
|
357
|
+
const violations = validateCoverage(lyingCatalog, live, emptyRegistry);
|
|
358
|
+
const unregistered = violations.filter((v) => v.kind === "migrated-entry-not-registered");
|
|
359
|
+
assert.ok(
|
|
360
|
+
unregistered.some((v) => v.detail.includes("synthetic_liar")),
|
|
361
|
+
"validator must flag a migrated entry with no backing registration",
|
|
362
|
+
);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
test("fitness validator catches a stale catalog entry (tool removed from the server)", () => {
|
|
366
|
+
// Seed: catalog claims a tool the server no longer advertises.
|
|
367
|
+
const staleCatalog: readonly McpToolEntry[] = [
|
|
368
|
+
...MCP_TOOLS,
|
|
369
|
+
{ tool: "ghost_tool_removed_from_server", operation: null },
|
|
370
|
+
];
|
|
371
|
+
const live = shortNamesOf(MCP_TOOLS); // server does NOT advertise the ghost
|
|
372
|
+
|
|
373
|
+
const violations = validateCoverage(staleCatalog, live, new Set(listRegisteredOperations()));
|
|
374
|
+
const stale = violations.filter((v) => v.kind === "catalog-tool-not-live");
|
|
375
|
+
assert.ok(stale.length > 0, "validator must flag a catalog entry with no live tool");
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test("dispatch validator catches a catalog entry whose operation is registered but never dispatched", () => {
|
|
379
|
+
// Seed: a catalog entry claims `operation: "memory_get"` — the operation IS
|
|
380
|
+
// registered — but the MCP dispatch map passed in is EMPTY, simulating a
|
|
381
|
+
// handler that flipped its catalog row without wiring dispatch through the
|
|
382
|
+
// boundary. Registration alone would pass the old gate; this check must
|
|
383
|
+
// catch the false migration (review P2: verify dispatch before counting).
|
|
384
|
+
const lyingCatalog: readonly McpToolEntry[] = [
|
|
385
|
+
{ tool: "synthetic_dispatch_liar", operation: "memory_get" },
|
|
386
|
+
];
|
|
387
|
+
const violations = validateDispatchCoverage(
|
|
388
|
+
lyingCatalog,
|
|
389
|
+
[],
|
|
390
|
+
new Map<string, string>(),
|
|
391
|
+
new Map<string, ReadonlySet<string>>(),
|
|
392
|
+
);
|
|
393
|
+
const noDispatch = violations.filter((v) => v.kind === "mcp-no-dispatch");
|
|
394
|
+
assert.ok(
|
|
395
|
+
noDispatch.some((v) => v.detail.includes("synthetic_dispatch_liar")),
|
|
396
|
+
"dispatch validator must flag a migrated entry with no surface dispatch wiring",
|
|
397
|
+
);
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
test("dispatch validator catches an HTTP route whose operation is dispatched by a different route", () => {
|
|
401
|
+
// Seed: catalog claims `GET /engram/v1/synthetic` migrated to `memory_get`.
|
|
402
|
+
// The operation IS registered, and `memory_get` IS dispatched — but by a
|
|
403
|
+
// DIFFERENT route (`/engram/v1/memories/:id`). The route-specific check must
|
|
404
|
+
// catch that `/engram/v1/synthetic` itself has no `getOperation("memory_get")`
|
|
405
|
+
// in its handler block. A global set would pass this false migration.
|
|
406
|
+
// (review P2: bind dispatch validation to method/path routes)
|
|
407
|
+
const lyingHttpCatalog: readonly HttpRouteEntry[] = [
|
|
408
|
+
{ method: "GET", pathname: "/engram/v1/synthetic", operation: "memory_get" },
|
|
409
|
+
];
|
|
410
|
+
const routeDispatch = new Map<string, ReadonlySet<string>>([
|
|
411
|
+
["GET /engram/v1/memories/:id", new Set(["memory_get"])],
|
|
412
|
+
]);
|
|
413
|
+
const violations = validateDispatchCoverage(
|
|
414
|
+
[],
|
|
415
|
+
lyingHttpCatalog,
|
|
416
|
+
new Map<string, string>(),
|
|
417
|
+
routeDispatch,
|
|
418
|
+
);
|
|
419
|
+
const noDispatch = violations.filter((v) => v.kind === "http-no-dispatch");
|
|
420
|
+
assert.ok(
|
|
421
|
+
noDispatch.some((v) => v.detail.includes("/engram/v1/synthetic")),
|
|
422
|
+
"dispatch validator must flag an HTTP route whose operation is dispatched by a different route",
|
|
423
|
+
);
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
test("dispatch validator catches cross-method contamination on a shared path", () => {
|
|
427
|
+
// Seed: catalog claims `GET /engram/v1/memories` migrated to `memory_store`.
|
|
428
|
+
// The operation IS registered, and `POST /engram/v1/memories` DOES dispatch
|
|
429
|
+
// `memory_store` — but the GET method's own block does not. A pathname-only
|
|
430
|
+
// key would find the POST block's dispatch and pass; the method+pathname key
|
|
431
|
+
// must reject it. (review P2: key HTTP dispatch coverage by method and path)
|
|
432
|
+
const lyingHttpCatalog: readonly HttpRouteEntry[] = [
|
|
433
|
+
{ method: "GET", pathname: "/engram/v1/memories", operation: "memory_store" },
|
|
434
|
+
];
|
|
435
|
+
const routeDispatch = new Map<string, ReadonlySet<string>>([
|
|
436
|
+
["POST /engram/v1/memories", new Set(["memory_store"])],
|
|
437
|
+
]);
|
|
438
|
+
const violations = validateDispatchCoverage(
|
|
439
|
+
[],
|
|
440
|
+
lyingHttpCatalog,
|
|
441
|
+
new Map<string, string>(),
|
|
442
|
+
routeDispatch,
|
|
443
|
+
);
|
|
444
|
+
const noDispatch = violations.filter((v) => v.kind === "http-no-dispatch");
|
|
445
|
+
assert.ok(
|
|
446
|
+
noDispatch.some((v) => v.detail.includes("GET /engram/v1/memories")),
|
|
447
|
+
"dispatch validator must flag a GET route whose operation is dispatched only by the POST route on the same path",
|
|
448
|
+
);
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
// ---------------------------------------------------------------------------
|
|
452
|
+
// Real surface coverage — the gate that runs on every build
|
|
453
|
+
// ---------------------------------------------------------------------------
|
|
454
|
+
|
|
455
|
+
test("MCP tools/list matches the catalog exactly (no untracked handlers)", async () => {
|
|
456
|
+
const live = await liveMcpToolShortNames();
|
|
457
|
+
const violations = validateCoverage(MCP_TOOLS, live, new Set(listRegisteredOperations()));
|
|
458
|
+
assert.deepEqual(
|
|
459
|
+
violations,
|
|
460
|
+
[],
|
|
461
|
+
`surface/catalog drift detected — either update access-surface-catalog.ts or migrate the new handler:\n${formatViolations(violations)}`,
|
|
462
|
+
);
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
// ---------------------------------------------------------------------------
|
|
466
|
+
// HTTP source-completeness — static extraction from access-http.ts
|
|
467
|
+
// ---------------------------------------------------------------------------
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* The MCP surface has a live coverage test (tools/list). HTTP has no
|
|
471
|
+
* equivalent introspection endpoint — routes are scattered if-branches in
|
|
472
|
+
* EngramAccessHttpServer.handle. This test statically extracts route
|
|
473
|
+
* patterns from the source and compares against HTTP_ROUTES so a new
|
|
474
|
+
* service-invoking route cannot land without a catalog entry.
|
|
475
|
+
*
|
|
476
|
+
* Infrastructure routes (health, adapters, admin console, UI assets, MCP
|
|
477
|
+
* delegate) are excluded — they carry no user-validated request envelope.
|
|
478
|
+
*/
|
|
479
|
+
test("HTTP handler source routes match the catalog (static completeness)", () => {
|
|
480
|
+
const httpSource = readFileSync(
|
|
481
|
+
new URL("./access-http.ts", import.meta.url),
|
|
482
|
+
"utf-8",
|
|
483
|
+
);
|
|
484
|
+
const lines = httpSource.split("\n");
|
|
485
|
+
|
|
486
|
+
// --- Phase 1: pathname-level extraction (catches missing pathnames) -------
|
|
487
|
+
const sourcePaths = new Set<string>();
|
|
488
|
+
|
|
489
|
+
for (const m of httpSource.matchAll(
|
|
490
|
+
/pathname\s*===\s*"((?:\/engram|\/remnic|\/v1)\/[^"]+)"/g,
|
|
491
|
+
)) {
|
|
492
|
+
sourcePaths.add(m[1]!.replace(/^\/remnic\//, "/engram/"));
|
|
493
|
+
}
|
|
494
|
+
for (const m of httpSource.matchAll(
|
|
495
|
+
/pathname\.startsWith\("((?:\/engram)\/v1\/[^"]+\/)"/g,
|
|
496
|
+
)) {
|
|
497
|
+
sourcePaths.add(m[1]!.replace(/\/$/, "") + "/:id");
|
|
498
|
+
}
|
|
499
|
+
const normalizeRegexRoute = (src: string): string =>
|
|
500
|
+
src.replace(/\\\//g, "/").replace(/\(\[\^\/\]\+\)/g, ":id");
|
|
501
|
+
for (const m of httpSource.matchAll(/\/\^(.+?)\$\/g?\.(?:exec|test)\(pathname\)/g)) {
|
|
502
|
+
const normalized = normalizeRegexRoute(m[1]!);
|
|
503
|
+
if (normalized.startsWith("/engram/v1/") || normalized.startsWith("/v1/")) {
|
|
504
|
+
sourcePaths.add(normalized);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
for (const m of httpSource.matchAll(/pathname\.match\(\/\^(.+?)\$\/g?\)/g)) {
|
|
508
|
+
const normalized = normalizeRegexRoute(m[1]!);
|
|
509
|
+
if (normalized.startsWith("/engram/v1/") || normalized.startsWith("/v1/")) {
|
|
510
|
+
sourcePaths.add(normalized);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const INFRA = [
|
|
515
|
+
/^\/engram\/v1\/health$/,
|
|
516
|
+
/^\/engram\/v1\/adapters$/,
|
|
517
|
+
/^\/engram\/v1\/admin\//,
|
|
518
|
+
/^\/engram\/ui/,
|
|
519
|
+
/^\/mcp$/,
|
|
520
|
+
];
|
|
521
|
+
const servicePaths = [...sourcePaths]
|
|
522
|
+
.filter((p) => !INFRA.some((re) => re.test(p)))
|
|
523
|
+
.sort();
|
|
524
|
+
|
|
525
|
+
const catalogPaths = new Set(HTTP_ROUTES.map((r) => r.pathname));
|
|
526
|
+
const missingFromCatalog = servicePaths.filter(
|
|
527
|
+
(p) => !catalogPaths.has(p),
|
|
528
|
+
);
|
|
529
|
+
assert.deepEqual(
|
|
530
|
+
missingFromCatalog,
|
|
531
|
+
[],
|
|
532
|
+
`HTTP routes found in access-http.ts but missing from HTTP_ROUTES catalog.\n` +
|
|
533
|
+
`Add each to access-surface-catalog.ts with operation: null (or migrate it).\n` +
|
|
534
|
+
`Missing:\n${missingFromCatalog.map((p) => ` ${p}`).join("\n")}`,
|
|
535
|
+
);
|
|
536
|
+
|
|
537
|
+
// --- Phase 2: (method, pathname) tuple extraction (catches missing methods)
|
|
538
|
+
// For each exact-match route, scan backward up to 5 lines for the HTTP method.
|
|
539
|
+
// For dynamic routes, scan forward up to 40 lines for method checks inside the block.
|
|
540
|
+
const sourceTuples = new Set<string>();
|
|
541
|
+
const isServicePath = (p: string) =>
|
|
542
|
+
!INFRA.some((re) => re.test(p));
|
|
543
|
+
|
|
544
|
+
for (let i = 0; i < lines.length; i++) {
|
|
545
|
+
const pathMatch = lines[i]!.match(
|
|
546
|
+
/pathname\s*===\s*"((?:\/engram|\/remnic|\/v1)\/[^"]+)"/,
|
|
547
|
+
);
|
|
548
|
+
if (pathMatch) {
|
|
549
|
+
const pathname = pathMatch[1]!.replace(/^\/remnic\//, "/engram/");
|
|
550
|
+
if (!isServicePath(pathname)) continue;
|
|
551
|
+
// Scan backward for the method declaration.
|
|
552
|
+
for (let j = i; j >= Math.max(0, i - 5); j--) {
|
|
553
|
+
const mMatch = lines[j]!.match(/req\.method\s*===\s*"(\w+)"/);
|
|
554
|
+
if (mMatch) {
|
|
555
|
+
sourceTuples.add(`${mMatch[1]} ${pathname}`);
|
|
556
|
+
break;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
// Dynamic routes: scan forward for method checks.
|
|
562
|
+
for (const m of httpSource.matchAll(
|
|
563
|
+
/(?:\/\^(.+?)\$\/g?\.(?:exec|test)\(pathname\)|pathname\.match\(\/\^(.+?)\$\/g?\)|pathname\.startsWith\("((?:\/engram)\/v1\/[^"]+)"\))/g,
|
|
564
|
+
)) {
|
|
565
|
+
const raw = m[1] ?? m[2] ?? m[3];
|
|
566
|
+
if (!raw) continue;
|
|
567
|
+
let pathname: string;
|
|
568
|
+
if (m[3]) {
|
|
569
|
+
pathname = m[3].replace(/\/$/, "") + "/:id";
|
|
570
|
+
} else {
|
|
571
|
+
pathname = normalizeRegexRoute(raw!);
|
|
572
|
+
}
|
|
573
|
+
if (!pathname.startsWith("/engram/v1/") || !isServicePath(pathname)) continue;
|
|
574
|
+
const matchIndex = m.index!;
|
|
575
|
+
const matchLine = httpSource.slice(0, matchIndex).split("\n").length;
|
|
576
|
+
// Scan forward for method checks inside the route block.
|
|
577
|
+
for (let j = matchLine; j < Math.min(lines.length, matchLine + 40); j++) {
|
|
578
|
+
// Stop at the next route block.
|
|
579
|
+
if (j > matchLine && /pathname\s*===\s*"|pathname\.startsWith\(|\/\^.*\$\/g?\.(?:exec|test)\(pathname\)|pathname\.match\(/.test(lines[j]!)) {
|
|
580
|
+
break;
|
|
581
|
+
}
|
|
582
|
+
const methodMatch = lines[j]!.match(/req\.method\s*===\s*"(\w+)"/);
|
|
583
|
+
if (methodMatch) {
|
|
584
|
+
sourceTuples.add(`${methodMatch[1]} ${pathname}`);
|
|
585
|
+
}
|
|
586
|
+
// Also handle negated checks: `req.method !== "GET"` → the route IS GET.
|
|
587
|
+
// Scan within the whole block, not just the match line — some routes
|
|
588
|
+
// (e.g. GET /engram/v1/peers/:id/profile) use the negation as a guard
|
|
589
|
+
// inside the block, not on the regex-match line.
|
|
590
|
+
const negMethodMatch = lines[j]!.match(/req\.method\s*!==\s*"(\w+)"/);
|
|
591
|
+
if (negMethodMatch) {
|
|
592
|
+
sourceTuples.add(`${negMethodMatch[1]} ${pathname}`);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const catalogTuples = new Set(
|
|
598
|
+
HTTP_ROUTES.map((r) => `${r.method} ${r.pathname}`),
|
|
599
|
+
);
|
|
600
|
+
const missingTuples = [...sourceTuples]
|
|
601
|
+
.filter((t) => !catalogTuples.has(t))
|
|
602
|
+
.sort();
|
|
603
|
+
assert.deepEqual(
|
|
604
|
+
missingTuples,
|
|
605
|
+
[],
|
|
606
|
+
`HTTP (method, pathname) tuples found in access-http.ts but missing from HTTP_ROUTES.\n` +
|
|
607
|
+
`Add each to access-surface-catalog.ts.\n` +
|
|
608
|
+
`Missing:\n${missingTuples.map((t) => ` ${t}`).join("\n")}`,
|
|
609
|
+
);
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
test("every migrated MCP/HTTP entry resolves to a registered AND dispatched boundary operation", () => {
|
|
613
|
+
// Phase 1 — registration: the operation name must exist in the live registry.
|
|
614
|
+
// This catches a flipped catalog row that references an operation no one
|
|
615
|
+
// ever defined.
|
|
616
|
+
const registered = new Set(listRegisteredOperations());
|
|
617
|
+
for (const entry of MCP_TOOLS) {
|
|
618
|
+
if (entry.operation !== null) {
|
|
619
|
+
assert.ok(
|
|
620
|
+
registered.has(entry.operation),
|
|
621
|
+
`MCP tool ${entry.tool} claims migration to ${entry.operation} but it is not registered`,
|
|
622
|
+
);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
for (const entry of HTTP_ROUTES) {
|
|
626
|
+
if (entry.operation !== null) {
|
|
627
|
+
assert.ok(
|
|
628
|
+
registered.has(entry.operation),
|
|
629
|
+
`HTTP ${entry.method} ${entry.pathname} claims migration to ${entry.operation} but it is not registered`,
|
|
630
|
+
);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// Phase 2 — dispatch: the live surface code must route through the boundary.
|
|
635
|
+
// Registration alone is not enough — a flipped catalog row without wired
|
|
636
|
+
// dispatch is a false migration. The ratchet would lower the unmigrated
|
|
637
|
+
// count while the handler still uses its old direct service branch.
|
|
638
|
+
// MCP dispatch is verified against MCP_MIGRATED_OPERATIONS; HTTP dispatch is
|
|
639
|
+
// verified route-specifically (getOperation("…") must be in the entry's own
|
|
640
|
+
// handler block, not just anywhere in the file).
|
|
641
|
+
// (review P2: verify dispatch before counting handlers as migrated;
|
|
642
|
+
// review P2: bind dispatch validation to method/path routes)
|
|
643
|
+
const mcpDispatch = extractMcpDispatchMap();
|
|
644
|
+
const httpRouteDispatch = extractHttpRouteDispatchMap();
|
|
645
|
+
const violations = validateDispatchCoverage(
|
|
646
|
+
MCP_TOOLS,
|
|
647
|
+
HTTP_ROUTES,
|
|
648
|
+
mcpDispatch,
|
|
649
|
+
httpRouteDispatch,
|
|
650
|
+
);
|
|
651
|
+
assert.deepEqual(
|
|
652
|
+
violations,
|
|
653
|
+
[],
|
|
654
|
+
`catalog entries claim migrations the surface does not dispatch through — ` +
|
|
655
|
+
`a handler is marked migrated but still uses its old direct service branch.\n` +
|
|
656
|
+
violations.map((v) => ` - [${v.kind}] ${v.detail}`).join("\n"),
|
|
657
|
+
);
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
test("unmigrated-handler count matches the ratchet baseline (may only decrease)", () => {
|
|
661
|
+
const actual = countUnmigratedHandlers();
|
|
662
|
+
assert.ok(
|
|
663
|
+
actual <= UNMIGRATED_HANDLER_BASELINE,
|
|
664
|
+
`unmigrated handler count grew from ${UNMIGRATED_HANDLER_BASELINE} to ${actual} — every new handler MUST go through the access boundary (issue #1525). Either migrate it or, if it carries no user input, document the exemption in access-surface-catalog.ts and bump the baseline with a justified commit.`,
|
|
665
|
+
);
|
|
666
|
+
// Equality, not just ≤, is the real gate once the baseline is set. We assert
|
|
667
|
+
// it explicitly so a silent catalog edit (e.g. flipping an entry to null
|
|
668
|
+
// during a refactor) is caught even when the count stays under the ceiling.
|
|
669
|
+
assert.equal(
|
|
670
|
+
actual,
|
|
671
|
+
UNMIGRATED_HANDLER_BASELINE,
|
|
672
|
+
`unmigrated handler count changed from ${UNMIGRATED_HANDLER_BASELINE} to ${actual} — update UNMIGRATED_HANDLER_BASELINE here AND run \`node scripts/check-ratchets.mjs --update\` to record the improvement.`,
|
|
673
|
+
);
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
// ---------------------------------------------------------------------------
|
|
678
|
+
// Boundary hook forwarding — quota parity (issue #1525 acceptance criterion)
|
|
679
|
+
// ---------------------------------------------------------------------------
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* The HTTP memory_store route enforces its write-quota atomically inside the
|
|
683
|
+
* service's idempotent-write lock via an `enforceWriteQuota` callback passed
|
|
684
|
+
* as the service call's second argument. The boundary MUST forward `ctx.hooks`
|
|
685
|
+
* to that second argument — silently dropping it would let writes bypass the
|
|
686
|
+
* quota gate, the exact regression class the #1434 Codex review locked down.
|
|
687
|
+
* These tests prove the pilot operation forwards the hook end-to-end.
|
|
688
|
+
*/
|
|
689
|
+
|
|
690
|
+
test("memory_store operation forwards ctx.hooks to service.memoryStore (quota hook parity)", async () => {
|
|
691
|
+
const captured: { hooks?: unknown } = {};
|
|
692
|
+
const service = {
|
|
693
|
+
memoryStore: async (_request: unknown, hooks?: unknown) => {
|
|
694
|
+
captured.hooks = hooks;
|
|
695
|
+
return {
|
|
696
|
+
schemaVersion: 1,
|
|
697
|
+
operation: "memory_store",
|
|
698
|
+
namespace: "default",
|
|
699
|
+
dryRun: false,
|
|
700
|
+
accepted: true,
|
|
701
|
+
queued: false,
|
|
702
|
+
status: "stored",
|
|
703
|
+
};
|
|
704
|
+
},
|
|
705
|
+
} as unknown as EngramAccessService;
|
|
706
|
+
|
|
707
|
+
let quotaCalled = false;
|
|
708
|
+
const op = getOperation("memory_store");
|
|
709
|
+
assert.ok(op, "memory_store must be registered for the pilot");
|
|
710
|
+
await op.run(
|
|
711
|
+
{ content: "quota-parity-probe", category: "fact", confidence: 0.9 },
|
|
712
|
+
{
|
|
713
|
+
service,
|
|
714
|
+
hooks: {
|
|
715
|
+
enforceWriteQuota: () => {
|
|
716
|
+
quotaCalled = true;
|
|
717
|
+
},
|
|
718
|
+
},
|
|
719
|
+
},
|
|
720
|
+
);
|
|
721
|
+
// The hooks object MUST reach the service's second argument.
|
|
722
|
+
assert.ok(captured.hooks, "ctx.hooks must be forwarded to service.memoryStore — dropping it silently bypasses the quota gate (#1434)");
|
|
723
|
+
const forwarded = captured.hooks as { enforceWriteQuota?: () => void };
|
|
724
|
+
assert.equal(typeof forwarded?.enforceWriteQuota, "function", "enforceWriteQuota must survive the forwarding");
|
|
725
|
+
// And the forwarded function must be the same callable (the service invokes
|
|
726
|
+
// it as `beforeExecute` inside the idempotent-write lock).
|
|
727
|
+
forwarded?.enforceWriteQuota?.();
|
|
728
|
+
assert.equal(quotaCalled, true, "the forwarded enforceWriteQuota must be invocable");
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
test("memory_store operation forwards undefined hooks when ctx.hooks is absent (CLI parity)", async () => {
|
|
732
|
+
// The CLI store command has no write-quota hook (one-shot process). The
|
|
733
|
+
// boundary must forward `undefined` cleanly so service.memoryStore's
|
|
734
|
+
// optional-hooks parameter stays unset, not an empty object that could
|
|
735
|
+
// mask a future signature change.
|
|
736
|
+
const captured: { hooks?: unknown } = {};
|
|
737
|
+
const service = {
|
|
738
|
+
memoryStore: async (_request: unknown, hooks?: unknown) => {
|
|
739
|
+
captured.hooks = hooks;
|
|
740
|
+
return {
|
|
741
|
+
schemaVersion: 1,
|
|
742
|
+
operation: "memory_store",
|
|
743
|
+
namespace: "default",
|
|
744
|
+
dryRun: false,
|
|
745
|
+
accepted: true,
|
|
746
|
+
queued: false,
|
|
747
|
+
status: "stored",
|
|
748
|
+
};
|
|
749
|
+
},
|
|
750
|
+
} as unknown as EngramAccessService;
|
|
751
|
+
|
|
752
|
+
const op = getOperation("memory_store");
|
|
753
|
+
assert.ok(op);
|
|
754
|
+
await op.run(
|
|
755
|
+
{ content: "cli-parity-probe", category: "fact", confidence: 0.9 },
|
|
756
|
+
{ service },
|
|
757
|
+
);
|
|
758
|
+
assert.equal(captured.hooks, undefined, "absent ctx.hooks must forward as undefined");
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
test("memory_get operation resolves through the boundary without hooks (read parity)", async () => {
|
|
762
|
+
const service = {
|
|
763
|
+
memoryGet: async () => ({ found: false, memoryId: "x", content: "" }),
|
|
764
|
+
} as unknown as EngramAccessService;
|
|
765
|
+
const op = getOperation("memory_get");
|
|
766
|
+
assert.ok(op);
|
|
767
|
+
const output = (await op.run(
|
|
768
|
+
{ memoryId: "abc" },
|
|
769
|
+
{ service },
|
|
770
|
+
)) as { result: { found: boolean } };
|
|
771
|
+
assert.equal(output.result.found, false);
|
|
772
|
+
});
|