Mechanical rename produced by scripts/msd-rename.cjs: gsd/Gsd/GSD -> msd/Msd/MSD across contents and paths, upstream package/repo coordinates -> @golem15/msd-core and golem15com/msd-core. Deep links into upstream history, sibling upstream packages, the GSD-2 import feature, CHANGELOG.md and .changeset/ are kept as-is. Hand edits on top: MSD block-letter banner and logos, LICENSE copyright line, package/plugin identity, regenerated lockfile, install-tree fixtures, derived registries and benchmark baseline; migration checksum baseline re-locked (MSD keeps its own install state, so no install had applied the old sums); sort-order and regex-escaped expectations in tests adjusted.
801 lines
30 KiB
JavaScript
801 lines
30 KiB
JavaScript
/**
|
|
* 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('../msd-core/bin/lib/learnings.cjs');
|
|
const { runMsdTools, 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(), 'msd-learnings-test-'));
|
|
}
|
|
|
|
/**
|
|
* Remove a directory recursively.
|
|
* @param {string} dir
|
|
*/
|
|
function cleanupDir(dir) {
|
|
cleanup(dir);
|
|
}
|
|
|
|
// ─── 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 = runMsdTools(['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 = runMsdTools(['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 = runMsdTools(['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 = runMsdTools(['learnings', 'unknown'], tmpDir, { HOME: tmpDir });
|
|
assert.strictEqual(res.success, false);
|
|
assert.ok(res.error.includes('Unknown learnings subcommand'));
|
|
});
|
|
});
|
|
|
|
// ─── Dedupe Scaling (#306) ───────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Build a LEARNINGS.md string with k unique ## sections.
|
|
* Mirrors the ## heading format that learningsCopyFromProject parses.
|
|
*/
|
|
function makeLearningsMd(k) {
|
|
const sections = [];
|
|
for (let i = 1; i <= k; i++) {
|
|
sections.push(`## Title ${i}\n\nBody content for item ${i}, unique text.`);
|
|
}
|
|
return `# Project Learnings\n\n${sections.join('\n\n')}\n`;
|
|
}
|
|
|
|
describe('learnings dedupe scaling (#306)', () => {
|
|
test('store directory scan count is independent of imported item count', (t) => {
|
|
// Test A: assert readdirSync call count does NOT scale with K (regression guard for #306)
|
|
// BEFORE the fix: count scales 1:1 with K (one scan per learningsWrite call).
|
|
// AFTER the fix: count is constant (one index-build scan per learningsCopyFromProject call).
|
|
|
|
const storeDir2 = makeTempDir();
|
|
const projectDir2 = makeTempDir();
|
|
t.after(() => {
|
|
cleanupDir(storeDir2);
|
|
cleanupDir(projectDir2);
|
|
});
|
|
|
|
const storeDir6 = makeTempDir();
|
|
const projectDir6 = makeTempDir();
|
|
t.after(() => {
|
|
cleanupDir(storeDir6);
|
|
cleanupDir(projectDir6);
|
|
});
|
|
|
|
// K=2
|
|
fs.writeFileSync(path.join(projectDir2, 'LEARNINGS.md'), makeLearningsMd(2), 'utf-8');
|
|
const spy2 = t.mock.method(fs, 'readdirSync');
|
|
const before2 = spy2.mock.calls.length;
|
|
learningsCopyFromProject(projectDir2, { storeDir: storeDir2, sourceProject: 'proj-a' });
|
|
const c1 = spy2.mock.calls.length - before2;
|
|
spy2.mock.restore();
|
|
|
|
// K=6
|
|
fs.writeFileSync(path.join(projectDir6, 'LEARNINGS.md'), makeLearningsMd(6), 'utf-8');
|
|
const spy6 = t.mock.method(fs, 'readdirSync');
|
|
const before6 = spy6.mock.calls.length;
|
|
learningsCopyFromProject(projectDir6, { storeDir: storeDir6, sourceProject: 'proj-b' });
|
|
const c6 = spy6.mock.calls.length - before6;
|
|
spy6.mock.restore();
|
|
|
|
assert.strictEqual(c1, c6,
|
|
`store directory scan count must be independent of imported item count (#306) — got ${c1} for K=2 vs ${c6} for K=6`);
|
|
});
|
|
|
|
test('dedupe semantics preserved: duplicate entry in existing store is skipped', (t) => {
|
|
// Test B part 1: importing a section that duplicates an already-stored entry → skipped
|
|
const storeDir = makeTempDir();
|
|
const projectDir = makeTempDir();
|
|
t.after(() => {
|
|
cleanupDir(storeDir);
|
|
cleanupDir(projectDir);
|
|
});
|
|
|
|
// Pre-seed one entry that matches the first section of our LEARNINGS.md
|
|
learningsWrite({
|
|
source_project: 'my-proj',
|
|
learning: 'Body content for item 1, unique text.',
|
|
context: 'Title 1',
|
|
tags: ['title'],
|
|
}, { storeDir });
|
|
|
|
// LEARNINGS.md has 3 sections; section 1 duplicates the pre-seeded entry
|
|
fs.writeFileSync(path.join(projectDir, 'LEARNINGS.md'), makeLearningsMd(3), 'utf-8');
|
|
const result = learningsCopyFromProject(projectDir, { storeDir, sourceProject: 'my-proj' });
|
|
|
|
assert.strictEqual(result.created, 2, 'two new sections should be created');
|
|
assert.strictEqual(result.skipped, 1, 'one duplicate section should be skipped');
|
|
|
|
const all = learningsList({ storeDir });
|
|
assert.strictEqual(all.length, 3);
|
|
});
|
|
|
|
test('dedupe semantics preserved: two identical sections in same file → one created, one skipped', (t) => {
|
|
// Test B part 2: exercises the index.set during-loop dedup path
|
|
const storeDir = makeTempDir();
|
|
const projectDir = makeTempDir();
|
|
t.after(() => {
|
|
cleanupDir(storeDir);
|
|
cleanupDir(projectDir);
|
|
});
|
|
|
|
// Two identical ## sections in the same LEARNINGS.md
|
|
const md = `# Learnings\n\n## Duplicate Section\n\nExact same body text.\n\n## Duplicate Section\n\nExact same body text.\n`;
|
|
fs.writeFileSync(path.join(projectDir, 'LEARNINGS.md'), md, 'utf-8');
|
|
|
|
const result = learningsCopyFromProject(projectDir, { storeDir, sourceProject: 'my-proj' });
|
|
|
|
assert.strictEqual(result.created, 1, 'exactly one entry should be created');
|
|
assert.strictEqual(result.skipped, 1, 'the duplicate should be skipped');
|
|
|
|
const all = learningsList({ storeDir });
|
|
assert.strictEqual(all.length, 1);
|
|
});
|
|
});
|
|
|
|
// ─── #3683 — phase-scoped learnings source (path agreement + gated wiring) ──
|
|
//
|
|
// The extractor writes {PHASE_DIR}/{PADDED}-LEARNINGS.md; the copy command read
|
|
// only <root>/LEARNINGS.md (project root), so learnings.copy ALWAYS no-oped —
|
|
// the global-learnings store stayed empty even after manual extraction. The
|
|
// copy must discover the most recent phase-scoped artifact; the project-root
|
|
// path remains the fallback (legacy shape). Pins also cover the gated
|
|
// completion wiring in execute-phase.md and the registry/docs agreement.
|
|
|
|
describe('#3683 learnings copy source resolution', () => {
|
|
let storeDir;
|
|
let projectDir;
|
|
|
|
beforeEach(() => {
|
|
storeDir = makeTempDir();
|
|
projectDir = makeTempDir();
|
|
});
|
|
afterEach(() => {
|
|
cleanupDir(storeDir);
|
|
cleanupDir(projectDir);
|
|
});
|
|
|
|
function writePhaseLearnings(phaseSlug, ageMinutes) {
|
|
const phaseDir = path.join(projectDir, 'phases', phaseSlug);
|
|
fs.mkdirSync(phaseDir, { recursive: true });
|
|
const file = path.join(phaseDir, `${phaseSlug}-LEARNINGS.md`);
|
|
fs.writeFileSync(file, `# Learnings\n\n## ${phaseSlug} Lesson\nBody for ${phaseSlug}.\n`, 'utf-8');
|
|
const when = new Date(Date.now() - ageMinutes * 60 * 1000);
|
|
fs.utimesSync(file, when, when);
|
|
return file;
|
|
}
|
|
|
|
test('learnings copy reads the phase-scoped artifact', () => {
|
|
writePhaseLearnings('1.0-discovery', 30);
|
|
const result = learningsCopyFromProject(projectDir, { storeDir, sourceProject: 'app' });
|
|
assert.strictEqual(result.created, 1, `phase-scoped artifact must be the copy source: ${JSON.stringify(result)}`);
|
|
const all = learningsList({ storeDir });
|
|
assert.ok(all.some((r) => r.learning.includes('Body for 1.0-discovery')), 'item body must land in learning');
|
|
});
|
|
|
|
test('learnings copy still reads the project-root artifact', () => {
|
|
fs.writeFileSync(path.join(projectDir, 'LEARNINGS.md'), '# L\n\n## Root Lesson\nBody.\n', 'utf-8');
|
|
const result = learningsCopyFromProject(projectDir, { storeDir, sourceProject: 'app' });
|
|
assert.strictEqual(result.created, 1);
|
|
assert.ok(learningsList({ storeDir }).some((r) => r.context === 'Root Lesson'), 'section title lands in context');
|
|
});
|
|
|
|
test('phase-scoped wins when both exist', () => {
|
|
writePhaseLearnings('2.0-build', 10);
|
|
fs.writeFileSync(path.join(projectDir, 'LEARNINGS.md'), '# L\n\n## Root Lesson\nBody.\n', 'utf-8');
|
|
const result = learningsCopyFromProject(projectDir, { storeDir, sourceProject: 'app' });
|
|
assert.strictEqual(result.created, 1);
|
|
assert.ok(
|
|
learningsList({ storeDir }).some((r) => r.learning.includes('Body for 2.0-build')),
|
|
'the most recent phase artifact must win over the project-root file',
|
|
);
|
|
});
|
|
|
|
test('learnings copy no-ops with no artifact', () => {
|
|
const result = learningsCopyFromProject(projectDir, { storeDir, sourceProject: 'app' });
|
|
assert.deepStrictEqual(result, { total: 0, created: 0, skipped: 0 });
|
|
});
|
|
|
|
test('learnings copy picks the most recent phase artifact', () => {
|
|
writePhaseLearnings('1.0-discovery', 240); // older
|
|
writePhaseLearnings('3.0-hardening', 5); // newer
|
|
const result = learningsCopyFromProject(projectDir, { storeDir, sourceProject: 'app' });
|
|
assert.strictEqual(result.created, 1);
|
|
assert.ok(learningsList({ storeDir }).some((r) => r.learning.includes('Body for 3.0-hardening')));
|
|
});
|
|
|
|
test('extractor-shaped artifact copies per-item entries, not category blobs', () => {
|
|
// The real producer writes ## category sections containing ### items
|
|
// (extract-learnings.md write_learnings). The copy must store each ###
|
|
// item as its own learning — aggregating a category into one mega-entry
|
|
// defeats the store's relevance contract (learnings.max_inject).
|
|
const phaseDir = path.join(projectDir, 'phases', '4.0-real');
|
|
fs.mkdirSync(phaseDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(phaseDir, '4.0-real-LEARNINGS.md'),
|
|
[
|
|
'# Phase 4 Learnings',
|
|
'',
|
|
'## Decisions',
|
|
'',
|
|
'### Use SQLite over Postgres',
|
|
'Zero-ops for single-node deployments.',
|
|
'',
|
|
'### Pin the runner image',
|
|
'Reproducible CI beats newest-libraries.',
|
|
'',
|
|
'## Surprises',
|
|
'',
|
|
'### npm dedupe changed lockfile',
|
|
'Expected; audit after upgrades.',
|
|
'',
|
|
].join('\n'),
|
|
'utf-8',
|
|
);
|
|
const result = learningsCopyFromProject(projectDir, { storeDir, sourceProject: 'app' });
|
|
assert.strictEqual(result.created, 3, 'each ### item must become one learning');
|
|
const all = learningsList({ storeDir });
|
|
// ### item TITLES land in context; their BODIES land in learning.
|
|
assert.ok(all.some((r) => r.context === 'Use SQLite over Postgres' && r.learning.includes('Zero-ops')));
|
|
assert.ok(all.some((r) => r.context === 'Pin the runner image' && r.learning.includes('Reproducible')));
|
|
assert.ok(all.some((r) => r.context === 'npm dedupe changed lockfile' && r.learning.includes('audit')));
|
|
for (const r of all) {
|
|
assert.ok(r.learning.length < 200, 'entries must be item-scoped, not category blobs');
|
|
}
|
|
});
|
|
|
|
test('malformed artifact yields no entries', () => {
|
|
const phaseDir = path.join(projectDir, 'phases', '1.0-x');
|
|
fs.mkdirSync(phaseDir, { recursive: true });
|
|
fs.writeFileSync(path.join(phaseDir, '1.0-x-LEARNINGS.md'), 'no sections at all\n', 'utf-8');
|
|
const result = learningsCopyFromProject(projectDir, { storeDir, sourceProject: 'app' });
|
|
assert.strictEqual(result.created, 0);
|
|
});
|
|
});
|
|
|
|
describe('#3683 completion wiring and registry pins', () => {
|
|
const EXECUTE_PHASE = path.join(__dirname, '..', 'msd-core', 'workflows', 'execute-phase.md');
|
|
const REGISTRY = path.join(__dirname, '..', 'msd-core', 'templates', 'README.md');
|
|
const FEATURES = path.join(__dirname, '..', 'docs', 'FEATURES.md');
|
|
|
|
test('execute-phase completion wires gated extraction', () => {
|
|
const content = fs.readFileSync(EXECUTE_PHASE, 'utf-8');
|
|
const stepStart = content.indexOf('<step name="auto_copy_learnings">');
|
|
assert.ok(stepStart !== -1, 'auto_copy_learnings step must exist');
|
|
const stepEnd = content.indexOf('</step>', stepStart);
|
|
const step = content.slice(stepStart, stepEnd);
|
|
assert.ok(
|
|
/extract-learnings|extract_learnings/.test(step),
|
|
'the gated step must invoke the extraction producer for the completed phase',
|
|
);
|
|
assert.ok(
|
|
/must NOT block phase completion|does not block phase completion|non-fatal/i.test(step),
|
|
'extraction failure must be explicitly non-fatal',
|
|
);
|
|
// Gate-first ordering: the disabled skip must precede the extraction wiring
|
|
// so disabled runs stay byte-identical.
|
|
const gateIdx = step.indexOf('GL_ENABLED');
|
|
const extractIdx = step.search(/Run the .extract[-_]learnings. workflow/);
|
|
assert.ok(gateIdx !== -1 && extractIdx !== -1 && gateIdx < extractIdx, 'gate check must precede extraction');
|
|
});
|
|
|
|
test('registry producer attribution is gated', () => {
|
|
const registry = fs.readFileSync(REGISTRY, 'utf-8');
|
|
const splitLines = require('../msd-core/bin/lib/text-lines.cjs').splitLines;
|
|
const row = splitLines(registry).find((l) => l.includes('LEARNINGS.md'));
|
|
assert.ok(row, 'registry must carry a LEARNINGS.md row');
|
|
assert.ok(
|
|
/global_learnings|gated/i.test(row),
|
|
`producer attribution must state the gate: ${row}`,
|
|
);
|
|
});
|
|
|
|
test('features doc agrees with the registry', () => {
|
|
const features = fs.readFileSync(FEATURES, 'utf-8');
|
|
// Anchor on the SECTION HEADING — the TOC also mentions extract-learnings
|
|
// ~2400 chars earlier and its window contains neither keyword.
|
|
const heading = features.search(/##+\s*\d*\d*\.?\s*Extract Learnings/i);
|
|
assert.ok(heading !== -1, 'FEATURES.md must have an Extract Learnings section');
|
|
const section = features.slice(heading, heading + 4000);
|
|
assert.ok(
|
|
/global_learnings|automatically/i.test(section),
|
|
'the learnings feature section must acknowledge the gated automatic path',
|
|
);
|
|
});
|
|
});
|