diff --git a/scripts/lib/skill-evolution/versioning.js b/scripts/lib/skill-evolution/versioning.js index 307fa599f..32bc83858 100644 --- a/scripts/lib/skill-evolution/versioning.js +++ b/scripts/lib/skill-evolution/versioning.js @@ -83,7 +83,8 @@ function parseVersionNumber(fileName) { return null; } - return Number(match[1]); + const version = Number(match[1]); + return Number.isSafeInteger(version) && version >= 1 ? version : null; } function listVersions(skillPath) { @@ -170,16 +171,19 @@ function getEvolutionLog(skillPath, logType) { function createVersion(skillPath, options = {}) { const skillFilePath = ensureSkillExists(skillPath); - ensureSkillVersioning(skillPath); const versions = listVersions(skillPath); const versionsDir = getVersionsDir(skillPath); - const occupiedPaths = new Set(fs.readdirSync(versionsDir)); + const occupiedPaths = new Set(fs.existsSync(versionsDir) ? fs.readdirSync(versionsDir) : []); let nextVersion = versions.length === 0 ? 1 : versions[versions.length - 1].version + 1; // Listing excludes non-files, but their paths remain user-owned and occupied. - while (occupiedPaths.has(`v${nextVersion}.md`)) { + while (Number.isSafeInteger(nextVersion) && occupiedPaths.has(`v${nextVersion}.md`)) { nextVersion += 1; } + if (!Number.isSafeInteger(nextVersion)) { + throw new Error('Skill snapshot version range is exhausted; existing snapshots were preserved'); + } + ensureSkillVersioning(skillPath); const snapshotPath = path.join(versionsDir, `v${nextVersion}.md`); const skillContent = fs.readFileSync(skillFilePath, 'utf8'); const createdAt = options.timestamp || new Date().toISOString(); diff --git a/tests/lib/skill-evolution-storage.test.js b/tests/lib/skill-evolution-storage.test.js index 4b070ba06..8cc7a8a1b 100644 --- a/tests/lib/skill-evolution-storage.test.js +++ b/tests/lib/skill-evolution-storage.test.js @@ -66,5 +66,41 @@ test('health skips malformed amendment rows while retaining a valid pending prop assert.equal(report.skills.find(skill => skill.skill_id === 'demo').pending_amendments, 1); }); +test('snapshot allocation ignores invalid numeric names and preserves their contents', fixture => { + const { skillDir, versionsDir } = fixture; + fs.mkdirSync(versionsDir); + const names = ['v0.md', 'v9007199254740992.md', `v${'9'.repeat(80)}.md`]; + for (const name of names) fs.writeFileSync(path.join(versionsDir, name), 'preserve'); + assert.deepEqual(versioning.listVersions(skillDir), []); + const snapshot = versioning.createVersion(skillDir); + assert.equal(snapshot.version, 1); + assert.equal(fs.readFileSync(snapshot.path, 'utf8'), '# Original\n'); + for (const name of names) assert.equal(fs.readFileSync(path.join(versionsDir, name), 'utf8'), 'preserve'); +}); + +test('exhausted snapshot numbering refuses creation before initializing evolution logs', fixture => { + const { skillDir, versionsDir } = fixture; + fs.mkdirSync(versionsDir); + const last = path.join(versionsDir, `v${Number.MAX_SAFE_INTEGER}.md`); + fs.writeFileSync(last, 'preserve'); + assert.throws(() => versioning.createVersion(skillDir), /snapshot version range is exhausted/); + assert.deepEqual(fs.readdirSync(versionsDir), [`v${Number.MAX_SAFE_INTEGER}.md`]); + assert.equal(fs.readFileSync(last, 'utf8'), 'preserve'); + assert.equal(fs.existsSync(versioning.getEvolutionDir(skillDir)), false); +}); + +test('an occupied final safe snapshot path refuses creation while preserving the directory', fixture => { + const { skillDir, versionsDir } = fixture; + fs.mkdirSync(versionsDir); + fs.writeFileSync(path.join(versionsDir, `v${Number.MAX_SAFE_INTEGER - 1}.md`), 'preserve'); + const occupied = path.join(versionsDir, `v${Number.MAX_SAFE_INTEGER}.md`); + fs.mkdirSync(occupied); + fs.writeFileSync(path.join(occupied, 'keep.txt'), 'preserve'); + assert.throws(() => versioning.createVersion(skillDir), /snapshot version range is exhausted/); + assert.equal(fs.readdirSync(versionsDir).length, 2); + assert.equal(fs.readFileSync(path.join(occupied, 'keep.txt'), 'utf8'), 'preserve'); + assert.equal(fs.existsSync(versioning.getEvolutionDir(skillDir)), false); +}); + console.log(`Results: Passed: ${passed}, Failed: ${failed}`); process.exitCode = failed > 0 ? 1 : 0;