Files
Jyotisha/frontend/tests/skill-registry.test.ts
T
Jesse_ChenandClaude Opus 5.5 8fc1a5bded fix(rectification): plain-word opening with examples in the stem, plain de-duplicated step receipt (BUG-1049, BUG-1050)
- BUG-1049 (recurrence of BUG-504): the opening stem carries six examples and
  an example answer again and is server-owned on the zero-evidence opening;
  the body is two plain sentences (no 大运/盘面/代表分钟/精确到秒, no year, no
  question). Stem de-dup compares whole sentences / near-equality instead of a
  12-char prefix, which had deleted the body's examples sentence since
  aa7ccb30 (BUG-604) + dd8f35f7 (BUG-648).
- BUG-1050: plain step labels; a finished step label shows once and
  「已完成 N 步」counts shown rows; failed rows read 「…未完成」 from the
  in-progress wording.
- Skill 10.0.30 -> 10.0.31 (OpeningPolicy); 10.0.30 kept as deprecated.
- VOICE / DESIGN / CHANGELOG / BUG_HISTORY / PROGRESS / real-device checklist
  and screenshots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
2026-09-26 22:04:56 +08:00

636 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import {
mkdirSync,
mkdtempSync,
readFileSync,
realpathSync,
rmSync,
symlinkSync,
writeFileSync,
existsSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { test, type TestContext } from "node:test";
import {
computeSkillPackageSha256,
createSkillPackageRegistry,
resolveActiveSkillPackage,
resolveExactSkillPackage,
resolveLiveJyotishSkill,
resolveLiveJyotishSkillRuntimePath,
resolveSkillPackageVersion,
verifyAllActiveSkillPackages,
type SkillPackageIdentity,
} from "../src/lib/skill-package-registry.ts";
const sourceCommit = "0fd111d16b45796086a6c1d0945dbd3de6755d8a";
const projectRoot = fileURLToPath(new URL("../../", import.meta.url));
type RegistryEntry = SkillPackageIdentity | Record<string, unknown>;
function sha256(bytes: string | Buffer): string {
return createHash("sha256").update(bytes).digest("hex");
}
function fixture(t: TestContext): {
root: string;
registryPath: string;
writeSkill: (packagePath: string, contents: string) => string;
writeRegistry: (packages: RegistryEntry[]) => void;
} {
const root = mkdtempSync(join(tmpdir(), "skill-registry-test-"));
const registryPath = join(root, "skills", "skill-package-registry.json");
mkdirSync(dirname(registryPath), { recursive: true });
t.after(() => rmSync(root, { recursive: true, force: true }));
return {
root,
registryPath,
writeSkill(packagePath, contents) {
const directory = join(root, packagePath);
mkdirSync(directory, { recursive: true });
writeFileSync(join(directory, "SKILL.md"), contents);
return computeSkillPackageSha256(directory);
},
writeRegistry(packages) {
writeFileSync(
registryPath,
`${JSON.stringify({ schemaVersion: 1, packages }, null, 2)}\n`,
);
},
};
}
function identity(
overrides: Partial<SkillPackageIdentity> = {},
): SkillPackageIdentity {
return {
name: "example-skill",
version: "1.0.0",
sha256: "a".repeat(64),
sourceCommit,
packagePath: "skills/example-skill-1.0.0",
status: "active",
...overrides,
};
}
test("checked-in registry verifies hashed product packages and leaves consult on the live skill", () => {
const packages = verifyAllActiveSkillPackages({ projectRoot });
assert.deepEqual(
packages.map(({ name, version, sha256 }) => ({ name, version, sha256 })),
[
{
name: "jyotish-birth-time-rectification",
// 原值: 10.0.30 / ade7b748…102c
// 新值: 10.0.31 / 51a91251…13c1
// 原因: 开场改大白话、题干带例子和示例回答写进 Skill OpeningPolicy(BUG-1049,2026-09-26)
version: "10.0.31",
sha256: "51a9125192c4e8693ba4786cb4ec79c981ec8ad98248c2fae5631e66d8e913c1",
},
{
name: "jyotish-personal-report",
// was 1.0.0 / 23149b9e...982; bumped 2026-09-01 with the interpretive
// fact layer and the static interpretation packs.
version: "1.1.0",
sha256: "6be2279b69b7da9446adeb508e27746c5aeb8b00dc9f5921f48f82bd8a8010e9",
},
],
);
for (const resolvedPackage of packages) {
assert.equal(isAbsolute(resolvedPackage.resolvedPath), true);
assert.equal(
resolvedPackage.resolvedPath,
resolve(projectRoot, resolvedPackage.packagePath),
);
}
assert.equal(packages[0]?.sourceCommit, null);
assert.equal(packages[1]?.sourceCommit, null);
assert.throws(
() => resolveActiveSkillPackage("jyotish-vedic-astrology", { projectRoot }),
/No active skill package found/,
);
const live = resolveLiveJyotishSkill({ projectRoot });
assert.equal(live.name, "jyotish-vedic-astrology");
assert.equal(basename(live.resolvedPath), "jyotish-vedic-astrology");
assert.equal(live.resolvedPath, resolve(projectRoot, "skills/jyotish-vedic-astrology"));
const liveRuntime = resolveLiveJyotishSkillRuntimePath(live);
assert.equal(basename(liveRuntime), live.name);
assert.equal(existsSync(join(liveRuntime, "versions")), false);
assert.equal(
realpathSync(join(liveRuntime, "SKILL.md")),
realpathSync(join(live.resolvedPath, "SKILL.md")),
);
const deprecatedRectification = resolveExactSkillPackage(
"jyotish-birth-time-rectification",
"10.0.0",
"b66f243d266e12527b6934cc0c5925df654a9e7bcbaabea3bf244b2e1dfaf2e7",
{ projectRoot },
);
assert.equal(deprecatedRectification.status, "deprecated");
assert.equal(
deprecatedRectification.sha256,
"b66f243d266e12527b6934cc0c5925df654a9e7bcbaabea3bf244b2e1dfaf2e7",
);
const deprecated1002 = resolveExactSkillPackage(
"jyotish-birth-time-rectification",
"10.0.2",
"8d7aa2d4bea0414e9a89ef908ccbc8c708c98f79f5b78ae4f7dc229b5f7dbb30",
{ projectRoot },
);
assert.equal(deprecated1002.status, "deprecated");
assert.equal(
deprecated1002.sha256,
"8d7aa2d4bea0414e9a89ef908ccbc8c708c98f79f5b78ae4f7dc229b5f7dbb30",
);
const deprecated1011 = resolveExactSkillPackage(
"jyotish-birth-time-rectification",
"10.0.11",
"66a3282be7b227bb6f77038455b8bc17ddaadaf0279a8b9a94284e9d186975c9",
{ projectRoot },
);
assert.equal(deprecated1011.status, "deprecated");
const deprecated1017 = resolveExactSkillPackage(
"jyotish-birth-time-rectification",
"10.0.17",
"f77b06de392feaa7c23e64beb05a90bcc0734db73747eb7915cba338b8eff1e6",
{ projectRoot },
);
assert.equal(deprecated1017.status, "deprecated");
assert.equal(
deprecated1017.sha256,
"f77b06de392feaa7c23e64beb05a90bcc0734db73747eb7915cba338b8eff1e6",
);
const deprecated1021 = resolveExactSkillPackage(
"jyotish-birth-time-rectification",
"10.0.21",
"3ced107366b2c4b0f26bae81440162032501f77a8d762d42c84a20fa394ea807",
{ projectRoot },
);
assert.equal(deprecated1021.status, "deprecated");
assert.equal(
deprecated1021.sha256,
"3ced107366b2c4b0f26bae81440162032501f77a8d762d42c84a20fa394ea807",
);
const deprecated1022 = resolveExactSkillPackage(
"jyotish-birth-time-rectification",
"10.0.22",
"e3765b20ce6b49a5c7c9ed6b0a8caacc6fc4b8a16a127b93bd92c537330a54e2",
{ projectRoot },
);
assert.equal(deprecated1022.status, "deprecated");
assert.equal(
deprecated1022.sha256,
"e3765b20ce6b49a5c7c9ed6b0a8caacc6fc4b8a16a127b93bd92c537330a54e2",
);
const deprecated1023 = resolveExactSkillPackage(
"jyotish-birth-time-rectification",
"10.0.23",
"91f5839e15514b80fa0370bb8b539686af211082e83bc951dc0e8250cbdbadcb",
{ projectRoot },
);
assert.equal(deprecated1023.status, "deprecated");
assert.equal(
deprecated1023.sha256,
"91f5839e15514b80fa0370bb8b539686af211082e83bc951dc0e8250cbdbadcb",
);
const deprecated1024 = resolveExactSkillPackage(
"jyotish-birth-time-rectification",
"10.0.24",
"f4267cb93b459a84ec0006b51501b174d5bbc809b571168307affbaede22e4e7",
{ projectRoot },
);
assert.equal(deprecated1024.status, "deprecated");
assert.equal(
deprecated1024.sha256,
"f4267cb93b459a84ec0006b51501b174d5bbc809b571168307affbaede22e4e7",
);
const deprecated1025 = resolveExactSkillPackage(
"jyotish-birth-time-rectification",
"10.0.25",
"263ab00d5a62ee38dde1e13474b50a5e194e412b742d1693acaa9974db9f02e8",
{ projectRoot },
);
assert.equal(deprecated1025.status, "deprecated");
assert.equal(
deprecated1025.sha256,
"263ab00d5a62ee38dde1e13474b50a5e194e412b742d1693acaa9974db9f02e8",
);
const deprecated1026 = resolveExactSkillPackage(
"jyotish-birth-time-rectification",
"10.0.26",
"bb9cf1623db43980568738f5c011d5aae5b1aa4a94c267fad0a85ca276a5ff39",
{ projectRoot },
);
assert.equal(deprecated1026.status, "deprecated");
assert.equal(
deprecated1026.sha256,
"bb9cf1623db43980568738f5c011d5aae5b1aa4a94c267fad0a85ca276a5ff39",
);
});
test("exact resolution of an old deprecated package is independent of active switches", (t) => {
const f = fixture(t);
const oldHash = f.writeSkill("skills/example-1.0.0", "old immutable bytes\n");
const currentHash = f.writeSkill(
"skills/example-2.0.0",
"current immutable bytes\n",
);
const nextHash = f.writeSkill("skills/example-3.0.0", "next immutable bytes\n");
const oldIdentity = identity({
version: "1.0.0",
sha256: oldHash,
packagePath: "skills/example-1.0.0",
status: "deprecated",
});
const currentIdentity = identity({
version: "2.0.0",
sha256: currentHash,
packagePath: "skills/example-2.0.0",
});
const nextIdentity = identity({
version: "3.0.0",
sha256: nextHash,
packagePath: "skills/example-3.0.0",
status: "deprecated",
});
f.writeRegistry([oldIdentity, currentIdentity, nextIdentity]);
const registry = createSkillPackageRegistry({
projectRoot: f.root,
registryPath: f.registryPath,
});
assert.equal(registry.resolveActive("example-skill").version, "2.0.0");
assert.deepEqual(registry.resolveExact("example-skill", "1.0.0", oldHash), {
...oldIdentity,
resolvedPath: realpathSync(join(f.root, "skills/example-1.0.0")),
});
f.writeRegistry([
oldIdentity,
{ ...currentIdentity, status: "deprecated" },
{ ...nextIdentity, status: "active" },
]);
assert.equal(registry.resolveActive("example-skill").version, "3.0.0");
assert.equal(
registry.resolveExact("example-skill", "1.0.0", oldHash).sha256,
oldHash,
);
});
test("version resolution requires one non-blocked identity and verifies its bytes", (t) => {
const f = fixture(t);
const oldHash = f.writeSkill("skills/example-old", "old version\n");
const oldIdentity = identity({
sha256: oldHash,
packagePath: "skills/example-old",
status: "deprecated",
});
f.writeRegistry([oldIdentity]);
const registry = createSkillPackageRegistry({
projectRoot: f.root,
registryPath: f.registryPath,
});
assert.throws(() => registry.resolveActive("example-skill"), /No active/);
assert.deepEqual(
resolveSkillPackageVersion("example-skill", "1.0.0", {
projectRoot: f.root,
registryPath: f.registryPath,
}),
{
...oldIdentity,
resolvedPath: realpathSync(join(f.root, "skills/example-old")),
},
);
const alternateHash = f.writeSkill(
"skills/example-old-repacked",
"different bytes\n",
);
f.writeRegistry([
oldIdentity,
identity({
sha256: alternateHash,
packagePath: "skills/example-old-repacked",
status: "deprecated",
}),
]);
assert.throws(
() =>
resolveSkillPackageVersion("example-skill", "1.0.0", {
projectRoot: f.root,
registryPath: f.registryPath,
}),
/Expected one registry identity/,
);
f.writeRegistry([{ ...oldIdentity, status: "blocked" }]);
assert.throws(
() =>
resolveSkillPackageVersion("example-skill", "1.0.0", {
projectRoot: f.root,
registryPath: f.registryPath,
}),
/blocked/,
);
});
test("missing lookup identities fail closed", (t) => {
const f = fixture(t);
f.writeRegistry([]);
const options = { projectRoot: f.root, registryPath: f.registryPath };
const registry = createSkillPackageRegistry(options);
assert.throws(() => registry.resolveActive("example-skill"), /No active/);
assert.throws(
() => registry.resolveExact("example-skill", "1.0.0", "a".repeat(64)),
/No exact skill package/,
);
assert.throws(
() => resolveSkillPackageVersion("example-skill", "1.0.0", options),
/found 0/,
);
});
test("missing identity fields fail closed", (t) => {
const f = fixture(t);
f.writeSkill("skills/example-skill-1.0.0", "bytes\n");
const missingHash: Record<string, unknown> = { ...identity() };
delete missingHash.sha256;
f.writeRegistry([missingHash]);
assert.throws(
() =>
createSkillPackageRegistry({
projectRoot: f.root,
registryPath: f.registryPath,
}).resolveActive("example-skill"),
/missing sha256/,
);
});
test("hash mismatch fails closed after reading the complete package tree", (t) => {
const f = fixture(t);
const packagePath = "skills/example-skill-1.0.0";
const expectedHash = f.writeSkill(packagePath, "expected bytes\n");
mkdirSync(join(f.root, packagePath, "references"), { recursive: true });
writeFileSync(
join(f.root, packagePath, "references", "runtime-policy.md"),
"tampered reference bytes\n",
);
f.writeRegistry([identity({ sha256: expectedHash })]);
assert.throws(
() =>
createSkillPackageRegistry({
projectRoot: f.root,
registryPath: f.registryPath,
}).resolveActive("example-skill"),
/SHA-256 mismatch/,
);
});
test("package hash covers paths, executable bits, references, scripts, and assets", (t) => {
const f = fixture(t);
const packageDirectory = join(f.root, "skills", "example-skill-1.0.0");
mkdirSync(join(packageDirectory, "references"), { recursive: true });
mkdirSync(join(packageDirectory, "scripts"), { recursive: true });
mkdirSync(join(packageDirectory, "assets"), { recursive: true });
writeFileSync(join(packageDirectory, "SKILL.md"), "skill bytes\n");
writeFileSync(join(packageDirectory, "references", "guide.md"), "guide v1\n");
writeFileSync(join(packageDirectory, "scripts", "run.py"), "print('v1')\n");
writeFileSync(join(packageDirectory, "assets", "template.txt"), "asset v1\n");
const initial = computeSkillPackageSha256(packageDirectory);
writeFileSync(join(packageDirectory, "references", "guide.md"), "guide v2\n");
const changedReference = computeSkillPackageSha256(packageDirectory);
assert.notEqual(changedReference, initial);
writeFileSync(join(packageDirectory, "references", "guide.md"), "guide v1\n");
writeFileSync(join(packageDirectory, "assets", "template.txt"), "asset v2\n");
assert.notEqual(computeSkillPackageSha256(packageDirectory), initial);
});
test("path traversal and symlink escape fail closed", (t) => {
const f = fixture(t);
f.writeRegistry([
identity({
packagePath: "skills/../outside",
sha256: sha256("outside\n"),
}),
]);
assert.throws(
() =>
createSkillPackageRegistry({
projectRoot: f.root,
registryPath: f.registryPath,
}).resolveActive("example-skill"),
/unsafe packagePath/,
);
const outside = mkdtempSync(join(tmpdir(), "skill-registry-outside-"));
t.after(() => rmSync(outside, { recursive: true, force: true }));
writeFileSync(join(outside, "SKILL.md"), "outside\n");
const packageDirectory = join(f.root, "skills", "symlinked-skill");
mkdirSync(packageDirectory, { recursive: true });
symlinkSync(join(outside, "SKILL.md"), join(packageDirectory, "SKILL.md"));
f.writeRegistry([
identity({
packagePath: "skills/symlinked-skill",
sha256: sha256("outside\n"),
}),
]);
assert.throws(
() =>
createSkillPackageRegistry({
projectRoot: f.root,
registryPath: f.registryPath,
}).resolveActive("example-skill"),
/resolves outside the project root/,
);
});
test("symbolic links are rejected even when their target stays inside the project root", (t) => {
const f = fixture(t);
const packageDirectory = join(f.root, "skills", "symlinked-skill");
const sharedDirectory = join(f.root, "shared");
mkdirSync(packageDirectory, { recursive: true });
mkdirSync(sharedDirectory, { recursive: true });
writeFileSync(join(packageDirectory, "SKILL.md"), "skill bytes\n");
writeFileSync(join(sharedDirectory, "guide.md"), "shared bytes\n");
symlinkSync(sharedDirectory, join(packageDirectory, "references"));
f.writeRegistry([
identity({
packagePath: "skills/symlinked-skill",
sha256: "a".repeat(64),
}),
]);
assert.throws(
() =>
createSkillPackageRegistry({
projectRoot: f.root,
registryPath: f.registryPath,
}).resolveActive("example-skill"),
/must not be a symbolic link/,
);
});
test("blocked packages are rejected by active, exact, and version resolution", (t) => {
const f = fixture(t);
const hash = f.writeSkill("skills/example-skill-1.0.0", "blocked bytes\n");
f.writeRegistry([identity({ sha256: hash, status: "blocked" })]);
const registry = createSkillPackageRegistry({
projectRoot: f.root,
registryPath: f.registryPath,
});
assert.throws(() => registry.resolveActive("example-skill"), /No active/);
assert.throws(
() => registry.resolveExact("example-skill", "1.0.0", hash),
/blocked/,
);
assert.throws(
() => registry.resolveVersion("example-skill", "1.0.0"),
/blocked/,
);
});
test("duplicate active packages fail the entire registry closed", (t) => {
const f = fixture(t);
const firstHash = f.writeSkill("skills/example-1.0.0", "first\n");
const secondHash = f.writeSkill("skills/example-2.0.0", "second\n");
f.writeRegistry([
identity({
sha256: firstHash,
packagePath: "skills/example-1.0.0",
}),
identity({
version: "2.0.0",
sha256: secondHash,
packagePath: "skills/example-2.0.0",
}),
]);
const registry = createSkillPackageRegistry({
projectRoot: f.root,
registryPath: f.registryPath,
});
assert.throws(() => registry.resolveActive("example-skill"), /duplicate active/);
assert.throws(() => registry.verifyAllActive(), /duplicate active/);
});
test("nullable source commits are accepted while malformed non-null commits fail closed", (t) => {
const f = fixture(t);
const hash = f.writeSkill("skills/example-skill-1.0.0", "bytes\n");
f.writeRegistry([identity({ sha256: hash, sourceCommit: null })]);
const resolved = createSkillPackageRegistry({
projectRoot: f.root,
registryPath: f.registryPath,
}).resolveActive("example-skill");
assert.equal(resolved.sourceCommit, null);
f.writeRegistry([identity({ sha256: hash, sourceCommit: "not-a-commit" })]);
assert.throws(
() =>
createSkillPackageRegistry({
projectRoot: f.root,
registryPath: f.registryPath,
}).resolveActive("example-skill"),
/invalid sourceCommit/,
);
});
test("invalid hash, commit, and non-directory package paths fail closed", (t) => {
const f = fixture(t);
f.writeRegistry([identity({ sha256: "A".repeat(64) })]);
assert.throws(
() =>
createSkillPackageRegistry({
projectRoot: f.root,
registryPath: f.registryPath,
}).resolveActive("example-skill"),
/invalid sha256/,
);
f.writeRegistry([identity({ sourceCommit: "not-a-commit" })]);
assert.throws(
() =>
createSkillPackageRegistry({
projectRoot: f.root,
registryPath: f.registryPath,
}).resolveActive("example-skill"),
/invalid sourceCommit/,
);
const filePath = join(f.root, "skills", "not-a-directory");
writeFileSync(filePath, "file\n");
f.writeRegistry([
identity({
packagePath: "skills/not-a-directory",
sha256: sha256("file\n"),
}),
]);
assert.throws(
() =>
createSkillPackageRegistry({
projectRoot: f.root,
registryPath: f.registryPath,
}).resolveActive("example-skill"),
/must be a directory/,
);
});
test("live consult skill reads a hand-updated tree without a registry hash", (t) => {
const f = fixture(t);
const dir = join(f.root, "skills", "jyotish-vedic-astrology");
mkdirSync(dir, { recursive: true });
writeFileSync(
join(dir, "SKILL.md"),
"---\nname: jyotish-vedic-astrology\nversion: 9.9.9\n---\n# live method\n",
);
mkdirSync(join(dir, "references"));
writeFileSync(join(dir, "references", "note.md"), "live reference\n");
mkdirSync(join(dir, "versions", "6.9.14"), { recursive: true });
writeFileSync(join(dir, "versions", "6.9.14", "SKILL.md"), "# stale snapshot\n");
const live = resolveLiveJyotishSkill({ projectRoot: f.root, skillPath: dir });
assert.equal(live.version, "9.9.9");
assert.equal(live.resolvedPath, realpathSync(dir));
const runtime = resolveLiveJyotishSkillRuntimePath(live);
assert.equal(existsSync(join(runtime, "versions")), false);
assert.match(readFileSync(join(runtime, "SKILL.md"), "utf8"), /live method/);
assert.equal(readFileSync(join(runtime, "references", "note.md"), "utf8"), "live reference\n");
});
test("live consult skill rejects a path outside the project", (t) => {
const f = fixture(t);
const dir = join(f.root, "skills", "jyotish-vedic-astrology");
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, "SKILL.md"), "---\nname: jyotish-vedic-astrology\n---\n# live\n");
const outside = mkdtempSync(join(tmpdir(), "jyotish-skill-outside-"));
t.after(() => rmSync(outside, { recursive: true, force: true }));
assert.throws(
() => resolveLiveJyotishSkill({ projectRoot: f.root, skillPath: outside }),
/escapes the project root/,
);
});
test("live consult skill rejects a directory that is not named jyotish-vedic-astrology", (t) => {
const f = fixture(t);
const dir = join(f.root, "skills", "other-skill");
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, "SKILL.md"), "---\nname: jyotish-vedic-astrology\n---\n# live\n");
assert.throws(
() => resolveLiveJyotishSkill({ projectRoot: f.root, skillPath: dir }),
/must be named jyotish-vedic-astrology/,
);
});