agent-md-refs.test.js 17.6 KB
/**
 * Unit Tests —— 本体 md 里相对路径引用的「静默降级」防线(28 号改造,裁决 A)
 *
 * 挡的是这一类缺陷:`system/agents/{aid}/agent.md` 里写着 `../../workspace/org-planner/prd.md`
 * 或 `../../../docs/todo/system-design/27-naming-conventions.md`,而 28 号把 CLI 的 cwd 从
 * 本体根目录下沉到了 `system/agents/{aid}/{companyId}/{taskId}/`(深度 3)。深度一变,
 * 每一条 `../` 引用都指到别处 —— **而这条失败路径上没有任何一处会报错**:
 *
 *   - 输入读不到 → agent 在「没看见 prd / sdd / acceptance / 设计文档」的状态下照样开工,
 *     产出看起来是正常产出,只是依据是它自己编的(org-coder 就是这么跑偏的);
 *   - 输出写错地方 → 落进 agent 自己的私有目录,`indexNodeOutputs` 扫不到、`graph_files`
 *     里没有记录,下游节点的「[上游输入]」段是空的 —— 同样不报错。
 *
 * 提示词开头的「运行时坐标」给的是绝对路径,是兜底的那一半;本文件是另一半:
 * 保证 md 里那些相对引用**本身**算得对。两半都在,改漏一处才有人吭声。
 *
 * ── 基准(裁决 A,cwd 深度恒为 3)──────────────────────────────────
 *   cwd            system/agents/{aid}/{companyId}/{taskId}/
 *   `../`          公司态目录 system/agents/{aid}/{companyId}/   (运行期创建,见下)
 *   `../../`       本体目录   system/agents/{aid}/
 *   `../../../`    本体注册表 system/agents/
 *   `../../../../` system/
 *   `../../../../../` 仓库根
 *
 * 深度是 path.relative 实算出来的,不照抄设计稿 —— 28 §4.1 的 `../../../` 把结尾斜杠
 * 数成了一级,prompt-builder 的注释已经记过这笔账。本文件第一条用例就把这个常量钉死。
 */
import { describe, it, expect } from 'vitest';
import fs from 'fs';
import path from 'path';

const ROOT = path.resolve(__dirname, '../../..');
const AGENTS_DIR = path.join(ROOT, 'system', 'agents');

/** 探针:真实 companyId / taskId 在运行期才有,这里拿两个不会与任何东西撞名的占位符顶上 */
const PROBE_CID = '_probe_cid';
const PROBE_TASK = '_probe_task';
const probeCwd = (aid) => path.join(AGENTS_DIR, aid, PROBE_CID, PROBE_TASK);

/**
 * 不算「本体」的 md:
 *   todo.md / plan.md / memory.md / _lessons.md —— agent 自己写的运行态清单(graph-engine
 *     的 AGENT_RUNTIME_STATE_RE 豁免的正是这几个),里面的路径是历史派单原文,不是指令。
 *   prompt.md —— DevLoop 六个 agent 的模板。它们走 loop-controller.callAgent,
 *     cwd 是 **DevLoop 项目根**(`protocol.connect(null, projectRoot)`),不是 agent 任务态目录,
 *     里面的 `../../sdd/` 是相对 `units/{unitId}/` 说的。拿本文件这套基准去量它会把对的判成错的。
 */
const NOT_BODY_MD = new Set(['todo.md', 'plan.md', 'memory.md', '_lessons.md', 'prompt.md']);

/**
 * 允许「目标暂时不存在」的引用落点 —— 键是解析后的仓库相对路径,值是理由。
 *
 * 只收**写产物的落点**:它们由 agent 首次运行时创建,静态检查看不到不代表算错了。
 * 判据不是「反正找不到就放行」,而是每一条都要能说清「谁在什么时候把它建出来」。
 * 名单必须保持极短;每加一条就是给「指到空气」开了一道口子。
 */
