fix(skill-stocktake): exclude trash and mark unmeasured usage

This commit is contained in:
Yann Roberto
2026-09-23 11:41:37 +08:00
parent bf70150eb2
commit dddbe52cb7
4 changed files with 127 additions and 7 deletions
+112 -1
View File
@@ -46,7 +46,8 @@ console.log('\nSkill stocktake discovery tests:');
test('both scanners use canonical, error-visible, NUL-delimited discovery', () => {
for (const scriptPath of [scanScript, quickDiffScript]) {
const source = fs.readFileSync(scriptPath, 'utf8');
assert.match(source, /find -L "\$dir" -name "SKILL\.md" -type f -print0/);
assert.match(source, /find -L "\$dir"/);
assert.match(source, /-name "SKILL\.md" -type f -print0/);
assert.match(source, /sort_nul_file "\$find_out"/);
assert.match(source, /records\.sort\(Buffer\.compare\)/);
assert.doesNotMatch(source, /sort -z/, `${path.basename(scriptPath)} still requires GNU sort`);
@@ -147,6 +148,116 @@ if (process.platform === 'win32') {
}
}
if (process.platform !== 'win32') {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-stocktake-inventory-'));
try {
const globalSkills = path.join(tempRoot, 'global', '.claude', 'skills');
const projectSkills = path.join(tempRoot, 'project', '.claude', 'skills');
const liveSkills = [
[path.join(globalSkills, 'active'), 'global-active'],
[path.join(globalSkills, '.trash-archive'), 'global-trash-archive'],
[path.join(projectSkills, 'active'), 'project-active'],
[path.join(projectSkills, '.trashcan'), 'project-trashcan'],
[path.join(projectSkills, 'space skill'), 'space-skill'],
[path.join(projectSkills, 'newline\nskill'), 'newline-skill'],
];
for (const [skillDir, name] of liveSkills) writeSkill(skillDir, name);
for (const [scope, skillsDir] of [['global', globalSkills], ['project', projectSkills]]) {
writeSkill(path.join(skillsDir, '.trash', 'deleted'), `${scope}-deleted`);
writeSkill(path.join(skillsDir, 'bundle', '.trash', 'nested'), `${scope}-nested-deleted`);
}
const linkedTarget = path.join(tempRoot, 'shared', 'linked');
const linkedSkill = path.join(projectSkills, 'linked');
writeSkill(linkedTarget, 'linked-skill');
fs.symlinkSync(linkedTarget, linkedSkill, 'dir');
const expectedPaths = [...liveSkills.map(([skillDir]) => path.join(skillDir, 'SKILL.md')),
path.join(linkedSkill, 'SKILL.md')].sort();
const resultsPath = path.join(tempRoot, 'results.json');
fs.writeFileSync(resultsPath, JSON.stringify({ evaluated_at: '2099-01-01T00:00:00Z', skills: [] }));
const emptyObservations = path.join(tempRoot, 'empty-observations.jsonl');
fs.writeFileSync(emptyObservations, '');
const env = {
SKILL_STOCKTAKE_GLOBAL_DIR: globalSkills,
SKILL_STOCKTAKE_PROJECT_DIR: projectSkills,
SKILL_STOCKTAKE_OBSERVATIONS: path.join(tempRoot, 'missing-observations.jsonl'),
};
test('scan excludes root and nested .trash directories in both scopes, preserving live skills', () => {
const result = runBash(scanScript, [], env);
assert.strictEqual(result.status, 0, result.stderr);
const output = JSON.parse(result.stdout);
assert.deepStrictEqual(output.skills.map(skill => skill.path).sort(), expectedPaths);
assert.strictEqual(output.scan_summary.global.count, 2);
assert.strictEqual(output.scan_summary.project.count, 5);
});
test('quick diff excludes .trash in both scopes while preserving similarly named and linked skills', () => {
const result = runBash(quickDiffScript, [resultsPath], env);
assert.strictEqual(result.status, 0, result.stderr);
const output = JSON.parse(result.stdout);
assert.deepStrictEqual(output.map(skill => skill.path).sort(), expectedPaths);
assert.ok(output.every(skill => skill.is_new === true));
});
for (const name of ['project-active', 'space-skill', 'newline-skill']) {
test(`scan reports unknown usage for ${name} when observations are missing`, () => {
const result = runBash(scanScript, [], env);
assert.strictEqual(result.status, 0, result.stderr);
const skill = JSON.parse(result.stdout).skills.find(entry => entry.name === name);
assert.ok(skill, `missing live skill: ${name}`);
assert.strictEqual(skill.use_7d, null);
assert.strictEqual(skill.use_30d, null);
});
}
test('scan reports numeric zero for unobserved skills when an empty observation file exists', () => {
const result = runBash(scanScript, [], { ...env, SKILL_STOCKTAKE_OBSERVATIONS: emptyObservations });
assert.strictEqual(result.status, 0, result.stderr);
const output = JSON.parse(result.stdout);
assert.ok(output.skills.length > 0);
for (const skill of output.skills) {
assert.strictEqual(skill.use_7d, 0, skill.path);
assert.strictEqual(skill.use_30d, 0, skill.path);
}
});
test('scan preserves exact usage counts and date windows for normal and whitespace paths', () => {
const observationsPath = path.join(tempRoot, 'matching-observations.jsonl');
const observedPaths = [path.join(linkedSkill, 'SKILL.md'),
...liveSkills.filter(([, name]) => ['space-skill', 'newline-skill'].includes(name))
.map(([skillDir]) => path.join(skillDir, 'SKILL.md'))];
const timestamp = days => new Date(Date.now() - days * 86400000).toISOString();
const observations = observedPaths.flatMap(skillPath => [
{ tool: 'Read', path: skillPath, timestamp: timestamp(0) },
{ tool: 'Read', path: skillPath, timestamp: timestamp(1) },
{ tool: 'Read', path: skillPath, timestamp: timestamp(10) },
{ tool: 'Read', path: skillPath, timestamp: timestamp(40) },
{ tool: 'Write', path: skillPath, timestamp: timestamp(0) },
{ tool: 'Read', path: `${skillPath}.backup`, timestamp: timestamp(0) },
]);
fs.writeFileSync(observationsPath, `${observations.map(record => JSON.stringify(record)).join('\n')}\n`);
const result = runBash(scanScript, [], { ...env, SKILL_STOCKTAKE_OBSERVATIONS: observationsPath });
assert.strictEqual(result.status, 0, result.stderr);
const output = JSON.parse(result.stdout);
for (const skillPath of observedPaths) {
const skill = output.skills.find(entry => entry.path === skillPath);
assert.ok(skill, `missing observed skill: ${skillPath}`);
assert.strictEqual(skill.use_7d, 2, skillPath);
assert.strictEqual(skill.use_30d, 3, skillPath);
}
const unobserved = output.skills.find(entry => entry.name === 'global-active');
assert.strictEqual(unobserved.use_7d, 0);
assert.strictEqual(unobserved.use_30d, 0);
});
} catch (error) {
console.log(` ✗ inventory fixture setup: ${error.message}`);
failed++;
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
}
console.log(`\nPassed: ${passed}`);
console.log(`Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);