morphit/scripts/source-marketing-prose-smoke.ts

289 lines
9.6 KiB
TypeScript

/**
* Source-marketing-prose smoke (cp152).
*
* Catches the cp146 F-mcp-16 class of bug: marketing-grade prose
* embedded in `.ts` source files (the strings AI agents quote
* verbatim to users) drifts from the project's #1 Privacy &
* anonymity priority without any smoke noticing.
*
* The cp146 walkthrough surfaced the original instance: the
* `describeMorphit` tool's summary said "no IP logging by design"
* which AI agents repeated to users — but the literal truth is
* "instances see the connecting IP at the HTTP layer; data model
* retains no per-user IP log; Tor onions available for
* unlinkability." cp146 fixed the copy. cp152 codifies it.
*
* The cp141 brag-list-claim-parity-smoke walks MARKETING_DOCS
* (the brag list, READMEs, etc.). It DOESN'T walk source files
* because marketing prose isn't supposed to live in source.
* But `apps/mcp-server/src/tools/describeMorphit.ts` and
* `apps/mcp-server/src/tools/searchOrders.ts` each contain
* marketing-grade claims by design — they're the structured
* surface AI agents read to summarize Morphit to users. This
* smoke gives those claims the same protection.
*
* Two invariants per (file, phrase) tuple:
*
* PINNED — phrase MUST be present. Catches accidental
* removal of a load-bearing honesty clause. Each
* pin has a `since` cp-ref so future maintainers
* know why it's pinned.
*
* BANNED — phrase MUST NOT appear. Catches accidental
* reintroduction of a known-misleading phrasing.
* Each ban has a `from` cp-ref pointing at the bug
* that taught us not to use the phrasing.
*
* Together: the marketing prose AI agents quote is locked at
* the cp146 corrected state, with no upward freedom for either
* removing necessary nuance or reintroducing misleading
* shorthand.
*/
import { readFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
const ANSI_GREEN = '\x1b[32m';
const ANSI_RED = '\x1b[31m';
const ANSI_RESET = '\x1b[0m';
const REPO_ROOT = resolve(new URL('..', import.meta.url).pathname);
interface Result {
name: string;
passed: boolean;
detail?: string;
}
const results: Result[] = [];
function pass(name: string) {
results.push({ name, passed: true });
}
function fail(name: string, detail: string) {
results.push({ name, passed: false, detail });
}
/* ---------------- pinned phrases ---------------- */
interface PinnedPhrase {
file: string;
phrase: string;
since: string;
rationale: string;
}
const PINNED: PinnedPhrase[] = [
// describeMorphit — the "what is Morphit" summary AI agents
// repeat to users when asked. cp146 F-mcp-16 fixed the
// dishonest "no IP logging by design" to the literal
// honest version below. Each clause is pinned because
// removing it would weaken the user's mental model.
{
file: 'apps/mcp-server/src/tools/describeMorphit.ts',
phrase: 'Instance operators see the connecting IP at the HTTP layer',
since: 'cp146 F-mcp-16',
rationale:
'this is the truthful version of the cp146-corrected IP-visibility clause. Without it the summary defaults to overclaiming privacy.'
},
{
file: 'apps/mcp-server/src/tools/describeMorphit.ts',
phrase: 'per-user IP log of its own',
since: 'cp146 F-mcp-16',
rationale:
'the data-model-side honesty clause. Pairs with the HTTP-layer-side clause above. Pinned in its source-contiguous form (the full sentence wraps across string-concat lines).'
},
{
file: 'apps/mcp-server/src/tools/describeMorphit.ts',
phrase: 'Tor onions',
since: 'cp146 F-mcp-16',
rationale:
'the user-actionable mitigation. Without it the summary leaves the user with no path to IP-level unlinkability.'
},
{
file: 'apps/mcp-server/src/tools/describeMorphit.ts',
phrase: 'non-custodial',
since: 'cp140 initial ship',
rationale:
'the keys-stay-on-device claim. Load-bearing for the AI-agent trust model — Charlie tells the user the agent never sees keys.'
},
{
file: 'apps/mcp-server/src/tools/describeMorphit.ts',
phrase: 'federated',
since: 'cp140 initial ship',
rationale:
"the no-single-point-of-failure claim. Privacy/decentralization priority #2."
},
{
file: 'apps/mcp-server/src/tools/describeMorphit.ts',
phrase: 'no email collection',
since: 'cp140 initial ship',
rationale: 'the explicit "we do not collect" attestation.'
},
// searchOrders — the description AI agents read when deciding
// whether to invoke the tool and what to expect.
{
file: 'apps/mcp-server/src/tools/searchOrders.ts',
phrase: 'non-custodial and KYC-free',
since: 'cp140 initial ship',
rationale:
'the tool description must reaffirm the trust model so AI agents reading just this string still get the right framing.'
},
{
file: 'apps/mcp-server/src/tools/searchOrders.ts',
phrase: 'the agent never sees keys',
since: 'cp140 initial ship',
rationale:
"explicit attestation that the MCP server is read-only. Pairs with cp149's structural read-only-by-construction smoke."
}
];
/* ---------------- banned phrases ---------------- */
interface BannedPhrase {
file: string;
phrase: string;
from: string;
rationale: string;
}
const BANNED: BannedPhrase[] = [
{
file: 'apps/mcp-server/src/tools/describeMorphit.ts',
phrase: 'no IP logging by design',
from: 'cp146 F-mcp-16',
rationale:
'this is the pre-cp146 misleading shorthand. Reads as "no IP visible" which is false — instances see connecting IPs at the HTTP layer. Use the literal "Instance operators see the connecting IP at the HTTP layer; data model retains no per-user IP log" pinning instead.'
},
{
file: 'apps/mcp-server/src/tools/describeMorphit.ts',
phrase: 'completely anonymous',
from: 'cp146 F-mcp-16 class',
rationale:
'overclaim. Morphit does not give anonymity by default — it gives non-custody + no-server-side-account + Tor-onion options. Anonymity is the user-effort end-state, not the default posture.'
},
{
file: 'apps/mcp-server/src/tools/describeMorphit.ts',
phrase: 'we cannot see',
from: 'cp146 F-mcp-16 class',
rationale:
'AI agents quote this clause to users; the literal claim "Morphit cannot see X" is rarely true at the HTTP layer. Use specific "data model retains no per-user log of X" framings instead.'
},
{
file: 'apps/mcp-server/src/tools/searchOrders.ts',
phrase: 'anonymous',
from: 'cp146 F-mcp-16 class',
rationale:
'searchOrders.ts description should not claim anonymity. The tool returns public on-chain data; "anonymous" implies more than the trust model delivers.'
}
];
/* ---------------- scan files ---------------- */
interface FileCache {
[path: string]: string;
}
const cache: FileCache = {};
function readFile(rel: string): string {
if (rel in cache) return cache[rel];
cache[rel] = readFileSync(join(REPO_ROOT, rel), 'utf8');
return cache[rel];
}
/* ---------------- invariant 1: every pinned phrase is present ---------------- */
interface PinHit {
file: string;
phrase: string;
since: string;
}
const missingPins: PinHit[] = [];
for (const p of PINNED) {
const src = readFile(p.file);
if (!src.includes(p.phrase)) {
missingPins.push({ file: p.file, phrase: p.phrase, since: p.since });
}
}
if (missingPins.length === 0) {
pass(`all ${PINNED.length} pinned marketing phrases present in their source files`);
} else {
const detail = missingPins
.map(
(h) =>
`${h.file} is missing "${h.phrase}" (pinned ${h.since}). If you intentionally removed it, you may also need to update the cp152 smoke's PINNED list — but first read the original cp-reference to understand why it was pinned.`
)
.join('\n ');
fail(`all ${PINNED.length} pinned marketing phrases present`, detail);
}
/* ---------------- invariant 2: no banned phrase appears ---------------- */
interface BanHit {
file: string;
phrase: string;
from: string;
}
const presentBans: BanHit[] = [];
for (const b of BANNED) {
const src = readFile(b.file);
if (src.includes(b.phrase)) {
presentBans.push({ file: b.file, phrase: b.phrase, from: b.from });
}
}
if (presentBans.length === 0) {
pass(`none of ${BANNED.length} banned phrasings present in source files`);
} else {
const detail = presentBans
.map((h) => {
const rec = BANNED.find((b) => b.file === h.file && b.phrase === h.phrase);
return `${h.file} contains banned phrase "${h.phrase}" (banned by ${h.from}). Rationale: ${rec?.rationale ?? '(see BANNED list)'}`;
})
.join('\n ');
fail(`no banned phrasings present`, detail);
}
/* ---------------- invariant 3: pinned and banned lists are non-empty ---------------- */
// Sanity guard: if a future refactor accidentally truncates one
// of the lists to empty, both invariants would trivially pass.
// Catch that early.
if (PINNED.length === 0) {
fail('PINNED list is non-empty', 'cp152 smoke is meaningless with no pinned phrases');
} else {
pass(`PINNED list has ${PINNED.length} entries (non-empty)`);
}
if (BANNED.length === 0) {
fail('BANNED list is non-empty', 'cp152 smoke is meaningless with no banned phrases');
} else {
pass(`BANNED list has ${BANNED.length} entries (non-empty)`);
}
/* ---------------- report ---------------- */
let failed = 0;
for (const r of results) {
if (r.passed) {
console.log(` ${ANSI_GREEN}${ANSI_RESET} ${r.name}`);
} else {
console.log(` ${ANSI_RED}${ANSI_RESET} ${r.name}`);
if (r.detail) console.log(` ${r.detail}`);
failed++;
}
}
console.log();
console.log('──────────────────────────────────────────────────────');
if (failed > 0) {
console.log(`${failed} of ${results.length} scenarios failed`);
process.exit(1);
} else {
console.log(`✓ all ${results.length} scenarios passed`);
}