@xulthekl/team-flow 0.22.4 → 0.24.0
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 -2
- package/CHANGELOG.md +103 -0
- package/GEMINI.md +1 -1
- package/HANDOFF.md +123 -98
- package/INSTALL.md +1 -1
- package/README.md +1 -1
- package/docs/README_en.md +1 -1
- package/docs/solutions/INDEX.md +5 -0
- package/docs/solutions/cross-phase/2026-07-28-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/arch-design.mjs +79 -0
- package/scripts/guard/checks/compound-captured.mjs +70 -0
- package/scripts/guard/guard.mjs +6 -2
- package/scripts/lib/arch-merge.mjs +459 -0
- package/scripts/lib/cmd-state.mjs +5 -0
- package/scripts/lib/hash.mjs +18 -0
- package/scripts/lib/solutions-promote.mjs +1 -1
- package/scripts/lib/state-loader.mjs +16 -0
- package/scripts/team-flow.mjs +3 -0
- package/skills/architecture-design/SKILL.md +42 -3
- package/skills/architecture-design/templates/api.md +71 -0
- package/skills/architecture-design/templates/architecture.md +82 -0
- package/skills/architecture-design/templates/change-brief.md +29 -0
- package/skills/architecture-design/templates/database.md +69 -0
- package/skills/architecture-design/templates/index.md +33 -0
- package/skills/architecture-design/templates/physical-model.md +93 -0
- package/skills/bug-investigator/SKILL.md +1 -1
- package/skills/build-executor/SKILL.md +23 -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 +6 -0
- package/skills/code-reviewer/SKILL.md +6 -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/release-archivist/SKILL.md +25 -12
- package/skills/release-archivist/references/closing-procedures.md +8 -8
- package/skills/spec-merger/SKILL.md +2 -2
- package/skills/spec-writer/SKILL.md +11 -9
- package/skills/workflow-bootstrap/SKILL.md +39 -5
- package/skills/workflow-bootstrap/references/b1-reconnaissance.md +22 -1
- 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 +44 -16
- package/skills/workflow-start/references/routing-rules.md +17 -17
- package/templates/api.md +177 -0
- package/templates/architecture.md +122 -0
- package/templates/change-brief.md +24 -0
- package/templates/database.md +114 -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,92 @@
|
|
|
1
|
+
// tests/lib/guard-compound-captured.test.mjs
|
|
2
|
+
// v0.24.0: compound-captured guard — executing:closing must require learnings.md
|
|
3
|
+
// or explicit compound_skipped=true in state.
|
|
4
|
+
|
|
5
|
+
import { describe, it, before, after } from 'node:test';
|
|
6
|
+
import assert from 'node:assert/strict';
|
|
7
|
+
import { mkdtempSync, writeFileSync, rmSync, mkdirSync, existsSync } from 'node:fs';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { tmpdir } from 'node:os';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
import { dirname } from 'node:path';
|
|
12
|
+
|
|
13
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
const ROOT = join(__dirname, '..', '..');
|
|
15
|
+
|
|
16
|
+
// Import the check function directly for unit-level testing.
|
|
17
|
+
const { checkCompoundCaptured } = await import(
|
|
18
|
+
join(ROOT, 'scripts', 'guard', 'checks', 'compound-captured.mjs')
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
function makeDir(prefix) {
|
|
22
|
+
return mkdtempSync(join(tmpdir(), `tf-compound-${prefix}-`));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function cleanup(dir) {
|
|
26
|
+
if (existsSync(dir)) rmSync(dir, { recursive: true, force: true });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('compound-captured guard', () => {
|
|
30
|
+
describe('learnings.md existence', () => {
|
|
31
|
+
let dir;
|
|
32
|
+
before(() => { dir = makeDir('learnings'); });
|
|
33
|
+
after(() => cleanup(dir));
|
|
34
|
+
|
|
35
|
+
it('SHALL fail when no learnings.md and no compound_skipped', () => {
|
|
36
|
+
writeFileSync(join(dir, '.team-flow.yaml'), 'state: executing\nworkflow: full\n');
|
|
37
|
+
const result = checkCompoundCaptured(dir);
|
|
38
|
+
assert.equal(result.pass, false);
|
|
39
|
+
assert.ok(result.failures.length > 0);
|
|
40
|
+
assert.match(result.failures[0], /Compound capture missing/);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('SHALL pass when learnings.md exists and is non-empty', () => {
|
|
44
|
+
writeFileSync(join(dir, 'learnings.md'), '# Session Learnings\n\n## Lesson 1\nTest.\n');
|
|
45
|
+
const result = checkCompoundCaptured(dir);
|
|
46
|
+
assert.equal(result.pass, true);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('SHALL fail when learnings.md exists but is empty', () => {
|
|
50
|
+
writeFileSync(join(dir, 'learnings.md'), '');
|
|
51
|
+
const result = checkCompoundCaptured(dir);
|
|
52
|
+
assert.equal(result.pass, false);
|
|
53
|
+
assert.match(result.failures[0], /empty/);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('compound_skipped explicit opt-out', () => {
|
|
58
|
+
let dir;
|
|
59
|
+
before(() => { dir = makeDir('skip'); });
|
|
60
|
+
after(() => cleanup(dir));
|
|
61
|
+
|
|
62
|
+
it('SHALL pass when compound_skipped=true and no learnings.md', () => {
|
|
63
|
+
writeFileSync(join(dir, '.team-flow.yaml'), 'state: executing\nworkflow: full\ncompound_skipped: true\n');
|
|
64
|
+
const result = checkCompoundCaptured(dir);
|
|
65
|
+
assert.equal(result.pass, true);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('SHALL fail when compound_skipped=false (not opted out)', () => {
|
|
69
|
+
writeFileSync(join(dir, '.team-flow.yaml'), 'state: executing\nworkflow: full\ncompound_skipped: false\n');
|
|
70
|
+
const result = checkCompoundCaptured(dir);
|
|
71
|
+
assert.equal(result.pass, false);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('no state file (backward compatibility)', () => {
|
|
76
|
+
let dir;
|
|
77
|
+
before(() => { dir = makeDir('no-state'); });
|
|
78
|
+
after(() => cleanup(dir));
|
|
79
|
+
|
|
80
|
+
it('SHALL fail when no state file and no learnings.md', () => {
|
|
81
|
+
// No .team-flow.yaml, no learnings.md — guard should still fail
|
|
82
|
+
const result = checkCompoundCaptured(dir);
|
|
83
|
+
assert.equal(result.pass, false);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('SHALL pass when no state file but learnings.md exists', () => {
|
|
87
|
+
writeFileSync(join(dir, 'learnings.md'), '# Learnings\nContent.\n');
|
|
88
|
+
const result = checkCompoundCaptured(dir);
|
|
89
|
+
assert.equal(result.pass, true);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -27,6 +27,8 @@ function makeChangeDir(withDelta) {
|
|
|
27
27
|
? '## ADDED Requirements\n\n### Requirement: New\n\nThe system SHALL do new.\n\n#### Scenario: New\n- **WHEN** x\n- **THEN** y\n'
|
|
28
28
|
: '## Requirements\n\n### Requirement: Existing\n\nThe system SHALL exist.\n\n#### Scenario: Existing\n- **WHEN** a\n- **THEN** b\n';
|
|
29
29
|
writeFileSync(join(dir, 'specs', 'test.md'), specsContent);
|
|
30
|
+
// v0.24.0: compound-captured dimension requires learnings.md
|
|
31
|
+
writeFileSync(join(dir, 'learnings.md'), '# Session Learnings\n\n## Lesson 1\nTest.\n');
|
|
30
32
|
initializeGitRepository(dir);
|
|
31
33
|
return dir;
|
|
32
34
|
}
|
|
@@ -25,6 +25,8 @@ function makeChangeDir() {
|
|
|
25
25
|
writeFileSync(join(dir, 'tasks.md'), '# Tasks\n\n- [x] Task 1\n- [x] Task 2\n');
|
|
26
26
|
writeFileSync(join(dir, 'specs', 'test.md'), '## ADDED Requirements\n\n### Requirement: Test\n\nThe system SHALL test.\n\n#### Scenario: Test\n- **WHEN** test\n- **THEN** test\n');
|
|
27
27
|
writeFileSync(join(dir, 'execution-contract.md'), '# Execution Contract\n\n## Intent Lock\nTest.\n');
|
|
28
|
+
// v0.24.0: compound-captured dimension requires learnings.md
|
|
29
|
+
writeFileSync(join(dir, 'learnings.md'), '# Session Learnings\n\n## Lesson 1\nTest.\n');
|
|
28
30
|
initializeGitRepository(dir);
|
|
29
31
|
return dir;
|
|
30
32
|
}
|
package/tests/lib/guard.test.mjs
CHANGED
|
@@ -312,6 +312,8 @@ describe('guard: execution control records', () => {
|
|
|
312
312
|
function recordPassingClosingPrerequisites() {
|
|
313
313
|
runNodeScript(CLI_PATH, ['state', 'set', dir, 'test_result', 'pass: unit tests']);
|
|
314
314
|
runNodeScript(CLI_PATH, ['state', 'set', dir, 'spec_merged', 'true']);
|
|
315
|
+
// v0.24.0: compound-captured dimension requires learnings.md (or explicit skip)
|
|
316
|
+
writeFileSync(join(dir, 'learnings.md'), '# Session Learnings\n\n## Lesson 1\nCaptured during guard test.\n');
|
|
315
317
|
}
|
|
316
318
|
|
|
317
319
|
function writeReviewReport(name, content = 'Review completed without blocking findings.\n') {
|
|
@@ -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
|
+
});
|