feat(tools): add global learnings store with CRUD library and CLI support (#1831)
* feat(tools): add global learnings store with CRUD library and CLI support * fix(tools): address review feedback for global learnings store - Validate learning IDs against path traversal in learningsRead, learningsDelete, and cmdLearningsDelete - Fix total invariant in learningsCopyFromProject (total = created + skipped) - Wrap cmdLearningsPrune in try/catch to handle invalid duration format - Rename raw -> content in readLearningFile to avoid variable shadowing - Add CLI integration tests for list, query, prune error, and unknown subcommand
This commit is contained in:
@@ -147,6 +147,13 @@
|
||||
*
|
||||
* Documentation:
|
||||
* docs-init Project context for docs-update workflow
|
||||
*
|
||||
* Learnings:
|
||||
* learnings list List all global learnings (JSON)
|
||||
* learnings query --tag <tag> Query learnings by tag
|
||||
* learnings copy Copy from current project's LEARNINGS.md
|
||||
* learnings prune --older-than <dur> Remove entries older than duration (e.g. 90d)
|
||||
* learnings delete <id> Delete a learning by ID
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
@@ -167,6 +174,7 @@ const profilePipeline = require('./lib/profile-pipeline.cjs');
|
||||
const profileOutput = require('./lib/profile-output.cjs');
|
||||
const workstream = require('./lib/workstream.cjs');
|
||||
const docs = require('./lib/docs.cjs');
|
||||
const learnings = require('./lib/learnings.cjs');
|
||||
|
||||
// ─── Arg parsing helpers ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -1003,6 +1011,34 @@ async function runCommand(command, args, cwd, raw) {
|
||||
break;
|
||||
}
|
||||
|
||||
// ─── Learnings ─────────────────────────────────────────────────────────
|
||||
|
||||
case 'learnings': {
|
||||
const subcommand = args[1];
|
||||
if (subcommand === 'list') {
|
||||
learnings.cmdLearningsList(raw);
|
||||
} else if (subcommand === 'query') {
|
||||
const tagIdx = args.indexOf('--tag');
|
||||
const tag = tagIdx !== -1 ? args[tagIdx + 1] : null;
|
||||
if (!tag) error('Usage: gsd-tools learnings query --tag <tag>');
|
||||
learnings.cmdLearningsQuery(tag, raw);
|
||||
} else if (subcommand === 'copy') {
|
||||
learnings.cmdLearningsCopy(cwd, raw);
|
||||
} else if (subcommand === 'prune') {
|
||||
const olderIdx = args.indexOf('--older-than');
|
||||
const olderThan = olderIdx !== -1 ? args[olderIdx + 1] : null;
|
||||
if (!olderThan) error('Usage: gsd-tools learnings prune --older-than <duration>');
|
||||
learnings.cmdLearningsPrune(olderThan, raw);
|
||||
} else if (subcommand === 'delete') {
|
||||
const id = args[2];
|
||||
if (!id) error('Usage: gsd-tools learnings delete <id>');
|
||||
learnings.cmdLearningsDelete(id, raw);
|
||||
} else {
|
||||
error('Unknown learnings subcommand. Available: list, query, copy, prune, delete');
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
error(`Unknown command: ${command}`);
|
||||
}
|
||||
|
||||
378
get-shit-done/bin/lib/learnings.cjs
Normal file
378
get-shit-done/bin/lib/learnings.cjs
Normal file
@@ -0,0 +1,378 @@
|
||||
/**
|
||||
* Learnings — Global knowledge store with CRUD operations
|
||||
*
|
||||
* Provides a cross-project learnings store at ~/.gsd/knowledge/.
|
||||
* Each learning is stored as an individual JSON file with content-hash
|
||||
* deduplication. Supports write, read, list, query, delete, copy-from-project,
|
||||
* and prune operations.
|
||||
*
|
||||
* Storage format: { id, source_project, date, context, learning, tags, content_hash }
|
||||
* File naming: {id}.json
|
||||
* Deduplication: SHA-256 of learning text + source_project
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const os = require('os');
|
||||
const { output, error: coreError } = require('./core.cjs');
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_STORE_DIR = path.join(os.homedir(), '.gsd', 'knowledge');
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get the store directory, allowing override for testing.
|
||||
* @param {object} [opts]
|
||||
* @param {string} [opts.storeDir] - Override store directory
|
||||
* @returns {string}
|
||||
*/
|
||||
function getStoreDir(opts) {
|
||||
return (opts && opts.storeDir) || DEFAULT_STORE_DIR;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the store directory exists. Created on first write, not on install.
|
||||
* @param {string} dir
|
||||
*/
|
||||
function ensureStoreDir(dir) {
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a content hash for deduplication.
|
||||
* Uses SHA-256 of learning text combined with source_project.
|
||||
* @param {string} learning
|
||||
* @param {string} sourceProject
|
||||
* @returns {string}
|
||||
*/
|
||||
function contentHash(learning, sourceProject) {
|
||||
return crypto.createHash('sha256')
|
||||
.update(learning + '\n' + sourceProject)
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique ID based on timestamp + random suffix.
|
||||
* @returns {string}
|
||||
*/
|
||||
function generateId() {
|
||||
const ts = Date.now().toString(36);
|
||||
const rand = crypto.randomBytes(4).toString('hex');
|
||||
return `${ts}-${rand}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and parse a single learning JSON file.
|
||||
* Returns null (with stderr warning) for malformed files.
|
||||
* @param {string} filePath
|
||||
* @returns {object|null}
|
||||
*/
|
||||
function readLearningFile(filePath) {
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
return JSON.parse(content);
|
||||
} catch (err) {
|
||||
process.stderr.write(`Warning: skipping malformed file ${filePath}: ${err.message}\n`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── CRUD Operations ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Write a learning to the global store.
|
||||
* Deduplicates by content hash — same content from same project is not stored twice.
|
||||
*
|
||||
* @param {object} entry
|
||||
* @param {string} entry.source_project - Project name or path
|
||||
* @param {string} entry.learning - The learning text
|
||||
* @param {string} [entry.context] - Additional context
|
||||
* @param {string[]} [entry.tags] - Tags for querying
|
||||
* @param {object} [opts]
|
||||
* @param {string} [opts.storeDir] - Override store directory
|
||||
* @returns {{ id: string, created: boolean, content_hash: string }}
|
||||
*/
|
||||
function learningsWrite(entry, opts) {
|
||||
const dir = getStoreDir(opts);
|
||||
ensureStoreDir(dir);
|
||||
|
||||
const hash = contentHash(entry.learning, entry.source_project);
|
||||
|
||||
// Check for duplicate by scanning existing files
|
||||
const files = fs.readdirSync(dir).filter(f => f.endsWith('.json'));
|
||||
for (const file of files) {
|
||||
const existing = readLearningFile(path.join(dir, file));
|
||||
if (existing && existing.content_hash === hash) {
|
||||
return { id: existing.id, created: false, content_hash: hash };
|
||||
}
|
||||
}
|
||||
|
||||
const id = generateId();
|
||||
const record = {
|
||||
id,
|
||||
source_project: entry.source_project,
|
||||
date: new Date().toISOString(),
|
||||
context: entry.context || '',
|
||||
learning: entry.learning,
|
||||
tags: entry.tags || [],
|
||||
content_hash: hash,
|
||||
};
|
||||
|
||||
fs.writeFileSync(path.join(dir, `${id}.json`), JSON.stringify(record, null, 2), 'utf-8');
|
||||
return { id, created: true, content_hash: hash };
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a single learning by ID.
|
||||
*
|
||||
* @param {string} id
|
||||
* @param {object} [opts]
|
||||
* @param {string} [opts.storeDir] - Override store directory
|
||||
* @returns {object|null}
|
||||
*/
|
||||
function learningsRead(id, opts) {
|
||||
if (!/^[a-z0-9]+-[a-f0-9]+$/.test(id)) return null;
|
||||
const dir = getStoreDir(opts);
|
||||
const filePath = path.join(dir, `${id}.json`);
|
||||
if (!fs.existsSync(filePath)) return null;
|
||||
return readLearningFile(filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* List all learnings, sorted by date (newest first).
|
||||
*
|
||||
* @param {object} [opts]
|
||||
* @param {string} [opts.storeDir] - Override store directory
|
||||
* @returns {object[]}
|
||||
*/
|
||||
function learningsList(opts) {
|
||||
const dir = getStoreDir(opts);
|
||||
if (!fs.existsSync(dir)) return [];
|
||||
|
||||
const files = fs.readdirSync(dir).filter(f => f.endsWith('.json'));
|
||||
const results = [];
|
||||
for (const file of files) {
|
||||
const record = readLearningFile(path.join(dir, file));
|
||||
if (record) results.push(record);
|
||||
}
|
||||
|
||||
// Sort by date descending (newest first)
|
||||
results.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query learnings by tag.
|
||||
*
|
||||
* @param {object} query
|
||||
* @param {string} [query.tag] - Tag to filter by
|
||||
* @param {object} [opts]
|
||||
* @param {string} [opts.storeDir] - Override store directory
|
||||
* @returns {object[]}
|
||||
*/
|
||||
function learningsQuery(query, opts) {
|
||||
const all = learningsList(opts);
|
||||
if (query && query.tag) {
|
||||
return all.filter(r => r.tags && r.tags.includes(query.tag));
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a learning by ID.
|
||||
*
|
||||
* @param {string} id
|
||||
* @param {object} [opts]
|
||||
* @param {string} [opts.storeDir] - Override store directory
|
||||
* @returns {boolean} true if deleted, false if not found
|
||||
*/
|
||||
function learningsDelete(id, opts) {
|
||||
if (!/^[a-z0-9]+-[a-f0-9]+$/.test(id)) return false;
|
||||
const dir = getStoreDir(opts);
|
||||
const filePath = path.join(dir, `${id}.json`);
|
||||
if (!fs.existsSync(filePath)) return false;
|
||||
fs.unlinkSync(filePath);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy learnings from a project's LEARNINGS.md into the global store.
|
||||
* Parses markdown sections as individual learnings. Deduplicates by content hash.
|
||||
*
|
||||
* Expected LEARNINGS.md format:
|
||||
* ## Section Title
|
||||
* Learning content paragraph(s)...
|
||||
*
|
||||
* ## Another Section
|
||||
* More content...
|
||||
*
|
||||
* @param {string} planningDir - Path to .planning/ directory (or directory containing LEARNINGS.md)
|
||||
* @param {object} [opts]
|
||||
* @param {string} [opts.storeDir] - Override store directory
|
||||
* @param {string} [opts.sourceProject] - Project name (defaults to directory basename)
|
||||
* @returns {{ total: number, created: number, skipped: number }}
|
||||
*/
|
||||
function learningsCopyFromProject(planningDir, opts) {
|
||||
const learningsPath = path.join(planningDir, 'LEARNINGS.md');
|
||||
if (!fs.existsSync(learningsPath)) {
|
||||
return { total: 0, created: 0, skipped: 0 };
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(learningsPath, 'utf-8');
|
||||
const sourceProject = (opts && opts.sourceProject) || path.basename(path.resolve(planningDir, '..'));
|
||||
|
||||
// Parse markdown: split on ## headings
|
||||
const sections = content.split(/^## /m).slice(1); // skip preamble before first ##
|
||||
let created = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const section of sections) {
|
||||
const lines = section.trim().split('\n');
|
||||
const title = lines[0].trim();
|
||||
const body = lines.slice(1).join('\n').trim();
|
||||
if (!body) continue;
|
||||
|
||||
// Extract tags from title (simple: use words as tags)
|
||||
const tags = title.toLowerCase().split(/\s+/).filter(w => w.length > 2);
|
||||
|
||||
const result = learningsWrite({
|
||||
source_project: sourceProject,
|
||||
learning: body,
|
||||
context: title,
|
||||
tags,
|
||||
}, opts);
|
||||
|
||||
if (result.created) {
|
||||
created++;
|
||||
} else {
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
return { total: created + skipped, created, skipped };
|
||||
}
|
||||
|
||||
/**
|
||||
* Prune learnings older than a given threshold.
|
||||
*
|
||||
* @param {string} olderThan - Duration string like "90d", "30d", "7d"
|
||||
* @param {object} [opts]
|
||||
* @param {string} [opts.storeDir] - Override store directory
|
||||
* @returns {{ removed: number, kept: number }}
|
||||
*/
|
||||
function learningsPrune(olderThan, opts) {
|
||||
const match = /^(\d+)d$/.exec(olderThan);
|
||||
if (!match) {
|
||||
throw new Error(`Invalid duration format: "${olderThan}" — expected format like "90d"`);
|
||||
}
|
||||
|
||||
const days = parseInt(match[1], 10);
|
||||
const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
|
||||
const dir = getStoreDir(opts);
|
||||
|
||||
if (!fs.existsSync(dir)) return { removed: 0, kept: 0 };
|
||||
|
||||
const files = fs.readdirSync(dir).filter(f => f.endsWith('.json'));
|
||||
let removed = 0;
|
||||
let kept = 0;
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = path.join(dir, file);
|
||||
const record = readLearningFile(filePath);
|
||||
if (!record) continue;
|
||||
|
||||
const recordDate = new Date(record.date);
|
||||
if (recordDate < cutoff) {
|
||||
fs.unlinkSync(filePath);
|
||||
removed++;
|
||||
} else {
|
||||
kept++;
|
||||
}
|
||||
}
|
||||
|
||||
return { removed, kept };
|
||||
}
|
||||
|
||||
// ─── CLI Command Handlers ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Handle `gsd-tools learnings list`
|
||||
* @param {boolean} raw - Raw output flag
|
||||
*/
|
||||
function cmdLearningsList(raw) {
|
||||
const results = learningsList();
|
||||
output({ learnings: results, count: results.length }, raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle `gsd-tools learnings query --tag <tag>`
|
||||
* @param {string} tag
|
||||
* @param {boolean} raw - Raw output flag
|
||||
*/
|
||||
function cmdLearningsQuery(tag, raw) {
|
||||
const results = learningsQuery({ tag });
|
||||
output({ learnings: results, count: results.length, tag }, raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle `gsd-tools learnings copy`
|
||||
* @param {string} cwd - Current working directory
|
||||
* @param {boolean} raw - Raw output flag
|
||||
*/
|
||||
function cmdLearningsCopy(cwd, raw) {
|
||||
const planningDir = path.join(cwd, '.planning');
|
||||
const result = learningsCopyFromProject(planningDir);
|
||||
output(result, raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle `gsd-tools learnings prune --older-than <duration>`
|
||||
* @param {string} olderThan - Duration string like "90d"
|
||||
* @param {boolean} raw - Raw output flag
|
||||
*/
|
||||
function cmdLearningsPrune(olderThan, raw) {
|
||||
try {
|
||||
const result = learningsPrune(olderThan);
|
||||
output(result, raw);
|
||||
} catch (err) {
|
||||
coreError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle `gsd-tools learnings delete <id>`
|
||||
* @param {string} id
|
||||
* @param {boolean} raw - Raw output flag
|
||||
*/
|
||||
function cmdLearningsDelete(id, raw) {
|
||||
if (!/^[a-z0-9]+-[a-f0-9]+$/.test(id)) {
|
||||
coreError(`Invalid learning ID: "${id}"`);
|
||||
}
|
||||
const deleted = learningsDelete(id);
|
||||
output({ id, deleted }, raw);
|
||||
}
|
||||
|
||||
// ─── Exports ─────────────────────────────────────────────────────────────────
|
||||
|
||||
module.exports = {
|
||||
learningsWrite,
|
||||
learningsRead,
|
||||
learningsList,
|
||||
learningsQuery,
|
||||
learningsDelete,
|
||||
learningsCopyFromProject,
|
||||
learningsPrune,
|
||||
cmdLearningsList,
|
||||
cmdLearningsQuery,
|
||||
cmdLearningsCopy,
|
||||
cmdLearningsPrune,
|
||||
cmdLearningsDelete,
|
||||
DEFAULT_STORE_DIR,
|
||||
};
|
||||
526
tests/learnings.test.cjs
Normal file
526
tests/learnings.test.cjs
Normal file
@@ -0,0 +1,526 @@
|
||||
/**
|
||||
* Learnings Store Tests
|
||||
*
|
||||
* Tests for the global learnings CRUD library: write, read, list, query,
|
||||
* delete, dedup, empty store, malformed file handling, copyFromProject, prune.
|
||||
*/
|
||||
|
||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
const {
|
||||
learningsWrite,
|
||||
learningsRead,
|
||||
learningsList,
|
||||
learningsQuery,
|
||||
learningsDelete,
|
||||
learningsCopyFromProject,
|
||||
learningsPrune,
|
||||
} = require('../get-shit-done/bin/lib/learnings.cjs');
|
||||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
||||
|
||||
// ─── Test Helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create a unique temp directory for each test.
|
||||
* @returns {string}
|
||||
*/
|
||||
function makeTempDir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-learnings-test-'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a directory recursively.
|
||||
* @param {string} dir
|
||||
*/
|
||||
function cleanupDir(dir) {
|
||||
if (fs.existsSync(dir)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Write ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('learningsWrite', () => {
|
||||
let storeDir;
|
||||
beforeEach(() => { storeDir = makeTempDir(); });
|
||||
afterEach(() => { cleanupDir(storeDir); });
|
||||
|
||||
test('creates a learning file with all required fields', () => {
|
||||
const result = learningsWrite({
|
||||
source_project: 'test-project',
|
||||
learning: 'Always validate inputs before processing',
|
||||
context: 'security review',
|
||||
tags: ['security', 'validation'],
|
||||
}, { storeDir });
|
||||
|
||||
assert.ok(result.id, 'should return an id');
|
||||
assert.strictEqual(result.created, true);
|
||||
assert.ok(result.content_hash, 'should return a content_hash');
|
||||
|
||||
// Verify file exists and has correct structure
|
||||
const filePath = path.join(storeDir, `${result.id}.json`);
|
||||
assert.ok(fs.existsSync(filePath), 'file should exist on disk');
|
||||
|
||||
const record = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
assert.strictEqual(record.id, result.id);
|
||||
assert.strictEqual(record.source_project, 'test-project');
|
||||
assert.strictEqual(record.learning, 'Always validate inputs before processing');
|
||||
assert.strictEqual(record.context, 'security review');
|
||||
assert.deepStrictEqual(record.tags, ['security', 'validation']);
|
||||
assert.strictEqual(record.content_hash, result.content_hash);
|
||||
assert.ok(record.date, 'should have a date');
|
||||
});
|
||||
|
||||
test('creates store directory on first write', () => {
|
||||
const nestedDir = path.join(storeDir, 'nested', 'store');
|
||||
assert.ok(!fs.existsSync(nestedDir), 'dir should not exist yet');
|
||||
|
||||
learningsWrite({
|
||||
source_project: 'test',
|
||||
learning: 'test learning',
|
||||
}, { storeDir: nestedDir });
|
||||
|
||||
assert.ok(fs.existsSync(nestedDir), 'dir should be created on write');
|
||||
});
|
||||
|
||||
test('defaults context to empty string and tags to empty array', () => {
|
||||
const result = learningsWrite({
|
||||
source_project: 'test',
|
||||
learning: 'minimal entry',
|
||||
}, { storeDir });
|
||||
|
||||
const record = learningsRead(result.id, { storeDir });
|
||||
assert.strictEqual(record.context, '');
|
||||
assert.deepStrictEqual(record.tags, []);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Deduplication ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('deduplication', () => {
|
||||
let storeDir;
|
||||
beforeEach(() => { storeDir = makeTempDir(); });
|
||||
afterEach(() => { cleanupDir(storeDir); });
|
||||
|
||||
test('same content from same project is not stored twice', () => {
|
||||
const entry = {
|
||||
source_project: 'my-project',
|
||||
learning: 'Use content hashing for dedup',
|
||||
tags: ['dedup'],
|
||||
};
|
||||
|
||||
const first = learningsWrite(entry, { storeDir });
|
||||
const second = learningsWrite(entry, { storeDir });
|
||||
|
||||
assert.strictEqual(first.created, true);
|
||||
assert.strictEqual(second.created, false);
|
||||
assert.strictEqual(first.content_hash, second.content_hash);
|
||||
assert.strictEqual(first.id, second.id);
|
||||
|
||||
// Only one file on disk
|
||||
const files = fs.readdirSync(storeDir).filter(f => f.endsWith('.json'));
|
||||
assert.strictEqual(files.length, 1);
|
||||
});
|
||||
|
||||
test('same learning from different projects creates separate entries', () => {
|
||||
const learning = 'Same learning text';
|
||||
|
||||
const first = learningsWrite({
|
||||
source_project: 'project-a',
|
||||
learning,
|
||||
}, { storeDir });
|
||||
|
||||
const second = learningsWrite({
|
||||
source_project: 'project-b',
|
||||
learning,
|
||||
}, { storeDir });
|
||||
|
||||
assert.strictEqual(first.created, true);
|
||||
assert.strictEqual(second.created, true);
|
||||
assert.notStrictEqual(first.content_hash, second.content_hash);
|
||||
|
||||
const files = fs.readdirSync(storeDir).filter(f => f.endsWith('.json'));
|
||||
assert.strictEqual(files.length, 2);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Read ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('learningsRead', () => {
|
||||
let storeDir;
|
||||
beforeEach(() => { storeDir = makeTempDir(); });
|
||||
afterEach(() => { cleanupDir(storeDir); });
|
||||
|
||||
test('returns a learning by ID', () => {
|
||||
const { id } = learningsWrite({
|
||||
source_project: 'test',
|
||||
learning: 'readable entry',
|
||||
tags: ['read'],
|
||||
}, { storeDir });
|
||||
|
||||
const record = learningsRead(id, { storeDir });
|
||||
assert.ok(record);
|
||||
assert.strictEqual(record.id, id);
|
||||
assert.strictEqual(record.learning, 'readable entry');
|
||||
});
|
||||
|
||||
test('returns null for non-existent ID', () => {
|
||||
const record = learningsRead('does-not-exist', { storeDir });
|
||||
assert.strictEqual(record, null);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── List ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('learningsList', () => {
|
||||
let storeDir;
|
||||
beforeEach(() => { storeDir = makeTempDir(); });
|
||||
afterEach(() => { cleanupDir(storeDir); });
|
||||
|
||||
test('returns empty array for empty store', () => {
|
||||
const results = learningsList({ storeDir });
|
||||
assert.deepStrictEqual(results, []);
|
||||
});
|
||||
|
||||
test('returns empty array when store dir does not exist', () => {
|
||||
const results = learningsList({ storeDir: path.join(storeDir, 'nonexistent') });
|
||||
assert.deepStrictEqual(results, []);
|
||||
});
|
||||
|
||||
test('returns all learnings sorted by date (newest first)', () => {
|
||||
// Write three entries with controlled dates
|
||||
const id1 = learningsWrite({
|
||||
source_project: 'p1',
|
||||
learning: 'first',
|
||||
}, { storeDir }).id;
|
||||
|
||||
// Manually adjust dates to control sort order
|
||||
const file1 = path.join(storeDir, `${id1}.json`);
|
||||
const rec1 = JSON.parse(fs.readFileSync(file1, 'utf-8'));
|
||||
rec1.date = '2025-01-01T00:00:00.000Z';
|
||||
fs.writeFileSync(file1, JSON.stringify(rec1));
|
||||
|
||||
const id2 = learningsWrite({
|
||||
source_project: 'p2',
|
||||
learning: 'second',
|
||||
}, { storeDir }).id;
|
||||
|
||||
const file2 = path.join(storeDir, `${id2}.json`);
|
||||
const rec2 = JSON.parse(fs.readFileSync(file2, 'utf-8'));
|
||||
rec2.date = '2025-06-15T00:00:00.000Z';
|
||||
fs.writeFileSync(file2, JSON.stringify(rec2));
|
||||
|
||||
const id3 = learningsWrite({
|
||||
source_project: 'p3',
|
||||
learning: 'third',
|
||||
}, { storeDir }).id;
|
||||
|
||||
const file3 = path.join(storeDir, `${id3}.json`);
|
||||
const rec3 = JSON.parse(fs.readFileSync(file3, 'utf-8'));
|
||||
rec3.date = '2025-03-10T00:00:00.000Z';
|
||||
fs.writeFileSync(file3, JSON.stringify(rec3));
|
||||
|
||||
const results = learningsList({ storeDir });
|
||||
assert.strictEqual(results.length, 3);
|
||||
assert.strictEqual(results[0].learning, 'second'); // newest
|
||||
assert.strictEqual(results[1].learning, 'third'); // middle
|
||||
assert.strictEqual(results[2].learning, 'first'); // oldest
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Query ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('learningsQuery', () => {
|
||||
let storeDir;
|
||||
beforeEach(() => { storeDir = makeTempDir(); });
|
||||
afterEach(() => { cleanupDir(storeDir); });
|
||||
|
||||
test('filters by tag', () => {
|
||||
learningsWrite({
|
||||
source_project: 'p1',
|
||||
learning: 'auth lesson',
|
||||
tags: ['auth', 'security'],
|
||||
}, { storeDir });
|
||||
|
||||
learningsWrite({
|
||||
source_project: 'p2',
|
||||
learning: 'ui lesson',
|
||||
tags: ['ui', 'css'],
|
||||
}, { storeDir });
|
||||
|
||||
learningsWrite({
|
||||
source_project: 'p3',
|
||||
learning: 'auth pattern',
|
||||
tags: ['auth', 'patterns'],
|
||||
}, { storeDir });
|
||||
|
||||
const results = learningsQuery({ tag: 'auth' }, { storeDir });
|
||||
assert.strictEqual(results.length, 2);
|
||||
assert.ok(results.every(r => r.tags.includes('auth')));
|
||||
});
|
||||
|
||||
test('returns all when no tag filter', () => {
|
||||
learningsWrite({ source_project: 'p1', learning: 'a' }, { storeDir });
|
||||
learningsWrite({ source_project: 'p2', learning: 'b' }, { storeDir });
|
||||
|
||||
const results = learningsQuery({}, { storeDir });
|
||||
assert.strictEqual(results.length, 2);
|
||||
});
|
||||
|
||||
test('returns empty array when tag not found', () => {
|
||||
learningsWrite({
|
||||
source_project: 'p1',
|
||||
learning: 'something',
|
||||
tags: ['other'],
|
||||
}, { storeDir });
|
||||
|
||||
const results = learningsQuery({ tag: 'nonexistent' }, { storeDir });
|
||||
assert.strictEqual(results.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Delete ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('learningsDelete', () => {
|
||||
let storeDir;
|
||||
beforeEach(() => { storeDir = makeTempDir(); });
|
||||
afterEach(() => { cleanupDir(storeDir); });
|
||||
|
||||
test('removes a learning by ID', () => {
|
||||
const { id } = learningsWrite({
|
||||
source_project: 'test',
|
||||
learning: 'to be deleted',
|
||||
}, { storeDir });
|
||||
|
||||
assert.strictEqual(learningsDelete(id, { storeDir }), true);
|
||||
assert.strictEqual(learningsRead(id, { storeDir }), null);
|
||||
|
||||
const files = fs.readdirSync(storeDir).filter(f => f.endsWith('.json'));
|
||||
assert.strictEqual(files.length, 0);
|
||||
});
|
||||
|
||||
test('returns false for non-existent ID', () => {
|
||||
assert.strictEqual(learningsDelete('nonexistent', { storeDir }), false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Malformed File Handling ─────────────────────────────────────────────────
|
||||
|
||||
describe('malformed file handling', () => {
|
||||
let storeDir;
|
||||
beforeEach(() => { storeDir = makeTempDir(); });
|
||||
afterEach(() => { cleanupDir(storeDir); });
|
||||
|
||||
test('list skips malformed JSON files with warning', () => {
|
||||
// Write a valid entry
|
||||
learningsWrite({
|
||||
source_project: 'test',
|
||||
learning: 'valid entry',
|
||||
}, { storeDir });
|
||||
|
||||
// Write a malformed JSON file
|
||||
fs.writeFileSync(path.join(storeDir, 'bad-entry.json'), '{not valid json!!!', 'utf-8');
|
||||
|
||||
const results = learningsList({ storeDir });
|
||||
assert.strictEqual(results.length, 1);
|
||||
assert.strictEqual(results[0].learning, 'valid entry');
|
||||
});
|
||||
|
||||
test('write dedup check skips malformed files without crashing', () => {
|
||||
// Write a malformed JSON file first
|
||||
fs.writeFileSync(path.join(storeDir, 'corrupt.json'), 'corrupted!', 'utf-8');
|
||||
|
||||
// Writing should still succeed
|
||||
const result = learningsWrite({
|
||||
source_project: 'test',
|
||||
learning: 'new entry after corrupt',
|
||||
}, { storeDir });
|
||||
|
||||
assert.strictEqual(result.created, true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Copy From Project ───────────────────────────────────────────────────────
|
||||
|
||||
describe('learningsCopyFromProject', () => {
|
||||
let storeDir;
|
||||
let projectDir;
|
||||
|
||||
beforeEach(() => {
|
||||
storeDir = makeTempDir();
|
||||
projectDir = makeTempDir();
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanupDir(storeDir);
|
||||
cleanupDir(projectDir);
|
||||
});
|
||||
|
||||
test('copies learnings from LEARNINGS.md into global store', () => {
|
||||
const learningsMd = `# Project Learnings
|
||||
|
||||
## Authentication Patterns
|
||||
Always use OAuth2 for third-party auth.
|
||||
Never store tokens in localStorage.
|
||||
|
||||
## Database Design
|
||||
Normalize to 3NF unless read performance demands denormalization.
|
||||
|
||||
## Error Handling
|
||||
Use custom error classes that extend Error.
|
||||
`;
|
||||
fs.writeFileSync(path.join(projectDir, 'LEARNINGS.md'), learningsMd, 'utf-8');
|
||||
|
||||
const result = learningsCopyFromProject(projectDir, {
|
||||
storeDir,
|
||||
sourceProject: 'my-app',
|
||||
});
|
||||
|
||||
assert.strictEqual(result.created, 3);
|
||||
assert.strictEqual(result.skipped, 0);
|
||||
|
||||
const all = learningsList({ storeDir });
|
||||
assert.strictEqual(all.length, 3);
|
||||
|
||||
// Verify content was captured
|
||||
const learningTexts = all.map(r => r.learning);
|
||||
assert.ok(learningTexts.some(t => t.includes('OAuth2')));
|
||||
assert.ok(learningTexts.some(t => t.includes('Normalize to 3NF')));
|
||||
assert.ok(learningTexts.some(t => t.includes('custom error classes')));
|
||||
});
|
||||
|
||||
test('deduplicates on second copy', () => {
|
||||
const learningsMd = `# Learnings
|
||||
|
||||
## Testing
|
||||
Always write tests first.
|
||||
`;
|
||||
fs.writeFileSync(path.join(projectDir, 'LEARNINGS.md'), learningsMd, 'utf-8');
|
||||
|
||||
learningsCopyFromProject(projectDir, { storeDir, sourceProject: 'app' });
|
||||
const second = learningsCopyFromProject(projectDir, { storeDir, sourceProject: 'app' });
|
||||
|
||||
assert.strictEqual(second.created, 0);
|
||||
assert.strictEqual(second.skipped, 1);
|
||||
|
||||
const all = learningsList({ storeDir });
|
||||
assert.strictEqual(all.length, 1);
|
||||
});
|
||||
|
||||
test('returns zero counts when LEARNINGS.md does not exist', () => {
|
||||
const result = learningsCopyFromProject(projectDir, { storeDir });
|
||||
assert.deepStrictEqual(result, { total: 0, created: 0, skipped: 0 });
|
||||
});
|
||||
|
||||
test('skips sections with empty body', () => {
|
||||
const learningsMd = `# Learnings
|
||||
|
||||
## Empty Section
|
||||
|
||||
## Has Content
|
||||
Real content here.
|
||||
`;
|
||||
fs.writeFileSync(path.join(projectDir, 'LEARNINGS.md'), learningsMd, 'utf-8');
|
||||
|
||||
const result = learningsCopyFromProject(projectDir, { storeDir, sourceProject: 'app' });
|
||||
assert.strictEqual(result.created, 1);
|
||||
|
||||
const all = learningsList({ storeDir });
|
||||
assert.strictEqual(all.length, 1);
|
||||
assert.ok(all[0].learning.includes('Real content'));
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Prune ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('learningsPrune', () => {
|
||||
let storeDir;
|
||||
beforeEach(() => { storeDir = makeTempDir(); });
|
||||
afterEach(() => { cleanupDir(storeDir); });
|
||||
|
||||
test('removes entries older than threshold', () => {
|
||||
// Create an old entry
|
||||
const oldId = learningsWrite({
|
||||
source_project: 'old-project',
|
||||
learning: 'ancient wisdom',
|
||||
}, { storeDir }).id;
|
||||
|
||||
// Backdate it to 100 days ago
|
||||
const oldFile = path.join(storeDir, `${oldId}.json`);
|
||||
const oldRec = JSON.parse(fs.readFileSync(oldFile, 'utf-8'));
|
||||
oldRec.date = new Date(Date.now() - 100 * 24 * 60 * 60 * 1000).toISOString();
|
||||
fs.writeFileSync(oldFile, JSON.stringify(oldRec));
|
||||
|
||||
// Create a recent entry
|
||||
learningsWrite({
|
||||
source_project: 'new-project',
|
||||
learning: 'fresh knowledge',
|
||||
}, { storeDir });
|
||||
|
||||
const result = learningsPrune('90d', { storeDir });
|
||||
assert.strictEqual(result.removed, 1);
|
||||
assert.strictEqual(result.kept, 1);
|
||||
|
||||
const remaining = learningsList({ storeDir });
|
||||
assert.strictEqual(remaining.length, 1);
|
||||
assert.strictEqual(remaining[0].learning, 'fresh knowledge');
|
||||
});
|
||||
|
||||
test('keeps all entries when none are old enough', () => {
|
||||
learningsWrite({ source_project: 'p', learning: 'recent' }, { storeDir });
|
||||
|
||||
const result = learningsPrune('30d', { storeDir });
|
||||
assert.strictEqual(result.removed, 0);
|
||||
assert.strictEqual(result.kept, 1);
|
||||
});
|
||||
|
||||
test('returns zeros when store does not exist', () => {
|
||||
const result = learningsPrune('90d', { storeDir: path.join(storeDir, 'nope') });
|
||||
assert.deepStrictEqual(result, { removed: 0, kept: 0 });
|
||||
});
|
||||
|
||||
test('throws on invalid duration format', () => {
|
||||
assert.throws(
|
||||
() => learningsPrune('invalid', { storeDir }),
|
||||
/Invalid duration format/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── CLI Integration ────────────────────────────────────────────────────────
|
||||
|
||||
describe('CLI integration', () => {
|
||||
let tmpDir;
|
||||
beforeEach(() => { tmpDir = createTempProject(); });
|
||||
afterEach(() => { cleanup(tmpDir); });
|
||||
|
||||
test('learnings list returns valid JSON', () => {
|
||||
const res = runGsdTools(['learnings', 'list'], tmpDir, { HOME: tmpDir });
|
||||
assert.strictEqual(res.success, true);
|
||||
const parsed = JSON.parse(res.output);
|
||||
assert.ok(Array.isArray(parsed.learnings));
|
||||
assert.strictEqual(typeof parsed.count, 'number');
|
||||
});
|
||||
|
||||
test('learnings query --tag succeeds', () => {
|
||||
const res = runGsdTools(['learnings', 'query', '--tag', 'auth'], tmpDir, { HOME: tmpDir });
|
||||
assert.strictEqual(res.success, true);
|
||||
const parsed = JSON.parse(res.output);
|
||||
assert.ok(Array.isArray(parsed.learnings));
|
||||
});
|
||||
|
||||
test('learnings prune with bad format exits non-zero', () => {
|
||||
const res = runGsdTools(['learnings', 'prune', '--older-than', 'badformat'], tmpDir, { HOME: tmpDir });
|
||||
assert.strictEqual(res.success, false);
|
||||
assert.ok(res.error.includes('Invalid duration format'));
|
||||
});
|
||||
|
||||
test('learnings unknown subcommand exits non-zero', () => {
|
||||
const res = runGsdTools(['learnings', 'unknown'], tmpDir, { HOME: tmpDir });
|
||||
assert.strictEqual(res.success, false);
|
||||
assert.ok(res.error.includes('Unknown learnings subcommand'));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user