test-id-stability.test.js 5.58 KB
/**
 * 元测试 —— 测试里不许用「随机 / 时间戳」拼 agent id 或公司 id(28 号 E2E 后的遗留项)
 *
 * ── 为什么这条要单独立一道防线 ────────────────────────────────────────────
 * 28 号定下的产品决策:**测试在真实 `system/agents/` 下建出来的本体是正式注册表条目,
 * 不删、不 ignore、进版本库**(复用得越多,agent 攒下的历史与经验越多)。
 * 这条决策一旦成立,「id 每跑一次换一个」就从「无害的隔离手法」变成了**注册表泄漏**:
 *   `tmp-draft-role-1787029777338-8idwe` → 跑 10 次 = 10 个再也没人认领的僵尸本体,
 *   而它们又是 `system/agents/` 全量遍历型用例(graduation-criteria-reality /
 *   template-verdicts / graph-seed-service)的输入,噪声会一路涨进断言里。
 * 公司 id 同理:E2E 基线里就躺着 `tmp-w7-blank-1787029777338-8idwe` 这类 `data/companies/` 垃圾。
 *
 * 反过来,id 固定之后第二次跑正好走「本体已存在 → 复用」那条分支 ——
 * 「第二家公司雇同一个角色」这条真实路径顺带被测到了,与决策本身同向。
 *
 * ── 判据为什么窄 ──────────────────────────────────────────────────────────
 * 只认**「字面量前缀 + `-` + 随机源」这一种拼 id 的形状**(`'c-test-' + Math.random()`、
 * `` `tmp-draft-role-${RUN_ID}` ``),随机源可以隔一层局部变量(RUN_ID 这种)。
 * 时间戳本身不是罪:`Date.now() - 60_000` 造一个「一分钟前」的时刻、
 * `/tmp/xxx-${Date.now()}.sqlite` 造一次性库文件、任务文本里加个去重 nonce,
 * 都与注册表增长无关,判据必须放它们过去(含 `/` 的字面量整条跳过 = 路径不是 id)。
 * 宽一档就会把这些一起判红,防线于是被人整条注释掉 —— 那比没有更糟。
 */
import { describe, it, expect } from 'vitest';
import fs from 'fs';
import path from 'path';

const TESTS_ROOT = path.resolve(__dirname, '..');

/** 随机源:这两个是本仓唯一用过的 */
const RAND = String.raw`(?:Date\.now\(\)|Math\.random\(\))`;

/**
 * 拼 id 的形状。两种写法各一条分支,都要求 `-` 紧挨着随机源 —— 那个连字符正是
 * 「前缀 + 唯一后缀」的签名:
 *   ① `'c-test-' + Math.random()...` / `'-' + Math.random()`(字符串加法)
 *   ② `` `tmp-draft-role-${RUN_ID}` ``(模板字面量)
 * 字面量里出现 `/` 一律跳过:那是路径(`/tmp/test-benchmark-${Date.now()}.sqlite`),不是 id。
 */
const idConcatRe = (sources) => new RegExp(
  String.raw`(?:'[^'\n/]*-'\s*\+\s*|"[^"\n/]*-"\s*\+\s*|\`[^\`\n/]*-\$\{\s*)(?:${sources})`,
  'g',
);

/** 值里带随机源的局部变量:`const RUN_ID = \`${Date.now()}-${Math.random()...}\`` */
function taintedNames(src) {
  const names = new Set();
  const re = new RegExp(String.raw`(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*[^;\n]*${RAND}`, 'g');
  let m;
  while ((m = re.exec(src))) names.add(m[1]);
  return [...names];
}

/** 一份源码里所有「随机拼 id」的行号 + 原文 */
function violationsIn(src, file) {
  const sources = [RAND, ...taintedNames(src).map((n) => String.raw`${n}\b`)].join('|');
  const re = idConcatRe(sources);
  const out = [];
  src.split('\n').forEach((line, i) => {
    re.lastIndex = 0;
    if (re.test(line)) out.push(`${file}:${i + 1} → ${line.trim()}`);
  });
  return out;
}

function testFiles(dir, out = []) {
  for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
    const p = path.join(dir, e.name);
    if (e.isDirectory()) testFiles(p, out);
    // 本文件自己除外:它的注释与「判据自身有效」那条用例**必须**逐字带着反例,
    // 否则判据就没法证明自己还抓得住东西 —— 唯一合法的例外,也只有这一份。
    else if (e.name.endsWith('.test.js') && p !== __filename) out.push(p);
  }
  return out;
}

const FILES = testFiles(TESTS_ROOT);

describe('测试用的 agent / 公司 id 必须是固定的', () => {
  it('扫到了测试文件(改目录结构后别静默变成空跑)', () => {
    expect(FILES.length).toBeGreaterThan(50);
  });

  it('判据自身有效:这三种写法必须被抓出来,这四种必须放过', () => {
    // 抓:不管随机源是直接写的还是隔了一层变量
    const caught = [
      'const RUN_ID = `${Date.now()}-x`;\nconst ROLE = `tmp-draft-role-${RUN_ID}`;',
      "cid = 'c-test-' + Math.random().toString(36).slice(2, 7);",
      "const cid = 'c-p3-' + name + '-' + Math.random().toString(36).slice(2, 7);",
    ];
    for (const src of caught) {
      expect(violationsIn(src, 'x'), src).not.toEqual([]);
    }
    // 放过:时间戳的正当用法 —— 相对时刻、一次性库文件路径、去重 nonce、纯序号
    const passed = [
      'createdAt: new Date(Date.now() - 60_000).toISOString(),',
      'const TEST_DB_PATH = `/tmp/test-benchmark-${Date.now()}.sqlite`;',
      'const RUN = `r${Date.now().toString(36)}`;\nawait dispatch(ALPHA, `写季度战略 ${RUN}`);',
      "const cid = 'c-test-' + (++seq);",
    ];
    for (const src of passed) {
      expect(violationsIn(src, 'x'), src).toEqual([]);
    }
  });

  it('server/tests 下没有一处用随机 / 时间戳拼 id', () => {
    const bad = FILES.flatMap((f) =>
      violationsIn(fs.readFileSync(f, 'utf-8'), path.relative(TESTS_ROOT, f)),
    );
    expect(bad).toEqual([]);
  });
});