const PENDING_WRITE_TARGETS = {
  'system/train/digester':
    'innovator-digest 的产出目录,首次 digest 时创建;同层的 system/train/hunter 已有实例,深度按它对齐',
  'system/agents/master/plan.md':
    'master 自己维护的子任务清单,首次规划时写出(AgentScaffold 的 _writePlan 也走同一处)',
  'system/agents/youtube-crawler/workspace/evolve/refactor_report.md':
    'evolver 的重构报告,跑 code-refactor 技能时写出;同目录的 benchmark_test.js / evolving_log.md 已在',
};

/**
 * 公司态目录(`../`)里的已知内容。这一层是 **(agentId, companyId) 运行期**才建的
 * (company-service 雇佣时写 company-context.md + user.md + 空 memory),静态断言「存在」
 * 恒为假。所以这一层只校验**名字**:写了个不属于这一层的名字,同样是算错了深度。
 */
const COMPANY_STATE_ENTRIES = new Set(['company-context.md', 'user.md', 'memory', 'conversations']);

/**
 * 列目录,目录中途消失就当空。
 *
 * 本文件扫的是**真实的** `system/agents/`,而同一轮 vitest 里别的用例会在这底下建了又删
 * 临时本体(`tmp-iso-agent-<ts>`,见 agent-chat-tenant-isolation)。并行跑起来必然撞上
 * 「readdir 列到了名字、进去时已经被删」这一瞬间 —— 那是**别人的**清理动作,不是本文件要挡的缺陷。
 * 只吞 ENOENT:真正的读不动(权限、坏盘)仍然要炸,否则这条防线会缩成一句「什么都没扫到」。
 */
function readDirSafe(dir) {
  try {
    return fs.readdirSync(dir, { withFileTypes: true });
  } catch (e) {
    if (e.code === 'ENOENT') return [];
    throw e;
  }
}

/** 收集本体 md:本体根下的 *.md(去掉运行态那几份)+ `.claude/` 下的全部 md */
function collectBodyMd() {
  const out = [];
  const walkClaude = (dir, aid) => {
    for (const e of readDirSafe(dir)) {
      const p = path.join(dir, e.name);
      if (e.isDirectory()) walkClaude(p, aid);
      else if (e.name.endsWith('.md')) out.push({ aid, file: p });
    }
  };
  for (const a of readDirSafe(AGENTS_DIR)) {
    if (!a.isDirectory()) continue;
    const adir = path.join(AGENTS_DIR, a.name);
    for (const e of readDirSafe(adir)) {
      if (e.isFile() && e.name.endsWith('.md') && !NOT_BODY_MD.has(e.name)) {
        out.push({ aid: a.name, file: path.join(adir, e.name) });
      }
    }
    const claude = path.join(adir, '.claude');
    if (fs.existsSync(claude)) walkClaude(claude, a.name);
  }
  return out;
}

/**
 * 抓 `../` 开头的引用。
 * 前置负向断言 `(?<![.\w])` 是必须的:正文里的省略号 `.../data/companies/` 和 URL 里的
 * `https://i.ytimg.com/vi/.../default.jpg` 都含 `../`,不排掉的话噪声会淹掉真信号。
 */
const REF_RE = /(?<![.\w])(?:\.\.\/)+[A-Za-z0-9_\-./{}<>]*/g;

/**
 * 引用里可能带 `{skill_name}` / `<公司ID>` 这类占位符(使用方替换)。
 * 只校验占位符**之前**那一段真实前缀 —— 那一段错了就是深度错了,后面替不替换都救不回来。
 */
