template-refs.test.js 6.02 KB
/**
 * Unit Tests — work graph 骨架的悬空本体引用防线(R6-F1)
 *
 * 挡的是这一类缺陷:`system/work-graph-templates/*.json` 里写了一个 `systemAgentId`,
 * 而 `system/templates/` 下根本没有这个 agent。它不会在任何静态检查里报错,
 * 一路要跑到生产才炸——`work_graph_nodes.system_agent_id` 有外键指向 `system_agents(id)`,
 * `createWorkGraph` 落库时抛 `FOREIGN KEY constraint failed`,
 * `createWorkGraphSafely` 把它翻成 `rule=reference` 打回 agent,连打三轮后抛错,
 * 节点按「无 fail_reason 的 fail」走兜底边。而 `org-validator` 的模板里写死了
 * 「必须用 validator.json 这张骨架」,所以这条路径是 100% 会踩的。
 *
 * 实际发生过:validator.json 引用 benchmark-validator / testdata-validator,
 * planner.json 引用 brd-planning / prd-planning / sdd-planning / goal-benchmark,
 * 六个全是空引用(R6-F1)。
 *
 * ── 为什么校验的是「目录」而不只是「_registry.json 条目」──────────────
 * `system_agents` 的数据源头是**目录扫描**:`graph-seed-service.seedTemplates()`
 * 读 `system/templates/*` 的子目录逐个 upsert,`_registry.json` 只用来补充可选的
 * `benchmarkScore`(匹配不上就留 NULL)。所以决定外键成不成立的是**目录在不在**。
 * 但 registry 是这批 agent 的人类可读清单,漏登记会让 `_registry.json` 与真实本体集
 * 悄悄发散——两条都查,一条挡外键、一条挡元数据漂移。
 *
 * ── 为什么不顺带查 `system/org-graph-templates/` ────────────────────
 * 那边的节点是 org node 角色名(`org-planner` / `org-coder` …),
 * 它们不是 `system/templates/` 下的模板,而是建公司时由
 * `graph-seed-service.seedCompanyAgents()` 以 `source_kind='company_hired'` 落库的。
 * 拿本文件这套判据去查它,会把正确的东西判成错的。
 */
import { describe, it, expect } from 'vitest';
import fs from 'fs';
import path from 'path';

const ROOT = path.resolve(__dirname, '../../..');
const WORK_GRAPH_DIR = path.join(ROOT, 'system/work-graph-templates');
const TEMPLATES_DIR = path.join(ROOT, 'system/templates');
const REGISTRY_PATH = path.join(TEMPLATES_DIR, '_registry.json');

/** `{{orgNode}}` 这类占位符不是合法本体名,由使用方替换(README「`{{占位符}}` 必须替换」) */
const isPlaceholder = (s) => /\{\{.*\}\}/.test(s);

function readSkeletons() {
  return fs
    .readdirSync(WORK_GRAPH_DIR)
    .filter((f) => f.endsWith('.json'))
    .map((f) => ({ file: f, json: JSON.parse(fs.readFileSync(path.join(WORK_GRAPH_DIR, f), 'utf-8')) }));
}

/**
 * 骨架里所有被引用的本体 id。
 * `normalizeNodes`(work-graph-db.js)在缺 systemAgentId 时回退到 nodeName——
 * 这里照同一条规则取,否则漏掉的正是最容易写错的那种节点。
 */
function referencedAgentIds(skeleton) {
  const out = [];
  for (const n of skeleton.json.nodes || []) {
    const nodeName = typeof n === 'string' ? n : n && (n.nodeName || n.node_name);
    const id = (typeof n === 'object' && n && (n.systemAgentId || n.system_agent_id)) || nodeName;
    if (id && !isPlaceholder(id)) out.push({ id, nodeName, file: skeleton.file });
  }
  return out;
}

describe('work graph 骨架的本体引用', () => {
  const skeletons = readSkeletons();

  it('本目录下有骨架可查(防止测试因目录改名而静默变成空跑)', () => {
    expect(skeletons.length).toBeGreaterThan(0);
  });

  it('每个 systemAgentId 在 system/templates/ 下都有目录(决定外键成不成立的那一环)', () => {
    const dangling = [];
    for (const s of skeletons) {
      for (const ref of referencedAgentIds(s)) {
        if (!fs.existsSync(path.join(TEMPLATES_DIR, ref.id))) dangling.push(ref);
      }
    }
    expect(
      dangling.map((d) => `${d.file}: 节点 ${d.nodeName} → system/templates/${d.id} 不存在`),
    ).toEqual([]);
  });

  it('每个 systemAgentId 在 _registry.json 里都有条目', () => {
    const registry = JSON.parse(fs.readFileSync(REGISTRY_PATH, 'utf-8'));
    expect(Array.isArray(registry)).toBe(true);
    const known = new Set(registry.map((e) => e.id));

    const missing = [];
    for (const s of skeletons) {
      for (const ref of referencedAgentIds(s)) {
        if (!known.has(ref.id)) missing.push(ref);
      }
    }
    expect(
      missing.map((m) => `${m.file}: 节点 ${m.nodeName} → _registry.json 里没有 ${m.id}`),
    ).toEqual([]);
  });

  it('引用的本体确实能落库:真的建一次库并写入无占位符的骨架', async () => {
    // 隔离手法与 graph-engine.test.js 一致:先设 GRAPH_DB_PATH 再 import,真实 data/graph.sqlite 不被碰。
    const os = await import('os');
    const dbPath = path.join(
      fs.mkdtempSync(path.join(os.default.tmpdir(), 'template-refs-')),
      'graph.sqlite',
    );
    process.env.GRAPH_DB_PATH = dbPath;

    const graphDb = await import('../../services/graph-db.js');
    const seed = await import('../../services/graph-seed-service.js');
    const wdb = await import('../../services/work-graph-db.js');

    graphDb.default.getDb();
    seed.default.seedTemplates(); // system/templates/* → system_agents(外键的另一端)

    const landed = [];
    for (const s of skeletons) {
      if (JSON.stringify(s.json).includes('{{')) continue; // 占位符骨架落库前必须先替换,不在本条覆盖范围
      const res = wdb.default.createWorkGraph({
        companyId: 'template-refs-test',
        nodes: s.json.nodes,
        edges: s.json.edges,
      });
      landed.push(`${s.file}: ${res.ok ? 'ok' : JSON.stringify(res.errors)}`);
    }

    // 无占位符的骨架必须至少有一张(validator.json 是 org-validator 强制使用的那张),
    // 否则这条用例会在"没有任何图被落库"的情况下静默通过。
    expect(landed.length).toBeGreaterThan(0);
    expect(landed.filter((l) => !l.endsWith(': ok'))).toEqual([]);
  });
});