From 8441900db33d57d950bf4b2e0e6a1c70d1194936 Mon Sep 17 00:00:00 2001 From: hongming-pc2 Date: Thu, 21 May 2026 19:03:22 -0700 Subject: [PATCH] test(contract): AST-level poll-uploads-resolved invariant (RFC#640 Layer D) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Layer D of the 4-layer cascade — fail-CI on any TS adapter that polls /workspaces//activity but does NOT also import the upload-resolution helpers from @molecule-ai/mcp-server. Catches the third failure surface (adapter forgot to wire in the resolution flow) that Layer A's MANDATORY spec + Layer B's TS implementation close from the spec/implementation side. Test design: producer-side CI no-op gate + consumer-side enforcement. # In a consumer repo's CI (channel adapter / telegram / codex / etc.): MCP_SERVER_CONTRACT_CONSUMERS=src/server.ts:src/poll.ts \ npx jest --testPathPatterns=poll-uploads-resolved-contract \ --rootDir=node_modules/@molecule-ai/mcp-server When MCP_SERVER_CONTRACT_CONSUMERS is unset (this repo's own CI), the test runs against an empty list and passes trivially. The contract is engaged in consumer repos via the env-driven list. Invariant per file: 1. Parse with TS compiler API (ts.createSourceFile + ts.forEachChild). 2. Detect /activity URL literal in any string or template literal (head + after-substitution spans). Pattern is /workspaces//activity with a non-word boundary after to avoid the false-friend /activities. 3. Detect named imports from @molecule-ai/mcp-server OR @molecule-ai/mcp-server/inbox-uploads of resolvePendingUpload / URICache / rewritePendingURIs. 4. Assert: (NOT pollsActivity) OR importsResolutionHelper OR hasOptOut. Magic-comment opt-out for intentional bypass: // @no-resolve-uploads-justification: Reason text is informational only (asserted in code review). The test just looks for the prefix anywhere in the file. Test envelope (10 cases): - producer-side no-op when MCP_SERVER_CONTRACT_CONSUMERS unset (1) - self-tests via tmpdir fixtures (9): - polls + imports → passes - polls + no-imports → caught - polls + opt-out → not caught - no-poll → trivially holds - subpath import @molecule-ai/mcp-server/inbox-uploads counts - false-friend /workspaces/x/activities NOT matched - template literal /activity in head detected - template literal /activity after substitution span detected - default ImportClause NOT counted as named import Verified on operator: tsc --noEmit : clean producer-side jest : 10/10 passed (no-op + 9 self-tests) consumer gate green : passing fixture → 10/10 passed consumer gate red : failing fixture → 1 expected failure with clear msg Sibling design reference: the runtime-pin-check pattern (CEO-A flagged template-claude-code's runtime-pin-check.yml; that repo wasn't available on Gitea so the env-var-driven consumer-paths shape was reconstructed from her dispatch brief). Origin: RFC#640 4-layer cascade Layer D. CTO chat GO via "4-layer, dispatch team and follow SOP" 2026-05-22T01:31:48Z. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../poll-uploads-resolved-contract.test.ts | 384 ++++++++++++++++++ 1 file changed, 384 insertions(+) create mode 100644 src/__tests__/poll-uploads-resolved-contract.test.ts diff --git a/src/__tests__/poll-uploads-resolved-contract.test.ts b/src/__tests__/poll-uploads-resolved-contract.test.ts new file mode 100644 index 0000000..35e7e99 --- /dev/null +++ b/src/__tests__/poll-uploads-resolved-contract.test.ts @@ -0,0 +1,384 @@ +/** + * Layer D (RFC#640 4-layer cascade) — AST-level contract test. + * + * Enforces the invariant: any TS file that polls `/workspaces/.../activity` + * (the activity endpoint that delivers `chat_upload_receive` rows) MUST + * also import the upload-resolution helpers from + * `@molecule-ai/mcp-server`. Otherwise the adapter will silently drop + * `platform-pending:` URIs the agent can't open — exactly the regression + * Layer A's MANDATORY contract section + Layer B's TS implementation + * close from the spec/implementation side. + * + * This test catches the THIRD failure surface: an adapter that has a + * poll loop but forgot to wire in the resolution helpers. AST-level + * (vs. runtime) means the failure shows up at CI parse-time, not at + * runtime when a user happens to paste a file. + * + * # How it runs + * + * Consumer repos (channel adapter, telegram adapter, codex bridge, etc.) + * point at this test via: + * + * # In the consumer repo's CI: + * MCP_SERVER_CONTRACT_CONSUMERS=src/server.ts:src/poll.ts \ + * npx jest --testPathPatterns=poll-uploads-resolved-contract \ + * --rootDir=node_modules/@molecule-ai/mcp-server + * + * The env var is colon-separated list of TS source files (paths + * relative to the consumer repo's cwd) to inspect. Each file is parsed + * with the TypeScript compiler API; the invariant is asserted per file. + * + * # On producer-side CI (this repo's own jest run): + * + * The env var is unset → the test runs against an empty consumer list → + * passes trivially. This means the test runs in this repo's CI without + * needing external consumers; the gate is engaged only when a consumer + * sets the env var. Same shape as the runtime-pin-check contract sibling + * pattern. Producer-side passes; consumer-side gates. + * + * # Magic-comment opt-out + * + * A consumer that intentionally polls /activity but DOES NOT need upload + * resolution (e.g. a logging-only inspector that never surfaces files to + * an agent) can opt out by adding the magic comment ANYWHERE in the file: + * + * // @no-resolve-uploads-justification: + * + * The reason text is informational — the test asserts the presence of + * the magic-comment header but doesn't parse the reason. A reviewer + * sees the comment + reason in code review. + * + * Origin: RFC#640 Layer D. CTO chat GO 2026-05-22T01:31:48Z. + */ + +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as ts from "typescript"; + +// --------------------------------------------------------------------------- +// Static config — keep in sync with src/inbox-uploads.ts public exports. +// --------------------------------------------------------------------------- + +/** Helper names that, when imported, signal upload-resolution capability. */ +const RESOLUTION_HELPER_NAMES = new Set([ + "resolvePendingUpload", + "URICache", + "rewritePendingURIs", +]); + +/** Module specifier patterns that source the resolution helpers. */ +const RESOLUTION_HELPER_SOURCES = [ + "@molecule-ai/mcp-server", + "@molecule-ai/mcp-server/inbox-uploads", +]; + +/** + * URL-literal patterns that mark a file as an /activity poller. Matches: + * `/workspaces//activity` + * `/workspaces//activity?include=peer_info` + * `/workspaces/${id}/activity?since_id=...` + * The walk is conservative: only literal strings + tagged-template + * sub-strings. A consumer that dynamically constructs the URL via a + * helper function (e.g. `buildActivityUrl(ws)`) would slip past this + * check; that's acceptable because the helper itself would land in a + * file that does the curl, and the check catches the curl-site file. + */ +const ACTIVITY_URL_PATTERN = /\/workspaces\/[^/]*\/activity(?:\?|$|[^a-zA-Z0-9_/-])/; + +/** + * Magic-comment opt-out. Anywhere in the file body / leading comments. + * The `` part is informational; the test only checks for the + * prefix. + */ +const OPT_OUT_COMMENT = /\/\/\s*@no-resolve-uploads-justification:/; + +// --------------------------------------------------------------------------- +// Test +// --------------------------------------------------------------------------- + +interface ConsumerCheckResult { + consumerPath: string; + pollsActivity: boolean; + importsResolutionHelper: boolean; + hasOptOut: boolean; + optOutLine?: number; + importedResolutionNames: string[]; +} + +function checkConsumerFile(consumerPath: string): ConsumerCheckResult { + const source = fs.readFileSync(consumerPath, "utf8"); + const sourceFile = ts.createSourceFile( + consumerPath, + source, + ts.ScriptTarget.ES2022, + /*setParentNodes*/ true, + consumerPath.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS, + ); + + let pollsActivity = false; + const importedFromMcpServer: string[] = []; + + const visit = (node: ts.Node): void => { + // Import declaration with named imports: track imports from our package. + if (ts.isImportDeclaration(node)) { + const moduleSpec = node.moduleSpecifier; + if (ts.isStringLiteral(moduleSpec) && RESOLUTION_HELPER_SOURCES.includes(moduleSpec.text)) { + const clause = node.importClause; + if (clause && clause.namedBindings && ts.isNamedImports(clause.namedBindings)) { + for (const el of clause.namedBindings.elements) { + importedFromMcpServer.push(el.name.text); + } + } + } + } + // String literal: any /activity URL in any string is a poll signal. + if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) { + if (ACTIVITY_URL_PATTERN.test(node.text)) { + pollsActivity = true; + } + } + // Template literal with substitutions: also check raw fragments. + if (ts.isTemplateExpression(node)) { + const allText = + node.head.text + + node.templateSpans.map((s) => `${s.literal.text}`).join(""); + if (ACTIVITY_URL_PATTERN.test(allText)) { + pollsActivity = true; + } + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); + + // Magic-comment opt-out scan (text-level — covers leading comments, + // mid-file block comments, etc.). + let optOutLine: number | undefined; + if (OPT_OUT_COMMENT.test(source)) { + const lines = source.split(/\r?\n/); + for (let i = 0; i < lines.length; i++) { + if (OPT_OUT_COMMENT.test(lines[i])) { + optOutLine = i + 1; + break; + } + } + } + + const importsResolutionHelper = importedFromMcpServer.some((name) => + RESOLUTION_HELPER_NAMES.has(name), + ); + + return { + consumerPath, + pollsActivity, + importsResolutionHelper, + hasOptOut: optOutLine !== undefined, + optOutLine, + importedResolutionNames: importedFromMcpServer.filter((n) => + RESOLUTION_HELPER_NAMES.has(n), + ), + }; +} + +describe("RFC#640 Layer D — poll-uploads-resolved contract", () => { + const consumersEnv = process.env.MCP_SERVER_CONTRACT_CONSUMERS ?? ""; + const consumers = consumersEnv + .split(":") + .map((s) => s.trim()) + .filter((s) => s.length > 0); + + if (consumers.length === 0) { + // Producer-side CI no-op gate. The contract is engaged in consumer + // repos via MCP_SERVER_CONTRACT_CONSUMERS= on their jest run. + it("no consumers declared (producer-side CI no-op)", () => { + expect(consumers.length).toBe(0); + }); + return; + } + + for (const consumerPath of consumers) { + describe(consumerPath, () => { + let result: ConsumerCheckResult; + + beforeAll(() => { + if (!fs.existsSync(consumerPath)) { + throw new Error( + `MCP_SERVER_CONTRACT_CONSUMERS lists ${consumerPath} but the file does not exist relative to cwd ${process.cwd()}`, + ); + } + result = checkConsumerFile(consumerPath); + }); + + it("either polls /activity AND imports resolution helpers, OR has the opt-out comment, OR does not poll /activity at all", () => { + // Three valid states: + // (a) does not poll /activity → invariant trivially holds + // (b) polls AND imports resolution → invariant holds + // (c) polls AND has opt-out comment → invariant escape hatch + const reasonLines: string[] = [ + `path: ${result.consumerPath}`, + `polls /activity: ${result.pollsActivity}`, + `imports resolution helper(s): ${ + result.importsResolutionHelper + ? `[${result.importedResolutionNames.join(", ")}]` + : "no" + }`, + `has @no-resolve-uploads-justification: ${ + result.hasOptOut ? `yes (line ${result.optOutLine})` : "no" + }`, + ]; + const status = + !result.pollsActivity || result.importsResolutionHelper || result.hasOptOut; + expect({ ok: status, info: reasonLines.join("\n ") }).toEqual({ + ok: true, + info: reasonLines.join("\n "), + }); + }); + }); + } +}); + +// --------------------------------------------------------------------------- +// Self-test fixtures: prove the checker logic catches each case correctly. +// These exercise the analysis function against synthesized source strings +// without requiring real fixture files on disk. +// --------------------------------------------------------------------------- + +describe("RFC#640 Layer D — checker self-tests", () => { + // Use tmpdir fixtures because checkConsumerFile reads from disk. + let tmpDir: string; + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(require("node:os").tmpdir(), "layer-d-self-")); + }); + afterEach(() => { + try { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } catch { + // best-effort + } + }); + + function fixture(name: string, content: string): string { + const p = path.join(tmpDir, name); + fs.writeFileSync(p, content); + return p; + } + + it("file that polls /activity AND imports resolvePendingUpload → passes", () => { + const p = fixture( + "ok.ts", + ` +import { resolvePendingUpload, URICache } from "@molecule-ai/mcp-server"; +async function pollLoop(wsId: string) { + const url = \`/workspaces/\${wsId}/activity?include=peer_info\`; + // ... +} +`, + ); + const r = checkConsumerFile(p); + expect(r.pollsActivity).toBe(true); + expect(r.importsResolutionHelper).toBe(true); + expect(r.hasOptOut).toBe(false); + }); + + it("file that polls /activity but does NOT import resolution helpers → caught", () => { + const p = fixture( + "missing.ts", + ` +import { apiCall } from "@molecule-ai/mcp-server"; +async function pollLoop(wsId: string) { + await apiCall("GET", \`/workspaces/\${wsId}/activity\`); +} +`, + ); + const r = checkConsumerFile(p); + expect(r.pollsActivity).toBe(true); + expect(r.importsResolutionHelper).toBe(false); + expect(r.hasOptOut).toBe(false); + }); + + it("file with magic-comment opt-out → not caught", () => { + const p = fixture( + "optout.ts", + ` +// @no-resolve-uploads-justification: this is a logging-only inspector +import { apiCall } from "@molecule-ai/mcp-server"; +async function pollLoop(wsId: string) { + await apiCall("GET", \`/workspaces/\${wsId}/activity\`); +} +`, + ); + const r = checkConsumerFile(p); + expect(r.pollsActivity).toBe(true); + expect(r.importsResolutionHelper).toBe(false); + expect(r.hasOptOut).toBe(true); + expect(r.optOutLine).toBe(2); + }); + + it("file that doesn't poll /activity at all → invariant trivially holds", () => { + const p = fixture( + "noPoll.ts", + ` +import { apiCall } from "@molecule-ai/mcp-server"; +async function listWorkspaces() { + await apiCall("GET", "/workspaces"); +} +`, + ); + const r = checkConsumerFile(p); + expect(r.pollsActivity).toBe(false); + expect(r.importsResolutionHelper).toBe(false); + expect(r.hasOptOut).toBe(false); + }); + + it("imports from subpath @molecule-ai/mcp-server/inbox-uploads also count", () => { + const p = fixture( + "subpath.ts", + ` +import { URICache } from "@molecule-ai/mcp-server/inbox-uploads"; +const url = "/workspaces/ws/activity"; +`, + ); + const r = checkConsumerFile(p); + expect(r.pollsActivity).toBe(true); + expect(r.importsResolutionHelper).toBe(true); + expect(r.importedResolutionNames).toContain("URICache"); + }); + + it("URL pattern: rejects /workspaces/X/activities (false-friend) but accepts /activity boundary", () => { + const p1 = fixture("trip.ts", `const u = "/workspaces/x/activities";`); + const p2 = fixture("good.ts", `const u = "/workspaces/x/activity?since_id=1";`); + expect(checkConsumerFile(p1).pollsActivity).toBe(false); + expect(checkConsumerFile(p2).pollsActivity).toBe(true); + }); + + it("template literal with /activity in head is detected", () => { + const p = fixture( + "tmpl.ts", + "const u = `/workspaces/${ws}/activity`;", + ); + expect(checkConsumerFile(p).pollsActivity).toBe(true); + }); + + it("template literal with /activity AFTER a substitution span is detected", () => { + // The /activity literal is in the SECOND fragment after the + // `${ws}` substitution — must still be caught by the walker. + const p = fixture( + "tmpl2.ts", + "const u = `/workspaces/${ws}/activity?since_id=${cursor}`;", + ); + expect(checkConsumerFile(p).pollsActivity).toBe(true); + }); + + it("default ImportClause (e.g. import foo from '@molecule-ai/mcp-server') does not count as named import", () => { + // Sanity: bare default imports don't pull in resolvePendingUpload. + const p = fixture( + "default.ts", + ` +import mcpserver from "@molecule-ai/mcp-server"; +const url = "/workspaces/x/activity"; +`, + ); + const r = checkConsumerFile(p); + expect(r.pollsActivity).toBe(true); + expect(r.importsResolutionHelper).toBe(false); + }); +}); -- 2.52.0