function staticPrefix(ref) {
  const segs = ref.split('/');
  const i = segs.findIndex((s) => /[{<]/.test(s));
  return i < 0 ? ref : segs.slice(0, i).join('/');
}

/**
 * ── 第二类缺陷:**裸相对**交付路径(没有 `../` 前缀,所以上面那条 REF_RE 整类看不见)──
 *
 * `workspace/strategy.md` 这种写法在旧布局下是对的(cwd = 本体根目录 = 私有工作区),
 * 28 号之后 cwd 变成 `system/agents/{aid}/{cid}/{taskId}/` —— 一个 §4.1 明写「一次性、
 * 可按保留策略清理」的任务态目录。于是同一份提示词里出现两条互斥指令:坐标声明说
 * 「你的交付物必须写到 <公司 workspace 绝对路径>」,正文说「写 `workspace/strategy.md`」。
 * **两条都不报错**:裸写照样创建成功,只是落在 agent 自己的任务态目录里,
 * CTO / COO / org-planner 谁也读不到,下一次清理还会连痕迹一起抹掉。
 *
 * 上面那条 `../` 规则抓不到它,正是因为裸相对路径**根本没有 `../` 可数**。这条补上。
 *
 * 判据是「路径的第一段是不是交付物根」,不是「这行看起来像不像写文件」——
 * 后者要靠猜动词,猜漏一个就等于没写这条规则。三个根的选取理由:
 *   workspace / output / outputs —— §4.1 明确规定任务态目录**不建** workspace/、
 *     也从来没有 output/,所以以它们开头的裸路径**没有一条**是对的,零误报、无需白名单。
 *
 * 故意**不**收进来的三个根,理由都在「它们在任务态目录里是合法落点或根本不是文件路径」:
 *   logs/      —— §4.1 给任务态目录留的就是 logs/,写在 cwd 下是对的;
 *   artifacts/ —— graph 链路里它是**相对 run 根记录下来的 rel_path**(写进 attempt-*.json
 *                 的字段值,不是 agent 拿去拼 open() 的路径),收进来只会逼出一串白名单;
 *   data/      —— 本体里出现的 `data/` 绝大多数是 `| 直接写 `data/` 下的 sqlite |` 这类**反例表**,
 *                 以及 `data/companies/<公司ID>/runs/…` 这类「从仓库根说全的地址」(读命令用,
 *                 拼错是当场 ENOENT 的响动,不是本文件要挡的静默类)。收进来需要的豁免
 *                 比规则本身还长,那就不是防线而是装饰。
 */
const DELIVERY_ROOTS = ['workspace', 'outputs', 'output'];
const BARE_RE = new RegExp(
  // 前置负向断言排掉「有前缀因而不裸」的三种:`../workspace/`(上面那条规则管)、
  // `<你的本体目录>/workspace/` 与 `$W/workspace/`(前面已经有绝对锚点)、以及 `my-workspace/` 这种词中命中。
  String.raw`(?<![\w./~-])(?:${DELIVERY_ROOTS.join('|')})\/[A-Za-z0-9_\-./{}<>*]*`,
  'g',
);

/**
 * 两类**不是指令**的行,扫描时跳过:
 *
 * 1. `- ✅ [2026-04-09] 成功创建/修改了 2 个文件: workspace/digest/` —— SKILL.md 末尾
 *    「经验教训(自动沉淀)」那一段,由跑完的 run 自动追加。它记的是**过去发生过什么**,
 *    和 todo.md / plan.md 同类(本文件顶上的 NOT_BODY_MD 正是按这个理由排掉那几份的),
 *    改写它等于伪造历史。它进提示词只是噪声,不会被当成落点指令。
 * 2. `⚠️ 别写相对路径 `workspace/...`` —— 正文在教节点**不要**这么写。`...` 三个点不可能是
 *    真文件名,把这个 token 当反面例句放行,比让 workgraph-planning 把话说抽象要好:
 *    那三处反例本来就是上一轮为了挡同一个缺陷才写进去的。
 */
const AUTO_LESSON_LINE = /^\s*[-*]\s*[✅❌⚠️]\s*\[\d{4}-\d{2}-\d{2}\]/;
const COUNTEREXAMPLE_REF = 'workspace/...';

/** 单行取裸相对交付路径 —— 单独抽出来是为了让规则本身能被用例直接证伪 */
function bareRefsInLine(line) {
  if (AUTO_LESSON_LINE.test(line)) return [];
  return (line.match(BARE_RE) || [])
    // 反例先排、再削尾标点:顺序反过来的话 `workspace/...` 会被削成 `workspace/`,反例就漏出来了
    .filter((raw) => raw !== COUNTEREXAMPLE_REF)
    .map((m) => m.replace(/[.,)\]`]+$/, ''));
}

function collectBareRefs() {
  const rows = [];
  for (const { aid, file } of collectBodyMd()) {
    let text;
    try {
      text = fs.readFileSync(file, 'utf-8');
    } catch (e) {
      if (e.code === 'ENOENT') continue;
      throw e;
    }
    text.split('\n').forEach((line, i) => {
      for (const ref of bareRefsInLine(line)) {
        rows.push({ aid, ref, where: `${path.relative(ROOT, file)}:${i + 1}` });
      }
    });
  }
  return rows;
}

function collectRefs() {
  const rows = [];
  for (const { aid, file } of collectBodyMd()) {
    let text;
    try {
      text = fs.readFileSync(file, 'utf-8');
    } catch (e) {
      if (e.code === 'ENOENT') continue; // 同 readDirSafe:列到名字之后才被别的用例删掉
      throw e;
    }
    text.split('\n').forEach((line, i) => {
      for (const m of line.match(REF_RE) || []) {
        const ref = m.replace(/[.,)\]`]+$/, '');
        const prefix = staticPrefix(ref);
        if (!prefix) continue;
        rows.push({
          aid,
          ref,
          prefix,
          where: `${path.relative(ROOT, file)}:${i + 1}`,
          upCount: (ref.match(/\.\.\//g) || []).length,
          // 静态前缀只剩 `../../` 这种纯上跳:占位符紧跟在上跳后面(`../../../<对方的 agent-id>/todo.md`),
          // 或正文就是在讲深度常量本身(「`../../../` 即 system/agents/」)。没有具体落点可查存在性,
          // 但**跳了几级**仍然是可证伪的 —— 见下面那条「不许跳出仓库」。
          pureUp: /^(\.\.\/)+$/.test(prefix),
          target: path.relative(ROOT, path.resolve(probeCwd(aid), prefix)),
        });
      }
    });
  }
  return rows;
}

