Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions extensions/skill-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ const EXCLUDE_NAMES = new Set(["_shared", "skill-registry"]);
const EXCLUDE_PREFIXES = ["sdd-"];
const ATL_IGNORE_ENTRY = ".atl/";
const WATCH_DEBOUNCE_MS = 500;
const REGISTRY_SCHEMA_VERSION = 6;
const REGISTRY_SCHEMA_VERSION = 7;
const NO_SKILL_REGISTRY_FLAG = "no-skill-registry";
const NO_SKILL_REGISTRY_ENV = "GENTLE_PI_NO_SKILL_REGISTRY";
const LEGACY_PROJECT_REGISTRY_REL_PATH = ".pi/extensions/skill-registry.ts";
Expand Down Expand Up @@ -253,7 +253,14 @@ async function fingerprint(files: string[]): Promise<string> {
for (const file of files) {
try {
const info = await stat(file);
lines.push(`${file}:${info.mtimeMs}:${info.size}`);
let contentHash: string;
try {
contentHash = createHash("sha1").update(await readFile(file)).digest("hex");
} catch {
lines.push(`${file}:unreadable`);
continue;
}
lines.push(`${file}:${info.mtimeMs}:${info.size}:${contentHash}`);
} catch {
lines.push(`${file}:missing`);
}
Expand Down Expand Up @@ -526,6 +533,7 @@ export const __testing = {
normalizeSkillDescription,
parseFrontmatter,
renderRegistry,
regenerateRegistry,
shouldSkipSkillRegistryStartup,
shouldSkipDuplicateExtensionLoad,
startSkillRegistryWatcher,
Expand Down
51 changes: 50 additions & 1 deletion tests/skill-registry.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import assert from "node:assert/strict";
import { mkdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs";
import { mkdirSync, readFileSync, statSync, symlinkSync, utimesSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import test from "node:test";
Expand Down Expand Up @@ -267,3 +267,52 @@ test("orchestrator documents path injection protocol", () => {
assert.match(source, /paths-injected/);
assert.doesNotMatch(source, /Use matching compact rules based on code context and task intent/);
});

test("non-forced regeneration invalidates cache when skill bytes change but path, size, and mtime are restored", async () => {
const cwd = join(tmpdir(), `gentle-pi-fingerprint-${Date.now()}`);
const skillPath = join(cwd, "skills", "alpha", "SKILL.md");
mkdirSync(dirname(skillPath), { recursive: true });

const contentV1 =
'---\nname: alpha\ndescription: "Trigger: alpha skill. Variant one. Body A."\n---\n\n## Rules\n\n- Rule A.\n';
const contentV2 =
'---\nname: alpha\ndescription: "Trigger: alpha skill. Variant two. Body B."\n---\n\n## Rules\n\n- Rule B.\n';
assert.equal(
Buffer.byteLength(contentV1),
Buffer.byteLength(contentV2),
"test fixtures must have identical byte length",
);

const fixedMtimeSeconds = 1_000_000_000;
writeFileSync(skillPath, contentV1);
utimesSync(skillPath, fixedMtimeSeconds, fixedMtimeSeconds);
const beforeStat = statSync(skillPath);
const beforeMtimeMs = beforeStat.mtimeMs;
const beforeSize = beforeStat.size;

const first = await __testing.regenerateRegistry(cwd, false);
assert.equal(first.regenerated, true, "initial non-forced regeneration writes the registry");
assert.equal(first.reason, "fingerprint-changed");

const registryPath = join(cwd, ".atl", "skill-registry.md");
const firstRegistry = readFileSync(registryPath, "utf8");
assert.match(firstRegistry, /Variant one\. Body A\./);

writeFileSync(skillPath, contentV2);
utimesSync(skillPath, fixedMtimeSeconds, fixedMtimeSeconds);
const midStat = statSync(skillPath);
assert.equal(midStat.size, beforeSize, "byte size must be unchanged after rewrite");
assert.equal(midStat.mtimeMs, beforeMtimeMs, "mtime must be restored exactly");

const second = await __testing.regenerateRegistry(cwd, false);
assert.equal(
second.regenerated,
true,
"non-forced regeneration must invalidate cache when content bytes changed",
);
assert.equal(second.reason, "fingerprint-changed");

const secondRegistry = readFileSync(registryPath, "utf8");
assert.match(secondRegistry, /Variant two\. Body B\./);
assert.doesNotMatch(secondRegistry, /Variant one\. Body A\./);
});
Loading