Files
Jyotisha/frontend/tests/skill-registry.test.ts
T
Jesse_ChenandClaude Opus 5.5 ea21743b09 fix(rectification): stop spoken collect once the training gate opens (BUG-1084..1087)
Once the discriminator training gate is open, only choice cards are asked
and the range card goes out when they are exhausted; targeted lines, their
re-ask and guided windows no longer hold the card or invite more events.
Delivery body says how many choice questions were used instead of the event
fit percent; narration names an excluded cluster instead of "range
unchanged"; a delivered turn no longer carries a collect question.

Offline replay (v4, 3 radii x 2 directions): truth in range 20/20 in every
cell; guided-window injections give the same width in truth and opposite
directions, so red line 1 was revised by product to truth-in-range only.
Skill 10.0.31 -> 10.0.32 (10.0.31 kept as deprecated for pinned cases).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
2026-09-29 10:06:57 +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.31 / 51a91251…13c1
// 新值: 10.0.32 / c2cab136…4cee
// 原因: 训练门开后只问点选卡、交付正文去吻合率改「用了几道选择题」写进 Skill(BUG-1084/1085,2026-09-29 D1/D2)
version: "10.0.32",
sha256: "c2cab1364542e96290be416bb3d445c8c2baa90a52371aff2067b4d338184cee",
},
{
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/,
);
});