describe('本体 md 的相对引用(cwd 深度 3)', () => {
  const refs = collectRefs();

  it('确实扫到了本体与引用(防止目录改名后测试静默空跑)', () => {
    expect(collectBodyMd().length).toBeGreaterThan(50);
    expect(refs.length).toBeGreaterThan(20);
  });

  it('深度基准本身钉死:本体在 cwd 的 `../../`,不是 `../../../`', () => {
    // 这一条是全文件的地基。`_base/agent.md` 教所有新 agent 怎么找自己的人格文件,
    // 多写一级就指到 system/agents/ 本身 —— 存在、可列目录、但没有 soul.md。
    const cwd = probeCwd('_base');
    expect(fs.existsSync(path.resolve(cwd, '../../soul.md'))).toBe(true);
    expect(fs.existsSync(path.resolve(cwd, '../../../soul.md'))).toBe(false);
    expect(path.relative(cwd, path.join(AGENTS_DIR, '_base'))).toBe(path.join('..', '..'));
  });

  it('每条跨越本体层的引用都解析到真实存在的目标', () => {
    const missing = refs
      .filter((r) => !r.pureUp)
      .filter((r) => r.upCount >= 2)
      .filter((r) => !(r.target in PENDING_WRITE_TARGETS))
      .filter((r) => !fs.existsSync(path.join(ROOT, r.target)));
    expect(
      missing.map((m) => `${m.where}  ${m.ref}  →  ${m.target} 不存在`),
    ).toEqual([]);
  });

  it('没有一条引用跳出仓库(上一条查不了的纯上跳引用,唯一还能查的就是它跳了几级)', () => {
    // 为什么单开一条:`../../../<对方的 agent-id>/todo.md` 这种「上跳后面紧跟占位符」的写法,
    // 静态前缀只剩纯上跳,上面那条存在性断言**整条跳过**——也就是说改造时把它多写一级或少写一级,
    // 全文件没有任何一处会吭声。而深度写多了必然跳出仓库根(`../../../../../../` 起就出界),
    // 这是纯上跳引用身上仅剩的、仍然可证伪的性质。少写一级抓不到,认这个上限。
    const escaped = refs.filter((r) => r.target.startsWith('..'));
    expect(
      escaped.map((e) => `${e.where}  ${e.ref}  跳到仓库外:${e.target}`),
    ).toEqual([]);
  });

  it('`../` 那一层只准出现公司态目录自己的东西', () => {
    const strays = refs
      .filter((r) => !r.pureUp)
      .filter((r) => r.upCount === 1)
      .filter((r) => !COMPANY_STATE_ENTRIES.has(r.prefix.split('/')[1]));
    expect(
      strays.map((s) => `${s.where}  ${s.ref}  不是公司态目录(${[...COMPANY_STATE_ENTRIES].join(' / ')})里的东西`),
    ).toEqual([]);
  });

  it('规则本身可证伪:裸的抓得到,带锚点的不误伤', () => {
    // 没有这一条,正则写坏(比如某天把 `/` 前置断言改宽)就会退化成「一条都扫不出来」,
    // 而「扫不出来」和「全都合格」在断言里长得一模一样 —— 上一轮漏掉这类缺陷就是这么发生的。
    expect(bareRefsInLine('产出一份 `workspace/strategy.md`:')).toEqual(['workspace/strategy.md']);
    expect(bareRefsInLine('- 所有自主创建的文件仅限在 workspace/ 内操作')).toEqual(['workspace/']);
    expect(bareRefsInLine('见 `../../../../../data/companies/c/workspace/x.md`')).toEqual([]);
    expect(bareRefsInLine('写到 `$W/workspace/x.md`')).toEqual([]);
    expect(bareRefsInLine('落在 `<你的本体目录>/workspace/hunt/`')).toEqual([]);
    expect(bareRefsInLine('- ✅ [2026-04-09] 成功创建/修改了 1 个文件: workspace/digest_v2/`')).toEqual([]);
  });

  /**
   * `AgentScaffold` 的 bootstrap 模板(`AgentScaffold.js` 与
   * `packages/agent-core/base/common/tools-base.md`)曾经写着「所有自主创建的文件仅限在
   * workspace/ 内操作」—— 裸相对路径,28 号之后 cwd 是任务态目录,照这句写出来的交付物
   * 会静默丢失。2026-08-23(36 号文档阶段二试验时顺手撞见并修掉):模板与
   * `AgentScaffold.js` 都已改成「写到运行时坐标给出的绝对路径」,新脚手架不会再带这个缺陷。
   *
   * 但 `zz-scaffold-readonly-fresh` 是 `scaffold-body-readonly.test.js` 依赖的**冻结夹具**
   * ——28 号裁决「测试建出来的本体不删」,且它的存在与否本身就是那份测试的断言载体
   * (建/判重两分支),所以它永远停在被建出来那一刻的旧模板内容上,不会随模板修好而更新。
   * 登记成 (文件, 引用) 二元组而不是「tools.md 一律放行」:别的本体的 tools.md
   * 再出现同一行照样红。
   */
  const FROZEN_FIXTURE_DEFECTS = new Set([
    'system/agents/zz-scaffold-readonly-fresh/tools.md::workspace/',
  ]);
  const defectKey = (b) => `${b.where.split(':')[0]}::${b.ref}`;

  it('没有一条裸相对交付路径(cwd 是会被清理的任务态目录,写进去等于没交付)', () => {
    const bare = collectBareRefs().filter((b) => !FROZEN_FIXTURE_DEFECTS.has(defectKey(b)));
    expect(
      bare.map((b) => `${b.where}  ${b.ref}  裸相对 → 落进 cwd(任务态目录,一次性、会被清理、同事看不见)`),
    ).toEqual([]);
  });

  it('豁免名单里没有过期条目(引用都删了还留着白名单 = 下次悄悄放行别的错)', () => {
    const referenced = new Set(refs.map((r) => r.target));
    expect(Object.keys(PENDING_WRITE_TARGETS).filter((t) => !referenced.has(t))).toEqual([]);
  });
});