@xulthekl/team-flow 0.23.0 → 0.25.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/always/phase-guard.md +1 -1
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor-plugin/marketplace.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/.github/plugin/marketplace.json +2 -2
- package/AGENTS.md +3 -3
- package/CHANGELOG.md +121 -0
- package/GEMINI.md +1 -1
- package/INSTALL.md +1 -1
- package/README.md +1 -1
- package/docs/README_en.md +1 -1
- package/docs/solutions/INDEX.md +6 -0
- package/docs/solutions/cross-phase/2026-07-28-no-summary.md +17 -0
- package/docs/solutions/cross-phase/2026-07-29-no-summary.md +17 -0
- package/gemini-extension.json +1 -1
- package/hooks/pre-tool-use-guard +9 -9
- package/hooks/session-start +2 -2
- package/llms.txt +1 -1
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/scripts/guard/checks/compound-captured.mjs +70 -0
- package/scripts/guard/guard.mjs +3 -1
- package/scripts/lib/cmd-state.mjs +2 -0
- package/scripts/lib/config-loader.mjs +3 -0
- package/scripts/lib/solutions-promote.mjs +1 -1
- package/scripts/lib/state-loader.mjs +5 -0
- package/skills/bug-investigator/SKILL.md +1 -1
- package/skills/build-executor/SKILL.md +19 -19
- package/skills/build-executor/implementer-prompt.md +1 -1
- package/skills/build-executor/references/execution-modes.md +6 -6
- package/skills/build-executor/task-reviewer-prompt.md +1 -1
- package/skills/ce-brainstorm/SKILL.md +49 -348
- package/skills/ce-brainstorm/references/brainstorm-sections.md +11 -0
- package/skills/ce-brainstorm/references/grounding.md +47 -0
- package/skills/ce-brainstorm/references/output-format.md +25 -0
- package/skills/ce-brainstorm/references/phase0-routing.md +96 -0
- package/skills/ce-brainstorm/references/prd-mapping.md +37 -0
- package/skills/ce-brainstorm/references/prototype-loop.md +55 -0
- package/skills/ce-ideate/SKILL.md +31 -348
- package/skills/ce-ideate/references/grounding.md +107 -0
- package/skills/ce-ideate/references/phase0-scope.md +134 -0
- package/skills/ce-ideate/references/topic-surface.md +37 -0
- package/skills/ce-proof/SKILL.md +8 -266
- package/skills/ce-proof/references/api-recipes.md +145 -0
- package/skills/ce-proof/references/workflows.md +124 -0
- package/skills/code-reviewer/SKILL.md +2 -2
- package/skills/code-reviewer/code-reviewer-prompt.md +1 -1
- package/skills/contract-builder/SKILL.md +6 -6
- package/skills/need-explorer/SKILL.md +2 -2
- package/skills/prototype/SKILL.md +16 -2
- package/skills/prototype/references/agents/design-system-architect.md +11 -122
- package/skills/prototype/references/interactive-prototype.md +110 -0
- package/skills/prototype/references/layouts.md +188 -2
- package/skills/prototype/references/orchestration-flow.md +8 -1
- package/skills/prototype/references/template.html +32 -0
- package/skills/prototype/references/wireframe.md +117 -0
- package/skills/release-archivist/SKILL.md +14 -12
- package/skills/release-archivist/references/closing-procedures.md +11 -9
- package/skills/spec-merger/SKILL.md +2 -2
- package/skills/spec-writer/SKILL.md +3 -3
- package/skills/workflow-bootstrap/SKILL.md +13 -2
- package/skills/workflow-bootstrap/references/b1-reconnaissance.md +13 -7
- package/skills/workflow-bootstrap/scripts/recon-probe.sh +122 -1
- package/skills/workflow-orchestrator/references/s2-prd-prototype-loop.md +1 -1
- package/skills/workflow-orchestrator/references/s4-split-validate.md +1 -1
- package/skills/workflow-orchestrator/references/s5-monitoring.md +1 -1
- package/skills/workflow-start/SKILL.md +16 -16
- package/skills/workflow-start/references/routing-rules.md +17 -17
- package/templates/prd-brainstorm-profile.md +69 -0
- package/tests/lib/guard-compound-captured.test.mjs +92 -0
- package/tests/lib/guard-specs-merged.test.mjs +2 -0
- package/tests/lib/guard-tests-passing.test.mjs +2 -0
- package/tests/lib/guard.test.mjs +2 -0
- package/tests/lib/solutions-capture.test.mjs +108 -0
- package/tests/lib/solutions-index-gen.test.mjs +147 -0
- package/tests/lib/solutions-inject.test.mjs +115 -0
- package/tests/lib/solutions-promote.test.mjs +200 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// tests/lib/solutions-capture.test.mjs
|
|
2
|
+
// Tests for scripts/lib/solutions-capture.mjs — compound capture mechanism
|
|
3
|
+
|
|
4
|
+
import { describe, it, before, after, beforeEach } from 'node:test';
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
import { mkdtempSync, readFileSync, existsSync, rmSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { tmpdir } from 'node:os';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
import { dirname } from 'node:path';
|
|
11
|
+
|
|
12
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
const ROOT = join(__dirname, '..', '..');
|
|
14
|
+
|
|
15
|
+
const { run: captureRun } = await import(join(ROOT, 'scripts', 'lib', 'solutions-capture.mjs'));
|
|
16
|
+
|
|
17
|
+
function makeDir() {
|
|
18
|
+
return mkdtempSync(join(tmpdir(), 'tf-sol-capture-'));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function cleanup(dir) {
|
|
22
|
+
if (existsSync(dir)) rmSync(dir, { recursive: true, force: true });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('solutions-capture', () => {
|
|
26
|
+
let dir;
|
|
27
|
+
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
dir = makeDir();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
after(() => {
|
|
33
|
+
// Clean up all temp dirs
|
|
34
|
+
cleanup(dir);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('creates experience file in correct phase directory', () => {
|
|
38
|
+
const result = captureRun({
|
|
39
|
+
phase: 'prd', domain: 'auth', type: 'pitfall', severity: 'high',
|
|
40
|
+
summary: 'OAuth token refresh fails silently', dir,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
assert.equal(result.phase, 'prd');
|
|
44
|
+
assert.equal(result.severity, 'high');
|
|
45
|
+
assert.match(result.file, /^prd\/\d{4}-\d{2}-\d{2}-.*\.md$/);
|
|
46
|
+
|
|
47
|
+
// Verify file exists and has frontmatter
|
|
48
|
+
const filePath = join(dir, result.file);
|
|
49
|
+
assert.ok(existsSync(filePath), 'experience file should exist');
|
|
50
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
51
|
+
assert.match(content, /^---\nphase: prd\n/);
|
|
52
|
+
assert.match(content, /domain: auth/);
|
|
53
|
+
assert.match(content, /type: pitfall/);
|
|
54
|
+
assert.match(content, /severity: high/);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('creates INDEX.md from scratch when missing', () => {
|
|
58
|
+
captureRun({
|
|
59
|
+
phase: 'build', domain: 'ci', type: 'pattern', severity: 'medium',
|
|
60
|
+
summary: 'Parallel test execution race condition', dir,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const indexPath = join(dir, 'INDEX.md');
|
|
64
|
+
assert.ok(existsSync(indexPath), 'INDEX.md should be created');
|
|
65
|
+
const content = readFileSync(indexPath, 'utf-8');
|
|
66
|
+
assert.match(content, /# Solutions Index/);
|
|
67
|
+
assert.match(content, /parallel test execution/i);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('appends to existing INDEX.md', () => {
|
|
71
|
+
captureRun({ phase: 'build', domain: 'ci', type: 'pattern', severity: 'high', summary: 'First issue', dir });
|
|
72
|
+
captureRun({ phase: 'spec', domain: 'api', type: 'insight', severity: 'low', summary: 'Second issue', dir });
|
|
73
|
+
|
|
74
|
+
const content = readFileSync(join(dir, 'INDEX.md'), 'utf-8');
|
|
75
|
+
assert.match(content, /First issue/);
|
|
76
|
+
assert.match(content, /Second issue/);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('handles Chinese characters in summary (slugify)', () => {
|
|
80
|
+
const result = captureRun({
|
|
81
|
+
phase: 'review', domain: 'frontend', type: 'pitfall', severity: 'high',
|
|
82
|
+
summary: '中文标题测试特殊字符', dir,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
assert.ok(result.file.includes('review/'), 'file should be in review phase');
|
|
86
|
+
assert.ok(existsSync(join(dir, result.file)), 'file with Chinese slug should exist');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('uses cross-phase as default when no phase specified', () => {
|
|
90
|
+
const result = captureRun({ dir });
|
|
91
|
+
assert.equal(result.phase, 'cross-phase');
|
|
92
|
+
assert.equal(result.domain, 'general');
|
|
93
|
+
assert.equal(result.severity, 'medium');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('rejects invalid phase', () => {
|
|
97
|
+
// capture calls process.exit(1) on invalid phase
|
|
98
|
+
// We test by checking that the function does not create files
|
|
99
|
+
const origExit = process.exit;
|
|
100
|
+
let exitCode = null;
|
|
101
|
+
process.exit = (code) => { exitCode = code; };
|
|
102
|
+
try {
|
|
103
|
+
captureRun({ phase: 'invalid-phase', dir });
|
|
104
|
+
} catch { /* ignore */ }
|
|
105
|
+
process.exit = origExit;
|
|
106
|
+
assert.equal(exitCode, 1, 'should exit with code 1 for invalid phase');
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// tests/lib/solutions-index-gen.test.mjs
|
|
2
|
+
// Tests for scripts/lib/solutions-index-gen.mjs — full INDEX.md rebuild
|
|
3
|
+
|
|
4
|
+
import { describe, it, before, after, beforeEach } from 'node:test';
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
import { mkdtempSync, writeFileSync, existsSync, rmSync, mkdirSync, readFileSync } from 'node:fs';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { tmpdir } from 'node:os';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
import { dirname } from 'node:path';
|
|
11
|
+
|
|
12
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
const ROOT = join(__dirname, '..', '..');
|
|
14
|
+
|
|
15
|
+
const { run: indexGenRun } = await import(join(ROOT, 'scripts', 'lib', 'solutions-index-gen.mjs'));
|
|
16
|
+
|
|
17
|
+
function makeDir() {
|
|
18
|
+
return mkdtempSync(join(tmpdir(), 'tf-sol-indexgen-'));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function cleanup(dir) {
|
|
22
|
+
if (existsSync(dir)) rmSync(dir, { recursive: true, force: true });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function writeEntry(dir, phase, filename, fm) {
|
|
26
|
+
const phaseDir = join(dir, phase);
|
|
27
|
+
mkdirSync(phaseDir, { recursive: true });
|
|
28
|
+
const fmLines = Object.entries(fm).map(([k, v]) => `${k}: ${v}`).join('\n');
|
|
29
|
+
writeFileSync(join(phaseDir, filename), `---\n${fmLines}\n---\n\n${fm.summary || 'Body text'}\n`, 'utf-8');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('solutions-index-gen', () => {
|
|
33
|
+
let dir;
|
|
34
|
+
|
|
35
|
+
beforeEach(() => { dir = makeDir(); });
|
|
36
|
+
after(() => { cleanup(dir); });
|
|
37
|
+
|
|
38
|
+
it('rebuilds INDEX.md from phase directories', () => {
|
|
39
|
+
writeEntry(dir, 'prd', '2026-07-01-auth-issue.md', {
|
|
40
|
+
phase: 'prd', domain: 'auth', type: 'pitfall', severity: 'high', date: '2026-07-01',
|
|
41
|
+
});
|
|
42
|
+
writeEntry(dir, 'build', '2026-07-02-ci-pattern.md', {
|
|
43
|
+
phase: 'build', domain: 'ci', type: 'pattern', severity: 'medium', date: '2026-07-02',
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
indexGenRun({ dir });
|
|
47
|
+
|
|
48
|
+
const content = readFileSync(join(dir, 'INDEX.md'), 'utf-8');
|
|
49
|
+
assert.match(content, /# Solutions Index/);
|
|
50
|
+
assert.match(content, /auth-issue/);
|
|
51
|
+
assert.match(content, /ci-pattern/);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('sorts by severity descending (high before medium before low)', () => {
|
|
55
|
+
writeEntry(dir, 'prd', 'low.md', {
|
|
56
|
+
phase: 'prd', domain: 'd1', type: 'pitfall', severity: 'low', date: '2026-07-03',
|
|
57
|
+
});
|
|
58
|
+
writeEntry(dir, 'prd', 'high.md', {
|
|
59
|
+
phase: 'prd', domain: 'd2', type: 'pitfall', severity: 'high', date: '2026-07-01',
|
|
60
|
+
});
|
|
61
|
+
writeEntry(dir, 'prd', 'medium.md', {
|
|
62
|
+
phase: 'prd', domain: 'd3', type: 'pitfall', severity: 'medium', date: '2026-07-02',
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
indexGenRun({ dir });
|
|
66
|
+
|
|
67
|
+
const content = readFileSync(join(dir, 'INDEX.md'), 'utf-8');
|
|
68
|
+
const highPos = content.indexOf('d2');
|
|
69
|
+
const medPos = content.indexOf('d3');
|
|
70
|
+
const lowPos = content.indexOf('d1');
|
|
71
|
+
assert.ok(highPos < medPos, 'high should appear before medium');
|
|
72
|
+
assert.ok(medPos < lowPos, 'medium should appear before low');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('sorts by date descending within same severity', () => {
|
|
76
|
+
writeEntry(dir, 'prd', 'older.md', {
|
|
77
|
+
phase: 'prd', domain: 'auth', type: 'pitfall', severity: 'high', date: '2026-07-01',
|
|
78
|
+
});
|
|
79
|
+
writeEntry(dir, 'prd', 'newer.md', {
|
|
80
|
+
phase: 'prd', domain: 'auth', type: 'pitfall', severity: 'high', date: '2026-07-15',
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
indexGenRun({ dir });
|
|
84
|
+
|
|
85
|
+
const content = readFileSync(join(dir, 'INDEX.md'), 'utf-8');
|
|
86
|
+
const newerPos = content.indexOf('2026-07-15');
|
|
87
|
+
const olderPos = content.indexOf('2026-07-01');
|
|
88
|
+
assert.ok(newerPos < olderPos, 'newer date should appear before older date');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('scans all 7 phase directories', () => {
|
|
92
|
+
const phases = ['prd', 'plan', 'prototype', 'spec', 'build', 'review', 'cross-phase'];
|
|
93
|
+
for (const phase of phases) {
|
|
94
|
+
writeEntry(dir, phase, `2026-07-01-${phase}-entry.md`, {
|
|
95
|
+
phase, domain: 'general', type: 'insight', severity: 'medium', date: '2026-07-01',
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
indexGenRun({ dir });
|
|
100
|
+
|
|
101
|
+
const content = readFileSync(join(dir, 'INDEX.md'), 'utf-8');
|
|
102
|
+
for (const phase of phases) {
|
|
103
|
+
assert.match(content, new RegExp(phase), `INDEX should contain ${phase} entries`);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('skips non-.md files', () => {
|
|
108
|
+
mkdirSync(join(dir, 'build'), { recursive: true });
|
|
109
|
+
writeFileSync(join(dir, 'build', 'readme.txt'), 'Not a markdown file', 'utf-8');
|
|
110
|
+
writeEntry(dir, 'build', '2026-07-01-real.md', {
|
|
111
|
+
phase: 'build', domain: 'ci', type: 'pattern', severity: 'high', date: '2026-07-01',
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
indexGenRun({ dir });
|
|
115
|
+
|
|
116
|
+
const content = readFileSync(join(dir, 'INDEX.md'), 'utf-8');
|
|
117
|
+
assert.doesNotMatch(content, /readme\.txt/);
|
|
118
|
+
assert.match(content, /real\.md/);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('truncates to 150 entries (drops lowest priority)', () => {
|
|
122
|
+
// Create 155 entries (all high severity, varying dates)
|
|
123
|
+
mkdirSync(join(dir, 'build'), { recursive: true });
|
|
124
|
+
for (let i = 0; i < 155; i++) {
|
|
125
|
+
const day = String((i % 28) + 1).padStart(2, '0');
|
|
126
|
+
writeEntry(dir, 'build', `entry-${String(i).padStart(3, '0')}.md`, {
|
|
127
|
+
phase: 'build', domain: `d${i}`, type: 'pattern', severity: 'high', date: `2026-07-${day}`,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
indexGenRun({ dir });
|
|
132
|
+
|
|
133
|
+
const content = readFileSync(join(dir, 'INDEX.md'), 'utf-8');
|
|
134
|
+
const dataLines = content.split('\n').filter(l => l.startsWith('|') && !l.startsWith('| date') && !l.startsWith('|--'));
|
|
135
|
+
assert.equal(dataLines.length, 150, 'INDEX should be truncated to 150 entries');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('handles empty solutions directory', () => {
|
|
139
|
+
// No phase directories exist
|
|
140
|
+
indexGenRun({ dir });
|
|
141
|
+
|
|
142
|
+
const content = readFileSync(join(dir, 'INDEX.md'), 'utf-8');
|
|
143
|
+
assert.match(content, /# Solutions Index/);
|
|
144
|
+
const dataLines = content.split('\n').filter(l => l.startsWith('|') && !l.startsWith('| date') && !l.startsWith('|--'));
|
|
145
|
+
assert.equal(dataLines.length, 0);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// tests/lib/solutions-inject.test.mjs
|
|
2
|
+
// Tests for scripts/lib/solutions-inject.mjs — compound injection mechanism
|
|
3
|
+
|
|
4
|
+
import { describe, it, before, after, beforeEach } from 'node:test';
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
import { mkdtempSync, writeFileSync, existsSync, rmSync, mkdirSync } from 'node:fs';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { tmpdir } from 'node:os';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
import { dirname } from 'node:path';
|
|
11
|
+
|
|
12
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
const ROOT = join(__dirname, '..', '..');
|
|
14
|
+
|
|
15
|
+
const { run: injectRun } = await import(join(ROOT, 'scripts', 'lib', 'solutions-inject.mjs'));
|
|
16
|
+
|
|
17
|
+
function makeDir() {
|
|
18
|
+
return mkdtempSync(join(tmpdir(), 'tf-sol-inject-'));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function cleanup(dir) {
|
|
22
|
+
if (existsSync(dir)) rmSync(dir, { recursive: true, force: true });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const INDEX_HEADER = `# Solutions Index
|
|
26
|
+
<!-- 每条一行,按 severity 降序,≤150 行硬上限 -->
|
|
27
|
+
| date | phase | domain | type | severity | summary | file |
|
|
28
|
+
|------|-------|--------|------|----------|---------|------|
|
|
29
|
+
`;
|
|
30
|
+
|
|
31
|
+
function writeIndex(dir, rows) {
|
|
32
|
+
mkdirSync(dir, { recursive: true });
|
|
33
|
+
writeFileSync(join(dir, 'INDEX.md'), INDEX_HEADER + rows.join('\n') + '\n', 'utf-8');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe('solutions-inject', () => {
|
|
37
|
+
let dir;
|
|
38
|
+
|
|
39
|
+
beforeEach(() => { dir = makeDir(); });
|
|
40
|
+
after(() => { cleanup(dir); });
|
|
41
|
+
|
|
42
|
+
it('returns empty when no INDEX.md exists', () => {
|
|
43
|
+
const result = injectRun({ phase: 'prd', dir });
|
|
44
|
+
assert.deepEqual(result.entries, []);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('filters by exact phase match', () => {
|
|
48
|
+
writeIndex(dir, [
|
|
49
|
+
'| 2026-07-01 | prd | auth | pitfall | high | PRD auth issue | prd/f1.md |',
|
|
50
|
+
'| 2026-07-01 | build | auth | pitfall | high | Build auth issue | build/f2.md |',
|
|
51
|
+
]);
|
|
52
|
+
const result = injectRun({ phase: 'prd', dir });
|
|
53
|
+
assert.equal(result.entries.length, 1);
|
|
54
|
+
assert.equal(result.entries[0].summary, 'PRD auth issue');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('includes cross-phase entries for any phase query', () => {
|
|
58
|
+
writeIndex(dir, [
|
|
59
|
+
'| 2026-07-01 | cross-phase | general | pattern | high | Global pattern | cross-phase/f1.md |',
|
|
60
|
+
'| 2026-07-01 | prd | auth | pitfall | high | PRD issue | prd/f2.md |',
|
|
61
|
+
]);
|
|
62
|
+
const result = injectRun({ phase: 'build', dir });
|
|
63
|
+
// cross-phase matches for any phase
|
|
64
|
+
assert.equal(result.entries.length, 1);
|
|
65
|
+
assert.equal(result.entries[0].phase, 'cross-phase');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('filters by domain (exact match)', () => {
|
|
69
|
+
writeIndex(dir, [
|
|
70
|
+
'| 2026-07-01 | prd | auth | pitfall | high | Auth issue | prd/f1.md |',
|
|
71
|
+
'| 2026-07-01 | prd | billing | pitfall | high | Billing issue | prd/f2.md |',
|
|
72
|
+
]);
|
|
73
|
+
const result = injectRun({ phase: 'prd', domain: 'auth', dir });
|
|
74
|
+
assert.equal(result.entries.length, 1);
|
|
75
|
+
assert.equal(result.entries[0].domain, 'auth');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('domain "general" matches any query', () => {
|
|
79
|
+
writeIndex(dir, [
|
|
80
|
+
'| 2026-07-01 | prd | general | pattern | high | General pattern | prd/f1.md |',
|
|
81
|
+
]);
|
|
82
|
+
const result = injectRun({ phase: 'prd', domain: 'auth', dir });
|
|
83
|
+
assert.equal(result.entries.length, 1);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('sorts by severity descending (high > medium > low)', () => {
|
|
87
|
+
writeIndex(dir, [
|
|
88
|
+
'| 2026-07-01 | prd | auth | pitfall | low | Low issue | prd/f1.md |',
|
|
89
|
+
'| 2026-07-01 | prd | auth | pitfall | high | High issue | prd/f2.md |',
|
|
90
|
+
'| 2026-07-01 | prd | auth | pitfall | medium | Medium issue | prd/f3.md |',
|
|
91
|
+
]);
|
|
92
|
+
const result = injectRun({ phase: 'prd', dir });
|
|
93
|
+
assert.equal(result.entries[0].severity, 'high');
|
|
94
|
+
assert.equal(result.entries[1].severity, 'medium');
|
|
95
|
+
assert.equal(result.entries[2].severity, 'low');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('limits to top-5 entries', () => {
|
|
99
|
+
const rows = [];
|
|
100
|
+
for (let i = 0; i < 10; i++) {
|
|
101
|
+
rows.push(`| 2026-07-0${(i % 9) + 1} | prd | auth | pitfall | high | Issue ${i} | prd/f${i}.md |`);
|
|
102
|
+
}
|
|
103
|
+
writeIndex(dir, rows);
|
|
104
|
+
const result = injectRun({ phase: 'prd', dir });
|
|
105
|
+
assert.equal(result.entries.length, 5, 'should cap at top-5');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('returns empty for no matching entries', () => {
|
|
109
|
+
writeIndex(dir, [
|
|
110
|
+
'| 2026-07-01 | build | ci | pattern | high | Build issue | build/f1.md |',
|
|
111
|
+
]);
|
|
112
|
+
const result = injectRun({ phase: 'prd', dir });
|
|
113
|
+
assert.deepEqual(result.entries, []);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// tests/lib/solutions-promote.test.mjs
|
|
2
|
+
// Tests for scripts/lib/solutions-promote.mjs — change→product promotion
|
|
3
|
+
|
|
4
|
+
import { describe, it, before, after, beforeEach } from 'node:test';
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
import { mkdtempSync, writeFileSync, existsSync, rmSync, mkdirSync, readFileSync } from 'node:fs';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { tmpdir } from 'node:os';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
import { dirname } from 'node:path';
|
|
11
|
+
|
|
12
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
const ROOT = join(__dirname, '..', '..');
|
|
14
|
+
|
|
15
|
+
// NODE_ENV=test is set below before import — promote's CLI guard skips when test.
|
|
16
|
+
process.env.NODE_ENV = 'test';
|
|
17
|
+
const { run: promoteRun } = await import(join(ROOT, 'scripts', 'lib', 'solutions-promote.mjs'));
|
|
18
|
+
|
|
19
|
+
function makeDir(prefix = '') {
|
|
20
|
+
return mkdtempSync(join(tmpdir(), `tf-sol-promote-${prefix}-`));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function cleanup(dir) {
|
|
24
|
+
if (existsSync(dir)) rmSync(dir, { recursive: true, force: true });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function writeLearnings(dir, content) {
|
|
28
|
+
writeFileSync(join(dir, 'learnings.md'), content, 'utf-8');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const INDEX_HEADER = `# Solutions Index
|
|
32
|
+
| date | phase | domain | type | severity | summary | file |
|
|
33
|
+
|------|-------|--------|------|----------|---------|------|
|
|
34
|
+
`;
|
|
35
|
+
|
|
36
|
+
function writeGlobalIndex(solDir, rows = []) {
|
|
37
|
+
mkdirSync(solDir, { recursive: true });
|
|
38
|
+
writeFileSync(join(solDir, 'INDEX.md'), INDEX_HEADER + rows.join('\n') + '\n', 'utf-8');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe('solutions-promote', () => {
|
|
42
|
+
let changeDir, solDir;
|
|
43
|
+
|
|
44
|
+
beforeEach(() => {
|
|
45
|
+
changeDir = makeDir('change');
|
|
46
|
+
solDir = makeDir('solutions');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
after(() => {
|
|
50
|
+
cleanup(changeDir);
|
|
51
|
+
cleanup(solDir);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('returns zero when no learnings.md exists', () => {
|
|
55
|
+
const result = promoteRun({ changeDir, dir: solDir });
|
|
56
|
+
assert.equal(result.promoted, 0);
|
|
57
|
+
assert.equal(result.updated, 0);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('promotes entry with severity=high and type=pitfall', () => {
|
|
61
|
+
writeLearnings(changeDir, `## Auth token refresh failure
|
|
62
|
+
---
|
|
63
|
+
phase: build
|
|
64
|
+
domain: auth
|
|
65
|
+
type: pitfall
|
|
66
|
+
severity: high
|
|
67
|
+
date: 2026-07-15
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
When OAuth token refresh fails, the system silently swallows the error.
|
|
71
|
+
`);
|
|
72
|
+
const result = promoteRun({ changeDir, dir: solDir });
|
|
73
|
+
assert.equal(result.promoted, 1);
|
|
74
|
+
assert.ok(existsSync(join(solDir, 'build')), 'build phase dir should exist');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('promotes entry with severity=medium and type=pattern', () => {
|
|
78
|
+
writeLearnings(changeDir, `## Retry with backoff pattern
|
|
79
|
+
---
|
|
80
|
+
phase: build
|
|
81
|
+
domain: networking
|
|
82
|
+
type: pattern
|
|
83
|
+
severity: medium
|
|
84
|
+
date: 2026-07-15
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
Use exponential backoff for API retry logic.
|
|
88
|
+
`);
|
|
89
|
+
const result = promoteRun({ changeDir, dir: solDir });
|
|
90
|
+
assert.equal(result.promoted, 1);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('skips entry with severity=low', () => {
|
|
94
|
+
writeLearnings(changeDir, `## Minor formatting issue
|
|
95
|
+
---
|
|
96
|
+
phase: build
|
|
97
|
+
domain: style
|
|
98
|
+
type: pitfall
|
|
99
|
+
severity: low
|
|
100
|
+
date: 2026-07-15
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
Some low severity issue.
|
|
104
|
+
`);
|
|
105
|
+
const result = promoteRun({ changeDir, dir: solDir });
|
|
106
|
+
assert.equal(result.promoted, 0);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('skips entry with type=insight (not pitfall/pattern)', () => {
|
|
110
|
+
writeLearnings(changeDir, `## Interesting observation
|
|
111
|
+
---
|
|
112
|
+
phase: build
|
|
113
|
+
domain: auth
|
|
114
|
+
type: insight
|
|
115
|
+
severity: high
|
|
116
|
+
date: 2026-07-15
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
An insight that should not be promoted.
|
|
120
|
+
`);
|
|
121
|
+
const result = promoteRun({ changeDir, dir: solDir });
|
|
122
|
+
assert.equal(result.promoted, 0);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('detects duplicate domain+type and marks as confirmed', () => {
|
|
126
|
+
writeGlobalIndex(solDir, [
|
|
127
|
+
'| 2026-07-01 | build | auth | pitfall | medium | Existing auth pitfall | build/existing.md |',
|
|
128
|
+
]);
|
|
129
|
+
writeLearnings(changeDir, `## Another auth pitfall
|
|
130
|
+
---
|
|
131
|
+
phase: build
|
|
132
|
+
domain: auth
|
|
133
|
+
type: pitfall
|
|
134
|
+
severity: high
|
|
135
|
+
date: 2026-07-15
|
|
136
|
+
---
|
|
137
|
+
|
|
138
|
+
Duplicate domain+type with existing index entry.
|
|
139
|
+
`);
|
|
140
|
+
const result = promoteRun({ changeDir, dir: solDir });
|
|
141
|
+
assert.equal(result.updated, 1, 'should detect duplicate and mark as confirmed');
|
|
142
|
+
assert.equal(result.promoted, 0, 'should not create new file for duplicate');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('promotes multiple entries from single learnings.md', () => {
|
|
146
|
+
writeLearnings(changeDir, `## Issue one
|
|
147
|
+
---
|
|
148
|
+
phase: spec
|
|
149
|
+
domain: api
|
|
150
|
+
type: pitfall
|
|
151
|
+
severity: high
|
|
152
|
+
date: 2026-07-15
|
|
153
|
+
---
|
|
154
|
+
|
|
155
|
+
First pitfall.
|
|
156
|
+
|
|
157
|
+
## Issue two
|
|
158
|
+
---
|
|
159
|
+
phase: spec
|
|
160
|
+
domain: db
|
|
161
|
+
type: pattern
|
|
162
|
+
severity: medium
|
|
163
|
+
date: 2026-07-15
|
|
164
|
+
---
|
|
165
|
+
|
|
166
|
+
Second pattern.
|
|
167
|
+
|
|
168
|
+
## Issue three (low sev, skipped)
|
|
169
|
+
---
|
|
170
|
+
phase: spec
|
|
171
|
+
domain: style
|
|
172
|
+
type: pitfall
|
|
173
|
+
severity: low
|
|
174
|
+
date: 2026-07-15
|
|
175
|
+
---
|
|
176
|
+
|
|
177
|
+
Skipped due to low severity.
|
|
178
|
+
`);
|
|
179
|
+
const result = promoteRun({ changeDir, dir: solDir });
|
|
180
|
+
assert.equal(result.promoted, 2, 'should promote 2 entries (high+medium, skip low)');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('appends promoted entry to global INDEX.md', () => {
|
|
184
|
+
writeGlobalIndex(solDir);
|
|
185
|
+
writeLearnings(changeDir, `## DB migration ordering
|
|
186
|
+
---
|
|
187
|
+
phase: build
|
|
188
|
+
domain: db
|
|
189
|
+
type: pitfall
|
|
190
|
+
severity: high
|
|
191
|
+
date: 2026-07-15
|
|
192
|
+
---
|
|
193
|
+
|
|
194
|
+
Migration files must be ordered by timestamp.
|
|
195
|
+
`);
|
|
196
|
+
promoteRun({ changeDir, dir: solDir });
|
|
197
|
+
const indexContent = readFileSync(join(solDir, 'INDEX.md'), 'utf-8');
|
|
198
|
+
assert.match(indexContent, /DB migration ordering/);
|
|
199
|
+
});
|
|
200
|
+
});
|