prism-mcp-server 20.21.4 → 20.21.6

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/connect.js CHANGED
@@ -591,7 +591,13 @@ function serializeClaudeStartupBlock(newline) {
591
591
  ].join(newline);
592
592
  }
593
593
  /** Install or refresh Claude Code's native, hook-free first-turn instruction. */
594
- export function configureClaudeNativeStartup(homeDir = homedir(), dryRun = false, beforeCommit) {
594
+ export function configureClaudeNativeStartup(homeDir = homedir(), dryRun = false, beforeCommit,
595
+ /** Refresh an existing managed block and nothing else. With this set the
596
+ * install branch is unreachable, so a caller that did not receive the
597
+ * operator's consent (the unattended startup refresh) cannot create a
598
+ * block — not through a marker that only appears in prose, and not through
599
+ * a marker removed between a caller's check and this read. */
600
+ refreshOnly = false) {
595
601
  const instructionPath = join(homeDir, ".claude", "CLAUDE.md");
596
602
  let writePath = instructionPath;
597
603
  let symlinkPath;
@@ -613,6 +619,11 @@ export function configureClaudeNativeStartup(homeDir = homedir(), dryRun = false
613
619
  const newline = currentText.includes("\r\n") ? "\r\n" : "\n";
614
620
  const startRanges = findExactLineRanges(currentText, CLAUDE_STARTUP_MANAGED_START);
615
621
  const endRanges = findExactLineRanges(currentText, CLAUDE_STARTUP_MANAGED_END);
622
+ // Not ours: no opening marker at all. Under refreshOnly that is the ordinary
623
+ // case (a host the operator never connected), not an ambiguity to shout about.
624
+ if (refreshOnly && startRanges.length === 0) {
625
+ return { path: instructionPath, status: "unmanaged" };
626
+ }
616
627
  if (startRanges.length !== endRanges.length || startRanges.length > 1) {
617
628
  throw new Error(`Claude instructions contain ambiguous Prism startup ownership markers: ${instructionPath}`);
618
629
  }
@@ -631,6 +642,8 @@ export function configureClaudeNativeStartup(homeDir = homedir(), dryRun = false
631
642
  action = "refresh";
632
643
  }
633
644
  else {
645
+ if (refreshOnly)
646
+ return { path: instructionPath, status: "unmanaged" };
634
647
  const separator = currentText.length === 0
635
648
  ? ""
636
649
  : currentText.endsWith("\n") || currentText.endsWith("\r")
@@ -688,7 +701,9 @@ function legacyGeminiStartupBlock(newline) {
688
701
  ].join(newline);
689
702
  }
690
703
  /** Install or refresh Gemini CLI's native, hook-free first-turn instruction. */
691
- export function configureGeminiNativeStartup(homeDir = homedir(), dryRun = false, beforeCommit) {
704
+ export function configureGeminiNativeStartup(homeDir = homedir(), dryRun = false, beforeCommit,
705
+ /** See configureClaudeNativeStartup. */
706
+ refreshOnly = false) {
692
707
  const instructionPath = join(homeDir, ".gemini", "GEMINI.md");
693
708
  let writePath = instructionPath;
694
709
  let symlinkPath;
@@ -710,6 +725,11 @@ export function configureGeminiNativeStartup(homeDir = homedir(), dryRun = false
710
725
  const newline = currentText.includes("\r\n") ? "\r\n" : "\n";
711
726
  const startRanges = findExactLineRanges(currentText, GEMINI_STARTUP_MANAGED_START);
712
727
  const endRanges = findExactLineRanges(currentText, GEMINI_STARTUP_MANAGED_END);
728
+ // Not ours: no opening marker at all. Under refreshOnly that is the ordinary
729
+ // case (a host the operator never connected), not an ambiguity to shout about.
730
+ if (refreshOnly && startRanges.length === 0) {
731
+ return { path: instructionPath, status: "unmanaged" };
732
+ }
713
733
  if (startRanges.length !== endRanges.length || startRanges.length > 1) {
714
734
  throw new Error(`Gemini instructions contain ambiguous Prism startup ownership markers: ${instructionPath}`);
715
735
  }
@@ -728,6 +748,8 @@ export function configureGeminiNativeStartup(homeDir = homedir(), dryRun = false
728
748
  action = "refresh";
729
749
  }
730
750
  else {
751
+ if (refreshOnly)
752
+ return { path: instructionPath, status: "unmanaged" };
731
753
  const legacyBlock = legacyGeminiStartupBlock(newline);
732
754
  if (currentText.startsWith(legacyBlock)) {
733
755
  nextText = managedBlock + newline + currentText.slice(legacyBlock.length);
@@ -757,7 +779,9 @@ function serializeCodexStartupBlock(newline) {
757
779
  ].join(newline);
758
780
  }
759
781
  /** Install or refresh Codex's official global, hook-free first-turn instruction. */
760
- export function configureCodexNativeStartup(homeDir, dryRun = false, beforeCommit, env = homeDir === undefined ? process.env : {}) {
782
+ export function configureCodexNativeStartup(homeDir, dryRun = false, beforeCommit, env = homeDir === undefined ? process.env : {},
783
+ /** See configureClaudeNativeStartup. */
784
+ refreshOnly = false) {
761
785
  const userHome = homeDir ?? homedir();
762
786
  const configuredCodexHome = env.CODEX_HOME?.trim();
763
787
  const codexHome = configuredCodexHome ? resolve(configuredCodexHome) : join(userHome, ".codex");
@@ -782,6 +806,11 @@ export function configureCodexNativeStartup(homeDir, dryRun = false, beforeCommi
782
806
  const newline = currentText.includes("\r\n") ? "\r\n" : "\n";
783
807
  const startRanges = findExactLineRanges(currentText, CODEX_STARTUP_MANAGED_START);
784
808
  const endRanges = findExactLineRanges(currentText, CODEX_STARTUP_MANAGED_END);
809
+ // Not ours: no opening marker at all. Under refreshOnly that is the ordinary
810
+ // case (a host the operator never connected), not an ambiguity to shout about.
811
+ if (refreshOnly && startRanges.length === 0) {
812
+ return { path: instructionPath, status: "unmanaged" };
813
+ }
785
814
  if (startRanges.length !== endRanges.length || startRanges.length > 1) {
786
815
  throw new Error(`Codex instructions contain ambiguous Prism startup ownership markers: ${instructionPath}`);
787
816
  }
@@ -800,6 +829,8 @@ export function configureCodexNativeStartup(homeDir, dryRun = false, beforeCommi
800
829
  action = "refresh";
801
830
  }
802
831
  else {
832
+ if (refreshOnly)
833
+ return { path: instructionPath, status: "unmanaged" };
803
834
  const separator = currentText.length === 0
804
835
  ? ""
805
836
  : currentText.endsWith("\n") || currentText.endsWith("\r")
@@ -844,7 +875,128 @@ function configureJsonAgentPolicy(configPath, label, mutate, dryRun, beforeCommi
844
875
  writeTextAtomically(writePath, `${JSON.stringify(config, null, 2)}\n`, originalText, beforeCommit, symlinkPath);
845
876
  return { path: configPath, status: action === "install" ? "installed" : "refreshed" };
846
877
  }
847
- /** Configure Claude Code's economy fallback model; policy text prevents routine fan-out. */
878
+ /** Rewrite a managed startup block whose content differs from what THIS binary
879
+ * writes, and nothing else. Not "older": there is no version ordering in the
880
+ * block, so a pinned older install can rewrite what a newer one wrote.
881
+ *
882
+ * The instruction files are the one delivery channel that does not travel with
883
+ * the package: the MCP `initialize` instructions and every tool description
884
+ * update the moment the server binary does, but a native instruction file keeps
885
+ * whatever text `prism connect` last wrote. Released 20.21.0 changed that text —
886
+ * it used to tell hosts to pass `cloud_fallback: false`, which made a paid
887
+ * plan's escalation unreachable — and a machine that never re-runs connect keeps
888
+ * telling its host the retracted thing. `prism update` deliberately never
889
+ * touches host configuration, autoupdate runs `update`, and npm's ignore-scripts
890
+ * blocks the postinstall refresh, so on an ordinary machine nothing heals it.
891
+ *
892
+ * This is the narrow, safe subset of connect, and the narrowness is the point:
893
+ *
894
+ * - MARKER-GATED. A file without the ownership marker is left byte-for-byte
895
+ * alone. Connect is still the only thing that can FIRST install a block;
896
+ * consent is never inferred from a server start.
897
+ * - Startup blocks ONLY. It never touches MCP host registration — that is what
898
+ * connect's "close target hosts before registration" warning is about, since
899
+ * a live host rewrites its own config. These files DO have another writer —
900
+ * Gemini CLI writes GEMINI.md on a remember request, Claude Code writes
901
+ * CLAUDE.md on /init — so only the marker-delimited block is replaced,
902
+ * marker lines included, and the file is re-checked immediately before
903
+ * committing; a write landing inside that final window would still be
904
+ * lost.
905
+ * - Content-addressed, so it is a no-op once current: the block is compared
906
+ * byte-for-byte and only a difference writes.
907
+ *
908
+ * The host reads its instruction file when a session starts, so a refresh made
909
+ * during this session lands on the NEXT one. That is the cost of healing
910
+ * without asking, and it is one session. */
911
+ export function refreshManagedStartupBlocks(options = {}) {
912
+ const homeDir = options.homeDir ?? homedir();
913
+ const env = options.env ?? process.env;
914
+ // The opt-out lives here, next to the writing, so it is one decision and a
915
+ // test can exercise it without starting a server.
916
+ if (env.PRISM_NO_STARTUP_REFRESH === "1")
917
+ return [];
918
+ const dryRun = !!options.dryRun;
919
+ const codexHome = env.CODEX_HOME?.trim() ? resolve(env.CODEX_HOME.trim()) : join(homeDir, ".codex");
920
+ const targets = [
921
+ { host: "claude-code", path: join(homeDir, ".claude", "CLAUDE.md"), configure: () => configureClaudeNativeStartup(homeDir, dryRun, undefined, true) },
922
+ { host: "gemini", path: join(homeDir, ".gemini", "GEMINI.md"), configure: () => configureGeminiNativeStartup(homeDir, dryRun, undefined, true) },
923
+ { host: "codex", path: join(codexHome, "AGENTS.md"), configure: () => configureCodexNativeStartup(homeDir, dryRun, undefined, env, true) },
924
+ ];
925
+ const results = [];
926
+ // Two hosts can resolve to ONE file — GEMINI.md symlinked to CLAUDE.md is a
927
+ // common single-file setup — and Claude and Gemini serialize the SAME
928
+ // ownership marker, so without this each start rewrote that file twice and
929
+ // never converged: two "refreshed" lines on every start, forever. Whoever
930
+ // gets there first owns it; the second reports it as already handled.
931
+ // Identity is the FILE, not the path: statSync follows symlinks, and dev:ino
932
+ // is shared by hard links too, which a path comparison misses entirely (an
933
+ // atomic replace would then break the link and hand both hosts a "refreshed"
934
+ // they should never have got). Resolved ONCE per target and reused, so the
935
+ // decision below is the one that was actually measured; a target retargeted
936
+ // after this point is the same inherent window as the compare-to-rename one
937
+ // documented on writeTextAtomically.
938
+ const identityOf = (path) => {
939
+ try {
940
+ const info = statSync(path);
941
+ return `${info.dev}:${info.ino}`;
942
+ }
943
+ catch {
944
+ return `path:${path}`; // absent or unreadable: the path itself is identity enough
945
+ }
946
+ };
947
+ const resolved = targets.map(target => ({ target, identity: identityOf(target.path) }));
948
+ const shared = new Map();
949
+ for (const { target, identity } of resolved) {
950
+ shared.set(identity, [...(shared.get(identity) ?? []), target.host]);
951
+ }
952
+ for (const { target, identity } of resolved) {
953
+ const sharing = shared.get(identity) ?? [target.host];
954
+ if (sharing.length > 1) {
955
+ // One file, two hosts. Claude and Gemini serialize the SAME ownership
956
+ // marker but DIFFERENT instructions (the tool is named differently for
957
+ // each), so there is no content that satisfies both: healing it would
958
+ // hand one host the other's block, and before this check each start
959
+ // rewrote the file once per host, forever. Whose block it should be is
960
+ // the operator's call, not a background process's.
961
+ results.push({
962
+ host: target.host,
963
+ path: target.path,
964
+ status: "failed",
965
+ detail: `shared with ${sharing.filter(h => h !== target.host).join(", ")} (one file cannot hold both blocks); run: prism connect`,
966
+ });
967
+ continue;
968
+ }
969
+ try {
970
+ // No independent pre-read. The configurator's own exact-line marker
971
+ // recognition decides, from ONE snapshot: a second, weaker check here
972
+ // (a substring scan) would call a file managed because the marker
973
+ // appears in its prose, and the window between two reads is a way to
974
+ // lose the marker after the check. With refreshOnly the install branch
975
+ // is unreachable either way.
976
+ const outcome = target.configure();
977
+ results.push({
978
+ host: target.host,
979
+ path: outcome.path,
980
+ // A dry run must not claim a write it did not make: would-refresh is
981
+ // carried through as itself. installed/would-install are unreachable
982
+ // under refreshOnly, so anything else is a completed refresh.
983
+ status: outcome.status === "unchanged" || outcome.status === "unmanaged" || outcome.status === "would-refresh"
984
+ ? outcome.status
985
+ : "refreshed",
986
+ });
987
+ }
988
+ catch (error) {
989
+ // A self-heal must never be the reason a server fails to start.
990
+ results.push({
991
+ host: target.host,
992
+ path: target.path,
993
+ status: "failed",
994
+ detail: error instanceof Error ? error.message : String(error),
995
+ });
996
+ }
997
+ }
998
+ return results;
999
+ }
848
1000
  export function configureClaudeAgentPolicy(homeDir = homedir(), dryRun = false, beforeCommit) {
849
1001
  const configPath = join(homeDir, ".claude", "settings.json");
850
1002
  return configureJsonAgentPolicy(configPath, "Claude agent settings", (config) => {
package/dist/server.js CHANGED
@@ -1307,6 +1307,66 @@ export async function startServer() {
1307
1307
  const transport = new StdioServerTransport();
1308
1308
  await server.connect(transport);
1309
1309
  console.error(`[Prism] MCP Server successfully started and listening on stdio...`);
1310
+ // Heal a startup block whose content differs from what this binary writes.
1311
+ // (Not "older": nothing orders versions, so a pinned older install can
1312
+ // rewrite what a newer one wrote.) The instruction files are the
1313
+ // one channel that does not travel with the package — `initialize`
1314
+ // instructions and every tool description update with the binary, a native
1315
+ // instruction file keeps whatever connect last wrote — and nothing on an
1316
+ // ordinary machine re-runs connect: `prism update` never touches host
1317
+ // configuration by design, autoupdate runs `update`, and the postinstall
1318
+ // path covers only the prompt-routing hook and is routinely disabled by
1319
+ // npm's ignore-scripts. 20.21.0 is the release that
1320
+ // proved the cost: the old text told hosts to pass `cloud_fallback: false`,
1321
+ // which made a paid plan's escalation unreachable.
1322
+ //
1323
+ // Refresh-only, so this can only ever rewrite a block the operator already
1324
+ // consented to by running connect once: the install branch is unreachable
1325
+ // from here, and a file without exactly one ordered marker pair is left
1326
+ // byte-for-byte alone. Startup blocks only — never MCP registration, which
1327
+ // is what connect's "close your hosts first" warning is about. These files
1328
+ // do have another writer (Gemini CLI writes GEMINI.md on a remember
1329
+ // request, Claude Code writes CLAUDE.md on /init), so the refresh replaces
1330
+ // only its own marker-delimited block, marker lines included, and re-checks
1331
+ // immediately before committing. After the transport is connected, so the handshake is never
1332
+ // held behind disk I/O. PRISM_NO_STARTUP_REFRESH=1 opts out.
1333
+ //
1334
+ // DEFERRED, and unref'd, for two reasons. `connect.js` is a large module
1335
+ // (the whole CLI surface, TOML parser included) and importing it is
1336
+ // synchronous CPU work: awaited here it competes with the FIRST tool call,
1337
+ // which on a cold host is the one that has to do a storage round trip. And
1338
+ // an unref'd timer can never hold the process open. The block is read by the
1339
+ // host at session start, so it was always landing on the next session — a
1340
+ // couple of seconds changes nothing about when the fix arrives.
1341
+ if (process.env.PRISM_NO_STARTUP_REFRESH !== "1") {
1342
+ setTimeout(() => { void refreshStartupBlocksInBackground(); }, 2_000).unref();
1343
+ }
1344
+ await resumeServerStartup(server);
1345
+ }
1346
+ async function refreshStartupBlocksInBackground() {
1347
+ try {
1348
+ const { refreshManagedStartupBlocks } = await import("./connect.js");
1349
+ for (const result of refreshManagedStartupBlocks()) {
1350
+ if (result.status === "refreshed") {
1351
+ // The host read this file when the session began, so the corrected
1352
+ // text applies from the next one.
1353
+ console.error(`[Prism] refreshed the ${result.host} startup block to match this version: ${result.path} (applies from your next session)`);
1354
+ }
1355
+ else if (result.status === "failed") {
1356
+ console.error(`[Prism] could not refresh the ${result.host} startup block (${result.detail}); run: prism connect`);
1357
+ }
1358
+ else if (process.env.PRISM_DEBUG) {
1359
+ console.error(`[Prism] startup block ${result.host}: ${result.status}${result.detail ? ` (${result.detail})` : ""}`);
1360
+ }
1361
+ }
1362
+ }
1363
+ catch (error) {
1364
+ // Never the reason a server fails to start.
1365
+ if (process.env.PRISM_DEBUG)
1366
+ console.error(`[Prism] startup refresh skipped: ${error instanceof Error ? error.message : error}`);
1367
+ }
1368
+ }
1369
+ async function resumeServerStartup(server) {
1310
1370
  // Start the authoritative tier-skill refresh only after the MCP transport is
1311
1371
  // connected. session_load_context awaits this same single-flight promise,
1312
1372
  // while the initialize handshake is never held behind portal I/O. The
@@ -2,6 +2,7 @@ import { spawnSync } from "node:child_process";
2
2
  const IMPLEMENTATION_REQUEST_RE = /\b(?:implement|write|create|generate|complete|finish|fix)\b[\s\S]{0,160}\b(?:code|source|function|method|class|interface|struct|enum|implementation|algorithm|component|endpoint)\b/i;
3
3
  const STRICT_SOURCE_REQUEST_RE = /\b(?:return|output|respond with)\s+only\s+(?:the\s+)?(?:implementation\s+)?(?:source\s+)?code\b/i;
4
4
  const CODE_SHAPE_RE = /(?:^|\n)\s*(?:(?:export|public|private|protected|internal|open|pub|static|final|abstract|async)\s+)*(?:class|interface|struct|enum|function|def|func|fun|fn|type)\s+[A-Za-z_$][\w$]*|(?:^|\n)\s*(?:const|let|var)\s+[A-Za-z_$][\w$]*\s*=|(?:^|\n)\s*(?:[A-Za-z_$][\w$:<>,.?*[\]&]*\s+)+[A-Za-z_$][\w$]*\s*\([^;\n]*\)\s*(?:const\s*)?(?:noexcept\s*)?(?:\{|=>)|=>\s*[{(]/m;
5
+ import { analyzeTypeScript } from "./typescriptDiagnostics.js";
5
6
  export const INCOMPLETE_IMPLEMENTATION_PATTERNS = [
6
7
  {
7
8
  reason: "code_placeholder",
@@ -46,6 +47,8 @@ const PYTHON_AST_SCRIPT = `import ast,sys; print('${PYTHON_READY_SENTINEL}', flu
46
47
  "tree=ast.parse(sys.stdin.read()); compile(tree, '<prism-coding-gate>', 'exec')";
47
48
  const PYTHON_COMMANDS = ["python3", "python"];
48
49
  const PYTHON_CHILDREN_KEYS_UNPACK_RE = /\bfor\s+[A-Za-z_]\w*\s*,\s*child(?:_node)?\s+in\s+(?:sorted\(\s*)?[A-Za-z_][\w.]*\.children\.keys\(\)\s*\)?\s*:/;
50
+ /** Enough of a type-annotation or declaration signal to call a block TypeScript. */
51
+ const TS_SIGNAL_RE = /:\s*(?:string|number|boolean|void|any|unknown|never|Promise<)\b|\binterface\s+[A-Z]|\bexport\s+(?:class|interface|type|abstract)\b|\b(?:private|public|protected|readonly)\s+\w+\s*[:=]|<[A-Z]\w*(?:\s*,\s*[A-Z]\w*)*>/;
49
52
  function extractUnfencedPythonCode(output) {
50
53
  const lines = output.trim().split(/\r?\n/);
51
54
  const start = lines.findIndex((line) => UNFENCED_PYTHON_START_RE.test(line));
@@ -75,9 +78,11 @@ function extractCode(output) {
75
78
  })).filter((block) => block.code.length > 0);
76
79
  if (blocks.length === 0) {
77
80
  const python = extractUnfencedPythonCode(output);
81
+ const bare = output.trim();
78
82
  return {
79
- all: output.trim(),
83
+ all: bare,
80
84
  ...(python ? { python } : {}),
85
+ ...(!python && TS_SIGNAL_RE.test(bare) ? { typescript: bare } : {}),
81
86
  hasFences: false,
82
87
  };
83
88
  }
@@ -86,11 +91,18 @@ function extractCode(output) {
86
91
  block.language === "py" ||
87
92
  (!block.language && PYTHON_SIGNAL_RE.test(block.code))))
88
93
  .map((block) => block.code);
94
+ const tsBlocks = blocks
95
+ .filter((block) => (block.language === "typescript" ||
96
+ block.language === "ts" ||
97
+ block.language === "tsx" ||
98
+ (!block.language && TS_SIGNAL_RE.test(block.code))))
99
+ .map((block) => block.code);
89
100
  return {
90
101
  all: blocks.map((block) => block.code).join("\n\n"),
91
102
  ...(pythonBlocks.length > 0
92
103
  ? { python: pythonBlocks.join("\n\n") }
93
104
  : {}),
105
+ ...(tsBlocks.length > 0 ? { typescript: tsBlocks.join("\n\n") } : {}),
94
106
  hasFences: true,
95
107
  };
96
108
  }
@@ -460,9 +472,18 @@ export function passesCodingQualityGate(prompt, output) {
460
472
  if (pythonFailure)
461
473
  return { pass: false, reason: pythonFailure };
462
474
  }
475
+ // The regex floor runs first: it is the one finding with a deterministic
476
+ // repair, and it works even if the compiler cannot be loaded.
463
477
  const tsFailure = tsStaticContractFailure(code);
464
478
  if (tsFailure)
465
479
  return { pass: false, reason: tsFailure };
480
+ // Then the compiler, over TypeScript blocks only.
481
+ if (extracted.typescript) {
482
+ const findings = analyzeTypeScript(extracted.typescript, extracted.hasFences);
483
+ if (findings.length > 0) {
484
+ return { pass: false, reason: `ts_static_contract:${findings.join(",")}` };
485
+ }
486
+ }
466
487
  return { pass: true };
467
488
  }
468
489
  const CODING_REPAIR_SYSTEM_INSTRUCTION = "Repair the supplied implementation. Return one complete replacement implementation with no prose, " +
@@ -477,6 +498,11 @@ const CODING_REPAIR_GUIDANCE = {
477
498
  python_method_missing_receiver: "Instance methods must take self first; class methods must take cls first unless decorated staticmethod.",
478
499
  python_undefined_private_helper: "Define every directly called private self helper or replace the call with the correct defined helper.",
479
500
  constructor_attribute_missing_receiver: "In __init__, persist instance state as self.<attribute>; do not assign it to a discarded local variable.",
501
+ syntax_error: "The code does not parse. Balance every brace, bracket and parenthesis, and finish every statement.",
502
+ optional_chain_assignment: "Optional chaining cannot appear on the left of an assignment. Guard with an if, or assert the value is present, before assigning to the property.",
503
+ type_not_assignable: "Make the returned value match the declared return type, or widen the declaration to what the implementation actually produces.",
504
+ implicit_any_param: "Annotate every parameter; under strict mode an inferred any is an error.",
505
+ deferred_mutation_returned_sync: "A value mutated inside a then/catch/setTimeout callback is returned before that callback runs. Await the work, or return a Promise that resolves after it.",
480
506
  bare_generic: "Every generic needs its type argument: write Array<T>, Map<K, V>, Set<T>, Promise<T> — never a bare Array, Map, Set or Promise in a type position.",
481
507
  dict_keys_unpack: "When unpacking key and value, iterate dictionary .items(); .keys() yields one key per iteration.",
482
508
  };
@@ -0,0 +1,248 @@
1
+ /**
2
+ * Real type checking for generated TypeScript, plus one AST rule a type checker
3
+ * cannot express.
4
+ *
5
+ * The regex gate that shipped in 20.21.4 catches exactly one defect class
6
+ * (TS2314, a generic with no type argument). The very next sample defeated it:
7
+ * a doubly-linked-list splice written as `node.next?.prev = this.head`, which is
8
+ * TS2779 and does not compile. Extending the regex per error code is an arms
9
+ * race the compiler already wins.
10
+ *
11
+ * WHY THIS IS NOT JUST `tsc`. A generated snippet has no tsconfig, no resolved
12
+ * imports, and no way to declare whether it targets the DOM or Node. Checking a
13
+ * fragment therefore produces errors about the HARNESS rather than the code.
14
+ * Measured while building this: checking one LRU cache against the DOM lib made
15
+ * the snippet's own `Node` class collide with the DOM's, producing twelve
16
+ * phantom errors beside the two real ones. A gate that fails correct code gets
17
+ * switched off, so the design is an ALLOWLIST: a diagnostic is reported only if
18
+ * its code appears in ALLOWED_CODES. That, and nothing else, is what prevents a
19
+ * context failure from being reported as a defect.
20
+ *
21
+ * Two things that look load-bearing and are not — established by mutation, and
22
+ * recorded so nobody trusts them for safety:
23
+ *
24
+ * IGNORED_CODES does NOT filter anything. It marks codes already triaged as
25
+ * context failures, so the debug log can flag genuinely unclassified ones.
26
+ * Emptying it changes no reported finding.
27
+ *
28
+ * The narrow default library (`lib.es2022.d.ts`, no DOM) is defence in depth,
29
+ * not protection. It was chosen after the DOM lib made a snippet's own `Node`
30
+ * class collide with the DOM's and produce twelve extra diagnostics — but all
31
+ * twelve were outside the allowlist, so none would have been reported anyway.
32
+ * No constructed case makes the library choice change a reported finding. It
33
+ * is kept because less noise and less work are both worth having.
34
+ */
35
+ import { createRequire } from "node:module";
36
+ import { dirname, join } from "node:path";
37
+ import { readFileSync } from "node:fs";
38
+ import { debugLog } from "./logger.js";
39
+ /** Semantic diagnostics that indict the SNIPPET. Each observed on real output. */
40
+ const ALLOWED_CODES = new Map([
41
+ [2314, "bare_generic"], // Map<string, Array>
42
+ [2779, "optional_chain_assignment"], // node.next?.prev = x
43
+ [2322, "type_not_assignable"], // Promise<PromiseSettledResult[]> as Promise<void>
44
+ [7006, "implicit_any_param"], // (listener) => ... under strict — CONDITIONAL, see below
45
+ ]);
46
+ /**
47
+ * An implicit `any` only indicts the snippet once everything else resolved.
48
+ *
49
+ * `app.get("/", (req, res) => ...)` is correct Express, and `req` is implicitly
50
+ * any ONLY because a fragment cannot resolve `express`. Reporting that blames
51
+ * the harness. So TS7006 is suppressed whenever a module or name failed to
52
+ * resolve — found by attacking the allowlist rather than by review.
53
+ */
54
+ const CONDITIONAL_ON_RESOLUTION = 7006;
55
+ const RESOLUTION_FAILURE_CODES = new Set([2304, 2307, 2792, 2583]);
56
+ /**
57
+ * Codes already triaged as context failures rather than defects.
58
+ *
59
+ * NOT a filter — the allowlist above is what decides what is reported. This
60
+ * exists so the debug log can distinguish "known to be noise" from "never seen
61
+ * before", which is how the allowlist gets extended from evidence.
62
+ *
63
+ * 2304/2583/2584 unresolved name, 2307 unresolved module, 2300 duplicate
64
+ * identifier, 2315/2554 a snippet type shadowed by a lib type (the `Node`
65
+ * collision), 6053 a lib file we did not serve, 2686/2695 UMD and expression
66
+ * complaints that only make sense inside a real project.
67
+ */
68
+ const IGNORED_CODES = new Set([2300, 2304, 2307, 2315, 2554, 2583, 2584, 2686, 2695, 2792, 6053]);
69
+ let tsModule;
70
+ /** Loaded once, synchronously, so the quality gate stays synchronous. */
71
+ function loadTypeScript() {
72
+ if (tsModule !== undefined)
73
+ return tsModule;
74
+ try {
75
+ tsModule = createRequire(import.meta.url)("typescript");
76
+ }
77
+ catch (e) {
78
+ // A declared dependency, so this means a broken install. The regex floor
79
+ // in codingQualityPolicy still runs; this enhancement simply does not.
80
+ debugLog(`[ts-diagnostics] typescript unavailable: ${e instanceof Error ? e.message : e}`);
81
+ tsModule = null;
82
+ }
83
+ return tsModule;
84
+ }
85
+ const libCache = new Map();
86
+ function readLib(libDir, file) {
87
+ if (!libCache.has(file)) {
88
+ try {
89
+ libCache.set(file, readFileSync(join(libDir, file), "utf8"));
90
+ }
91
+ catch {
92
+ libCache.set(file, undefined);
93
+ }
94
+ }
95
+ return libCache.get(file);
96
+ }
97
+ /**
98
+ * Allowed findings only, de-duplicated, stable order. Empty when unavailable.
99
+ *
100
+ * `fenced` says the text was inside a ``` block, i.e. the model MEANT it as
101
+ * code. Only then is a parse failure attributable to the snippet. Unfenced
102
+ * output is ambiguous: "the function takes a value: string and returns a
103
+ * formatted result" is a sentence, and reporting it as a syntax error rejects a
104
+ * correct answer. Ambiguity favours the caller.
105
+ */
106
+ export function typecheckSnippet(code, fenced = true) {
107
+ const ts = loadTypeScript();
108
+ if (!ts)
109
+ return [];
110
+ const libDir = dirname(createRequire(import.meta.url).resolve("typescript"));
111
+ const name = "snippet.ts";
112
+ const sourceOf = (file) => file === name ? code : readLib(libDir, file);
113
+ const host = {
114
+ getSourceFile: (file) => {
115
+ const text = sourceOf(file);
116
+ return text === undefined
117
+ ? undefined
118
+ : ts.createSourceFile(file, text, ts.ScriptTarget.ES2022, true);
119
+ },
120
+ getDefaultLibFileName: () => "lib.es2022.d.ts",
121
+ writeFile: () => { },
122
+ getCurrentDirectory: () => "/",
123
+ getCanonicalFileName: (file) => file,
124
+ useCaseSensitiveFileNames: () => true,
125
+ getNewLine: () => "\n",
126
+ fileExists: (file) => sourceOf(file) !== undefined,
127
+ readFile: sourceOf,
128
+ };
129
+ let syntactic;
130
+ let semantic;
131
+ try {
132
+ const program = ts.createProgram([name], {
133
+ strict: true,
134
+ target: ts.ScriptTarget.ES2022,
135
+ noEmit: true,
136
+ types: [],
137
+ skipLibCheck: true,
138
+ }, host);
139
+ syntactic = program.getSyntacticDiagnostics();
140
+ semantic = program.getSemanticDiagnostics();
141
+ }
142
+ catch (e) {
143
+ debugLog(`[ts-diagnostics] check failed: ${e instanceof Error ? e.message : e}`);
144
+ return [];
145
+ }
146
+ // A parse failure needs no allowlist. Nothing about a missing library or an
147
+ // unresolved import can make a brace go missing, so a syntactic diagnostic
148
+ // always indicts the snippet. And once the file does not parse, the semantic
149
+ // results describe a tree that was never valid, so they are not consulted.
150
+ if (syntactic.length > 0)
151
+ return fenced ? ["syntax_error"] : [];
152
+ const unresolved = semantic.some(d => RESOLUTION_FAILURE_CODES.has(d.code));
153
+ const found = new Set();
154
+ for (const d of semantic) {
155
+ if (d.code === CONDITIONAL_ON_RESOLUTION && unresolved)
156
+ continue;
157
+ const name_ = ALLOWED_CODES.get(d.code);
158
+ if (name_)
159
+ found.add(name_);
160
+ else if (!IGNORED_CODES.has(d.code)) {
161
+ // Neither indicted nor excused. Logged so the lists can be extended
162
+ // from evidence rather than guessed at; never reported as a finding,
163
+ // because an unclassified code is exactly the kind that turns out to
164
+ // be about the harness.
165
+ debugLog(`[ts-diagnostics] unclassified TS${d.code}`);
166
+ }
167
+ }
168
+ return [...found].sort();
169
+ }
170
+ const DEFERRED_METHOD = /^(then|catch|finally)$/;
171
+ const DEFERRED_FN = /^(setTimeout|setInterval|setImmediate|queueMicrotask)$/;
172
+ /**
173
+ * A value mutated inside a promise or timer callback and then returned
174
+ * synchronously by the enclosing function.
175
+ *
176
+ * Valid TypeScript, and wrong: the callback runs in a later microtask, so the
177
+ * returned value never includes it. From the first multi-turn benchmark, where
178
+ * `emit()` incremented its counter inside `result.then(() => count++)` and
179
+ * returned 1 for three listeners. No type checker expresses this, but the AST
180
+ * is already loaded, so the rule is nearly free.
181
+ */
182
+ export function findDeferredMutationReturnedSync(code) {
183
+ const ts = loadTypeScript();
184
+ if (!ts)
185
+ return [];
186
+ const sf = ts.createSourceFile("snippet.ts", code, ts.ScriptTarget.ES2022, true);
187
+ const hits = new Set();
188
+ const insideDeferredCallback = (node) => {
189
+ for (let p = node.parent; p; p = p.parent) {
190
+ if (ts.isCallExpression(p)) {
191
+ const callee = p.expression;
192
+ if (ts.isPropertyAccessExpression(callee) && DEFERRED_METHOD.test(callee.name.text))
193
+ return true;
194
+ if (ts.isIdentifier(callee) && DEFERRED_FN.test(callee.text))
195
+ return true;
196
+ }
197
+ // Stop at the enclosing function: a mutation in a sibling function
198
+ // says nothing about this one's return value.
199
+ if (ts.isFunctionDeclaration(p) || ts.isMethodDeclaration(p))
200
+ return false;
201
+ }
202
+ return false;
203
+ };
204
+ const inspect = (fn) => {
205
+ const mutatedLate = new Set();
206
+ const returned = new Set();
207
+ const visit = (n) => {
208
+ const target = (ts.isPostfixUnaryExpression(n) || ts.isPrefixUnaryExpression(n)) && ts.isIdentifier(n.operand)
209
+ ? n.operand.text
210
+ : ts.isBinaryExpression(n) && ts.isIdentifier(n.left) && [
211
+ ts.SyntaxKind.EqualsToken,
212
+ ts.SyntaxKind.PlusEqualsToken,
213
+ ts.SyntaxKind.MinusEqualsToken,
214
+ ].includes(n.operatorToken.kind)
215
+ ? n.left.text
216
+ : null;
217
+ if (target && insideDeferredCallback(n))
218
+ mutatedLate.add(target);
219
+ if (ts.isReturnStatement(n) && n.expression && ts.isIdentifier(n.expression)) {
220
+ returned.add(n.expression.text);
221
+ }
222
+ // Do not descend into a NESTED named function or method: it has its
223
+ // own scope and its own `count`, and `scan` visits it separately.
224
+ // Without this, `inner`'s deferred mutation was credited to `outer`,
225
+ // flagging correct code. Arrow functions and anonymous function
226
+ // expressions ARE entered, because that is what a callback is.
227
+ if (ts.isFunctionDeclaration(n) || ts.isMethodDeclaration(n))
228
+ return;
229
+ ts.forEachChild(n, visit);
230
+ };
231
+ ts.forEachChild(fn, visit);
232
+ for (const v of mutatedLate)
233
+ if (returned.has(v))
234
+ hits.add(v);
235
+ return;
236
+ };
237
+ const scan = (n) => {
238
+ if (ts.isMethodDeclaration(n) || ts.isFunctionDeclaration(n) || ts.isFunctionExpression(n))
239
+ inspect(n);
240
+ ts.forEachChild(n, scan);
241
+ };
242
+ ts.forEachChild(sf, scan);
243
+ return hits.size ? ["deferred_mutation_returned_sync"] : [];
244
+ }
245
+ /** Every TypeScript finding for a snippet, type errors and the AST rule. */
246
+ export function analyzeTypeScript(code, fenced = true) {
247
+ return [...typecheckSnippet(code, fenced), ...findDeferredMutationReturnedSync(code)].sort();
248
+ }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "prism-mcp-server",
3
- "version": "20.21.4",
3
+ "version": "20.21.6",
4
4
  "mcpName": "io.github.dcostenco/prism-coder",
5
- "description": "Persistent session memory for AI coding agents that never leaves your machine — including the on-device model that reasons over it. Restores your prior decisions, open TODOs, and changed files across sessions; adds associative recall of related past work, semantic drift detection, and local inference. Local-first by default. Works with Claude Code, Cursor, and Codex.",
5
+ "description": "Persistent session memory for AI coding agents that never leaves your machine \u2014 including the on-device model that reasons over it. Restores your prior decisions, open TODOs, and changed files across sessions; adds associative recall of related past work, semantic drift detection, and local inference. Local-first by default. Works with Claude Code, Cursor, and Codex.",
6
6
  "module": "index.ts",
7
7
  "type": "module",
8
8
  "main": "dist/server.js",
@@ -114,6 +114,7 @@
114
114
  "stream-json": "^3.6.0",
115
115
  "tldts": "^7.0.27",
116
116
  "turndown": "^7.2.2",
117
+ "typescript": "^5.9.3",
117
118
  "zod": "^4.3.6"
118
119
  }
119
120
  }