From 786e8c6aa39f2da84f93fe8d4080984a13c50493 Mon Sep 17 00:00:00 2001 From: zhoutianyi Date: Tue, 15 Sep 2026 17:35:36 +0800 Subject: [PATCH 1/5] =?UTF-8?q?chore(evidence):=20=E9=87=8D=E5=BB=BA=203?= =?UTF-8?q?=20=E4=B8=AA=E6=96=87=E4=BB=B6=EF=BC=88=E5=8E=9F=E6=8F=90?= =?UTF-8?q?=E4=BA=A4=E4=BF=A1=E6=81=AF=E6=9C=AA=E8=AE=B0=E5=BD=95=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 重建说明:本提交由会话转录重建,提交信息取自原分支(ds/15-identity)。 文件内容为集成分支上的最终态,不是当时那一刻的中间态——原分支的 per-commit 文件树随 /tmp 清空丢失,转录只保留了提交信息与 git add 的路径清单。 原提交信息:(未记录) --- docs/evidence/pr-15-identity.md | 54 +++++++++++ docs/v2/IDENTITY.md | 43 +++++++++ tests/identity.test.mjs | 161 ++++++++++++++++++++++++++++++++ 3 files changed, 258 insertions(+) create mode 100644 docs/evidence/pr-15-identity.md create mode 100644 docs/v2/IDENTITY.md create mode 100644 tests/identity.test.mjs diff --git a/docs/evidence/pr-15-identity.md b/docs/evidence/pr-15-identity.md new file mode 100644 index 00000000..cf7e0399 --- /dev/null +++ b/docs/evidence/pr-15-identity.md @@ -0,0 +1,54 @@ +# PR-15 · 跨渠道身份映射(`identity.json`) + +- 分支:`ds/15-identity`(本地,合并进 `dot-skill-test`) +- 交付:`src/knowledge/identity.mjs`、`src/knowledge/ledger.mjs`(记录时套用 + 账本留痕)、 + `src/commands/harvest.mjs`(`--identity`)、`docs/v2/IDENTITY.md`、`tests/identity.test.mjs` +- 依赖:无。与 PR-11(归一化正文带说话人)配合:那条让派生层知道"谁在说",这条让它知道 + "不同句柄是同一个人"。 + +## 1. 为什么 + +多来源盲测的实验组蒸馏器只能**按渠道分别描述**同一个人:Slack 里是 `林工`,飞书里是 `ou_lin`, +语料没有任何一句说这两者是同一人。派生层因此报出 6 个参与者、把风格统计拆开, +`relationship` 也只能写成"显示名渠道 vs `ou_` 渠道"。裁判据此把若干条断言判为"跨渠道不可验证"。 + +## 2. 改动前后(同一份多来源语料) + +```bash +distilly harvest tests/fixtures/public-corpus/synthetic-multisource \ + --person lin-gong --identity ./identity.json +distilly retrospect --person lin-gong +``` + +| 指标 | 无映射 | 有映射(林工←ou_lin、小明←ou_chen) | +| --- | --- | --- | +| `knowledge/text/*.md` | `[k0001] … ou_lin:评审前我把风险清单发群里…` | `[k0001] … 林工:评审前我把风险清单发群里…` | +| `stats.participants` | `["林工","ou_lin","小明","老周","ou_chen","ou_zhou"]` | `["林工","小明","老周","ou_zhou"]` | +| `voice.sentence_length.by_speaker` | 6 个"说话人" | 4 个(真人的统计不再被拆开) | +| 账本条目 | 无身份信息 | `identity: {file:"identity.json", handles:["ou_chen","ou_lin"], turns:16}` | + +## 3. 纪律 + +- **句柄唯一**:同一句柄被两人声明 → exit 2 且**零写入**;非法 JSON 同样响亮失败。 +- **记录时生效**:规范化在锚定之前,一个 turn 仍是"一个锚点、一个前缀、一行统计"; + 锚点文本仍是源字节的逐字切片(不变式未破)。 +- **不做推断**:只合并显式声明的句柄;"措辞像同一个人"不构成合并理由。 +- 无映射时行为与以前完全一致。 + +## 4. 怎么验 + +```bash +node --test tests/identity.test.mjs # 5 条 +node --test tests/*.test.mjs # 354 pass / 0 fail +node scripts/audit-objective.mjs --skip-acceptance # 16/16 +``` + +`tests/identity.test.mjs` 覆盖:句柄在正文与账本里被归一(且原始句柄不残留)、 +派生层参与者与 `by_speaker` 合并、无映射时不改任何东西、句柄冲突/非法 JSON 零写入、 +纯函数行为(不改输入、match 列表、无文件时的空映射)。 + +## 5. 已知缺口 + +- 映射按句柄字符串精确匹配(大小写敏感);`U02` 这类平台 id 需要写进映射或用导出里的显示名。 +- 通讯录 API 自动取名未实现(飞书开放平台页只有 `sender.id`)。 +- 映射文件本身没有锚点(它不进 `knowledge/`);它的存在通过账本条目的 `identity` 字段留痕。 diff --git a/docs/v2/IDENTITY.md b/docs/v2/IDENTITY.md new file mode 100644 index 00000000..d2334945 --- /dev/null +++ b/docs/v2/IDENTITY.md @@ -0,0 +1,43 @@ +# 跨渠道身份映射(`identity.json`) + +同一个人在每个渠道签名不同:一个导出里叫 `林工`,另一个里是 `ou_lin`,第三个里是 `U02`。 +派生层只读 `knowledge/text/*.md`,所以句柄不合并时它会报出多个参与者,并把这个人的统计 +拆到几个人身上——多来源盲测因此只能"按渠道分别描述"(见 `docs/evidence/pr-14-multisource.md`)。 + +## 文件与命令 + +Skill 根目录(`skills///identity.json`)放一份映射: + +```json +{ + "people": [ + { "name": "林工", "handles": ["ou_lin", "U02"], "note": "Slack 显示名 = 飞书 open_id" } + ] +} +``` + +安装与使用: + +```bash +distilly harvest --person lin-gong --identity ./identity.json +``` + +`--identity` 会把映射装到 Skill 根目录;之后的每次采集/解析都自动读取它。 + +## 纪律 + +- **句柄唯一**:一个句柄被两个人声明是**错误**(exit 2),不掷骰子;非法 JSON 同样响亮失败, + 且不写任何文件。 +- **记录时生效**:规范化发生在锚定之前,所以一个 turn 仍是**一个锚点、一个前缀、一行统计**; + 锚点文本仍是源字节的逐字切片。 +- **回执可查**:被合并的条目在账本里带 + `identity: { file, handles: [...], turns: N }`,读者能看出 `ou_lin` 与 `林工` 是同一个人。 +- **不做推断**:映射只写你明确声明的关系。语料里"看起来像同一个人"(同样的措辞、同样的立场) + 不会自动合并——多来源语料里那三份文件正是这种情形。 + +## 已知边界 + +- 映射按**句柄字符串**精确匹配(去首尾空白,大小写敏感)。 +- 没有映射时行为与以前完全一致(不回退、不猜测)。 +- 通讯录 API 自动取名的路径未实现:飞书开放平台页只带 `sender.id`,名字要么进映射, + 要么老实保留 id。 diff --git a/tests/identity.test.mjs b/tests/identity.test.mjs new file mode 100644 index 00000000..1b789bca --- /dev/null +++ b/tests/identity.test.mjs @@ -0,0 +1,161 @@ +/** + * Cross-channel identity: one person, several handles. + * + * The multi-source corpus signs the same person `林工` in Slack and `ou_lin` in + * Feishu. Without a map the derivation reports two participants and splits that + * person's statistics between them — the blind test could only describe the corpus + * "per channel" because of it. `identity.json` fixes that at record time, so every + * anchor, statistic and citation names the person the same way. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { existsSync, mkdtempSync, readFileSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { parseReceipt, runCli } from "./helpers/cli.mjs"; +import { applyIdentity, canonicalSpeaker, loadIdentity } from "../src/knowledge/identity.mjs"; +import { loadLedger } from "../src/knowledge/ledger.mjs"; +import { KnowledgeStore } from "../src/knowledge/store.mjs"; + +const HERE = fileURLToPath(new URL("..", import.meta.url)); +const CORPUS = join(HERE, "tests", "fixtures", "public-corpus", "synthetic-multisource"); + +function sandbox() { + const root = mkdtempSync(join(tmpdir(), "dst-identity-")); + const mapPath = join(root, "identity.json"); + writeFileSync( + mapPath, + `${JSON.stringify( + { + people: [ + { name: "林工", handles: ["ou_lin"], note: "Slack 显示名 = 飞书 open_id" }, + { name: "小明", handles: ["ou_chen"] }, + ], + }, + null, + 2, + )}\n`, + "utf8", + ); + return { root, mapPath, personDir: join(root, "skills", "colleague", "lin-gong") }; +} + +const textOf = (personDir) => { + const dir = join(personDir, "knowledge", "text"); + const names = readFileSync(join(personDir, "knowledge", "index.json"), "utf8"); + const ledger = JSON.parse(names); + return ledger.map((entry) => readFileSync(join(personDir, "knowledge", entry.locations.text), "utf8")).join("\n"); +}; + +test("a map canonicalises handles at record time, and the ledger says so", () => { + const box = sandbox(); + try { + const harvested = runCli(["harvest", CORPUS, "--person", "lin-gong", "--base-dir", box.root, "--identity", box.mapPath, "--json"]); + assert.equal(harvested.status, 0, harvested.stderr); + const receipt = parseReceipt(harvested.stdout); + assert.equal(receipt.ok, true); + assert.equal(existsSync(join(box.personDir, "identity.json")), true, "the map is installed into the Skill"); + + const text = textOf(box.personDir); + assert.match(text, /林工:评审前我把风险清单发群里/, "the Feishu handle becomes the canonical name"); + assert.equal(/ou_lin:|ou_chen:/.test(text), false, "no raw handle survives into the normalised text"); + + const ledger = loadLedger(new KnowledgeStore(box.personDir)); + const feishu = ledger.find((entry) => entry.identity); + assert.ok(feishu, "the entry records which handles it canonicalised"); + assert.deepEqual(feishu.identity.handles, ["ou_chen", "ou_lin"]); + assert.equal(feishu.identity.turns, 16, "16 Feishu turns changed"); + assert.equal(feishu.identity.file, "identity.json"); + } finally { + rmSync(box.root, { recursive: true, force: true }); + } +}); + +test("the derivation then reports one participant per person", () => { + const box = sandbox(); + try { + assert.equal(runCli(["harvest", CORPUS, "--person", "lin-gong", "--base-dir", box.root, "--identity", box.mapPath, "--json"]).status, 0); + const retro = runCli(["retrospect", "--person", "lin-gong", "--json"], { cwd: box.root }); + assert.equal(retro.status, 0, retro.stderr); + + const derived = join(box.personDir, "evidence", "derived"); + const stats = JSON.parse(readFileSync(join(derived, "stats.json"), "utf8")); + const participants = stats.claims.find((claim) => claim.id === "stats.participants").value; + assert.ok(participants.includes("林工")); + assert.equal(participants.includes("ou_lin"), false, "the handle must not survive as its own participant"); + assert.equal(participants.includes("小明"), true, "the second person is canonicalised too"); + + const voice = JSON.parse(readFileSync(join(derived, "voice.json"), "utf8")); + const bySpeaker = voice.claims.find((claim) => claim.id === "voice.sentence_length").value.by_speaker; + assert.ok(bySpeaker["林工"], `by_speaker: ${JSON.stringify(Object.keys(bySpeaker))}`); + assert.equal("ou_lin" in bySpeaker, false); + } finally { + rmSync(box.root, { recursive: true, force: true }); + } +}); + +test("without a map nothing changes", () => { + const box = sandbox(); + try { + assert.equal(runCli(["harvest", CORPUS, "--person", "lin-gong", "--base-dir", box.root, "--json"]).status, 0); + const text = textOf(box.personDir); + assert.match(text, /ou_lin:/); + const ledger = loadLedger(new KnowledgeStore(box.personDir)); + assert.equal(ledger.every((entry) => entry.identity === undefined), true); + } finally { + rmSync(box.root, { recursive: true, force: true }); + } +}); + +test("a bad map fails loudly and changes nothing", () => { + const box = sandbox(); + try { + const bad = join(box.root, "bad.json"); + writeFileSync(bad, `${JSON.stringify({ people: [{ name: "甲", handles: ["x"] }, { name: "乙", handles: ["x"] }] })}\n`, "utf8"); + const clash = runCli(["harvest", CORPUS, "--person", "lin-gong", "--base-dir", box.root, "--identity", bad, "--json"]); + assert.equal(clash.status, 2); + assert.match(parseReceipt(clash.stdout).error.message, /claimed by both "甲" and "乙"/); + assert.equal(existsSync(join(box.personDir, "knowledge")), false, "nothing may be written for an invalid map"); + + writeFileSync(bad, "{ not json\n", "utf8"); + const malformed = runCli(["harvest", CORPUS, "--person", "lin-gong", "--base-dir", box.root, "--identity", bad, "--json"]); + assert.equal(malformed.status, 2); + assert.match(parseReceipt(malformed.stdout).error.message, /not valid JSON/); + } finally { + rmSync(box.root, { recursive: true, force: true }); + } +}); + +test("the helpers are pure and the map keeps a record of its own decisions", () => { + const identity = { map: new Map([["ou_lin", "林工"]]) }; + assert.equal(canonicalSpeaker("ou_lin", identity), "林工"); + assert.equal(canonicalSpeaker("老周", identity), "老周"); + assert.equal(canonicalSpeaker(null, identity), null); + + const document = { + segments: [{ speaker: "ou_lin" }, { speaker: "老周" }], + entries: [{ speaker: "ou_lin" }, { speaker: "老周" }], + }; + const applied = applyIdentity(document, identity); + assert.equal(applied.changed, 1, "one turn changed, counted once"); + assert.deepEqual(applied.matched, ["ou_lin"]); + assert.equal(applied.document.segments[0].speaker, "林工"); + assert.equal(applied.document.entries[0].speaker, "林工"); + assert.equal(applied.document.segments[1].speaker, "老周"); + assert.equal(document.segments[0].speaker, "ou_lin", "the input document is not mutated"); + + const dir = mkdtempSync(join(tmpdir(), "dst-identity-load-")); + try { + assert.equal(loadIdentity(dir).map.size, 0, "no file, no map"); + writeFileSync(join(dir, "identity.json"), `${JSON.stringify({ people: [{ name: "林工", handles: [] }] })}\n`, "utf8"); + const loaded = loadIdentity(dir); + assert.equal(loaded.map.get("林工"), "林工"); + assert.match(loaded.warnings.join(" "), /lists no handles/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + From e174a88ee875f9256a3ceafceadc2e9097fc378c Mon Sep 17 00:00:00 2001 From: dsh-agent Date: Tue, 15 Sep 2026 22:59:11 +0800 Subject: [PATCH 2/5] =?UTF-8?q?ci:=20=E8=A7=A6=E5=8F=91=E5=90=8D=E5=8D=95?= =?UTF-8?q?=E5=8A=A0=20ds/**=EF=BC=88=E6=9C=AC=E5=88=86=E6=94=AF=E6=AD=A4?= =?UTF-8?q?=E5=89=8D=E6=B0=B8=E8=BF=9C=E8=B7=91=E4=B8=8D=E5=88=B0=20CI?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 这 19 个 PR 是堆叠的,base 是上一条 ds/* 分支,而 CI 的触发名单只有 [dot-skill-test, dot-skill, main],所以本分支的 push / PR 都不会触发工作流, PR 页面永远显示 no checks reported。这里只改触发条件,不动任何产品代码。 --- .github/workflows/ci.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 876f980b..586fd51f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,10 +1,13 @@ name: CI on: + # The per-feature PRs are a stack whose bases are other `ds/*` branches; with the + # trigger list limited to the integration branches, none of them could ever run CI. push: - branches: [dot-skill-test, dot-skill, main] + branches: [dot-skill-test, dot-skill, main, 'ds/**'] pull_request: - branches: [dot-skill-test, dot-skill, main] + branches: [dot-skill-test, dot-skill, main, 'ds/**'] + workflow_dispatch: jobs: test: From c3881c3e2102d63287ead35121bb719a023d50d3 Mon Sep 17 00:00:00 2001 From: zhoutianyi Date: Tue, 15 Sep 2026 19:05:44 +0800 Subject: [PATCH 3/5] =?UTF-8?q?fix(ci):=20=E4=B8=A4=E4=B8=AA=E8=AE=A9=20CI?= =?UTF-8?q?=20=E6=AF=8F=E4=B8=80=E6=AC=A1=E9=83=BD=E7=BA=A2=E7=9A=84?= =?UTF-8?q?=E9=97=A8=E7=A6=81=E7=BC=BA=E9=99=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI 从建立起**没有一次是绿的**(30 次运行全部 failure),原因有两个,都不是产品 代码的问题,而是门禁自己坏了: 一、`npm test` 在 Node 20 上根本跑不起来 "test": "node --test \"tests/*.test.mjs\"" 引号挡住了 shell 的 glob 展开,而 Node 20 的 `--test` 只接受字面路径(不支持 glob),于是 Node 20 那条腿报 `Could not find '…/tests/*.test.mjs'` 直接退出; Node 22 支持 glob,所以本机永远是绿的。矩阵里同时有 20 和 22,CI 必红。 更要命的是有一条测试**把这个坏字符串钉死了**: `assert.equal(manifest.scripts.test, 'node --test "tests/*.test.mjs"')`。 断言一条命令的**文本**,不等于断言它能跑 —— 这正是它没拦住的原因。 改成 `node --test tests/*.test.mjs`(不加引号):shell 先展开,Node 20 拿到真实 路径;Node 22 拿到同样的路径也认。(试过 `node --test tests/`:Node 20 认目录, Node 22 把位置参数当 glob、拿目录当模块,报 `Cannot find module '…/tests'`。) 新测试改为断言:命令不是裸 `node --test`、参数里没有引号、**每个参数都能对上 真实存在的文件**。 二、目标审计在 CI 里直接崩,而不是报告 推送行的 `rev-parse --verify --quiet` 包在会抛异常的 `git()` 里:ref 不存在时 git 以 1 退出且无输出,于是这一行 —— 本该报告"这个分支不在任何远端上"的那一行 —— 把整个审计抛崩(CI 日志里 stdout/stderr 全空)。CI 的 checkout 本来就没有 `origin/dot-skill-test` 跟踪 ref,所以每次必崩。 改动:ref 探测用不抛异常的 `gitRef()`;CI 环境里这一行记为**缺口**并说明理由 (CI 的源码本来就来自远端,这条要求在这里无法验证),而不是假装验证过;本地 仍严格,且把"工作树干净"这句标题真正纳入判定(此前标题这么写,检查只看 ahead,脏工作树照样绿)。 三、验收红行不再空着理由 visual-check 缺少 playwright 的提示打在 **stderr**,而这一行只引用 stdout, 于是失败信息是空的。现在缺 playwright 时单独报一行并给出该设的变量。 本地核对:Node 20 与 22 都收集 383 项。 (cherry picked from commit 2c90893274a934d804e94e2a2329bab04eb35368) --- package.json | 2 +- scripts/acceptance.mjs | 9 +++++- scripts/audit-objective.mjs | 53 ++++++++++++++++++++++++------- tests/package-payload.test.mjs | 57 ++++++++++++++++++++++++++++------ 4 files changed, 98 insertions(+), 23 deletions(-) diff --git a/package.json b/package.json index 81fefabe..fc2c7a26 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "CITATION.cff" ], "scripts": { - "test": "node --test \"tests/*.test.mjs\"", + "test": "node --test tests/*.test.mjs", "prepack": "node bin/distilly.mjs --check-package" }, "keywords": [ diff --git a/scripts/acceptance.mjs b/scripts/acceptance.mjs index 84ef7b08..fddeeaf8 100644 --- a/scripts/acceptance.mjs +++ b/scripts/acceptance.mjs @@ -183,10 +183,17 @@ try { // 4. visual-check(八项) const vcScript = path.join(root, 'scripts/visual-check.mjs'); const vc = spawnSync('node', [vcScript, htmlPath, '--out', evidenceDir], { cwd: workdir, encoding: 'utf8' }); + // The hint that playwright is missing goes to **stderr**, so a row that quoted + // only stdout went red with an empty reason — a gate nobody can act on. + const vcDetail = + (vc.stdout ?? '').trim().split('\n').slice(-3).join(' / ') || + (vc.stderr ?? '').trim().split('\n').slice(-2).join(' / '); if (/Cannot find module|ENOENT/.test(vc.stderr ?? '')) { record('visual-check 可用', false, 'scripts/visual-check.mjs 尚不存在(ds/03-render 的产出)'); + } else if (/DISTILLY_PLAYWRIGHT_ROOT/.test(vc.stderr ?? '')) { + record('visual-check 可用', false, '未提供 playwright:设 DISTILLY_PLAYWRIGHT_ROOT=<含 node_modules 的目录>'); } else { - record('visual-check 八项通过', vc.status === 0, (vc.stdout ?? '').trim().split('\n').slice(-3).join(' / ')); + record('visual-check 八项通过', vc.status === 0, vcDetail); } } catch (error) { record('验收流程未中断', false, String(error.message).split('\n')[0]); diff --git a/scripts/audit-objective.mjs b/scripts/audit-objective.mjs index 5c186a7e..4baa0933 100644 --- a/scripts/audit-objective.mjs +++ b/scripts/audit-objective.mjs @@ -33,6 +33,22 @@ const record = (demand, ok, evidence, { gap = false } = {}) => { }; const git = (...args) => execFileSync("git", args, { cwd: root, encoding: "utf8" }).trim(); +/** + * `git rev-parse --verify --quiet ` → the ref's sha, or `null` when the ref + * does not exist. + * + * `git()` cannot be used for an existence probe: it throws on a non-zero exit, and + * a missing ref exits 1 with empty output. That turned the push row — the row whose + * whole job is to report "this branch is on no remote" — into a crash of the entire + * audit inside CI, where the checkout has no `origin/dot-skill-test` tracking ref. + */ +const gitRef = (ref) => { + try { + return execFileSync("git", ["rev-parse", "--verify", "--quiet", ref], { cwd: root, encoding: "utf8" }).trim(); + } catch { + return null; + } +}; /** 1. Node single stack: no Python left anywhere in the tree. */ { @@ -219,10 +235,16 @@ const git = (...args) => execFileSync("git", args, { cwd: root, encoding: "utf8" if (skipAcceptance) { record("端到端验收(本审计已跳过)", true, "--skip-acceptance was passed", { gap: true }); } else { + // `DISTILLY_PLAYWRIGHT_ROOT` is **passed through, never invented**. It used to + // default to `/tmp/audit-mcp`, which made this audit's result depend on a + // directory in a volatile temp path: on any other machine — or on this one after + // a reboot — the end-to-end row went red for a reason nothing stated. A caller + // that has playwright sets the variable (see docs/evidence/pr-03-render.md); a + // caller that does not gets a named, actionable failure from acceptance itself. const output = execFileSync(process.execPath, [join(root, "scripts", "acceptance.mjs")], { cwd: root, encoding: "utf8", - env: { ...process.env, DISTILLY_PLAYWRIGHT_ROOT: process.env.DISTILLY_PLAYWRIGHT_ROOT ?? "/tmp/audit-mcp" }, + env: { ...process.env }, }); const summary = output.trim().split("\n").pop() ?? ""; const match = /(\d+)\/(\d+)\s*通过/.exec(summary); @@ -236,22 +258,31 @@ if (skipAcceptance) { // `upstream`. Resolve whichever remote actually has the branch, and say so when // none does, instead of throwing on a hardcoded name. const remotes = git("remote").split("\n").map((line) => line.trim()).filter(Boolean); - const tracking = remotes - .map((remote) => `${remote}/dot-skill-test`) - .find((ref) => git("rev-parse", "--verify", "--quiet", ref) !== ""); + const tracking = remotes.map((remote) => `${remote}/dot-skill-test`).find((ref) => gitRef(ref) !== null); const ahead = tracking === undefined ? null : Number(git("rev-list", "--count", `${tracking}..HEAD`)); - const dirty = git("status", "--porcelain"); + const dirty = git("status", "--porcelain").split("\n").filter(Boolean); // "Pushed" is the thing the user asked for (an off-site copy). A PR is a // separate, still-unrequested step, so it is reported rather than required. - const pushed = ahead === 0; + // A dirty tree is counted as **the same risk** as an unpushed commit — the title + // has always claimed it, and uncommitted work is the more volatile of the two — + // so the check now enforces what the title says instead of merely printing it. + const pushed = ahead === 0 && dirty.length === 0; + + // In CI the demand cannot apply: the checkout *came from* the remote, and there + // is no tracking ref for the branch to measure against. Recording that as a gap + // keeps it visible; pretending to have verified it would be the silent pass this + // audit exists to prevent. + const inCi = process.env.GITHUB_ACTIONS === "true"; record( "推送:集成分支已推送到远端(异地备份),工作树干净", - pushed, - tracking === undefined - ? "no remote carries dot-skill-test; every commit exists only on this machine" - : `${tracking}: ${ahead} commit(s) ahead; working tree ${dirty === "" ? "clean" : "dirty"}; PR bodies staged in dst-evidence/PR-BODIES/`, - { gap: !pushed }, + inCi ? true : pushed, + inCi + ? `CI checkout: ${remotes.length} remote(s) configured, no local record of the branch, so "off-site" is this run's own source — not verified here` + : tracking === undefined + ? "no remote carries dot-skill-test; every commit exists only on this machine" + : `${tracking}: ${ahead} commit(s) ahead; working tree ${dirty.length === 0 ? "clean" : `${dirty.length} path(s) dirty`}; PR bodies staged in dst-evidence/PR-BODIES/`, + { gap: inCi || !pushed }, ); } diff --git a/tests/package-payload.test.mjs b/tests/package-payload.test.mjs index e42a7e31..64bfbf5f 100644 --- a/tests/package-payload.test.mjs +++ b/tests/package-payload.test.mjs @@ -17,7 +17,7 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { fileURLToPath } from "node:url"; import path from "node:path"; @@ -27,6 +27,25 @@ import { payloadEntries, validatePayload } from "../bin/distilly.mjs"; const root = path.dirname(path.dirname(fileURLToPath(import.meta.url))); const manifest = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")); +/** + * The files one `npm test` argument names, resolving a single `*` against the tree. + * + * Deliberately not a general globber: what the command must look like is asserted + * separately, and a hand-rolled full glob would only be a second thing to keep + * correct. + */ +function namedFiles(arg) { + if (!path.basename(arg).includes("*")) return existsSync(path.join(root, arg)) ? [arg] : []; + const pattern = new RegExp( + `^${path + .basename(arg) + .split("*") + .map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) + .join(".*")}$`, + ); + return readdirSync(path.join(root, path.dirname(arg))).filter((name) => pattern.test(name)); +} + /** * A root that really has every `payloadEntries` path (symlinked, so the check * sees them) but the candidate `files` list under test. @@ -114,18 +133,36 @@ test("these fixtures never write through to the repository manifest", () => { assert.equal(readFileSync(manifestPath, "utf8"), before, "the repository manifest must be untouched"); }); -test("`npm test` and CI run the same command, and it names the tests directory", () => { +test("`npm test` and CI run the same command, and every path it names exists", () => { // A bare `node --test` also collects `scripts/blind-test.mjs` (`**/*-test.mjs`) - // and records its usage error as a failing test — which is how CI went red - // while every local command looked green. + // and records it as a passing "test" — which is how CI went red while every local + // command looked green. + // + // This used to assert the command's exact text, `node --test "tests/*.test.mjs"`. + // That froze a command which **does not run on the oldest Node the package claims + // to support**: the quotes stop the shell expanding the glob, and Node 20's + // `--test` takes literal paths only, so `npm test` died with `Could not find + // '…/tests/*.test.mjs'` on the Node 20 leg of the matrix while passing on Node 22. + // + // The unquoted glob is the only form both legs accept: the shell expands it, so + // Node 20 receives real paths (as it requires), while Node 22 — which treats a + // positional argument as a glob of its own and refuses a bare directory with + // `Cannot find module '…/tests'` — receives paths it can also handle. const ci = readFileSync(path.join(root, ".github", "workflows", "ci.yml"), "utf8"); assert.match(ci, /^\s*run: npm test\s*$/m, "CI must invoke `npm test`"); - assert.equal(manifest.scripts.test, 'node --test "tests/*.test.mjs"'); - assert.equal( - /\bnode --test\s*$/.test(manifest.scripts.test), - false, - "`npm test` must not be a bare `node --test`: it would collect scripts/ as tests", - ); + + const argv = manifest.scripts.test.split(/\s+/); + assert.equal(argv[0], "node"); + assert.equal(argv[1], "--test"); + assert.ok(argv.length > 2, "`npm test` must not be a bare `node --test`: it would collect scripts/ as tests"); + for (const arg of argv.slice(2)) { + assert.equal( + /["']/.test(arg), + false, + `no quoting in \`npm test\`: a quoted glob reaches Node 20 unexpanded, and Node 20 has no glob support (got ${arg})`, + ); + assert.ok(namedFiles(arg).length > 0, `${arg} must name at least one file that exists`); + } }); test("`engines` matches what CI actually tests", () => { From 09127686a1bcb7de48a1eeb4394cf9fbe8edc9d0 Mon Sep 17 00:00:00 2001 From: zhoutianyi Date: Tue, 15 Sep 2026 19:09:44 +0800 Subject: [PATCH 4/5] =?UTF-8?q?fix(tests):=20=E6=B5=8B=E8=AF=95=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E8=AF=BB=E5=BC=80=E5=8F=91=E6=9C=BA=E7=9C=9F=E5=AE=9E?= =?UTF-8?q?=20home=20=E9=87=8C=E7=9A=84=E5=87=AD=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI 终于能跑测试之后(前一个提交修好了 npm test),Node 20 与 22 两条腿都稳定 红在同一条上: not ok 123 - cli: --mode mcp routes to the MCP client and needs a target 本机复现的办法很直接 —— 把 `~/.colleague-skill/` 移走,这条立刻变红。原来 `credentialPaths()` 除了 `DISTILLY_HOME` 还会回落到 `~/.colleague-skill/`, 而 feishu-mcp 的两条 CLI 路由测试调 `runCollectCli(..., { transport })` 时不传 env,于是 `loadCredential` 拿的是 `process.env` —— **我笔记本上那份改名前的旧 凭据**。CI 上没有那个文件,所以必红。同一个文件里"缺凭据要响亮失败"的那条测试 早就为此把 HOME 指到空目录并写了注释,这两条却漏了。 改动: 1. `tests/feishu-mcp.test.mjs` 的 sandbox 现在把凭据写进自己的 home,并带一份 `{ DISTILLY_HOME, HOME }`;两条路由测试显式传它。移走真实旧凭据后 8/8 通过。 2. `src/collect/slack.mjs` 的 `credentialPaths()` 改为 `env.HOME ?? homedir()`, 与 `kit.mjs` 一致:旧路径回落必须跟着被覆盖的 HOME 走。此前它无视 `env.HOME`, 一次"隔离"的运行照样能读到真实 home 的凭据,两个渠道对"刚读的是哪个文件" 也会给出不同答案。 3. `tests/collect.test.mjs` 里两条只会打印 `false !== true` 的断言补上现场信息 (收据、stderr、work 目录清单)—— 这条 CI-only 失败此前从日志里根本 看不出是"没写文件"还是"写到别处去了"。 全量:无真实旧凭据时 383/385(另 2 条是本机未提交/未推送时的审计行)。 (cherry picked from commit 2551c9b6d9793188a59dd5b13c888a610c4bd686) --- src/collect/slack.mjs | 8 +++++++- tests/collect.test.mjs | 30 ++++++++++++++++++++++++++++-- tests/feishu-mcp.test.mjs | 23 +++++++++++++++++++++-- 3 files changed, 56 insertions(+), 5 deletions(-) diff --git a/src/collect/slack.mjs b/src/collect/slack.mjs index 51515dfd..9dd35cb8 100644 --- a/src/collect/slack.mjs +++ b/src/collect/slack.mjs @@ -88,9 +88,15 @@ export function distillyHome(env = process.env) { } export function credentialPaths(env = process.env) { + // `env.HOME ?? homedir()`, matching `kit.mjs`: the pre-rename + // `~/.colleague-skill/` fallback has to move when a caller points `HOME` + // somewhere else. Reading the real home here ignored `env.HOME` — so an isolated + // run (a test, a sandboxed collect) could still pick up the credential sitting in + // the developer's own home, and the two channels disagreed about which file they + // had just read. return { primary: join(distillyHome(env), CONFIG_FILE), - legacy: join(homedir(), LEGACY_CONFIG_FILE), + legacy: join(env?.HOME ?? homedir(), LEGACY_CONFIG_FILE), }; } diff --git a/tests/collect.test.mjs b/tests/collect.test.mjs index 1974b3cb..c8a5b1bf 100644 --- a/tests/collect.test.mjs +++ b/tests/collect.test.mjs @@ -86,6 +86,26 @@ function sandbox(name = "case") { }; } +/** + * Every file written under `dir`, relative and sorted — for failure messages. + * + * A failing "the page was not written" assertion used to say only `false !== true`, + * which is unactionable from a CI log: it cannot distinguish "nothing was written" + * from "something was written somewhere else". + */ +function written(dir) { + const out = []; + const walk = (current, prefix) => { + for (const entry of readdirSync(current, { withFileTypes: true })) { + const rel = prefix ? `${prefix}/${entry.name}` : entry.name; + if (entry.isDirectory()) walk(join(current, entry.name), rel); + else out.push(rel); + } + }; + if (existsSync(dir)) walk(dir, ""); + return out.length === 0 ? "(nothing)" : out.sort().join(", "); +} + function json(body, { status = 200, headers = {} } = {}) { return new Response(JSON.stringify(body), { status, headers }); } @@ -548,10 +568,16 @@ test("CLI success path: a fake key never reaches stdout, stderr or the receipt", assert.ok(!run.stdout.includes(SECRET.slack), "stdout leaked the credential"); assert.ok(!run.stderr.includes(SECRET.slack), "stderr leaked the credential"); const receipt = receiptOf(run.stdout); - assert.equal(receipt.ok, true); + // The messages name what was seen: a bare `false !== true` on either of these made + // a CI-only failure impossible to diagnose from the log alone. + assert.equal(receipt.ok, true, `receipt was not ok: ${JSON.stringify(receipt)}`); assert.equal(receipt.credential_file, "slack_config.json"); assert.ok(!JSON.stringify(receipt).includes(SECRET.slack)); - assert.equal(box.exists("knowledge/raw/slack/C0123-p001.json"), true); + assert.equal( + box.exists("knowledge/raw/slack/C0123-p001.json"), + true, + `the page was not written; receipt=${JSON.stringify(receipt)} stderr=${run.stderr} tree=${written(box.work)}`, + ); }); test("CLI failure path: a rejected key leaks nothing and names where to reconfigure", () => { diff --git a/tests/feishu-mcp.test.mjs b/tests/feishu-mcp.test.mjs index eff6c07e..ea4f54db 100644 --- a/tests/feishu-mcp.test.mjs +++ b/tests/feishu-mcp.test.mjs @@ -36,7 +36,22 @@ function sandbox() { const root = mkdtempSync(join(tmpdir(), "dst-mcp-")); const home = join(root, "distilly"); mkdirSync(home, { recursive: true }); - return { root, home, work: join(root, "work"), config: { app_id: "cli_x", app_secret: SECRET } }; + const config = { app_id: "cli_x", app_secret: SECRET }; + // An **isolated home with the credential written into it**, plus the env that + // points at it. Without this the CLI-routing tests below read the developer's + // real home: `credentialPaths` falls back to `~/.colleague-skill/`, this + // machine still has that pre-rename directory, and the run found a credential + // there. Locally green, and red in CI, where the file does not exist — the exact + // "a test that depends on whose laptop it runs on" failure this file already + // warned about in its missing-credential test. + writeFileSync(join(home, "feishu_config.json"), JSON.stringify(config, null, 2)); + return { + root, + home, + work: join(root, "work"), + config, + env: { DISTILLY_HOME: home, HOME: home }, + }; } /** An MCP transport that answers from a script and records every call. */ @@ -200,13 +215,17 @@ test("cli: --mode mcp routes to the MCP client and needs a target", async () => const box = sandbox(); const transport = fakeTransport({ result: [{ type: "text", text: JSON.stringify({ items: textMessages(3) }) }] }); - const result = await runCollectCli(["--mode", "mcp", "--chat-id", "oc_demo", "--person", "demo", "--base-dir", box.work, "--json"], { transport }); + const result = await runCollectCli(["--mode", "mcp", "--chat-id", "oc_demo", "--person", "demo", "--base-dir", box.work, "--json"], { + transport, + env: box.env, + }); assert.equal(result.ok, true, JSON.stringify(result.receipt)); assert.equal(transport.calls.length, 1); assert.deepEqual(transport.calls[0].args, { chat_id: "oc_demo", page_size: 50 }); const lines = []; const human = await runCollectCli(["--mode", "mcp", "--chat-id", "oc_demo", "--person", "demo", "--base-dir", box.work], { transport, + env: box.env, stdout: (line) => lines.push(line), stderr: () => {}, }); From 446cc6640a21ee56acf676cf83e81853fa1c66d0 Mon Sep 17 00:00:00 2001 From: zhoutianyi Date: Tue, 15 Sep 2026 19:11:05 +0800 Subject: [PATCH 5/5] =?UTF-8?q?fix(tests):=20=E4=B8=80=E6=9D=A1=E5=8F=AA?= =?UTF-8?q?=E5=9C=A8=E5=A4=A7=E5=B0=8F=E5=86=99=E4=B8=8D=E6=95=8F=E6=84=9F?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E7=B3=BB=E7=BB=9F=E4=B8=8A=E9=80=9A=E8=BF=87?= =?UTF-8?q?=E7=9A=84=E8=B7=AF=E5=BE=84=E6=96=AD=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI 现在给得出失败现场了,于是这条一看就清楚: the page was not written; receipt={… "path":"…/knowledge/raw/slack/c0123-p001.json" …} tree=knowledge/index.json, knowledge/raw/slack/c0123-p001.json 文件就在那里,只是名字是小写。`writeRaw` 会把名字过一遍 `slug()`,所以频道 `C0123` 落盘成 `c0123-p001.json`(收据里的 `channel_id` 仍然是 `C0123`)。断言 写的是 `C0123-p001.json`,在 macOS 上 APFS 大小写不敏感所以"通过",在 Linux runner 上就红了 —— 又是一条"取决于在哪台机器上跑"的测试。 断言改成真实文件名,并在注释里写明为什么是小写,免得下次又被"修"回去。 (cherry picked from commit a467934e788053f72f2d8cd5f4c14bcd1520d9d6) --- tests/collect.test.mjs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/collect.test.mjs b/tests/collect.test.mjs index c8a5b1bf..7413ab61 100644 --- a/tests/collect.test.mjs +++ b/tests/collect.test.mjs @@ -574,8 +574,13 @@ test("CLI success path: a fake key never reaches stdout, stderr or the receipt", assert.equal(receipt.credential_file, "slack_config.json"); assert.ok(!JSON.stringify(receipt).includes(SECRET.slack)); assert.equal( - box.exists("knowledge/raw/slack/C0123-p001.json"), + box.exists("knowledge/raw/slack/c0123-p001.json"), true, + // The channel id is `C0123` and the **file name is slugged to `c0123`** + // (`writeRaw` runs the name through `slug()`), while the receipt keeps + // `channel_id: "C0123"`. This assertion used to read `C0123-p001.json` and + // passed on macOS only because APFS compares names case-insensitively; on the + // Linux runner it failed while the file sat right there in the listing. `the page was not written; receipt=${JSON.stringify(receipt)} stderr=${run.stderr} tree=${written(box.work)}`, ); });