build(#188): retire sdk generator and freshness scripts
This commit is contained in:
@@ -1,355 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Parity test: configuration.generated.cjs (CJS) vs sdk/dist/config/index.js (ESM).
|
||||
*
|
||||
* For every fixture in the vitest pinning tests, asserts that both sides produce
|
||||
* identical output. This ensures the generator faithfully replicates the TS source.
|
||||
*
|
||||
* Uses node:test + dynamic import() for the ESM side.
|
||||
*/
|
||||
|
||||
const { describe, test, before } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
|
||||
// ─── CJS side (synchronous require) ──────────────────────────────────────────
|
||||
|
||||
const cjs = require('../get-shit-done/bin/lib/configuration.generated.cjs');
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeTmpProject() {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-parity-'));
|
||||
fs.mkdirSync(path.join(dir, '.planning'), { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
function writeConfig(dir, data) {
|
||||
fs.writeFileSync(path.join(dir, '.planning', 'config.json'), JSON.stringify(data, null, 2));
|
||||
}
|
||||
|
||||
function readConfigRaw(dir) {
|
||||
return fs.readFileSync(path.join(dir, '.planning', 'config.json'), 'utf-8');
|
||||
}
|
||||
|
||||
function cleanup(dir) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// ─── ESM side (loaded once via before()) ─────────────────────────────────────
|
||||
|
||||
let esm;
|
||||
|
||||
before(async () => {
|
||||
esm = await import('../sdk/dist/config/index.js');
|
||||
});
|
||||
|
||||
// ─── Parity helper ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Deep-equal assertion that normalizes Sets to arrays for comparison.
|
||||
*/
|
||||
function assertDeepEqual(label, actual, expected) {
|
||||
const normalize = (v) => JSON.parse(JSON.stringify(v, (_k, val) =>
|
||||
val instanceof Set ? [...val].sort() : val
|
||||
));
|
||||
assert.deepStrictEqual(normalize(actual), normalize(expected), `${label} mismatch`);
|
||||
}
|
||||
|
||||
// ─── CONFIG_DEFAULTS parity ───────────────────────────────────────────────────
|
||||
|
||||
describe('CONFIG_DEFAULTS parity', () => {
|
||||
test('model_profile matches', () => {
|
||||
assert.strictEqual(cjs.CONFIG_DEFAULTS.model_profile, esm.CONFIG_DEFAULTS.model_profile);
|
||||
});
|
||||
|
||||
test('git section matches', () => {
|
||||
assertDeepEqual('git', cjs.CONFIG_DEFAULTS.git, esm.CONFIG_DEFAULTS.git);
|
||||
});
|
||||
|
||||
test('workflow section matches', () => {
|
||||
assertDeepEqual('workflow', cjs.CONFIG_DEFAULTS.workflow, esm.CONFIG_DEFAULTS.workflow);
|
||||
});
|
||||
|
||||
test('hooks section matches', () => {
|
||||
assertDeepEqual('hooks', cjs.CONFIG_DEFAULTS.hooks, esm.CONFIG_DEFAULTS.hooks);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── VALID_CONFIG_KEYS parity ─────────────────────────────────────────────────
|
||||
|
||||
describe('VALID_CONFIG_KEYS parity', () => {
|
||||
test('same size', () => {
|
||||
assert.strictEqual(cjs.VALID_CONFIG_KEYS.size, esm.VALID_CONFIG_KEYS.size);
|
||||
});
|
||||
|
||||
test('same entries', () => {
|
||||
for (const key of esm.VALID_CONFIG_KEYS) {
|
||||
assert.ok(cjs.VALID_CONFIG_KEYS.has(key), `CJS missing key: ${key}`);
|
||||
}
|
||||
for (const key of cjs.VALID_CONFIG_KEYS) {
|
||||
assert.ok(esm.VALID_CONFIG_KEYS.has(key), `ESM missing key: ${key}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── DYNAMIC_KEY_PATTERNS parity ─────────────────────────────────────────────
|
||||
|
||||
describe('DYNAMIC_KEY_PATTERNS parity', () => {
|
||||
test('same length', () => {
|
||||
assert.strictEqual(cjs.DYNAMIC_KEY_PATTERNS.length, esm.DYNAMIC_KEY_PATTERNS.length);
|
||||
});
|
||||
|
||||
test('same topLevel and source strings', () => {
|
||||
for (let i = 0; i < esm.DYNAMIC_KEY_PATTERNS.length; i++) {
|
||||
assert.strictEqual(cjs.DYNAMIC_KEY_PATTERNS[i].topLevel, esm.DYNAMIC_KEY_PATTERNS[i].topLevel, `topLevel[${i}]`);
|
||||
assert.strictEqual(cjs.DYNAMIC_KEY_PATTERNS[i].source, esm.DYNAMIC_KEY_PATTERNS[i].source, `source[${i}]`);
|
||||
}
|
||||
});
|
||||
|
||||
test('test functions produce same results', () => {
|
||||
const sampleKeys = [
|
||||
'agent_skills.planner',
|
||||
'agent_skills.executor',
|
||||
'review.models.ollama',
|
||||
'features.thinking_partner',
|
||||
'claude_md_assembly.blocks.intro',
|
||||
'model_profile_overrides.openai.opus',
|
||||
'models.planning',
|
||||
'dynamic_routing.enabled',
|
||||
'model_overrides.my-agent',
|
||||
'workflow.research',
|
||||
'unknown_key',
|
||||
];
|
||||
for (const key of sampleKeys) {
|
||||
for (let i = 0; i < esm.DYNAMIC_KEY_PATTERNS.length; i++) {
|
||||
const esmResult = esm.DYNAMIC_KEY_PATTERNS[i].test(key);
|
||||
const cjsResult = cjs.DYNAMIC_KEY_PATTERNS[i].test(key);
|
||||
assert.strictEqual(cjsResult, esmResult, `pattern[${i}].test('${key}')`);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── normalizeLegacyKeys parity ───────────────────────────────────────────────
|
||||
|
||||
describe('normalizeLegacyKeys parity', () => {
|
||||
test('branching_strategy migration', () => {
|
||||
const input = { branching_strategy: 'phase' };
|
||||
const esmR = esm.normalizeLegacyKeys(input);
|
||||
const cjsR = cjs.normalizeLegacyKeys(input);
|
||||
assertDeepEqual('parsed', cjsR.parsed, esmR.parsed);
|
||||
assertDeepEqual('normalizations', cjsR.normalizations, esmR.normalizations);
|
||||
});
|
||||
|
||||
test('sub_repos migration', () => {
|
||||
const input = { sub_repos: ['app1', 'app2'] };
|
||||
const esmR = esm.normalizeLegacyKeys(input);
|
||||
const cjsR = cjs.normalizeLegacyKeys(input);
|
||||
assertDeepEqual('parsed', cjsR.parsed, esmR.parsed);
|
||||
assertDeepEqual('normalizations', cjsR.normalizations, esmR.normalizations);
|
||||
});
|
||||
|
||||
test('multiRepo migration', () => {
|
||||
const input = { multiRepo: true };
|
||||
const esmR = esm.normalizeLegacyKeys(input);
|
||||
const cjsR = cjs.normalizeLegacyKeys(input);
|
||||
assertDeepEqual('parsed', cjsR.parsed, esmR.parsed);
|
||||
assert.strictEqual(cjsR.normalizations.length, esmR.normalizations.length);
|
||||
assert.strictEqual(cjsR.normalizations[0].requiresFilesystem, esmR.normalizations[0].requiresFilesystem);
|
||||
});
|
||||
|
||||
test('depth: comprehensive migration', () => {
|
||||
const input = { depth: 'comprehensive' };
|
||||
const esmR = esm.normalizeLegacyKeys(input);
|
||||
const cjsR = cjs.normalizeLegacyKeys(input);
|
||||
assertDeepEqual('parsed', cjsR.parsed, esmR.parsed);
|
||||
});
|
||||
|
||||
test('already-normalized returns empty normalizations', () => {
|
||||
const input = { git: { branching_strategy: 'phase' } };
|
||||
const esmR = esm.normalizeLegacyKeys(input);
|
||||
const cjsR = cjs.normalizeLegacyKeys(input);
|
||||
assert.strictEqual(cjsR.normalizations.length, 0);
|
||||
assert.strictEqual(esmR.normalizations.length, 0);
|
||||
});
|
||||
|
||||
test('idempotent — second call returns empty normalizations', () => {
|
||||
const input = { branching_strategy: 'milestone' };
|
||||
const first_cjs = cjs.normalizeLegacyKeys(input);
|
||||
const second_cjs = cjs.normalizeLegacyKeys(first_cjs.parsed);
|
||||
const first_esm = esm.normalizeLegacyKeys(input);
|
||||
const second_esm = esm.normalizeLegacyKeys(first_esm.parsed);
|
||||
assert.strictEqual(second_cjs.normalizations.length, 0);
|
||||
assert.strictEqual(second_esm.normalizations.length, 0);
|
||||
assertDeepEqual('second_parsed', second_cjs.parsed, second_esm.parsed);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── mergeDefaults parity ─────────────────────────────────────────────────────
|
||||
|
||||
describe('mergeDefaults parity', () => {
|
||||
test('empty input returns CONFIG_DEFAULTS shape', () => {
|
||||
const esmR = esm.mergeDefaults({});
|
||||
const cjsR = cjs.mergeDefaults({});
|
||||
assert.strictEqual(cjsR.model_profile, esmR.model_profile);
|
||||
assertDeepEqual('git', cjsR.git, esmR.git);
|
||||
assertDeepEqual('workflow', cjsR.workflow, esmR.workflow);
|
||||
assertDeepEqual('hooks', cjsR.hooks, esmR.hooks);
|
||||
});
|
||||
|
||||
test('partial nested preserves siblings', () => {
|
||||
const input = { git: { base_branch: 'main' } };
|
||||
const esmR = esm.mergeDefaults(input);
|
||||
const cjsR = cjs.mergeDefaults(input);
|
||||
assert.strictEqual(cjsR.git.base_branch, esmR.git.base_branch);
|
||||
assert.strictEqual(cjsR.git.branching_strategy, esmR.git.branching_strategy);
|
||||
});
|
||||
|
||||
test('boolean false preserved', () => {
|
||||
const input = { workflow: { research: false } };
|
||||
const esmR = esm.mergeDefaults(input);
|
||||
const cjsR = cjs.mergeDefaults(input);
|
||||
assert.strictEqual(cjsR.workflow.research, false);
|
||||
assert.strictEqual(esmR.workflow.research, false);
|
||||
});
|
||||
|
||||
test('null preserved', () => {
|
||||
const input = { project_code: null };
|
||||
const esmR = esm.mergeDefaults(input);
|
||||
const cjsR = cjs.mergeDefaults(input);
|
||||
assert.strictEqual(cjsR.project_code, null);
|
||||
assert.strictEqual(esmR.project_code, null);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── loadConfig parity ────────────────────────────────────────────────────────
|
||||
|
||||
describe('loadConfig parity', () => {
|
||||
test('missing config.json returns defaults', async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-parity-lc-'));
|
||||
try {
|
||||
const esmR = await esm.loadConfig(dir);
|
||||
const cjsR = await cjs.loadConfig(dir);
|
||||
assert.strictEqual(cjsR.model_profile, esmR.model_profile);
|
||||
assertDeepEqual('git', cjsR.git, esmR.git);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test('empty {} config.json returns defaults', async () => {
|
||||
const dir = makeTmpProject();
|
||||
writeConfig(dir, {});
|
||||
try {
|
||||
const esmR = await esm.loadConfig(dir);
|
||||
const cjsR = await cjs.loadConfig(dir);
|
||||
assert.strictEqual(cjsR.model_profile, esmR.model_profile);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test('nested git.branching_strategy preserved', async () => {
|
||||
const dir = makeTmpProject();
|
||||
writeConfig(dir, { git: { branching_strategy: 'phase' } });
|
||||
try {
|
||||
const esmR = await esm.loadConfig(dir);
|
||||
const cjsR = await cjs.loadConfig(dir);
|
||||
assert.strictEqual(cjsR.git.branching_strategy, 'phase');
|
||||
assert.strictEqual(esmR.git.branching_strategy, 'phase');
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test('legacy top-level branching_strategy normalized, disk unchanged', async () => {
|
||||
const dir = makeTmpProject();
|
||||
writeConfig(dir, { branching_strategy: 'phase' });
|
||||
const before = readConfigRaw(dir);
|
||||
try {
|
||||
const esmR = await esm.loadConfig(dir);
|
||||
const cjsR = await cjs.loadConfig(dir);
|
||||
assert.strictEqual(cjsR.git.branching_strategy, 'phase');
|
||||
assert.strictEqual(esmR.git.branching_strategy, 'phase');
|
||||
// Disk must be unchanged
|
||||
assert.strictEqual(readConfigRaw(dir), before);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test('throws on malformed JSON', async () => {
|
||||
const dir = makeTmpProject();
|
||||
fs.writeFileSync(path.join(dir, '.planning', 'config.json'), '{bad json');
|
||||
try {
|
||||
await assert.rejects(() => cjs.loadConfig(dir), /parse|invalid|json/i);
|
||||
await assert.rejects(() => esm.loadConfig(dir), /parse|invalid|json/i);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── migrateOnDisk parity ─────────────────────────────────────────────────────
|
||||
|
||||
describe('migrateOnDisk parity', () => {
|
||||
test('no-op for already-normalized config', async () => {
|
||||
const dir = makeTmpProject();
|
||||
writeConfig(dir, { git: { branching_strategy: 'phase' } });
|
||||
try {
|
||||
const esmR = await esm.migrateOnDisk(dir);
|
||||
// Reset file for CJS test
|
||||
writeConfig(dir, { git: { branching_strategy: 'phase' } });
|
||||
const cjsR = await cjs.migrateOnDisk(dir);
|
||||
assert.strictEqual(cjsR.migrated, false);
|
||||
assert.strictEqual(esmR.migrated, false);
|
||||
assert.strictEqual(cjsR.wrote, null);
|
||||
assert.strictEqual(esmR.wrote, null);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test('migrates legacy key and writes disk', async () => {
|
||||
const dirEsm = makeTmpProject();
|
||||
const dirCjs = makeTmpProject();
|
||||
writeConfig(dirEsm, { branching_strategy: 'phase' });
|
||||
writeConfig(dirCjs, { branching_strategy: 'phase' });
|
||||
try {
|
||||
const esmR = await esm.migrateOnDisk(dirEsm);
|
||||
const cjsR = await cjs.migrateOnDisk(dirCjs);
|
||||
assert.strictEqual(cjsR.migrated, true);
|
||||
assert.strictEqual(esmR.migrated, true);
|
||||
assert.ok(cjsR.wrote !== null);
|
||||
assert.ok(esmR.wrote !== null);
|
||||
// Both should have normalized the disk file
|
||||
const cjsDisk = JSON.parse(readConfigRaw(dirCjs));
|
||||
const esmDisk = JSON.parse(readConfigRaw(dirEsm));
|
||||
assert.strictEqual(cjsDisk.branching_strategy, undefined);
|
||||
assert.strictEqual(esmDisk.branching_strategy, undefined);
|
||||
assert.strictEqual(cjsDisk.git?.branching_strategy, 'phase');
|
||||
assert.strictEqual(esmDisk.git?.branching_strategy, 'phase');
|
||||
} finally {
|
||||
cleanup(dirEsm);
|
||||
cleanup(dirCjs);
|
||||
}
|
||||
});
|
||||
|
||||
test('missing file returns migrated:false, wrote:null', async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-parity-md-'));
|
||||
try {
|
||||
const esmR = await esm.migrateOnDisk(dir);
|
||||
const cjsR = await cjs.migrateOnDisk(dir);
|
||||
assert.strictEqual(cjsR.migrated, false);
|
||||
assert.strictEqual(esmR.migrated, false);
|
||||
assert.strictEqual(cjsR.wrote, null);
|
||||
assert.strictEqual(esmR.wrote, null);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,217 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Parity test: decisions.generated.cjs vs sdk/src/query/decisions.ts
|
||||
*
|
||||
* Verifies that the generated CJS artifact matches the SDK source-of-truth
|
||||
* for all supported ID formats (numeric and alphanumeric) and edge cases.
|
||||
*
|
||||
* Covers: Phase 6 (#3575) MIGRATE_ME resolution for decisions.cjs.
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const { describe, test } = require('node:test');
|
||||
const { parseDecisions } = require('../get-shit-done/bin/lib/decisions.cjs');
|
||||
|
||||
// ─── Core parity: numeric IDs (legacy format) ────────────────────────────────
|
||||
|
||||
describe('decisions-generator parity — numeric IDs (legacy)', () => {
|
||||
test('extracts D-NN entries with {id, text}', () => {
|
||||
const md = `
|
||||
<decisions>
|
||||
## Implementation Decisions
|
||||
|
||||
### Auth
|
||||
- **D-01:** Use OAuth 2.0 with PKCE
|
||||
- **D-02:** Session storage in Redis
|
||||
|
||||
### Storage
|
||||
- **D-03:** Postgres 15 with pgvector
|
||||
</decisions>
|
||||
`;
|
||||
const ds = parseDecisions(md);
|
||||
assert.deepStrictEqual(ds.map(d => d.id), ['D-01', 'D-02', 'D-03']);
|
||||
assert.strictEqual(ds[0].text, 'Use OAuth 2.0 with PKCE');
|
||||
});
|
||||
|
||||
test('returns [] when no <decisions> block is present', () => {
|
||||
assert.deepStrictEqual(parseDecisions('# Just a header\nno decisions here'), []);
|
||||
});
|
||||
|
||||
test('returns [] for empty / null / undefined input', () => {
|
||||
assert.deepStrictEqual(parseDecisions(''), []);
|
||||
assert.deepStrictEqual(parseDecisions(null), []);
|
||||
assert.deepStrictEqual(parseDecisions(undefined), []);
|
||||
});
|
||||
|
||||
test('ignores D-IDs outside the <decisions> block', () => {
|
||||
const md = `
|
||||
Top of file. - **D-99:** Not a real decision (outside block).
|
||||
<decisions>
|
||||
- **D-01:** Real decision
|
||||
</decisions>
|
||||
After the block. - **D-77:** Also not real.
|
||||
`;
|
||||
const ds = parseDecisions(md);
|
||||
assert.deepStrictEqual(ds.map(d => d.id), ['D-01']);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Phase 6 extension: alphanumeric IDs ─────────────────────────────────────
|
||||
|
||||
describe('decisions-generator parity — alphanumeric IDs (Phase 6 extension)', () => {
|
||||
test('accepts alphanumeric IDs: D-INFRA-01', () => {
|
||||
const md = `
|
||||
<decisions>
|
||||
### Infrastructure
|
||||
- **D-INFRA-01:** Use Kubernetes for orchestration
|
||||
</decisions>
|
||||
`;
|
||||
const ds = parseDecisions(md);
|
||||
assert.strictEqual(ds.length, 1);
|
||||
assert.strictEqual(ds[0].id, 'D-INFRA-01');
|
||||
assert.strictEqual(ds[0].text, 'Use Kubernetes for orchestration');
|
||||
});
|
||||
|
||||
test('accepts alphanumeric IDs: D-42 (single numeric)', () => {
|
||||
const md = `
|
||||
<decisions>
|
||||
### Architecture
|
||||
- **D-42:** Use microservices
|
||||
</decisions>
|
||||
`;
|
||||
const ds = parseDecisions(md);
|
||||
assert.strictEqual(ds[0].id, 'D-42');
|
||||
});
|
||||
|
||||
test('accepts mixed numeric and alphanumeric IDs in same block', () => {
|
||||
const md = `
|
||||
<decisions>
|
||||
### Planning
|
||||
- **D-01:** First numeric decision
|
||||
- **D-FOO_BAR:** Alphanumeric with underscore
|
||||
- **D-ARCH-123:** Mixed alphanumeric with hyphen
|
||||
</decisions>
|
||||
`;
|
||||
const ds = parseDecisions(md);
|
||||
const ids = ds.map(d => d.id);
|
||||
assert.ok(ids.includes('D-01'), 'should have D-01');
|
||||
assert.ok(ids.includes('D-FOO_BAR'), 'should have D-FOO_BAR');
|
||||
assert.ok(ids.includes('D-ARCH-123'), 'should have D-ARCH-123');
|
||||
});
|
||||
|
||||
test('CJS callers can use {id, text} shape — extra fields present but safe to ignore', () => {
|
||||
const md = `
|
||||
<decisions>
|
||||
### Category
|
||||
- **D-INFRA-01:** Database selection
|
||||
</decisions>
|
||||
`;
|
||||
const ds = parseDecisions(md);
|
||||
const d = ds[0];
|
||||
// Verify {id, text} is present as CJS callers expect
|
||||
assert.strictEqual(typeof d.id, 'string');
|
||||
assert.strictEqual(typeof d.text, 'string');
|
||||
// Extra SDK fields are present but can be ignored
|
||||
assert.ok('category' in d, 'category field present');
|
||||
assert.ok('tags' in d, 'tags field present');
|
||||
assert.ok('trackable' in d, 'trackable field present');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Richer schema fields (SDK extension) ────────────────────────────────────
|
||||
|
||||
describe('decisions-generator parity — richer schema', () => {
|
||||
test('marks decisions under "Claude\'s Discretion" as non-trackable', () => {
|
||||
const md = `
|
||||
<decisions>
|
||||
### Claude's Discretion
|
||||
- **D-50:** Internal naming is flexible
|
||||
</decisions>
|
||||
`;
|
||||
const ds = parseDecisions(md);
|
||||
assert.strictEqual(ds[0].trackable, false);
|
||||
});
|
||||
|
||||
test('marks [informational] tagged decisions as non-trackable', () => {
|
||||
const md = `
|
||||
<decisions>
|
||||
### Info
|
||||
- **D-03 [informational]:** Background context only
|
||||
</decisions>
|
||||
`;
|
||||
const ds = parseDecisions(md);
|
||||
assert.strictEqual(ds[0].trackable, false);
|
||||
assert.ok(ds[0].tags.includes('informational'));
|
||||
});
|
||||
|
||||
test('marks [folded] tagged decisions as non-trackable', () => {
|
||||
const md = `
|
||||
<decisions>
|
||||
### Deferred
|
||||
- **D-05 [folded]:** Will handle later
|
||||
</decisions>
|
||||
`;
|
||||
const ds = parseDecisions(md);
|
||||
assert.strictEqual(ds[0].trackable, false);
|
||||
});
|
||||
|
||||
test('extracts category from ### heading', () => {
|
||||
const md = `
|
||||
<decisions>
|
||||
### Storage Backend
|
||||
- **D-01:** Use PostgreSQL
|
||||
</decisions>
|
||||
`;
|
||||
const ds = parseDecisions(md);
|
||||
assert.strictEqual(ds[0].category, 'Storage Backend');
|
||||
});
|
||||
|
||||
test('parses ALL <decisions> blocks (not just first)', () => {
|
||||
const md = `
|
||||
<decisions>
|
||||
### One
|
||||
- **D-01:** First batch
|
||||
</decisions>
|
||||
|
||||
Some prose.
|
||||
|
||||
<decisions>
|
||||
### Two
|
||||
- **D-02:** Second batch
|
||||
</decisions>
|
||||
`;
|
||||
const ids = parseDecisions(md).map(d => d.id);
|
||||
assert.ok(ids.includes('D-01'));
|
||||
assert.ok(ids.includes('D-02'));
|
||||
});
|
||||
|
||||
test('strips fenced code blocks before parsing', () => {
|
||||
const md = `
|
||||
\`\`\`
|
||||
<decisions>
|
||||
### Fake
|
||||
- **D-99:** Should not be parsed
|
||||
</decisions>
|
||||
\`\`\`
|
||||
|
||||
<decisions>
|
||||
### Real
|
||||
- **D-01:** Real decision
|
||||
</decisions>
|
||||
`;
|
||||
const ds = parseDecisions(md);
|
||||
const ids = ds.map(d => d.id);
|
||||
assert.ok(ids.includes('D-01'));
|
||||
assert.ok(!ids.includes('D-99'));
|
||||
});
|
||||
|
||||
test('curly-quote "Claude’s Discretion" variant is non-trackable', () => {
|
||||
const content =
|
||||
'<decisions>\n### Claude’s Discretion\n- **D-50:** Should be non-trackable\n</decisions>';
|
||||
const ds = parseDecisions(content);
|
||||
const d50 = ds.find(d => d.id === 'D-50');
|
||||
assert.ok(d50, 'D-50 should be found');
|
||||
assert.strictEqual(d50.trackable, false);
|
||||
});
|
||||
});
|
||||
@@ -1,351 +0,0 @@
|
||||
'use strict';
|
||||
/**
|
||||
* Generator correctness, parity, and atomicity (#3598).
|
||||
*
|
||||
* Existing per-generator tests (configuration-generator.test.cjs et al.)
|
||||
* assert positive-path behavior and CJS/ESM API parity for one or two
|
||||
* generators each. None of them assert:
|
||||
*
|
||||
* 1. The committed `.generated.cjs` is byte-equal to a fresh re-run
|
||||
* of the generator's `build*Cjs()` export (stale-but-timestamp-valid
|
||||
* detection — issue #3598 AC #4).
|
||||
* 2. The generator is deterministic — two back-to-back calls return
|
||||
* identical strings (AC #1, defends against time/random/env-order
|
||||
* sneaking into output).
|
||||
* 3. The runtime CJS alias surface and the SDK TS alias surface
|
||||
* expose the same canonical/alias set (AC #3, beyond timestamp
|
||||
* freshness).
|
||||
* 4. The live command registry contains no duplicate aliases
|
||||
* (AC #1: "duplicate aliases" — translated to a behavioral
|
||||
* structural invariant on the in-memory registry the generator
|
||||
* reads from, since the generator has no fixture/--source seam).
|
||||
* 5. `build-hooks.js` is idempotent (running twice leaves `hooks/dist/`
|
||||
* byte-identical and clears its per-PID staging directory — proves
|
||||
* the atomic-write seam does not leak partial artifacts; AC #2).
|
||||
*
|
||||
* This suite fills those gaps without duplicating any happy-path
|
||||
* coverage that already exists.
|
||||
*/
|
||||
|
||||
const { describe, test, before } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const crypto = require('node:crypto');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
|
||||
const REPO_ROOT = path.resolve(__dirname, '..');
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
const sdkScriptUrl = (script) =>
|
||||
new URL(`file://${path.join(REPO_ROOT, 'sdk', 'scripts', script)}`).href;
|
||||
|
||||
function sha256(buf) {
|
||||
return crypto.createHash('sha256').update(buf).digest('hex');
|
||||
}
|
||||
|
||||
function normalizeEol(text) {
|
||||
return String(text).replace(/\r\n/g, '\n');
|
||||
}
|
||||
|
||||
/** Read a directory recursively into a Map<relPath, sha256>. */
|
||||
function snapshotDir(dir) {
|
||||
const out = new Map();
|
||||
if (!fs.existsSync(dir)) return out;
|
||||
const walk = (sub) => {
|
||||
for (const e of fs.readdirSync(sub, { withFileTypes: true })) {
|
||||
const full = path.join(sub, e.name);
|
||||
if (e.isDirectory()) walk(full);
|
||||
else if (e.isFile()) out.set(path.relative(dir, full), sha256(fs.readFileSync(full)));
|
||||
}
|
||||
};
|
||||
walk(dir);
|
||||
return out;
|
||||
}
|
||||
|
||||
// ─── Suite 1: build*Cjs() === committed file (stale-detection) ──────────────
|
||||
|
||||
// Each row: { script, exportName, committed }
|
||||
// `committed` is the path the generator writes to. The generator's exported
|
||||
// build*Cjs() must produce a string equal to fs.readFileSync(committed). If
|
||||
// the committed file has drifted (manual edit, partial generator run,
|
||||
// stale-but-timestamp-valid), this assertion fails — which is the
|
||||
// behavioral coverage the issue's AC #4 calls for.
|
||||
const CJS_GENERATORS = [
|
||||
{ script: 'gen-configuration.mjs', exportName: 'buildConfigurationCjs', committed: 'get-shit-done/bin/lib/configuration.generated.cjs' },
|
||||
{ script: 'gen-project-root.mjs', exportName: 'buildProjectRootCjs', committed: 'get-shit-done/bin/lib/project-root.generated.cjs' },
|
||||
{ script: 'gen-state-document.ts', exportName: 'buildStateDocumentCjs', committed: 'get-shit-done/bin/lib/state-document.generated.cjs' },
|
||||
{ script: 'gen-workstream-inventory-builder.mjs', exportName: 'buildWorkstreamInventoryBuilderCjs', committed: 'get-shit-done/bin/lib/workstream-inventory-builder.generated.cjs' },
|
||||
{ script: 'gen-decisions.mjs', exportName: 'buildDecisionsCjs', committed: 'get-shit-done/bin/lib/decisions.generated.cjs' },
|
||||
{ script: 'gen-plan-scan.mjs', exportName: 'buildPlanScanCjs', committed: 'get-shit-done/bin/lib/plan-scan.generated.cjs' },
|
||||
{ script: 'gen-schema-detect.mjs', exportName: 'buildSchemaDetectCjs', committed: 'get-shit-done/bin/lib/schema-detect.generated.cjs' },
|
||||
{ script: 'gen-secrets.mjs', exportName: 'buildSecretsCjs', committed: 'get-shit-done/bin/lib/secrets.generated.cjs' },
|
||||
{ script: 'gen-workstream-name-policy.mjs', exportName: 'buildWorkstreamNamePolicyCjs', committed: 'get-shit-done/bin/lib/workstream-name-policy.generated.cjs' },
|
||||
];
|
||||
|
||||
describe('feat-3598: build*Cjs() output matches committed .generated.cjs (stale-detection)', () => {
|
||||
for (const g of CJS_GENERATORS) {
|
||||
test(`${g.script} → ${path.basename(g.committed)} is fresh`, async () => {
|
||||
// `.ts` generators need ts-node/loader to import directly from .cjs
|
||||
// tests. Skip them here — their fresh-check covers the same property
|
||||
// via the `check-*-fresh.mjs` subprocess pathway, which runs in CI.
|
||||
if (g.script.endsWith('.ts')) {
|
||||
return; // covered by sdk/scripts/check-state-document-fresh.mjs
|
||||
}
|
||||
const mod = await import(sdkScriptUrl(g.script));
|
||||
const builder = mod[g.exportName];
|
||||
assert.equal(typeof builder, 'function',
|
||||
`${g.script} must export ${g.exportName} as a function`);
|
||||
|
||||
const fresh = await builder();
|
||||
assert.equal(typeof fresh, 'string', `${g.exportName}() must return a string`);
|
||||
const committedPath = path.join(REPO_ROOT, g.committed);
|
||||
const committed = fs.readFileSync(committedPath, 'utf-8');
|
||||
assert.equal(normalizeEol(fresh), normalizeEol(committed),
|
||||
`${g.committed} drifted from generator output — run "cd sdk && npm run gen:${g.script.replace(/^gen-/, '').replace(/\.mjs$/, '')}" to regenerate`);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Suite 2: generators are deterministic ──────────────────────────────────
|
||||
|
||||
describe('feat-3598: build*Cjs() is deterministic across calls', () => {
|
||||
for (const g of CJS_GENERATORS) {
|
||||
test(`${g.exportName} produces identical output on back-to-back calls`, async () => {
|
||||
if (g.script.endsWith('.ts')) return; // see suite 1 note
|
||||
const mod = await import(sdkScriptUrl(g.script));
|
||||
const builder = mod[g.exportName];
|
||||
const a = await builder();
|
||||
const b = await builder();
|
||||
assert.equal(a, b,
|
||||
`${g.exportName}() output differed between two calls — generator has non-deterministic input ` +
|
||||
`(time, random, env-order, Map/Set iteration)`);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Suite 3: runtime CJS ↔ SDK TS alias parity ─────────────────────────────
|
||||
|
||||
describe('feat-3598: command-aliases CJS and TS surfaces expose the same alias set', () => {
|
||||
const CJS_PATH = path.join(REPO_ROOT, 'get-shit-done', 'bin', 'lib', 'command-aliases.generated.cjs');
|
||||
const TS_PATH = path.join(REPO_ROOT, 'sdk', 'src', 'query', 'command-aliases.generated.ts');
|
||||
|
||||
test('both files exist', () => {
|
||||
assert.ok(fs.existsSync(CJS_PATH), `missing ${CJS_PATH}`);
|
||||
assert.ok(fs.existsSync(TS_PATH), `missing ${TS_PATH}`);
|
||||
});
|
||||
|
||||
test('canonical command names are identical between runtimes', () => {
|
||||
const cjs = require(CJS_PATH);
|
||||
const cjsArrays = [
|
||||
'STATE_COMMAND_ALIASES',
|
||||
'VERIFY_COMMAND_ALIASES',
|
||||
'INIT_COMMAND_ALIASES',
|
||||
'PHASE_COMMAND_ALIASES',
|
||||
'PHASES_COMMAND_ALIASES',
|
||||
'VALIDATE_COMMAND_ALIASES',
|
||||
'ROADMAP_COMMAND_ALIASES',
|
||||
'NON_FAMILY_COMMAND_ALIASES',
|
||||
];
|
||||
const cjsCanonicals = new Set();
|
||||
for (const key of cjsArrays) {
|
||||
assert.ok(Array.isArray(cjs[key]), `CJS export ${key} must be an array`);
|
||||
for (const e of cjs[key]) cjsCanonicals.add(e.canonical);
|
||||
}
|
||||
|
||||
// The TS file is the same data emitted as a TS source. Read it and
|
||||
// extract canonical strings via a structural pattern (`canonical: '...'`)
|
||||
// restricted to the generated-file format the generator emits — never
|
||||
// a free-form text scan. This satisfies the CONTRIBUTING typed-IR
|
||||
// requirement because the source file *is* the generator's output:
|
||||
// its lexical shape is part of the deployed contract.
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
const ts = fs.readFileSync(TS_PATH, 'utf-8');
|
||||
const tsCanonicals = new Set(
|
||||
[...ts.matchAll(/canonical:\s*'([^']+)'/g)].map((m) => m[1]),
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
[...tsCanonicals].sort(),
|
||||
[...cjsCanonicals].sort(),
|
||||
'TS and CJS surfaces must expose the same canonical command set — regenerate via "cd sdk && npm run gen:command-aliases"',
|
||||
);
|
||||
});
|
||||
|
||||
test('alias strings are identical between runtimes (set equality)', () => {
|
||||
const cjs = require(CJS_PATH);
|
||||
const cjsAliases = new Set();
|
||||
for (const key of [
|
||||
'STATE_COMMAND_ALIASES',
|
||||
'VERIFY_COMMAND_ALIASES',
|
||||
'INIT_COMMAND_ALIASES',
|
||||
'PHASE_COMMAND_ALIASES',
|
||||
'PHASES_COMMAND_ALIASES',
|
||||
'VALIDATE_COMMAND_ALIASES',
|
||||
'ROADMAP_COMMAND_ALIASES',
|
||||
'NON_FAMILY_COMMAND_ALIASES',
|
||||
]) {
|
||||
for (const e of cjs[key]) for (const a of e.aliases || []) cjsAliases.add(a);
|
||||
}
|
||||
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
const ts = fs.readFileSync(TS_PATH, 'utf-8');
|
||||
// Aliases are emitted as: aliases: ['foo', 'bar', 'baz']
|
||||
// Match the literal-array bodies, then extract individual quoted strings.
|
||||
const tsAliases = new Set();
|
||||
for (const m of ts.matchAll(/aliases:\s*\[([^\]]*)\]/g)) {
|
||||
for (const a of m[1].matchAll(/'([^']+)'/g)) tsAliases.add(a[1]);
|
||||
}
|
||||
|
||||
assert.deepEqual(
|
||||
[...tsAliases].sort(),
|
||||
[...cjsAliases].sort(),
|
||||
'TS and CJS alias sets must be identical — regenerate via "cd sdk && npm run gen:command-aliases"',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Suite 4: no duplicate aliases in the live registry ─────────────────────
|
||||
|
||||
describe('feat-3598: live command registry has no duplicate aliases', () => {
|
||||
// This is the behavioral equivalent of the issue's example test
|
||||
// "generator rejects duplicate command aliases with actionable error".
|
||||
// The generator has no fixture seam — it reads in-memory
|
||||
// COMMAND_DEFINITIONS_BY_FAMILY. The structural invariant that the
|
||||
// generator MUST emit a registry with no duplicates is what we assert
|
||||
// here, on the deployed surface. If two definitions ever collide,
|
||||
// this test fails with the colliding alias named — actionable in the
|
||||
// same way the issue's example error would be.
|
||||
test('no alias appears twice across all command families', () => {
|
||||
const cjs = require(path.join(
|
||||
REPO_ROOT, 'get-shit-done', 'bin', 'lib', 'command-aliases.generated.cjs',
|
||||
));
|
||||
const seen = new Map(); // alias → canonical
|
||||
const collisions = [];
|
||||
for (const key of [
|
||||
'STATE_COMMAND_ALIASES',
|
||||
'VERIFY_COMMAND_ALIASES',
|
||||
'INIT_COMMAND_ALIASES',
|
||||
'PHASE_COMMAND_ALIASES',
|
||||
'PHASES_COMMAND_ALIASES',
|
||||
'VALIDATE_COMMAND_ALIASES',
|
||||
'ROADMAP_COMMAND_ALIASES',
|
||||
'NON_FAMILY_COMMAND_ALIASES',
|
||||
]) {
|
||||
for (const e of cjs[key]) {
|
||||
for (const a of e.aliases || []) {
|
||||
if (seen.has(a)) {
|
||||
collisions.push(`alias "${a}" claimed by both "${seen.get(a)}" and "${e.canonical}"`);
|
||||
} else {
|
||||
seen.set(a, e.canonical);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.equal(collisions.length, 0,
|
||||
`duplicate aliases in command registry:\n ${collisions.join('\n ')}`);
|
||||
});
|
||||
|
||||
test('no canonical command appears twice', () => {
|
||||
const cjs = require(path.join(
|
||||
REPO_ROOT, 'get-shit-done', 'bin', 'lib', 'command-aliases.generated.cjs',
|
||||
));
|
||||
const seen = new Set();
|
||||
const duplicates = [];
|
||||
for (const key of [
|
||||
'STATE_COMMAND_ALIASES',
|
||||
'VERIFY_COMMAND_ALIASES',
|
||||
'INIT_COMMAND_ALIASES',
|
||||
'PHASE_COMMAND_ALIASES',
|
||||
'PHASES_COMMAND_ALIASES',
|
||||
'VALIDATE_COMMAND_ALIASES',
|
||||
'ROADMAP_COMMAND_ALIASES',
|
||||
'NON_FAMILY_COMMAND_ALIASES',
|
||||
]) {
|
||||
for (const e of cjs[key]) {
|
||||
if (seen.has(e.canonical)) duplicates.push(e.canonical);
|
||||
seen.add(e.canonical);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(duplicates, [],
|
||||
`canonical command appears in more than one family: ${duplicates.join(', ')}`);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Suite 5: build-hooks atomicity (idempotence + no orphan staging) ───────
|
||||
|
||||
describe('feat-3598: build-hooks.js is idempotent and leaves no staging residue', () => {
|
||||
const HOOKS_DIR = path.join(REPO_ROOT, 'hooks');
|
||||
const DIST_DIR = path.join(HOOKS_DIR, 'dist');
|
||||
|
||||
// A baseline snapshot taken once before either run. The "fresh dist" the
|
||||
// first run produces is compared to the second run's dist. We do NOT
|
||||
// compare against the disk state before the first run, because the
|
||||
// baseline dist on disk could itself be stale at the time the suite
|
||||
// happens to run (e.g. a fresh clone with an old build artifact).
|
||||
let snapshotA;
|
||||
let stagingBefore;
|
||||
|
||||
before(() => {
|
||||
// Run build:hooks once to land a known-fresh dist.
|
||||
const r1 = spawnSync(process.execPath, [path.join('scripts', 'build-hooks.js')], {
|
||||
cwd: REPO_ROOT,
|
||||
encoding: 'utf-8',
|
||||
timeout: 60000,
|
||||
});
|
||||
assert.equal(r1.status, 0,
|
||||
`build-hooks first run must exit 0; stderr=${r1.stderr.slice(0, 400)}`);
|
||||
snapshotA = snapshotDir(DIST_DIR);
|
||||
assert.ok(snapshotA.size > 0, 'build-hooks must produce at least one file in hooks/dist/');
|
||||
|
||||
stagingBefore = fs.readdirSync(HOOKS_DIR)
|
||||
.filter((n) => n.startsWith('.dist-staging-'));
|
||||
});
|
||||
|
||||
test('second run produces byte-identical hooks/dist/ contents', () => {
|
||||
const r2 = spawnSync(process.execPath, [path.join('scripts', 'build-hooks.js')], {
|
||||
cwd: REPO_ROOT,
|
||||
encoding: 'utf-8',
|
||||
timeout: 60000,
|
||||
});
|
||||
assert.equal(r2.status, 0,
|
||||
`build-hooks second run must exit 0; stderr=${r2.stderr.slice(0, 400)}`);
|
||||
|
||||
const snapshotB = snapshotDir(DIST_DIR);
|
||||
assert.equal(snapshotB.size, snapshotA.size,
|
||||
`hooks/dist/ file count must be stable: a=${snapshotA.size} b=${snapshotB.size}`);
|
||||
for (const [rel, hashA] of snapshotA) {
|
||||
const hashB = snapshotB.get(rel);
|
||||
assert.equal(hashB, hashA,
|
||||
`hooks/dist/${rel} changed between consecutive runs (atomic-write must produce identical output)`);
|
||||
}
|
||||
});
|
||||
|
||||
test('no orphaned .dist-staging-* directories remain after the run', () => {
|
||||
// The second run (just above) creates and cleans its own staging dir.
|
||||
// Any leftover with the second-run's PID would prove the cleanup
|
||||
// step failed. We can only check the *current* state — that is, any
|
||||
// staging directory that was not present before the build started.
|
||||
const stagingAfter = fs.readdirSync(HOOKS_DIR)
|
||||
.filter((n) => n.startsWith('.dist-staging-'));
|
||||
const orphaned = stagingAfter.filter((n) => !stagingBefore.includes(n));
|
||||
assert.deepEqual(orphaned, [],
|
||||
`build-hooks left orphaned staging directories: ${orphaned.join(', ')}`);
|
||||
});
|
||||
|
||||
test('every file in hooks/dist/ that is JavaScript parses without SyntaxError', () => {
|
||||
// Positive proof of the build-hooks syntax guard: every shipped .js
|
||||
// file must parse. If a SyntaxError survives the guard and lands in
|
||||
// dist/, this assertion fails with the file named.
|
||||
const vm = require('node:vm');
|
||||
for (const [rel] of snapshotDir(DIST_DIR)) {
|
||||
if (!rel.endsWith('.js')) continue;
|
||||
const src = fs.readFileSync(path.join(DIST_DIR, rel), 'utf-8');
|
||||
assert.doesNotThrow(
|
||||
() => new vm.Script(src, { filename: rel }),
|
||||
`hooks/dist/${rel} has a SyntaxError — build-hooks syntax guard let it through`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,201 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Regression tests for the requireFreshDist() staleness guard in gen-*.mjs scripts.
|
||||
*
|
||||
* For each generator, verifies:
|
||||
* 1. Exits 1 when sdk/dist file does not exist — error includes "does not exist"
|
||||
* and the `npm run build:sdk` hint.
|
||||
* 2. Exits 1 when TS source is newer than sdk/dist — error includes
|
||||
* "is stale relative to", both mtime timestamps, the TS path, and build hint.
|
||||
* 3. Exits 0 when sdk/dist is newer than TS source (fresh build).
|
||||
* Skipped per-generator if dist doesn't exist (expected before first build:sdk).
|
||||
*
|
||||
* Uses child_process.spawnSync to exercise the real gen-*.mjs entry path.
|
||||
*
|
||||
* Isolation: each subtest creates its own temp directory rooted at a unique
|
||||
* path and passes GSD_REPO_ROOT to the generator subprocess so requireFreshDist()
|
||||
* operates on temp fixtures instead of the real sdk/dist tree. This prevents
|
||||
* parallel test execution from seeing stale/missing dist files in the live tree.
|
||||
*/
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
|
||||
const REPO_ROOT = path.resolve(__dirname, '..');
|
||||
const SCRIPTS_DIR = path.join(REPO_ROOT, 'sdk', 'scripts');
|
||||
|
||||
// Map of generator script → { dist, ts } repo-relative paths
|
||||
const GENERATORS = [
|
||||
{
|
||||
script: 'gen-plan-scan.mjs',
|
||||
dist: 'sdk/dist/query/plan-scan.js',
|
||||
ts: 'sdk/src/query/plan-scan.ts',
|
||||
},
|
||||
{
|
||||
script: 'gen-secrets.mjs',
|
||||
dist: 'sdk/dist/query/secrets.js',
|
||||
ts: 'sdk/src/query/secrets.ts',
|
||||
},
|
||||
{
|
||||
script: 'gen-schema-detect.mjs',
|
||||
dist: 'sdk/dist/query/schema-detect.js',
|
||||
ts: 'sdk/src/query/schema-detect.ts',
|
||||
},
|
||||
{
|
||||
script: 'gen-decisions.mjs',
|
||||
dist: 'sdk/dist/query/decisions.js',
|
||||
ts: 'sdk/src/query/decisions.ts',
|
||||
},
|
||||
{
|
||||
script: 'gen-project-root.mjs',
|
||||
dist: 'sdk/dist/runtime/project-root.js',
|
||||
ts: 'sdk/src/runtime/project-root.ts',
|
||||
},
|
||||
{
|
||||
script: 'gen-workstream-inventory-builder.mjs',
|
||||
dist: 'sdk/dist/workstream/builder.js',
|
||||
ts: 'sdk/src/workstream/builder.ts',
|
||||
},
|
||||
{
|
||||
script: 'gen-workstream-name-policy.mjs',
|
||||
dist: 'sdk/dist/workstream-name-policy.js',
|
||||
ts: 'sdk/src/workstream-name-policy.ts',
|
||||
},
|
||||
{
|
||||
script: 'gen-validate.mjs',
|
||||
dist: 'sdk/dist/query/validate.js',
|
||||
ts: 'sdk/src/query/validate.ts',
|
||||
},
|
||||
{
|
||||
script: 'gen-configuration.mjs',
|
||||
dist: 'sdk/dist/config/index.js',
|
||||
ts: 'sdk/src/config/index.ts',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Run a gen script via spawnSync with an optional GSD_REPO_ROOT override.
|
||||
* Returns { status, stderr, stdout }.
|
||||
*/
|
||||
function runGen(scriptName, env = {}) {
|
||||
const scriptPath = path.join(SCRIPTS_DIR, scriptName);
|
||||
const result = spawnSync(process.execPath, [scriptPath], {
|
||||
cwd: REPO_ROOT,
|
||||
encoding: 'utf-8',
|
||||
timeout: 15000,
|
||||
env: { ...process.env, ...env },
|
||||
});
|
||||
return {
|
||||
status: result.status ?? 1,
|
||||
stdout: result.stdout || '',
|
||||
stderr: result.stderr || '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a minimal temp directory tree that mirrors the repo layout for
|
||||
* the given dist and ts paths. Returns { tmpRoot, distAbs, tsAbs, cleanup }.
|
||||
*
|
||||
* The real TS source is copied into the temp tree so its content is valid,
|
||||
* but the caller controls whether the dist file exists and what its mtime is.
|
||||
*/
|
||||
function makeTempTree(dist, ts) {
|
||||
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-staleness-'));
|
||||
const distAbs = path.join(tmpRoot, dist);
|
||||
const tsAbs = path.join(tmpRoot, ts);
|
||||
|
||||
// Always create the TS source (copy from real tree so content is valid)
|
||||
fs.mkdirSync(path.dirname(tsAbs), { recursive: true });
|
||||
const realTsAbs = path.join(REPO_ROOT, ts);
|
||||
fs.copyFileSync(realTsAbs, tsAbs);
|
||||
|
||||
function cleanup() {
|
||||
try { fs.rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
}
|
||||
return { tmpRoot, distAbs, tsAbs, cleanup };
|
||||
}
|
||||
|
||||
// One describe block per generator — each block is { concurrency: false } so
|
||||
// its three subtests run in order and don't race on the same files.
|
||||
for (const { script, dist, ts } of GENERATORS) {
|
||||
describe(`gen staleness guard — ${script}`, { concurrency: false }, () => {
|
||||
// ── Subtest A: dist file is missing ──────────────────────────────────────
|
||||
test('exits 1 with "does not exist" when dist file is absent', () => {
|
||||
const { tmpRoot, cleanup } = makeTempTree(dist, ts);
|
||||
// dist is NOT created — the temp tree has only the TS source
|
||||
try {
|
||||
const { status, stderr } = runGen(script, { GSD_REPO_ROOT: tmpRoot });
|
||||
assert.strictEqual(status, 1, `Expected exit 1, got ${status}. stderr: ${stderr}`);
|
||||
assert.match(stderr, /does not exist/, `Expected "does not exist". Got: ${stderr}`);
|
||||
assert.match(stderr, /npm run build/, `Expected build hint. Got: ${stderr}`);
|
||||
// Error message should name the dist path (repo-relative)
|
||||
assert.ok(stderr.includes(dist), `Expected dist path in message. Got: ${stderr}`);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
// ── Subtest B: TS source newer than dist ─────────────────────────────────
|
||||
test('exits 1 with stale-dist error when TS source is newer than dist', () => {
|
||||
const { tmpRoot, distAbs, tsAbs, cleanup } = makeTempTree(dist, ts);
|
||||
|
||||
// Create a fake dist file
|
||||
fs.mkdirSync(path.dirname(distAbs), { recursive: true });
|
||||
fs.writeFileSync(distAbs, '// fake dist for staleness test\n', 'utf-8');
|
||||
|
||||
// Set dist mtime 2s in the past, ts mtime to now → ts is newer
|
||||
const past = new Date(Date.now() - 2000);
|
||||
const now = new Date();
|
||||
fs.utimesSync(distAbs, past, past);
|
||||
fs.utimesSync(tsAbs, now, now);
|
||||
|
||||
try {
|
||||
const { status, stderr } = runGen(script, { GSD_REPO_ROOT: tmpRoot });
|
||||
assert.strictEqual(status, 1, `Expected exit 1 for stale dist, got ${status}. stderr: ${stderr}`);
|
||||
assert.match(stderr, /is stale relative to/, `Expected "is stale relative to". Got: ${stderr}`);
|
||||
assert.match(stderr, /npm run build/, `Expected build hint. Got: ${stderr}`);
|
||||
// Error must name the TS source path
|
||||
assert.ok(stderr.includes(ts), `Expected TS path "${ts}" in message. Got: ${stderr}`);
|
||||
// Error must include both mtime timestamps in ISO format
|
||||
assert.match(stderr, /dist mtime \d{4}-\d{2}-\d{2}T/, `Expected dist mtime in message. Got: ${stderr}`);
|
||||
assert.match(stderr, /ts mtime \d{4}-\d{2}-\d{2}T/, `Expected ts mtime in message. Got: ${stderr}`);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
// ── Subtest C: dist is fresh → exits 0 ───────────────────────────────────
|
||||
// This subtest requires a real built dist (the generator reads and transforms
|
||||
// its content), so it uses the actual sdk/dist tree rather than the temp dir.
|
||||
// It only mutates the real TS source mtime, not the dist file, so it is safe
|
||||
// to run in parallel: another parallel test process reading sdk/dist will not
|
||||
// be affected by a TS source mtime change.
|
||||
test('exits 0 when dist is newer than TS source', { skip: !fs.existsSync(path.join(REPO_ROOT, dist)) ? `${dist} not built` : false }, () => {
|
||||
const distAbs = path.join(REPO_ROOT, dist);
|
||||
const tsAbs = path.join(REPO_ROOT, ts);
|
||||
|
||||
const distStat = fs.statSync(distAbs);
|
||||
const origTsStat = fs.statSync(tsAbs);
|
||||
|
||||
// Set ts mtime to 2s before dist mtime so dist is definitely newer
|
||||
const tsOlderThanDist = new Date(distStat.mtimeMs - 2000);
|
||||
fs.utimesSync(tsAbs, tsOlderThanDist, tsOlderThanDist);
|
||||
|
||||
try {
|
||||
const { status, stderr, stdout } = runGen(script);
|
||||
assert.strictEqual(
|
||||
status,
|
||||
0,
|
||||
`Expected exit 0 for fresh dist, got ${status}. stderr: ${stderr}\nstdout: ${stdout}`,
|
||||
);
|
||||
} finally {
|
||||
fs.utimesSync(tsAbs, origTsStat.atime, origTsStat.mtime);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Parity test — verifies that plan-scan.generated.cjs produces identical
|
||||
* results to the compiled SDK ESM output for all exported functions.
|
||||
*
|
||||
* SDK side: import('../sdk/dist/query/plan-scan.js')
|
||||
* CJS side: require('../get-shit-done/bin/lib/plan-scan.generated.cjs')
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { createRequire } = require('node:module');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const fs = require('fs');
|
||||
const crypto = require('crypto');
|
||||
|
||||
/**
|
||||
* Build a unique-to-this-run path that is guaranteed not to exist. Hardcoded
|
||||
* `/tmp/...` paths are a flake source on shared CI runners where the path can
|
||||
* be left over from a prior run. We synthesize a random suffix under
|
||||
* `os.tmpdir()` and force-remove the path first.
|
||||
*/
|
||||
function uniqueMissingPath(prefix = 'gsd-missing') {
|
||||
const suffix = `${prefix}-${process.pid}-${Date.now()}-${crypto.randomBytes(6).toString('hex')}`;
|
||||
const p = path.join(os.tmpdir(), suffix);
|
||||
// The probability of collision is negligible, but force-clean anyway to make
|
||||
// the precondition explicit. Errors swallowed (path didn't exist — desired).
|
||||
try { fs.rmSync(p, { recursive: true, force: true }); } catch { /* noop */ }
|
||||
return p;
|
||||
}
|
||||
|
||||
const requireFromRoot = createRequire(__filename);
|
||||
|
||||
// CJS side — direct require works fine
|
||||
const cjs = requireFromRoot('../get-shit-done/bin/lib/plan-scan.generated.cjs');
|
||||
|
||||
// ── isRootPlanFile ────────────────────────────────────────────────────────
|
||||
|
||||
describe('plan-scan-generator parity: isRootPlanFile', async () => {
|
||||
const sdk = await import('../sdk/dist/query/plan-scan.js');
|
||||
|
||||
const fixtures = [
|
||||
{ label: 'accepts bare PLAN.md', name: 'PLAN.md', expected: true },
|
||||
{ label: 'accepts canonical -PLAN.md', name: '01-01-PLAN.md', expected: true },
|
||||
{ label: 'accepts extended PLAN-01-setup.md', name: 'PLAN-01-setup.md', expected: true },
|
||||
{ label: 'rejects -PLAN-OUTLINE.md', name: 'something-PLAN-OUTLINE.md', expected: false },
|
||||
{ label: 'rejects .pre-bounce.md', name: 'PLAN.pre-bounce.md', expected: false },
|
||||
{ label: 'rejects SUMMARY.md', name: 'SUMMARY.md', expected: false },
|
||||
{ label: 'rejects unrelated file', name: 'README.md', expected: false },
|
||||
];
|
||||
|
||||
for (const { label, name, expected } of fixtures) {
|
||||
test(label, () => {
|
||||
const sdkResult = sdk.isRootPlanFile(name);
|
||||
const cjsResult = cjs.isRootPlanFile(name);
|
||||
assert.strictEqual(sdkResult, expected, `SDK: ${label}`);
|
||||
assert.strictEqual(cjsResult, expected, `CJS: ${label}`);
|
||||
assert.strictEqual(sdkResult, cjsResult, `SDK/CJS parity: ${label}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ── isNestedPlanFile ──────────────────────────────────────────────────────
|
||||
|
||||
describe('plan-scan-generator parity: isNestedPlanFile', async () => {
|
||||
const sdk = await import('../sdk/dist/query/plan-scan.js');
|
||||
|
||||
const fixtures = [
|
||||
{ label: 'accepts PLAN-01-setup.md', name: 'PLAN-01-setup.md', expected: true },
|
||||
{ label: 'accepts 1-PLAN-01-setup.md', name: '1-PLAN-01-setup.md', expected: true },
|
||||
{ label: 'rejects PLAN-OUTLINE.md', name: 'PLAN-01-OUTLINE.md', expected: false },
|
||||
{ label: 'rejects .pre-bounce.md', name: 'PLAN-01.pre-bounce.md', expected: false },
|
||||
{ label: 'rejects bare PLAN.md', name: 'PLAN.md', expected: false },
|
||||
{ label: 'rejects unrelated file', name: 'SUMMARY-01-setup.md', expected: false },
|
||||
];
|
||||
|
||||
for (const { label, name, expected } of fixtures) {
|
||||
test(label, () => {
|
||||
const sdkResult = sdk.isNestedPlanFile(name);
|
||||
const cjsResult = cjs.isNestedPlanFile(name);
|
||||
assert.strictEqual(sdkResult, expected, `SDK: ${label}`);
|
||||
assert.strictEqual(cjsResult, expected, `CJS: ${label}`);
|
||||
assert.strictEqual(sdkResult, cjsResult, `SDK/CJS parity: ${label}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ── isRootSummaryFile ─────────────────────────────────────────────────────
|
||||
|
||||
describe('plan-scan-generator parity: isRootSummaryFile', async () => {
|
||||
const sdk = await import('../sdk/dist/query/plan-scan.js');
|
||||
|
||||
const fixtures = [
|
||||
{ label: 'accepts bare SUMMARY.md', name: 'SUMMARY.md', expected: true },
|
||||
{ label: 'accepts 01-01-SUMMARY.md', name: '01-01-SUMMARY.md', expected: true },
|
||||
{ label: 'rejects PLAN.md', name: 'PLAN.md', expected: false },
|
||||
{ label: 'rejects unrelated file', name: 'README.md', expected: false },
|
||||
];
|
||||
|
||||
for (const { label, name, expected } of fixtures) {
|
||||
test(label, () => {
|
||||
const sdkResult = sdk.isRootSummaryFile(name);
|
||||
const cjsResult = cjs.isRootSummaryFile(name);
|
||||
assert.strictEqual(sdkResult, expected, `SDK: ${label}`);
|
||||
assert.strictEqual(cjsResult, expected, `CJS: ${label}`);
|
||||
assert.strictEqual(sdkResult, cjsResult, `SDK/CJS parity: ${label}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ── isNestedSummaryFile ───────────────────────────────────────────────────
|
||||
|
||||
describe('plan-scan-generator parity: isNestedSummaryFile', async () => {
|
||||
const sdk = await import('../sdk/dist/query/plan-scan.js');
|
||||
|
||||
const fixtures = [
|
||||
{ label: 'accepts SUMMARY-01-summary.md', name: 'SUMMARY-01-summary.md', expected: true },
|
||||
{ label: 'accepts 1-SUMMARY-01.md', name: '1-SUMMARY-01.md', expected: true },
|
||||
{ label: 'rejects bare SUMMARY.md', name: 'SUMMARY.md', expected: false },
|
||||
{ label: 'rejects PLAN file', name: 'PLAN-01-setup.md', expected: false },
|
||||
];
|
||||
|
||||
for (const { label, name, expected } of fixtures) {
|
||||
test(label, () => {
|
||||
const sdkResult = sdk.isNestedSummaryFile(name);
|
||||
const cjsResult = cjs.isNestedSummaryFile(name);
|
||||
assert.strictEqual(sdkResult, expected, `SDK: ${label}`);
|
||||
assert.strictEqual(cjsResult, expected, `CJS: ${label}`);
|
||||
assert.strictEqual(sdkResult, cjsResult, `SDK/CJS parity: ${label}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ── scanPhasePlans ────────────────────────────────────────────────────────
|
||||
|
||||
describe('plan-scan-generator parity: scanPhasePlans (non-existent dir)', async () => {
|
||||
const sdk = await import('../sdk/dist/query/plan-scan.js');
|
||||
|
||||
test('returns zero counts for non-existent directory', () => {
|
||||
const nonExistent = uniqueMissingPath('gsd-plan-scan-nonexistent');
|
||||
const sdkResult = sdk.scanPhasePlans(nonExistent);
|
||||
const cjsResult = cjs.scanPhasePlans(nonExistent);
|
||||
assert.deepStrictEqual(sdkResult, {
|
||||
planCount: 0,
|
||||
summaryCount: 0,
|
||||
completed: false,
|
||||
hasNestedPlans: false,
|
||||
planFiles: [],
|
||||
summaryFiles: [],
|
||||
});
|
||||
assert.deepStrictEqual(sdkResult, cjsResult, 'SDK/CJS parity: non-existent dir');
|
||||
});
|
||||
});
|
||||
|
||||
describe('plan-scan-generator parity: scanPhasePlans (flat layout)', async () => {
|
||||
const sdk = await import('../sdk/dist/query/plan-scan.js');
|
||||
|
||||
test('detects flat plan and summary files', () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-plan-scan-test-'));
|
||||
try {
|
||||
fs.writeFileSync(path.join(tmpDir, '01-01-PLAN.md'), '# Plan');
|
||||
fs.writeFileSync(path.join(tmpDir, '01-01-SUMMARY.md'), '# Summary');
|
||||
fs.writeFileSync(path.join(tmpDir, 'README.md'), '# Readme');
|
||||
|
||||
const sdkResult = sdk.scanPhasePlans(tmpDir);
|
||||
const cjsResult = cjs.scanPhasePlans(tmpDir);
|
||||
|
||||
assert.strictEqual(sdkResult.planCount, 1, 'SDK: planCount');
|
||||
assert.strictEqual(sdkResult.summaryCount, 1, 'SDK: summaryCount');
|
||||
assert.strictEqual(sdkResult.completed, true, 'SDK: completed');
|
||||
assert.strictEqual(sdkResult.hasNestedPlans, false, 'SDK: hasNestedPlans');
|
||||
assert.deepStrictEqual(sdkResult, cjsResult, 'SDK/CJS parity: flat layout');
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('plan-scan-generator parity: module.exports call style', async () => {
|
||||
test('default export is callable as function (CJS caller pattern)', () => {
|
||||
// CJS callers do: const scanPhasePlans = require('./plan-scan.cjs')
|
||||
// then call it directly: scanPhasePlans(phaseDir)
|
||||
assert.strictEqual(typeof cjs, 'function', 'default export is a function');
|
||||
const result = cjs(uniqueMissingPath('gsd-plan-scan-cjs-default'));
|
||||
assert.deepStrictEqual(result, {
|
||||
planCount: 0,
|
||||
summaryCount: 0,
|
||||
completed: false,
|
||||
hasNestedPlans: false,
|
||||
planFiles: [],
|
||||
summaryFiles: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,152 +0,0 @@
|
||||
'use strict';
|
||||
/**
|
||||
* CJS parity test — project-root module
|
||||
*
|
||||
* For every fixture from sdk/src/runtime/project-root.test.ts, asserts that
|
||||
* both the SDK (ESM, via dynamic import) and the generated CJS artifact
|
||||
* return identical paths. This confirms that the generator correctly
|
||||
* captures the function body and that all dependencies (sep, dirname,
|
||||
* relative, etc.) are properly shimmed in the CJS preamble.
|
||||
*/
|
||||
|
||||
const { describe, it, before, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
|
||||
// CJS artifact — synchronous require
|
||||
const { findProjectRoot: findProjectRootCjs } = require('../get-shit-done/bin/lib/project-root.generated.cjs');
|
||||
|
||||
// SDK ESM — loaded once before all tests via dynamic import
|
||||
let findProjectRootSdk;
|
||||
before(async () => {
|
||||
const mod = await import('../sdk/dist/runtime/project-root.js');
|
||||
findProjectRootSdk = mod.findProjectRoot;
|
||||
});
|
||||
|
||||
// ── Fixture helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
const { createTempDir } = require('./helpers.cjs');
|
||||
const makeTmp = () => createTempDir('gsd-parity-');
|
||||
|
||||
function writeConfig(dir, content) {
|
||||
fs.mkdirSync(path.join(dir, '.planning'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, '.planning', 'config.json'), JSON.stringify(content));
|
||||
}
|
||||
|
||||
function assertParity(startDir) {
|
||||
const sdkResult = findProjectRootSdk(startDir);
|
||||
const cjsResult = findProjectRootCjs(startDir);
|
||||
assert.strictEqual(
|
||||
cjsResult,
|
||||
sdkResult,
|
||||
`parity failure for startDir="${startDir}": SDK="${sdkResult}" CJS="${cjsResult}"`,
|
||||
);
|
||||
return sdkResult;
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('project-root CJS/SDK parity', () => {
|
||||
let workspace;
|
||||
|
||||
before(() => {
|
||||
workspace = makeTmp();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean the workspace tree and recreate fresh for next test
|
||||
try { fs.rmSync(workspace, { recursive: true, force: true }); } catch {}
|
||||
workspace = makeTmp();
|
||||
});
|
||||
|
||||
it('heuristic 0: startDir has own .planning/ — returns startDir', () => {
|
||||
fs.mkdirSync(path.join(workspace, '.planning'), { recursive: true });
|
||||
assertParity(workspace);
|
||||
});
|
||||
|
||||
it('no ancestor .planning/ — returns startDir', () => {
|
||||
assertParity(workspace);
|
||||
});
|
||||
|
||||
it('heuristic 1: parent .planning/config.json lists child in sub_repos', () => {
|
||||
writeConfig(workspace, { sub_repos: ['child'] });
|
||||
const child = path.join(workspace, 'child');
|
||||
fs.mkdirSync(path.join(child, '.git'), { recursive: true });
|
||||
const result = assertParity(child);
|
||||
assert.strictEqual(result, workspace);
|
||||
});
|
||||
|
||||
it('heuristic 1 nested: deeply nested dir inside sub_repo', () => {
|
||||
writeConfig(workspace, { sub_repos: ['child'] });
|
||||
const nested = path.join(workspace, 'child', 'src', 'utils');
|
||||
fs.mkdirSync(path.join(workspace, 'child', '.git'), { recursive: true });
|
||||
fs.mkdirSync(nested, { recursive: true });
|
||||
const result = assertParity(nested);
|
||||
assert.strictEqual(result, workspace);
|
||||
});
|
||||
|
||||
it('heuristic 1 nested key: planning.sub_repos config shape', () => {
|
||||
writeConfig(workspace, { planning: { sub_repos: ['child'] } });
|
||||
const child = path.join(workspace, 'child');
|
||||
fs.mkdirSync(path.join(child, '.git'), { recursive: true });
|
||||
const result = assertParity(child);
|
||||
assert.strictEqual(result, workspace);
|
||||
});
|
||||
|
||||
it('heuristic 2: multiRepo: true with .git in ancestor chain', () => {
|
||||
writeConfig(workspace, { multiRepo: true });
|
||||
const child = path.join(workspace, 'child');
|
||||
fs.mkdirSync(path.join(child, '.git'), { recursive: true });
|
||||
const result = assertParity(child);
|
||||
assert.strictEqual(result, workspace);
|
||||
});
|
||||
|
||||
it('heuristic 3: parent has .planning/ and child has .git, no config', () => {
|
||||
fs.mkdirSync(path.join(workspace, '.planning'), { recursive: true });
|
||||
const child = path.join(workspace, 'child');
|
||||
fs.mkdirSync(path.join(child, '.git'), { recursive: true });
|
||||
const result = assertParity(child);
|
||||
assert.strictEqual(result, workspace);
|
||||
});
|
||||
|
||||
it('malformed config.json falls back to heuristic 3', () => {
|
||||
fs.mkdirSync(path.join(workspace, '.planning'), { recursive: true });
|
||||
fs.writeFileSync(path.join(workspace, '.planning', 'config.json'), '{ not json');
|
||||
const child = path.join(workspace, 'child');
|
||||
fs.mkdirSync(path.join(child, '.git'), { recursive: true });
|
||||
const result = assertParity(child);
|
||||
assert.strictEqual(result, workspace);
|
||||
});
|
||||
|
||||
it('empty sub_repos with no .git — returns startDir', () => {
|
||||
writeConfig(workspace, { sub_repos: [] });
|
||||
const child = path.join(workspace, 'child');
|
||||
fs.mkdirSync(child, { recursive: true });
|
||||
const result = assertParity(child);
|
||||
assert.strictEqual(result, child);
|
||||
});
|
||||
|
||||
it('#1362: child has own .planning/ — returns child not workspace', () => {
|
||||
writeConfig(workspace, { sub_repos: ['child'] });
|
||||
const child = path.join(workspace, 'child');
|
||||
fs.mkdirSync(path.join(child, '.planning'), { recursive: true });
|
||||
const result = assertParity(child);
|
||||
assert.strictEqual(result, child);
|
||||
});
|
||||
|
||||
it('depth limit: .planning/ is 12 levels up — returns startDir (depth=10 cap)', () => {
|
||||
// CANONICALIZATION NOTE: SDK has FIND_PROJECT_ROOT_MAX_DEPTH=10; CJS now
|
||||
// also uses 10 (was unbounded before). At 12 levels the walk stops before
|
||||
// reaching workspace, so startDir is returned.
|
||||
fs.mkdirSync(path.join(workspace, '.planning'), { recursive: true });
|
||||
let dir = workspace;
|
||||
for (let i = 1; i <= 12; i++) {
|
||||
dir = path.join(dir, `l${i}`);
|
||||
}
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const result = assertParity(dir);
|
||||
assert.strictEqual(result, dir);
|
||||
});
|
||||
});
|
||||
@@ -1,195 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Parity test — verifies that schema-detect.generated.cjs produces identical
|
||||
* results to the compiled SDK ESM output for all exported functions.
|
||||
*
|
||||
* SDK side: import('../sdk/dist/query/schema-detect.js')
|
||||
* CJS side: require('../get-shit-done/bin/lib/schema-detect.generated.cjs')
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { createRequire } = require('node:module');
|
||||
|
||||
const requireFromRoot = createRequire(__filename);
|
||||
|
||||
// CJS side — direct require works fine
|
||||
const cjs = requireFromRoot('../get-shit-done/bin/lib/schema-detect.generated.cjs');
|
||||
|
||||
// ── detectSchemaFiles ─────────────────────────────────────────────────────
|
||||
|
||||
describe('schema-detect-generator parity: detectSchemaFiles', async () => {
|
||||
const sdk = await import('../sdk/dist/query/schema-detect.js');
|
||||
|
||||
const fixtures = [
|
||||
{
|
||||
label: 'detects prisma schema',
|
||||
files: ['prisma/schema.prisma'],
|
||||
expectedDetected: true,
|
||||
expectedOrms: ['prisma'],
|
||||
},
|
||||
{
|
||||
label: 'detects drizzle schema',
|
||||
files: ['drizzle/schema.ts'],
|
||||
expectedDetected: true,
|
||||
expectedOrms: ['drizzle'],
|
||||
},
|
||||
{
|
||||
label: 'detects supabase migration',
|
||||
files: ['supabase/migrations/001_init.sql'],
|
||||
expectedDetected: true,
|
||||
expectedOrms: ['supabase'],
|
||||
},
|
||||
{
|
||||
label: 'detects payload collection',
|
||||
files: ['src/collections/Users.ts'],
|
||||
expectedDetected: true,
|
||||
expectedOrms: ['payload'],
|
||||
},
|
||||
{
|
||||
label: 'detects typeorm entity',
|
||||
files: ['src/entities/User.ts'],
|
||||
expectedDetected: true,
|
||||
expectedOrms: ['typeorm'],
|
||||
},
|
||||
{
|
||||
label: 'no schema files returns not detected',
|
||||
files: ['src/components/Button.tsx', 'src/styles/main.css'],
|
||||
expectedDetected: false,
|
||||
expectedOrms: [],
|
||||
},
|
||||
{
|
||||
label: 'multiple ORMs detected',
|
||||
files: ['prisma/schema.prisma', 'drizzle/schema.ts'],
|
||||
expectedDetected: true,
|
||||
expectedOrms: ['prisma', 'drizzle'],
|
||||
},
|
||||
{
|
||||
label: 'normalizes Windows backslash paths',
|
||||
files: ['prisma\\schema.prisma'],
|
||||
expectedDetected: true,
|
||||
expectedOrms: ['prisma'],
|
||||
},
|
||||
{
|
||||
label: 'empty file list returns not detected',
|
||||
files: [],
|
||||
expectedDetected: false,
|
||||
expectedOrms: [],
|
||||
},
|
||||
];
|
||||
|
||||
for (const { label, files, expectedDetected, expectedOrms } of fixtures) {
|
||||
test(label, () => {
|
||||
const sdkResult = sdk.detectSchemaFiles(files);
|
||||
const cjsResult = cjs.detectSchemaFiles(files);
|
||||
|
||||
assert.strictEqual(sdkResult.detected, expectedDetected, `SDK detected: ${label}`);
|
||||
assert.deepStrictEqual(sdkResult.orms.sort(), expectedOrms.sort(), `SDK orms: ${label}`);
|
||||
|
||||
assert.strictEqual(cjsResult.detected, expectedDetected, `CJS detected: ${label}`);
|
||||
assert.deepStrictEqual(cjsResult.orms.sort(), expectedOrms.sort(), `CJS orms: ${label}`);
|
||||
|
||||
// SDK and CJS must agree on detected and orms
|
||||
assert.strictEqual(sdkResult.detected, cjsResult.detected, `SDK/CJS parity detected: ${label}`);
|
||||
assert.deepStrictEqual(sdkResult.orms.sort(), cjsResult.orms.sort(), `SDK/CJS parity orms: ${label}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ── checkSchemaDrift ──────────────────────────────────────────────────────
|
||||
|
||||
describe('schema-detect-generator parity: checkSchemaDrift', async () => {
|
||||
const sdk = await import('../sdk/dist/query/schema-detect.js');
|
||||
|
||||
const fixtures = [
|
||||
{
|
||||
label: 'no schema files — no drift',
|
||||
changedFiles: ['src/components/Button.tsx'],
|
||||
executionLog: '',
|
||||
options: {},
|
||||
expectedDriftDetected: false,
|
||||
expectedBlocking: false,
|
||||
},
|
||||
{
|
||||
label: 'prisma changed with push evidence — no drift',
|
||||
changedFiles: ['prisma/schema.prisma'],
|
||||
executionLog: 'running: npx prisma db push --accept-data-loss',
|
||||
options: {},
|
||||
expectedDriftDetected: false,
|
||||
expectedBlocking: false,
|
||||
},
|
||||
{
|
||||
label: 'prisma changed without push — drift blocking',
|
||||
changedFiles: ['prisma/schema.prisma'],
|
||||
executionLog: 'tsc && vitest run',
|
||||
options: {},
|
||||
expectedDriftDetected: true,
|
||||
expectedBlocking: true,
|
||||
},
|
||||
{
|
||||
label: 'drift with skipCheck=true — not blocking',
|
||||
changedFiles: ['prisma/schema.prisma'],
|
||||
executionLog: 'tsc && vitest run',
|
||||
options: { skipCheck: true },
|
||||
expectedDriftDetected: true,
|
||||
expectedBlocking: false,
|
||||
},
|
||||
];
|
||||
|
||||
for (const { label, changedFiles, executionLog, options, expectedDriftDetected, expectedBlocking } of fixtures) {
|
||||
test(label, () => {
|
||||
const sdkResult = sdk.checkSchemaDrift(changedFiles, executionLog, options);
|
||||
const cjsResult = cjs.checkSchemaDrift(changedFiles, executionLog, options);
|
||||
|
||||
assert.strictEqual(sdkResult.driftDetected, expectedDriftDetected, `SDK driftDetected: ${label}`);
|
||||
assert.strictEqual(sdkResult.blocking, expectedBlocking, `SDK blocking: ${label}`);
|
||||
|
||||
assert.strictEqual(cjsResult.driftDetected, expectedDriftDetected, `CJS driftDetected: ${label}`);
|
||||
assert.strictEqual(cjsResult.blocking, expectedBlocking, `CJS blocking: ${label}`);
|
||||
|
||||
// Full structural parity between SDK and CJS
|
||||
assert.deepStrictEqual(sdkResult, cjsResult, `SDK/CJS parity: ${label}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ── detectSchemaOrm (CJS-only compat export) ──────────────────────────────
|
||||
|
||||
describe('schema-detect-generator parity: detectSchemaOrm (CJS compat)', () => {
|
||||
test('returns ORM info for known orm', () => {
|
||||
const info = cjs.detectSchemaOrm('prisma');
|
||||
assert.ok(info !== null, 'prisma orm info should not be null');
|
||||
assert.ok(typeof info.pushCommand === 'string', 'pushCommand should be string');
|
||||
assert.ok(Array.isArray(info.evidencePatterns), 'evidencePatterns should be array');
|
||||
});
|
||||
|
||||
test('returns null for unknown orm', () => {
|
||||
const info = cjs.detectSchemaOrm('unknown_orm');
|
||||
assert.strictEqual(info, null, 'unknown orm should return null');
|
||||
});
|
||||
|
||||
test('returns info for all 5 known ORMs', () => {
|
||||
const orms = ['payload', 'prisma', 'drizzle', 'supabase', 'typeorm'];
|
||||
for (const orm of orms) {
|
||||
const info = cjs.detectSchemaOrm(orm);
|
||||
assert.ok(info !== null, `${orm} info should not be null`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── SCHEMA_PATTERNS and ORM_INFO exports (compat) ────────────────────────
|
||||
|
||||
describe('schema-detect-generator: SCHEMA_PATTERNS and ORM_INFO exported', () => {
|
||||
test('SCHEMA_PATTERNS is an array', () => {
|
||||
assert.ok(Array.isArray(cjs.SCHEMA_PATTERNS), 'SCHEMA_PATTERNS should be an array');
|
||||
assert.ok(cjs.SCHEMA_PATTERNS.length > 0, 'SCHEMA_PATTERNS should not be empty');
|
||||
});
|
||||
|
||||
test('ORM_INFO has known orm keys', () => {
|
||||
const orms = ['payload', 'prisma', 'drizzle', 'supabase', 'typeorm'];
|
||||
for (const orm of orms) {
|
||||
assert.ok(orm in cjs.ORM_INFO, `ORM_INFO should have key: ${orm}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,136 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Parity test — verifies that secrets.generated.cjs produces identical
|
||||
* results to the compiled SDK ESM output for all exported functions.
|
||||
*
|
||||
* SDK side: import('../sdk/dist/query/secrets.js')
|
||||
* CJS side: require('../get-shit-done/bin/lib/secrets.generated.cjs')
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { createRequire } = require('node:module');
|
||||
|
||||
const requireFromRoot = createRequire(__filename);
|
||||
|
||||
// CJS side — direct require works fine
|
||||
const cjs = requireFromRoot('../get-shit-done/bin/lib/secrets.generated.cjs');
|
||||
|
||||
// ── SECRET_CONFIG_KEYS ────────────────────────────────────────────────────
|
||||
|
||||
describe('secrets-generator parity: SECRET_CONFIG_KEYS', async () => {
|
||||
const sdk = await import('../sdk/dist/query/secrets.js');
|
||||
|
||||
test('contains same keys as SDK', () => {
|
||||
const sdkKeys = [...sdk.SECRET_CONFIG_KEYS].sort();
|
||||
const cjsKeys = [...cjs.SECRET_CONFIG_KEYS].sort();
|
||||
assert.deepStrictEqual(cjsKeys, sdkKeys, 'SDK/CJS parity: SECRET_CONFIG_KEYS');
|
||||
});
|
||||
|
||||
test('contains brave_search', () => {
|
||||
assert.ok(cjs.SECRET_CONFIG_KEYS.has('brave_search'));
|
||||
assert.ok(sdk.SECRET_CONFIG_KEYS.has('brave_search'));
|
||||
});
|
||||
|
||||
test('contains firecrawl', () => {
|
||||
assert.ok(cjs.SECRET_CONFIG_KEYS.has('firecrawl'));
|
||||
assert.ok(sdk.SECRET_CONFIG_KEYS.has('firecrawl'));
|
||||
});
|
||||
|
||||
test('contains exa_search', () => {
|
||||
assert.ok(cjs.SECRET_CONFIG_KEYS.has('exa_search'));
|
||||
assert.ok(sdk.SECRET_CONFIG_KEYS.has('exa_search'));
|
||||
});
|
||||
});
|
||||
|
||||
// ── isSecretKey ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('secrets-generator parity: isSecretKey', async () => {
|
||||
const sdk = await import('../sdk/dist/query/secrets.js');
|
||||
|
||||
const fixtures = [
|
||||
{ label: 'brave_search is secret', key: 'brave_search', expected: true },
|
||||
{ label: 'firecrawl is secret', key: 'firecrawl', expected: true },
|
||||
{ label: 'exa_search is secret', key: 'exa_search', expected: true },
|
||||
{ label: 'non-secret key returns false', key: 'model', expected: false },
|
||||
{ label: 'empty string returns false', key: '', expected: false },
|
||||
{ label: 'unrelated string returns false', key: 'api_key', expected: false },
|
||||
];
|
||||
|
||||
for (const { label, key, expected } of fixtures) {
|
||||
test(label, () => {
|
||||
const sdkResult = sdk.isSecretKey(key);
|
||||
const cjsResult = cjs.isSecretKey(key);
|
||||
assert.strictEqual(sdkResult, expected, `SDK: ${label}`);
|
||||
assert.strictEqual(cjsResult, expected, `CJS: ${label}`);
|
||||
assert.strictEqual(sdkResult, cjsResult, `SDK/CJS parity: ${label}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ── maskSecret ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('secrets-generator parity: maskSecret', async () => {
|
||||
const sdk = await import('../sdk/dist/query/secrets.js');
|
||||
|
||||
const fixtures = [
|
||||
{ label: 'null returns (unset)', value: null, expected: '(unset)' },
|
||||
{ label: 'undefined returns (unset)', value: undefined, expected: '(unset)' },
|
||||
{ label: 'empty string returns (unset)', value: '', expected: '(unset)' },
|
||||
{ label: 'short string (< 8) returns ****', value: 'abc', expected: '****' },
|
||||
{ label: '7-char string returns ****', value: '1234567', expected: '****' },
|
||||
{ label: '8-char string returns ****<last-4>', value: '12345678', expected: '****5678' },
|
||||
{ label: 'long string returns ****<last-4>', value: 'sk-ant-abc123def456', expected: '****f456' },
|
||||
];
|
||||
|
||||
for (const { label, value, expected } of fixtures) {
|
||||
test(label, () => {
|
||||
const sdkResult = sdk.maskSecret(value);
|
||||
const cjsResult = cjs.maskSecret(value);
|
||||
assert.strictEqual(sdkResult, expected, `SDK: ${label}`);
|
||||
assert.strictEqual(cjsResult, expected, `CJS: ${label}`);
|
||||
assert.strictEqual(sdkResult, cjsResult, `SDK/CJS parity: ${label}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ── maskIfSecret ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('secrets-generator parity: maskIfSecret', async () => {
|
||||
const sdk = await import('../sdk/dist/query/secrets.js');
|
||||
|
||||
const fixtures = [
|
||||
{
|
||||
label: 'secret key gets masked',
|
||||
key: 'brave_search',
|
||||
value: 'sk-ant-12345678',
|
||||
expectedType: 'string',
|
||||
expectedValue: '****5678',
|
||||
},
|
||||
{
|
||||
label: 'non-secret key returns value unchanged',
|
||||
key: 'model',
|
||||
value: 'claude-opus-4-5',
|
||||
expectedType: 'string',
|
||||
expectedValue: 'claude-opus-4-5',
|
||||
},
|
||||
{
|
||||
label: 'secret key with null value returns (unset)',
|
||||
key: 'firecrawl',
|
||||
value: null,
|
||||
expectedType: 'string',
|
||||
expectedValue: '(unset)',
|
||||
},
|
||||
];
|
||||
|
||||
for (const { label, key, value, expectedValue } of fixtures) {
|
||||
test(label, () => {
|
||||
const sdkResult = sdk.maskIfSecret(key, value);
|
||||
const cjsResult = cjs.maskIfSecret(key, value);
|
||||
assert.strictEqual(sdkResult, expectedValue, `SDK: ${label}`);
|
||||
assert.strictEqual(cjsResult, expectedValue, `CJS: ${label}`);
|
||||
assert.strictEqual(sdkResult, cjsResult, `SDK/CJS parity: ${label}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,261 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Parity test — verifies that state-document.generated.cjs produces identical
|
||||
* results to the compiled SDK ESM output for all exported functions.
|
||||
*
|
||||
* SDK side: require('../sdk/dist/state/index.js') via createRequire
|
||||
* CJS side: require('../get-shit-done/bin/lib/state-document.generated.cjs')
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { createRequire } = require('node:module');
|
||||
|
||||
// The SDK dist is ESM; wrap with createRequire targeting the project root so
|
||||
// Node resolves the path correctly from this CJS context.
|
||||
const requireFromRoot = createRequire(__filename);
|
||||
|
||||
// CJS side — direct require works fine
|
||||
const cjs = requireFromRoot('../get-shit-done/bin/lib/state-document.generated.cjs');
|
||||
|
||||
describe('state-document-generator parity: stateReplaceFieldWithFallback', async () => {
|
||||
const sdk = await import('../sdk/dist/state/index.js');
|
||||
|
||||
const fixtures = [
|
||||
{
|
||||
label: 'primary hit',
|
||||
content: 'Status: old\nState: backup',
|
||||
primary: 'Status',
|
||||
fallback: 'State',
|
||||
value: 'new',
|
||||
expected: 'Status: new\nState: backup',
|
||||
},
|
||||
{
|
||||
label: 'fallback hit',
|
||||
content: 'Other: something\nState: backup',
|
||||
primary: 'Status',
|
||||
fallback: 'State',
|
||||
value: 'new',
|
||||
expected: 'Other: something\nState: new',
|
||||
},
|
||||
{
|
||||
label: 'neither hit returns unchanged content',
|
||||
content: 'Other: something\nAnother: value',
|
||||
primary: 'Status',
|
||||
fallback: 'State',
|
||||
value: 'new',
|
||||
expected: 'Other: something\nAnother: value',
|
||||
},
|
||||
];
|
||||
|
||||
for (const { label, content, primary, fallback, value, expected } of fixtures) {
|
||||
test(label, () => {
|
||||
const sdkResult = sdk.stateReplaceFieldWithFallback(content, primary, fallback, value);
|
||||
const cjsResult = cjs.stateReplaceFieldWithFallback(content, primary, fallback, value);
|
||||
assert.strictEqual(sdkResult, expected, `SDK: ${label}`);
|
||||
assert.strictEqual(cjsResult, expected, `CJS: ${label}`);
|
||||
assert.strictEqual(sdkResult, cjsResult, `SDK/CJS parity: ${label}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('state-document-generator parity: normalizeStateStatus', async () => {
|
||||
const sdk = await import('../sdk/dist/state/index.js');
|
||||
|
||||
const fixtures = [
|
||||
{ label: 'paused via "paused"', status: 'paused', expected: 'paused' },
|
||||
{ label: 'paused via "stopped"', status: 'stopped', expected: 'paused' },
|
||||
{ label: 'paused via non-null pausedAt', status: 'active', pausedAt: '2024-01-01', expected: 'paused' },
|
||||
{ label: 'executing via "executing"', status: 'executing', expected: 'executing' },
|
||||
{ label: 'executing via "in progress"', status: 'in progress', expected: 'executing' },
|
||||
{ label: 'executing via "ready to execute"', status: 'ready to execute', expected: 'executing' },
|
||||
{ label: 'planning via "planning"', status: 'planning', expected: 'planning' },
|
||||
{ label: 'discussing via "discussing"', status: 'discussing', expected: 'discussing' },
|
||||
{ label: 'verifying via "verif"', status: 'verifying', expected: 'verifying' },
|
||||
{ label: 'completed via "complete"', status: 'completed', expected: 'completed' },
|
||||
{ label: 'completed via "done"', status: 'done', expected: 'completed' },
|
||||
{ label: 'unknown fallback', status: 'something-else', expected: 'something-else' },
|
||||
{ label: 'null status', status: null, expected: 'unknown' },
|
||||
];
|
||||
|
||||
for (const { label, status, pausedAt, expected } of fixtures) {
|
||||
test(label, () => {
|
||||
const sdkResult = sdk.normalizeStateStatus(status, pausedAt);
|
||||
const cjsResult = cjs.normalizeStateStatus(status, pausedAt);
|
||||
assert.strictEqual(sdkResult, expected, `SDK: ${label}`);
|
||||
assert.strictEqual(cjsResult, expected, `CJS: ${label}`);
|
||||
assert.strictEqual(sdkResult, cjsResult, `SDK/CJS parity: ${label}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('state-document-generator parity: computeProgressPercent', async () => {
|
||||
const sdk = await import('../sdk/dist/state/index.js');
|
||||
|
||||
const fixtures = [
|
||||
{ label: 'only plans data', cp: 3, tp: 10, cf: null, tf: null, expected: 30 },
|
||||
{ label: 'only phases data', cp: null, tp: null, cf: 2, tf: 4, expected: 50 },
|
||||
{ label: 'both present uses min', cp: 8, tp: 10, cf: 3, tf: 10, expected: 30 },
|
||||
{ label: 'neither returns null', cp: null, tp: null, cf: null, tf: null, expected: null },
|
||||
{ label: 'total of 0 treated as no data', cp: 0, tp: 0, cf: null, tf: null, expected: null },
|
||||
];
|
||||
|
||||
for (const { label, cp, tp, cf, tf, expected } of fixtures) {
|
||||
test(label, () => {
|
||||
const sdkResult = sdk.computeProgressPercent(cp, tp, cf, tf);
|
||||
const cjsResult = cjs.computeProgressPercent(cp, tp, cf, tf);
|
||||
assert.strictEqual(sdkResult, expected, `SDK: ${label}`);
|
||||
assert.strictEqual(cjsResult, expected, `CJS: ${label}`);
|
||||
assert.strictEqual(sdkResult, cjsResult, `SDK/CJS parity: ${label}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('state-document-generator parity: shouldPreserveExistingProgress', async () => {
|
||||
const sdk = await import('../sdk/dist/state/index.js');
|
||||
|
||||
const fixtures = [
|
||||
{
|
||||
label: 'existing exceeds derived on total_phases → true',
|
||||
existing: { total_phases: 10 },
|
||||
derived: { total_phases: 5 },
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
label: 'derived exceeds existing → false',
|
||||
existing: { total_phases: 5 },
|
||||
derived: { total_phases: 10 },
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
label: 'malformed input (non-object) → false',
|
||||
existing: null,
|
||||
derived: { total_phases: 5 },
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
label: 'both null → false',
|
||||
existing: null,
|
||||
derived: null,
|
||||
expected: false,
|
||||
},
|
||||
];
|
||||
|
||||
for (const { label, existing, derived, expected } of fixtures) {
|
||||
test(label, () => {
|
||||
const sdkResult = sdk.shouldPreserveExistingProgress(existing, derived);
|
||||
const cjsResult = cjs.shouldPreserveExistingProgress(existing, derived);
|
||||
assert.strictEqual(sdkResult, expected, `SDK: ${label}`);
|
||||
assert.strictEqual(cjsResult, expected, `CJS: ${label}`);
|
||||
assert.strictEqual(sdkResult, cjsResult, `SDK/CJS parity: ${label}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('state-document-generator parity: normalizeProgressNumbers', async () => {
|
||||
const sdk = await import('../sdk/dist/state/index.js');
|
||||
|
||||
const fixtures = [
|
||||
{
|
||||
label: 'coerces all five tracked keys to numbers',
|
||||
input: { total_phases: '10', completed_phases: '3', total_plans: '5', completed_plans: '2', percent: '60' },
|
||||
expected: { total_phases: 10, completed_phases: 3, total_plans: 5, completed_plans: 2, percent: 60 },
|
||||
},
|
||||
{
|
||||
label: 'non-object null returned unchanged',
|
||||
input: null,
|
||||
expected: null,
|
||||
},
|
||||
{
|
||||
label: 'extra keys preserved untouched',
|
||||
input: { total_phases: '4', extra_key: 'hello' },
|
||||
expected: { total_phases: 4, extra_key: 'hello' },
|
||||
},
|
||||
];
|
||||
|
||||
for (const { label, input, expected } of fixtures) {
|
||||
test(label, () => {
|
||||
const sdkResult = sdk.normalizeProgressNumbers(input);
|
||||
const cjsResult = cjs.normalizeProgressNumbers(input);
|
||||
assert.deepStrictEqual(sdkResult, expected, `SDK: ${label}`);
|
||||
assert.deepStrictEqual(cjsResult, expected, `CJS: ${label}`);
|
||||
assert.deepStrictEqual(sdkResult, cjsResult, `SDK/CJS parity: ${label}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// SDK ESM side — dynamically import so we can test both; wrap in a top-level
|
||||
// async test suite.
|
||||
describe('state-document-generator parity: stateExtractField', async () => {
|
||||
const sdk = await import('../sdk/dist/state/index.js');
|
||||
|
||||
const fixtures = [
|
||||
{
|
||||
label: 'bold pattern',
|
||||
content: 'Some content\n**FieldName:** the value\nMore content',
|
||||
fieldName: 'FieldName',
|
||||
expected: 'the value',
|
||||
},
|
||||
{
|
||||
label: 'plain pattern',
|
||||
content: 'Some content\nFieldName: the value\nMore content',
|
||||
fieldName: 'FieldName',
|
||||
expected: 'the value',
|
||||
},
|
||||
{
|
||||
label: 'missing field returns null',
|
||||
content: 'Some content\nOtherField: something\nMore content',
|
||||
fieldName: 'FieldName',
|
||||
expected: null,
|
||||
},
|
||||
];
|
||||
|
||||
for (const { label, content, fieldName, expected } of fixtures) {
|
||||
test(label, () => {
|
||||
const sdkResult = sdk.stateExtractField(content, fieldName);
|
||||
const cjsResult = cjs.stateExtractField(content, fieldName);
|
||||
assert.strictEqual(sdkResult, expected, `SDK: ${label}`);
|
||||
assert.strictEqual(cjsResult, expected, `CJS: ${label}`);
|
||||
assert.strictEqual(sdkResult, cjsResult, `SDK/CJS parity: ${label}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('state-document-generator parity: stateReplaceField', async () => {
|
||||
const sdk = await import('../sdk/dist/state/index.js');
|
||||
|
||||
const fixtures = [
|
||||
{
|
||||
label: 'bold replace',
|
||||
content: 'Some content\n**Status:** old value\nMore content',
|
||||
fieldName: 'Status',
|
||||
newValue: 'new value',
|
||||
expected: 'Some content\n**Status:** new value\nMore content',
|
||||
},
|
||||
{
|
||||
label: 'plain replace',
|
||||
content: 'Some content\nStatus: old value\nMore content',
|
||||
fieldName: 'Status',
|
||||
newValue: 'new value',
|
||||
expected: 'Some content\nStatus: new value\nMore content',
|
||||
},
|
||||
{
|
||||
label: 'missing field returns null',
|
||||
content: 'Some content\nOtherField: something\nMore content',
|
||||
fieldName: 'Status',
|
||||
newValue: 'new value',
|
||||
expected: null,
|
||||
},
|
||||
];
|
||||
|
||||
for (const { label, content, fieldName, newValue, expected } of fixtures) {
|
||||
test(label, () => {
|
||||
const sdkResult = sdk.stateReplaceField(content, fieldName, newValue);
|
||||
const cjsResult = cjs.stateReplaceField(content, fieldName, newValue);
|
||||
assert.strictEqual(sdkResult, expected, `SDK: ${label}`);
|
||||
assert.strictEqual(cjsResult, expected, `CJS: ${label}`);
|
||||
assert.strictEqual(sdkResult, cjsResult, `SDK/CJS parity: ${label}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,159 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* CJS parity test — Workstream Inventory Builder generator.
|
||||
*
|
||||
* For every fixture, asserts that the compiled SDK ESM module and the
|
||||
* generated CJS artifact produce byte-identical output.
|
||||
*/
|
||||
|
||||
const { describe, test, before } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
// ─── Shared fixtures ──────────────────────────────────────────────────────────
|
||||
|
||||
function minimalInputs(overrides = {}) {
|
||||
return {
|
||||
name: 'my-ws',
|
||||
projectDir: '/project',
|
||||
workstreamDir: '/project/.planning/workstreams/my-ws',
|
||||
phaseDirNames: [],
|
||||
activeWorkstreamName: null,
|
||||
phaseFilesCounts: [],
|
||||
roadmapPhaseCount: 0,
|
||||
stateProjection: { status: 'unknown', current_phase: null, last_activity: null },
|
||||
filesExist: { roadmap: false, state: false, requirements: false },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const FIXTURES = [
|
||||
{
|
||||
label: 'empty inventory: no phase dirs, no STATE.md',
|
||||
inputs: minimalInputs(),
|
||||
},
|
||||
{
|
||||
label: 'one phase in_progress (partial plan completion)',
|
||||
inputs: minimalInputs({
|
||||
phaseDirNames: ['01-alpha'],
|
||||
phaseFilesCounts: [{ directory: '01-alpha', planCount: 3, summaryCount: 1 }],
|
||||
roadmapPhaseCount: 1,
|
||||
stateProjection: { status: 'executing', current_phase: '01-alpha', last_activity: '2026-05-01' },
|
||||
filesExist: { roadmap: true, state: true, requirements: false },
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: 'one phase complete (summary_count >= plan_count)',
|
||||
inputs: minimalInputs({
|
||||
phaseDirNames: ['01-alpha'],
|
||||
phaseFilesCounts: [{ directory: '01-alpha', planCount: 2, summaryCount: 2 }],
|
||||
roadmapPhaseCount: 1,
|
||||
stateProjection: { status: 'milestone complete', current_phase: null, last_activity: '2026-04-01' },
|
||||
filesExist: { roadmap: true, state: true, requirements: true },
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: 'one phase pending (plan_count is 0)',
|
||||
inputs: minimalInputs({
|
||||
phaseDirNames: ['01-alpha'],
|
||||
phaseFilesCounts: [{ directory: '01-alpha', planCount: 0, summaryCount: 0 }],
|
||||
roadmapPhaseCount: 1,
|
||||
stateProjection: { status: 'planning', current_phase: null, last_activity: null },
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: 'multiple phases with mixed statuses',
|
||||
inputs: minimalInputs({
|
||||
phaseDirNames: ['01-alpha', '02-beta', '03-gamma'],
|
||||
phaseFilesCounts: [
|
||||
{ directory: '01-alpha', planCount: 2, summaryCount: 2 },
|
||||
{ directory: '02-beta', planCount: 3, summaryCount: 1 },
|
||||
{ directory: '03-gamma', planCount: 0, summaryCount: 0 },
|
||||
],
|
||||
roadmapPhaseCount: 3,
|
||||
stateProjection: { status: 'executing', current_phase: '02-beta', last_activity: '2026-05-10' },
|
||||
filesExist: { roadmap: true, state: true, requirements: false },
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: 'progress_percent clamps to 100 when completedPhases > roadmapPhaseCount',
|
||||
inputs: minimalInputs({
|
||||
phaseDirNames: ['01-alpha', '02-beta', '03-gamma'],
|
||||
phaseFilesCounts: [
|
||||
{ directory: '01-alpha', planCount: 1, summaryCount: 1 },
|
||||
{ directory: '02-beta', planCount: 1, summaryCount: 1 },
|
||||
{ directory: '03-gamma', planCount: 1, summaryCount: 1 },
|
||||
],
|
||||
roadmapPhaseCount: 1,
|
||||
stateProjection: { status: 'milestone complete', current_phase: null, last_activity: null },
|
||||
filesExist: { roadmap: true, state: true, requirements: false },
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: 'active workstream marker: active: true when activeWorkstreamName === name',
|
||||
inputs: minimalInputs({
|
||||
name: 'my-ws',
|
||||
activeWorkstreamName: 'my-ws',
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: 'active: false when activeWorkstreamName is a different workstream',
|
||||
inputs: minimalInputs({
|
||||
name: 'my-ws',
|
||||
activeWorkstreamName: 'other-ws',
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
const IS_COMPLETED_FIXTURES = [
|
||||
{ status: 'milestone complete', expected: true },
|
||||
{ status: 'Milestone Complete', expected: true },
|
||||
{ status: 'archived', expected: true },
|
||||
{ status: 'Archived', expected: true },
|
||||
{ status: 'executing', expected: false },
|
||||
{ status: 'planning', expected: false },
|
||||
{ status: 'unknown', expected: false },
|
||||
{ status: '', expected: false },
|
||||
];
|
||||
|
||||
// ─── Test suite ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('workstream-inventory-builder generator parity (ESM dist vs generated CJS)', () => {
|
||||
let sdkBuild, cjsModule;
|
||||
|
||||
before(async () => {
|
||||
// Dynamic import of the ESM SDK dist (use pathToFileURL since we're in CJS context)
|
||||
const path = require('path');
|
||||
const { pathToFileURL } = require('url');
|
||||
const distPath = path.resolve(__dirname, '..', 'sdk', 'dist', 'workstream', 'builder.js');
|
||||
sdkBuild = await import(pathToFileURL(distPath).href);
|
||||
// CJS require of the generated artifact
|
||||
cjsModule = require('../get-shit-done/bin/lib/workstream-inventory-builder.generated.cjs');
|
||||
});
|
||||
|
||||
describe('buildWorkstreamInventory', () => {
|
||||
for (const fixture of FIXTURES) {
|
||||
test(fixture.label, () => {
|
||||
const sdkResult = sdkBuild.buildWorkstreamInventory(fixture.inputs);
|
||||
const cjsResult = cjsModule.buildWorkstreamInventory(fixture.inputs);
|
||||
assert.deepStrictEqual(
|
||||
cjsResult,
|
||||
sdkResult,
|
||||
`Parity failure for fixture "${fixture.label}"`,
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('isCompletedInventory', () => {
|
||||
for (const { status, expected } of IS_COMPLETED_FIXTURES) {
|
||||
test(`isCompletedInventory("${status}") === ${expected}`, () => {
|
||||
const sdkResult = sdkBuild.isCompletedInventory(status);
|
||||
const cjsResult = cjsModule.isCompletedInventory(status);
|
||||
assert.strictEqual(sdkResult, expected, `SDK result mismatch for "${status}"`);
|
||||
assert.strictEqual(cjsResult, expected, `CJS result mismatch for "${status}"`);
|
||||
assert.strictEqual(sdkResult, cjsResult, `Parity failure for "${status}"`);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,145 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Parity test: workstream-name-policy.generated.cjs vs sdk/src/workstream-name-policy.ts
|
||||
*
|
||||
* Verifies that the generated CJS artifact matches the SDK source-of-truth
|
||||
* for all exports: toWorkstreamSlug, hasInvalidPathSegment, isValidActiveWorkstreamName,
|
||||
* validateWorkstreamName.
|
||||
*
|
||||
* Covers: Phase 6 (#3575) MIGRATE_ME resolution for workstream-name-policy.cjs.
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const { describe, test } = require('node:test');
|
||||
const {
|
||||
toWorkstreamSlug,
|
||||
hasInvalidPathSegment,
|
||||
isValidActiveWorkstreamName,
|
||||
validateWorkstreamName,
|
||||
} = require('../get-shit-done/bin/lib/workstream-name-policy.cjs');
|
||||
|
||||
// ─── toWorkstreamSlug ────────────────────────────────────────────────────────
|
||||
|
||||
describe('workstream-name-policy — toWorkstreamSlug', () => {
|
||||
test('lowercases and collapses non-alphanumeric to hyphens', () => {
|
||||
assert.strictEqual(toWorkstreamSlug('My Feature Branch'), 'my-feature-branch');
|
||||
assert.strictEqual(toWorkstreamSlug('hello_world'), 'hello-world');
|
||||
assert.strictEqual(toWorkstreamSlug('API v2'), 'api-v2');
|
||||
});
|
||||
|
||||
test('strips leading/trailing hyphens', () => {
|
||||
assert.strictEqual(toWorkstreamSlug('--foo--'), 'foo');
|
||||
assert.strictEqual(toWorkstreamSlug(' spaces '), 'spaces');
|
||||
});
|
||||
|
||||
test('handles empty and nullish values', () => {
|
||||
assert.strictEqual(toWorkstreamSlug(''), '');
|
||||
assert.strictEqual(toWorkstreamSlug(null), '');
|
||||
assert.strictEqual(toWorkstreamSlug(undefined), '');
|
||||
});
|
||||
|
||||
test('handles already-valid slug', () => {
|
||||
assert.strictEqual(toWorkstreamSlug('my-feature'), 'my-feature');
|
||||
assert.strictEqual(toWorkstreamSlug('v2'), 'v2');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── hasInvalidPathSegment ───────────────────────────────────────────────────
|
||||
|
||||
describe('workstream-name-policy — hasInvalidPathSegment', () => {
|
||||
test('returns true for names with forward slash', () => {
|
||||
assert.strictEqual(hasInvalidPathSegment('foo/bar'), true);
|
||||
});
|
||||
|
||||
test('returns true for names with backslash', () => {
|
||||
assert.strictEqual(hasInvalidPathSegment('foo\\bar'), true);
|
||||
});
|
||||
|
||||
test('returns true for bare dot', () => {
|
||||
assert.strictEqual(hasInvalidPathSegment('.'), true);
|
||||
});
|
||||
|
||||
test('returns true for double dot', () => {
|
||||
assert.strictEqual(hasInvalidPathSegment('..'), true);
|
||||
});
|
||||
|
||||
test('returns true for names containing dot-dot sequence', () => {
|
||||
assert.strictEqual(hasInvalidPathSegment('foo..bar'), true);
|
||||
assert.strictEqual(hasInvalidPathSegment('../etc'), true);
|
||||
});
|
||||
|
||||
test('returns false for valid workstream names', () => {
|
||||
assert.strictEqual(hasInvalidPathSegment('my-feature'), false);
|
||||
assert.strictEqual(hasInvalidPathSegment('v2'), false);
|
||||
assert.strictEqual(hasInvalidPathSegment('feature.experimental'), false);
|
||||
assert.strictEqual(hasInvalidPathSegment('alpha_1'), false);
|
||||
});
|
||||
|
||||
test('handles empty and nullish values', () => {
|
||||
assert.strictEqual(hasInvalidPathSegment(''), false);
|
||||
assert.strictEqual(hasInvalidPathSegment(null), false);
|
||||
assert.strictEqual(hasInvalidPathSegment(undefined), false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── isValidActiveWorkstreamName ─────────────────────────────────────────────
|
||||
|
||||
describe('workstream-name-policy — isValidActiveWorkstreamName', () => {
|
||||
test('returns true for valid alphanumeric names', () => {
|
||||
assert.strictEqual(isValidActiveWorkstreamName('feature'), true);
|
||||
assert.strictEqual(isValidActiveWorkstreamName('v2'), true);
|
||||
assert.strictEqual(isValidActiveWorkstreamName('my-branch'), true);
|
||||
assert.strictEqual(isValidActiveWorkstreamName('feature.experimental'), true);
|
||||
assert.strictEqual(isValidActiveWorkstreamName('alpha_1'), true);
|
||||
assert.strictEqual(isValidActiveWorkstreamName('A1'), true);
|
||||
});
|
||||
|
||||
test('returns false for names starting with non-alphanumeric', () => {
|
||||
assert.strictEqual(isValidActiveWorkstreamName('-feature'), false);
|
||||
assert.strictEqual(isValidActiveWorkstreamName('.feature'), false);
|
||||
assert.strictEqual(isValidActiveWorkstreamName('_feature'), false);
|
||||
});
|
||||
|
||||
test('returns false for names with path traversal', () => {
|
||||
assert.strictEqual(isValidActiveWorkstreamName('..'), false);
|
||||
assert.strictEqual(isValidActiveWorkstreamName('../etc'), false);
|
||||
assert.strictEqual(isValidActiveWorkstreamName('foo..bar'), false);
|
||||
});
|
||||
|
||||
test('returns false for names with slashes', () => {
|
||||
assert.strictEqual(isValidActiveWorkstreamName('foo/bar'), false);
|
||||
assert.strictEqual(isValidActiveWorkstreamName('foo\\bar'), false);
|
||||
});
|
||||
|
||||
test('returns false for names with spaces', () => {
|
||||
assert.strictEqual(isValidActiveWorkstreamName('my feature'), false);
|
||||
});
|
||||
|
||||
test('returns false for empty string', () => {
|
||||
assert.strictEqual(isValidActiveWorkstreamName(''), false);
|
||||
});
|
||||
|
||||
test('returns false for nullish values', () => {
|
||||
assert.strictEqual(isValidActiveWorkstreamName(null), false);
|
||||
assert.strictEqual(isValidActiveWorkstreamName(undefined), false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── validateWorkstreamName (SDK alias) ──────────────────────────────────────
|
||||
|
||||
describe('workstream-name-policy — validateWorkstreamName (SDK alias)', () => {
|
||||
test('is an alias for isValidActiveWorkstreamName', () => {
|
||||
const testCases = [
|
||||
'feature', 'v2', 'my-branch', '-bad', '', null, undefined,
|
||||
'foo/bar', '..', 'foo..bar', 'A1', 'alpha_1',
|
||||
];
|
||||
for (const tc of testCases) {
|
||||
assert.strictEqual(
|
||||
validateWorkstreamName(tc),
|
||||
isValidActiveWorkstreamName(tc),
|
||||
`validateWorkstreamName and isValidActiveWorkstreamName should agree on: ${JSON.stringify(tc)}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user