2e6f1c3de5
Два структурных слепых пятна: (1) сервер грузит СКОМПИЛИРОВАННЫЙ
packages/mcp/build/index.js, а серверные guard-тесты читают src/tool-specs.ts —
правка src без пересборки оставляет тесты зелёными, но рантайм расходится со
спеками; (2) спеки добавляют в packages/mcp, а parity-тесты живут в jest-сьюте
apps/server — PR, трогающий только пакет, проходит зелёным, сломанная in-app-
проводка всплывает уже на develop (кейс f46d89ea).
- REGISTRY_STAMP: codegen (scripts/gen-registry-stamp.mjs) считает sha256 от
нормализованного (CRLF→LF, один хвостовой \n снят) сырого текста
src/tool-specs.ts, пишет src/registry-stamp.generated.ts (gitignored),
index.ts реэкспортит → попадает в build/. Вшит в build/pretest/watch ДО tsc.
- Loader (dev/test): computeSrcRegistryStamp пересчитывает стамп из src рядом с
build/index.js (dev-vs-prod по existsSync, любая ошибка → null), сверяет с
build-стампом → при рассинхроне бросает «build is stale — rebuild». В prod
(src нет) и на pre-#447 билдах (нет REGISTRY_STAMP) — чистый no-op.
- CI: job mcp-server-parity собирает shared-deps+mcp (регенерит стамп) и гоняет
ОБА сьюта вместе (mcp node:test + server guard-спеки) — именованный гейт, его
нельзя случайно расщепить.
- AGENTS.md: правка спеков требует ребилда @docmost/mcp.
Тесты (20): mcp-сайд (детерминизм, нормализация, desync-гард стамп-vs-билд) +
server-сайд (null при отсутствии src = prod no-op; mismatch → throw точного
сообщения; pre-#447 no-op). Кросс-импл equality-гард: один фиксированный вход →
один хэш на ОБЕИХ сторонах, ловит рассинхрон двух нормализаций. Внутреннее
ревью: APPROVE WITH SUGGESTIONS (обе — покрытие guard'а — закрыты этим тестом).
Мутационно: любой из двух normalize-имплов расходится → equality-тест краснеет.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
68 lines
3.6 KiB
JavaScript
68 lines
3.6 KiB
JavaScript
// Codegen: emit src/registry-stamp.generated.ts with a REGISTRY_STAMP hash of
|
|
// the tool-specs REGISTRY CONTENT, so a build/ vs src/ skew (issue #447) is
|
|
// detectable at runtime.
|
|
//
|
|
// WHY hash the raw source text (not extracted structured data):
|
|
// SHARED_TOOL_SPECS carries `buildShape` functions (the input SCHEMAS) which are
|
|
// NOT serializable. The input schema is exactly one of the things that MUST stay
|
|
// in sync between build/ and src/, so we cannot drop it from the hash. Rather
|
|
// than probe zod with a fragile shim to reconstruct the schema shape, we hash the
|
|
// STABLE, deterministic source TEXT of tool-specs.ts. That text fully captures
|
|
// every field that must stay in sync — mcpName, inAppKey, description, tier,
|
|
// catalogLine AND the buildShape bodies (input schemas) — with zero probing
|
|
// fragility. Any edit to a spec (a renamed tool, a reworded description, a
|
|
// changed schema field) changes the text and therefore the stamp.
|
|
//
|
|
// DETERMINISM: the hash is computed over the file bytes with line endings
|
|
// normalized to LF and a single trailing newline stripped, so a CRLF checkout or
|
|
// an editor's trailing-newline habit cannot make build/ and src/ disagree. No
|
|
// Date.now / randomness. The loader's dev-only stale-check (docmost-client.loader.ts)
|
|
// re-runs THIS SAME normalization + sha256 over src/tool-specs.ts and compares to
|
|
// the built REGISTRY_STAMP; the two must compute identically.
|
|
//
|
|
// This script runs from the `build` and `pretest` npm scripts BEFORE tsc, so
|
|
// build/ always carries a stamp derived from the tool-specs.ts that was compiled.
|
|
|
|
import { createHash } from 'node:crypto';
|
|
import { readFileSync, writeFileSync } from 'node:fs';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { dirname, join } from 'node:path';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const SRC_DIR = join(__dirname, '..', 'src');
|
|
const TOOL_SPECS_PATH = join(SRC_DIR, 'tool-specs.ts');
|
|
const OUT_PATH = join(SRC_DIR, 'registry-stamp.generated.ts');
|
|
|
|
/**
|
|
* Deterministic stamp of the tool-specs registry content. Kept as a plain
|
|
* function (exported) so the algorithm has a single home; the loader duplicates
|
|
* only the tiny normalize+sha256 steps because it lives in the CJS server build
|
|
* and cannot import this ESM script. If you change the normalization here, mirror
|
|
* it in apps/server/src/core/ai-chat/tools/docmost-client.loader.ts.
|
|
*/
|
|
export function computeRegistryStamp(toolSpecsSource) {
|
|
const normalized = toolSpecsSource.replace(/\r\n/g, '\n').replace(/\n$/, '');
|
|
return createHash('sha256').update(normalized, 'utf8').digest('hex');
|
|
}
|
|
|
|
function main() {
|
|
const source = readFileSync(TOOL_SPECS_PATH, 'utf8');
|
|
const stamp = computeRegistryStamp(source);
|
|
const out =
|
|
'// AUTO-GENERATED by scripts/gen-registry-stamp.mjs — DO NOT EDIT BY HAND.\n' +
|
|
'// A deterministic hash of src/tool-specs.ts content (tool names, descriptions,\n' +
|
|
'// tiers, catalog lines and input schemas). Regenerated on every build/pretest\n' +
|
|
'// so build/ always matches the compiled src. The in-app loader recomputes this\n' +
|
|
'// from src and refuses to run on a mismatch (issue #447). This file is\n' +
|
|
'// gitignored and produced by the build — see .gitignore.\n' +
|
|
`export const REGISTRY_STAMP = ${JSON.stringify(stamp)};\n`;
|
|
writeFileSync(OUT_PATH, out, 'utf8');
|
|
// eslint-disable-next-line no-console
|
|
console.log(`gen-registry-stamp: wrote ${OUT_PATH} (${stamp.slice(0, 12)}…)`);
|
|
}
|
|
|
|
// Only run when invoked directly (not when imported for computeRegistryStamp).
|
|
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
|
main();
|
|
}
|