@ktpartners/dgs-platform 2.9.0 → 3.3.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/CHANGELOG.md +197 -0
- package/README.md +34 -2
- package/agents/dgs-executor.md +124 -3
- package/agents/dgs-idea-researcher.md +447 -0
- package/agents/dgs-plan-checker.md +61 -3
- package/agents/dgs-planner.md +51 -8
- package/bin/install.js +44 -0
- package/commands/dgs/abandon-quick.md +28 -0
- package/commands/dgs/add-tests.md +2 -2
- package/commands/dgs/audit-milestone.md +4 -3
- package/commands/dgs/capture-principle.md +11 -11
- package/commands/dgs/cleanup.md +2 -2
- package/commands/dgs/complete-milestone.md +11 -11
- package/commands/dgs/complete-quick.md +28 -0
- package/commands/dgs/create-milestone-job.md +2 -2
- package/commands/dgs/debug.md +3 -3
- package/commands/dgs/develop-idea.md +1 -1
- package/commands/dgs/diff-report.md +124 -0
- package/commands/dgs/fast.md +3 -1
- package/commands/dgs/health.md +1 -1
- package/commands/dgs/map-codebase.md +6 -6
- package/commands/dgs/new-milestone.md +5 -5
- package/commands/dgs/new-project.md +8 -21
- package/commands/dgs/package-scan.md +43 -0
- package/commands/dgs/plan-milestone-gaps.md +1 -1
- package/commands/dgs/progress.md +3 -3
- package/commands/dgs/quick-abandon.md +8 -0
- package/commands/dgs/quick-complete.md +8 -0
- package/commands/dgs/quick.md +10 -3
- package/commands/dgs/research-idea.md +3 -2
- package/commands/dgs/research-phase.md +3 -3
- package/commands/dgs/switch-project.md +14 -1
- package/commands/dgs/write-spec.md +3 -3
- package/deliver-great-systems/bin/dgs-tools.cjs +401 -32
- package/deliver-great-systems/bin/lib/audit-tolerance.cjs +77 -0
- package/deliver-great-systems/bin/lib/audit-tolerance.test.cjs +101 -0
- package/deliver-great-systems/bin/lib/commands.cjs +626 -46
- package/deliver-great-systems/bin/lib/commands.test.cjs +451 -0
- package/deliver-great-systems/bin/lib/commit-verify.test.cjs +236 -0
- package/deliver-great-systems/bin/lib/config.cjs +80 -6
- package/deliver-great-systems/bin/lib/config.test.cjs +309 -0
- package/deliver-great-systems/bin/lib/context.cjs +120 -0
- package/deliver-great-systems/bin/lib/core.cjs +35 -14
- package/deliver-great-systems/bin/lib/core.test.cjs +79 -1
- package/deliver-great-systems/bin/lib/execution.cjs +49 -17
- package/deliver-great-systems/bin/lib/fast-routing.cjs +199 -0
- package/deliver-great-systems/bin/lib/fast-routing.test.cjs +108 -0
- package/deliver-great-systems/bin/lib/final-commit-precondition.test.cjs +87 -0
- package/deliver-great-systems/bin/lib/fixtures/package-scan/bundler-audit-gemfile.json +21 -0
- package/deliver-great-systems/bin/lib/fixtures/package-scan/gate-parity-expected.md +186 -0
- package/deliver-great-systems/bin/lib/fixtures/package-scan/gate-parity-runresult.json +235 -0
- package/deliver-great-systems/bin/lib/fixtures/package-scan/govulncheck-import.json +3 -0
- package/deliver-great-systems/bin/lib/fixtures/package-scan/npm-audit-v10.json +37 -0
- package/deliver-great-systems/bin/lib/fixtures/package-scan/osv-clean.json +3 -0
- package/deliver-great-systems/bin/lib/fixtures/package-scan/osv-vulns.json +77 -0
- package/deliver-great-systems/bin/lib/fixtures/package-scan/pip-audit-requirements.json +28 -0
- package/deliver-great-systems/bin/lib/fixtures/package-scan/snyk-lodash.json +30 -0
- package/deliver-great-systems/bin/lib/fixtures/package-scan/snyk-workspaces.json +55 -0
- package/deliver-great-systems/bin/lib/flat-migration.test.cjs +396 -0
- package/deliver-great-systems/bin/lib/frontmatter.cjs +1 -1
- package/deliver-great-systems/bin/lib/governance.cjs +211 -0
- package/deliver-great-systems/bin/lib/governance.test.cjs +339 -0
- package/deliver-great-systems/bin/lib/health-untracked-phase.test.cjs +269 -0
- package/deliver-great-systems/bin/lib/ideas.cjs +206 -91
- package/deliver-great-systems/bin/lib/ideas.test.cjs +244 -1
- package/deliver-great-systems/bin/lib/init.cjs +357 -61
- package/deliver-great-systems/bin/lib/init.test.cjs +625 -8
- package/deliver-great-systems/bin/lib/jobs.cjs +131 -25
- package/deliver-great-systems/bin/lib/jobs.test.cjs +193 -74
- package/deliver-great-systems/bin/lib/migration.cjs +409 -1
- package/deliver-great-systems/bin/lib/migration.test.cjs +158 -1
- package/deliver-great-systems/bin/lib/milestone.cjs +154 -31
- package/deliver-great-systems/bin/lib/milestone.test.cjs +203 -0
- package/deliver-great-systems/bin/lib/package-adapters.cjs +530 -0
- package/deliver-great-systems/bin/lib/package-adapters.test.cjs +618 -0
- package/deliver-great-systems/bin/lib/package-ecosystems.cjs +350 -0
- package/deliver-great-systems/bin/lib/package-ecosystems.test.cjs +348 -0
- package/deliver-great-systems/bin/lib/package-runner.cjs +199 -0
- package/deliver-great-systems/bin/lib/package-runner.test.cjs +198 -0
- package/deliver-great-systems/bin/lib/package-scan-provenance.cjs +56 -0
- package/deliver-great-systems/bin/lib/package-scan-provenance.test.cjs +103 -0
- package/deliver-great-systems/bin/lib/package-scan-report.cjs +1140 -0
- package/deliver-great-systems/bin/lib/package-scan-report.test.cjs +1963 -0
- package/deliver-great-systems/bin/lib/package-scan-skill.cjs +96 -0
- package/deliver-great-systems/bin/lib/package-scan-skill.test.cjs +136 -0
- package/deliver-great-systems/bin/lib/package-scan.cjs +919 -0
- package/deliver-great-systems/bin/lib/package-scan.test.cjs +2147 -0
- package/deliver-great-systems/bin/lib/phase.cjs +146 -3
- package/deliver-great-systems/bin/lib/phase.test.cjs +420 -0
- package/deliver-great-systems/bin/lib/plan-number-validity.test.cjs +48 -0
- package/deliver-great-systems/bin/lib/projects.cjs +65 -10
- package/deliver-great-systems/bin/lib/projects.test.cjs +198 -2
- package/deliver-great-systems/bin/lib/quick.cjs +739 -0
- package/deliver-great-systems/bin/lib/quick.test.cjs +730 -0
- package/deliver-great-systems/bin/lib/repos.cjs +37 -13
- package/deliver-great-systems/bin/lib/review.cjs +1821 -0
- package/deliver-great-systems/bin/lib/roadmap.cjs +34 -13
- package/deliver-great-systems/bin/lib/specs.cjs +3 -81
- package/deliver-great-systems/bin/lib/state-transition-gate.test.cjs +160 -0
- package/deliver-great-systems/bin/lib/state.cjs +147 -55
- package/deliver-great-systems/bin/lib/summary-frontmatter.cjs +54 -0
- package/deliver-great-systems/bin/lib/summary-frontmatter.test.cjs +78 -0
- package/deliver-great-systems/bin/lib/sweep-scope.test.cjs +263 -0
- package/deliver-great-systems/bin/lib/sync.cjs +75 -0
- package/deliver-great-systems/bin/lib/verify.cjs +198 -7
- package/deliver-great-systems/bin/lib/verify.test.cjs +82 -0
- package/deliver-great-systems/bin/lib/wave-0-template-rename.test.cjs +40 -0
- package/deliver-great-systems/bin/lib/worktrees.cjs +790 -0
- package/deliver-great-systems/bin/lib/worktrees.test.cjs +963 -0
- package/deliver-great-systems/references/agent-step-reliability.md +60 -0
- package/deliver-great-systems/references/conflict-resolution.md +4 -0
- package/deliver-great-systems/references/context-tiers.md +4 -0
- package/deliver-great-systems/references/package-scan-config.md +151 -0
- package/deliver-great-systems/references/questioning.md +0 -30
- package/deliver-great-systems/references/spec-review-loop.md +1 -2
- package/deliver-great-systems/references/workflow-conventions.md +29 -0
- package/deliver-great-systems/skills/dgs-tests/package-scan.md +44 -0
- package/deliver-great-systems/templates/REVIEW.md +35 -0
- package/deliver-great-systems/templates/VALIDATION.md +1 -1
- package/deliver-great-systems/templates/claude-md.md +27 -0
- package/deliver-great-systems/templates/package-scan-report.md +108 -0
- package/deliver-great-systems/templates/project.md +6 -170
- package/deliver-great-systems/templates/summary.md +3 -1
- package/deliver-great-systems/workflows/abandon-quick.md +89 -0
- package/deliver-great-systems/workflows/add-idea.md +3 -3
- package/deliver-great-systems/workflows/add-phase.md +5 -0
- package/deliver-great-systems/workflows/add-tests.md +14 -0
- package/deliver-great-systems/workflows/add-todo.md +1 -0
- package/deliver-great-systems/workflows/approve-spec.md +25 -4
- package/deliver-great-systems/workflows/audit-milestone.md +66 -10
- package/deliver-great-systems/workflows/audit-phase.md +15 -5
- package/deliver-great-systems/workflows/cancel-job.md +2 -2
- package/deliver-great-systems/workflows/check-todos.md +2 -3
- package/deliver-great-systems/workflows/codereview.md +103 -9
- package/deliver-great-systems/workflows/complete-milestone.md +218 -24
- package/deliver-great-systems/workflows/complete-quick.md +106 -0
- package/deliver-great-systems/workflows/consolidate-ideas.md +1 -1
- package/deliver-great-systems/workflows/create-milestone-job.md +4 -4
- package/deliver-great-systems/workflows/develop-idea.md +11 -11
- package/deliver-great-systems/workflows/diagnose-issues.md +14 -0
- package/deliver-great-systems/workflows/discuss-idea.md +1 -1
- package/deliver-great-systems/workflows/discuss-phase.md +3 -2
- package/deliver-great-systems/workflows/execute-phase.md +209 -33
- package/deliver-great-systems/workflows/execute-plan.md +22 -22
- package/deliver-great-systems/workflows/help.md +53 -20
- package/deliver-great-systems/workflows/import-spec.md +65 -7
- package/deliver-great-systems/workflows/init-product.md +45 -167
- package/deliver-great-systems/workflows/new-milestone.md +140 -33
- package/deliver-great-systems/workflows/new-project.md +60 -331
- package/deliver-great-systems/workflows/package-scan.md +59 -0
- package/deliver-great-systems/workflows/plan-phase.md +79 -1
- package/deliver-great-systems/workflows/progress-all.md +133 -0
- package/deliver-great-systems/workflows/quick-abandon.md +89 -0
- package/deliver-great-systems/workflows/quick-complete.md +106 -0
- package/deliver-great-systems/workflows/quick.md +328 -26
- package/deliver-great-systems/workflows/refine-spec.md +1 -1
- package/deliver-great-systems/workflows/research-idea.md +77 -139
- package/deliver-great-systems/workflows/resume-project.md +2 -2
- package/deliver-great-systems/workflows/run-job.md +29 -43
- package/deliver-great-systems/workflows/settings.md +13 -77
- package/deliver-great-systems/workflows/validate-phase.md +39 -1
- package/deliver-great-systems/workflows/verify-work.md +14 -0
- package/deliver-great-systems/workflows/write-spec.md +11 -13
- package/hooks/dist/dgs-enforce-discipline.js +196 -0
- package/package.json +1 -1
- package/scripts/build-hooks.js +1 -0
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for migrateFlatStatus — flat status directory migration
|
|
3
|
+
*
|
|
4
|
+
* Phase 134 (v20.0): Migrates ideas/todos/jobs from state subdirectories
|
|
5
|
+
* to flat directories with frontmatter status fields.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const { describe, it, beforeEach, afterEach } = require('node:test');
|
|
9
|
+
const assert = require('node:assert/strict');
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
|
|
13
|
+
const { createTempDir, cleanupDir, writeFile, initGitRepo } = require('./test-helpers.cjs');
|
|
14
|
+
const { migrateFlatStatus } = require('./migration.cjs');
|
|
15
|
+
const { extractFrontmatter } = require('./frontmatter.cjs');
|
|
16
|
+
|
|
17
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Creates a temp directory with git init, returns realpathSync'd path
|
|
21
|
+
* (macOS /var -> /private/var symlink fix from Phase 118).
|
|
22
|
+
*/
|
|
23
|
+
function makeGitDir() {
|
|
24
|
+
const tmpDir = createTempDir('flat-mig-test-');
|
|
25
|
+
initGitRepo(tmpDir);
|
|
26
|
+
return fs.realpathSync(tmpDir);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Creates a temp directory without git, returns realpathSync'd path.
|
|
31
|
+
*/
|
|
32
|
+
function makePlainDir() {
|
|
33
|
+
const tmpDir = createTempDir('flat-mig-test-');
|
|
34
|
+
return fs.realpathSync(tmpDir);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function gitCommitAll(dir, msg) {
|
|
38
|
+
const { execSync } = require('child_process');
|
|
39
|
+
execSync('git add -A', { cwd: dir, stdio: 'pipe' });
|
|
40
|
+
execSync(`git commit -m "${msg}" --allow-empty`, { cwd: dir, stdio: 'pipe' });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function readFile(dir, relPath) {
|
|
44
|
+
return fs.readFileSync(path.join(dir, relPath), 'utf-8');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function fileExists(dir, relPath) {
|
|
48
|
+
return fs.existsSync(path.join(dir, relPath));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Create a minimal idea file with frontmatter
|
|
53
|
+
*/
|
|
54
|
+
function makeIdea(id, title, opts = {}) {
|
|
55
|
+
const fm = [
|
|
56
|
+
'---',
|
|
57
|
+
`id: ${id}`,
|
|
58
|
+
`title: "${title}"`,
|
|
59
|
+
];
|
|
60
|
+
if (opts.status) fm.push(`status: ${opts.status}`);
|
|
61
|
+
if (opts.consolidated_into) fm.push(`consolidated_into: "${opts.consolidated_into}"`);
|
|
62
|
+
if (opts.consolidated_from) {
|
|
63
|
+
fm.push('consolidated_from:');
|
|
64
|
+
for (const src of opts.consolidated_from) {
|
|
65
|
+
fm.push(` - "${src}"`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
fm.push(`created: "2026-01-01T00:00:00Z"`);
|
|
69
|
+
fm.push(`updated: "2026-01-01T00:00:00Z"`);
|
|
70
|
+
fm.push('---');
|
|
71
|
+
fm.push('');
|
|
72
|
+
fm.push(`# ${title}`);
|
|
73
|
+
fm.push('');
|
|
74
|
+
fm.push(opts.body || 'Some idea content.');
|
|
75
|
+
if (opts.researchLog) {
|
|
76
|
+
fm.push('');
|
|
77
|
+
fm.push('## Research Log');
|
|
78
|
+
fm.push('');
|
|
79
|
+
fm.push(opts.researchLog);
|
|
80
|
+
}
|
|
81
|
+
return fm.join('\n') + '\n';
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Create a minimal todo file (no frontmatter — tests that frontmatter gets added)
|
|
86
|
+
*/
|
|
87
|
+
function makeTodo(title) {
|
|
88
|
+
return `# ${title}\n\nSome todo content.\n`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Create a minimal job file with bold-key Status header
|
|
93
|
+
*/
|
|
94
|
+
function makeJob(title, status) {
|
|
95
|
+
return `---\ntitle: "${title}"\n---\n\n# ${title}\n\n**Status:** ${status}\n\nJob details.\n`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ─── Tests ──────────────────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
describe('migrateFlatStatus', () => {
|
|
101
|
+
let tmpDir;
|
|
102
|
+
|
|
103
|
+
afterEach(() => {
|
|
104
|
+
if (tmpDir) cleanupDir(tmpDir);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe('idempotency', () => {
|
|
108
|
+
it('returns early when flat_status_migration_done is true', () => {
|
|
109
|
+
tmpDir = makePlainDir();
|
|
110
|
+
writeFile(tmpDir, 'config.local.json', JSON.stringify({ flat_status_migration_done: true }));
|
|
111
|
+
const result = migrateFlatStatus(tmpDir);
|
|
112
|
+
assert.equal(result.migrated, false);
|
|
113
|
+
assert.equal(result.actions.length, 0);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('sets flag and returns when no legacy files exist', () => {
|
|
117
|
+
tmpDir = makePlainDir();
|
|
118
|
+
writeFile(tmpDir, 'ideas/001-test.md', makeIdea(1, 'Test', { status: 'pending' }));
|
|
119
|
+
const result = migrateFlatStatus(tmpDir);
|
|
120
|
+
assert.equal(result.migrated, false);
|
|
121
|
+
assert.equal(result.actions.length, 0);
|
|
122
|
+
const config = JSON.parse(readFile(tmpDir, 'config.local.json'));
|
|
123
|
+
assert.equal(config.flat_status_migration_done, true);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe('dry-run mode', () => {
|
|
128
|
+
it('returns actions without modifying files', () => {
|
|
129
|
+
tmpDir = makePlainDir();
|
|
130
|
+
writeFile(tmpDir, 'ideas/pending/001-test.md', makeIdea(1, 'Test'));
|
|
131
|
+
|
|
132
|
+
const result = migrateFlatStatus(tmpDir);
|
|
133
|
+
|
|
134
|
+
assert.equal(result.dryRun, true);
|
|
135
|
+
assert.equal(result.migrated, false);
|
|
136
|
+
assert.ok(result.actions.length > 0, 'should have actions');
|
|
137
|
+
assert.equal(result.filesMoved, 1);
|
|
138
|
+
// File should NOT have been moved
|
|
139
|
+
assert.ok(fileExists(tmpDir, 'ideas/pending/001-test.md'), 'original file should still exist');
|
|
140
|
+
assert.ok(!fileExists(tmpDir, 'ideas/001-test.md'), 'destination should not exist');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('reports correct counts for multi-subsystem migration', () => {
|
|
144
|
+
tmpDir = makePlainDir();
|
|
145
|
+
writeFile(tmpDir, 'ideas/pending/001-idea.md', makeIdea(1, 'Idea'));
|
|
146
|
+
writeFile(tmpDir, 'ideas/done/002-done-idea.md', makeIdea(2, 'Done Idea'));
|
|
147
|
+
writeFile(tmpDir, 'todos/pending/todo-1.md', makeTodo('Todo 1'));
|
|
148
|
+
writeFile(tmpDir, 'jobs/completed/job-v1.md', makeJob('Job v1', 'completed'));
|
|
149
|
+
|
|
150
|
+
const result = migrateFlatStatus(tmpDir);
|
|
151
|
+
|
|
152
|
+
assert.equal(result.dryRun, true);
|
|
153
|
+
assert.equal(result.filesMoved, 4);
|
|
154
|
+
assert.equal(result.statusFieldsAdded, 4);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe('apply mode', () => {
|
|
159
|
+
it('moves idea files and adds status frontmatter', () => {
|
|
160
|
+
tmpDir = makeGitDir();
|
|
161
|
+
writeFile(tmpDir, 'ideas/pending/001-test.md', makeIdea(1, 'Test'));
|
|
162
|
+
writeFile(tmpDir, 'ideas/done/002-shipped.md', makeIdea(2, 'Shipped'));
|
|
163
|
+
gitCommitAll(tmpDir, 'add ideas');
|
|
164
|
+
|
|
165
|
+
const result = migrateFlatStatus(tmpDir, { apply: true });
|
|
166
|
+
|
|
167
|
+
assert.equal(result.migrated, true);
|
|
168
|
+
assert.equal(result.dryRun, false);
|
|
169
|
+
assert.ok(result.commitHash, 'should have commit hash');
|
|
170
|
+
|
|
171
|
+
// Files moved to flat directory
|
|
172
|
+
assert.ok(fileExists(tmpDir, 'ideas/001-test.md'), 'idea should be in flat dir');
|
|
173
|
+
assert.ok(fileExists(tmpDir, 'ideas/002-shipped.md'), 'done idea should be in flat dir');
|
|
174
|
+
assert.ok(!fileExists(tmpDir, 'ideas/pending/001-test.md'), 'original should be gone');
|
|
175
|
+
assert.ok(!fileExists(tmpDir, 'ideas/done/002-shipped.md'), 'original should be gone');
|
|
176
|
+
|
|
177
|
+
// Status frontmatter added
|
|
178
|
+
const pendingContent = readFile(tmpDir, 'ideas/001-test.md');
|
|
179
|
+
const pendingFm = extractFrontmatter(pendingContent);
|
|
180
|
+
assert.equal(pendingFm.status, 'pending');
|
|
181
|
+
|
|
182
|
+
const doneContent = readFile(tmpDir, 'ideas/002-shipped.md');
|
|
183
|
+
const doneFm = extractFrontmatter(doneContent);
|
|
184
|
+
assert.equal(doneFm.status, 'done');
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('maps todo states correctly (completed -> done, resolved -> done)', () => {
|
|
188
|
+
tmpDir = makeGitDir();
|
|
189
|
+
writeFile(tmpDir, 'todos/pending/todo-1.md', makeTodo('Pending Todo'));
|
|
190
|
+
writeFile(tmpDir, 'todos/completed/todo-2.md', makeTodo('Completed Todo'));
|
|
191
|
+
writeFile(tmpDir, 'todos/done/todo-3.md', makeTodo('Done Todo'));
|
|
192
|
+
writeFile(tmpDir, 'todos/resolved/todo-4.md', makeTodo('Resolved Todo'));
|
|
193
|
+
gitCommitAll(tmpDir, 'add todos');
|
|
194
|
+
|
|
195
|
+
const result = migrateFlatStatus(tmpDir, { apply: true });
|
|
196
|
+
|
|
197
|
+
assert.equal(result.migrated, true);
|
|
198
|
+
|
|
199
|
+
// All files in flat dir
|
|
200
|
+
assert.ok(fileExists(tmpDir, 'todos/todo-1.md'));
|
|
201
|
+
assert.ok(fileExists(tmpDir, 'todos/todo-2.md'));
|
|
202
|
+
assert.ok(fileExists(tmpDir, 'todos/todo-3.md'));
|
|
203
|
+
assert.ok(fileExists(tmpDir, 'todos/todo-4.md'));
|
|
204
|
+
|
|
205
|
+
// Status mappings correct
|
|
206
|
+
const todo1 = extractFrontmatter(readFile(tmpDir, 'todos/todo-1.md'));
|
|
207
|
+
assert.equal(todo1.status, 'pending');
|
|
208
|
+
const todo2 = extractFrontmatter(readFile(tmpDir, 'todos/todo-2.md'));
|
|
209
|
+
assert.equal(todo2.status, 'done');
|
|
210
|
+
const todo3 = extractFrontmatter(readFile(tmpDir, 'todos/todo-3.md'));
|
|
211
|
+
assert.equal(todo3.status, 'done');
|
|
212
|
+
const todo4 = extractFrontmatter(readFile(tmpDir, 'todos/todo-4.md'));
|
|
213
|
+
assert.equal(todo4.status, 'done');
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('flattens research docs and updates path references in ideas', () => {
|
|
217
|
+
tmpDir = makeGitDir();
|
|
218
|
+
const ideaWithResearch = makeIdea(5, 'Research Idea', {
|
|
219
|
+
researchLog: '**Document:** docs/ideas/pending/research-idea-research.md\n**Date:** 2026-01-01'
|
|
220
|
+
});
|
|
221
|
+
writeFile(tmpDir, 'ideas/pending/005-research-idea.md', ideaWithResearch);
|
|
222
|
+
writeFile(tmpDir, 'docs/ideas/pending/research-idea-research.md', '# Research\n\nFindings here.\n');
|
|
223
|
+
gitCommitAll(tmpDir, 'add research');
|
|
224
|
+
|
|
225
|
+
const result = migrateFlatStatus(tmpDir, { apply: true });
|
|
226
|
+
|
|
227
|
+
assert.equal(result.migrated, true);
|
|
228
|
+
|
|
229
|
+
// Research doc flattened
|
|
230
|
+
assert.ok(fileExists(tmpDir, 'docs/ideas/research-idea-research.md'), 'research doc in flat dir');
|
|
231
|
+
assert.ok(!fileExists(tmpDir, 'docs/ideas/pending/research-idea-research.md'), 'research doc removed from state dir');
|
|
232
|
+
|
|
233
|
+
// Path reference updated in idea
|
|
234
|
+
const ideaContent = readFile(tmpDir, 'ideas/005-research-idea.md');
|
|
235
|
+
assert.ok(ideaContent.includes('docs/ideas/research-idea-research.md'), 'path should be flat');
|
|
236
|
+
assert.ok(!ideaContent.includes('docs/ideas/pending/research-idea-research.md'), 'state path should be gone');
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('normalizes consolidated_into and consolidated_from paths', () => {
|
|
240
|
+
tmpDir = makeGitDir();
|
|
241
|
+
const consolidatedIdea = makeIdea(3, 'Old Idea', {
|
|
242
|
+
consolidated_into: 'ideas/pending/010-merged.md'
|
|
243
|
+
});
|
|
244
|
+
const mergedIdea = makeIdea(10, 'Merged', {
|
|
245
|
+
consolidated_from: ['ideas/pending/003-old-idea.md', 'ideas/done/004-another.md']
|
|
246
|
+
});
|
|
247
|
+
writeFile(tmpDir, 'ideas/consolidated/003-old-idea.md', consolidatedIdea);
|
|
248
|
+
writeFile(tmpDir, 'ideas/pending/010-merged.md', mergedIdea);
|
|
249
|
+
gitCommitAll(tmpDir, 'add consolidated');
|
|
250
|
+
|
|
251
|
+
const result = migrateFlatStatus(tmpDir, { apply: true });
|
|
252
|
+
|
|
253
|
+
assert.equal(result.migrated, true);
|
|
254
|
+
assert.ok(result.pathsNormalized > 0, 'should have normalized paths');
|
|
255
|
+
|
|
256
|
+
// Check consolidated_into normalized
|
|
257
|
+
const oldContent = readFile(tmpDir, 'ideas/003-old-idea.md');
|
|
258
|
+
assert.ok(!oldContent.includes('ideas/pending/'), 'should not have state dir in path');
|
|
259
|
+
assert.ok(oldContent.includes('ideas/010-merged.md'), 'should have flat path');
|
|
260
|
+
|
|
261
|
+
// Check consolidated_from normalized
|
|
262
|
+
const mergedContent = readFile(tmpDir, 'ideas/010-merged.md');
|
|
263
|
+
assert.ok(!mergedContent.includes('ideas/pending/003'), 'should not have state dir');
|
|
264
|
+
assert.ok(!mergedContent.includes('ideas/done/004'), 'should not have state dir');
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('validates file count (no data loss)', () => {
|
|
268
|
+
tmpDir = makeGitDir();
|
|
269
|
+
writeFile(tmpDir, 'ideas/pending/001-a.md', makeIdea(1, 'A'));
|
|
270
|
+
writeFile(tmpDir, 'ideas/done/002-b.md', makeIdea(2, 'B'));
|
|
271
|
+
writeFile(tmpDir, 'ideas/rejected/003-c.md', makeIdea(3, 'C'));
|
|
272
|
+
gitCommitAll(tmpDir, 'add ideas');
|
|
273
|
+
|
|
274
|
+
const result = migrateFlatStatus(tmpDir, { apply: true });
|
|
275
|
+
|
|
276
|
+
assert.equal(result.migrated, true);
|
|
277
|
+
assert.equal(result.filesMoved, 3);
|
|
278
|
+
|
|
279
|
+
// Count files in flat dir (should be 3 ideas)
|
|
280
|
+
const flatFiles = fs.readdirSync(path.join(tmpDir, 'ideas')).filter(f => f.endsWith('.md'));
|
|
281
|
+
assert.equal(flatFiles.length, 3, 'all 3 ideas should be in flat dir');
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it('handles jobs with dual-write status (frontmatter + bold-key header)', () => {
|
|
285
|
+
tmpDir = makeGitDir();
|
|
286
|
+
writeFile(tmpDir, 'jobs/completed/milestone-v5.md', makeJob('v5 Build', 'completed'));
|
|
287
|
+
writeFile(tmpDir, 'jobs/in-progress/milestone-v6.md', makeJob('v6 Build', 'in-progress'));
|
|
288
|
+
gitCommitAll(tmpDir, 'add jobs');
|
|
289
|
+
|
|
290
|
+
const result = migrateFlatStatus(tmpDir, { apply: true });
|
|
291
|
+
|
|
292
|
+
assert.equal(result.migrated, true);
|
|
293
|
+
|
|
294
|
+
// Check frontmatter status
|
|
295
|
+
const v5Content = readFile(tmpDir, 'jobs/milestone-v5.md');
|
|
296
|
+
const v5Fm = extractFrontmatter(v5Content);
|
|
297
|
+
assert.equal(v5Fm.status, 'completed');
|
|
298
|
+
|
|
299
|
+
// Check bold-key header still present
|
|
300
|
+
assert.ok(v5Content.includes('**Status:** completed'), 'bold-key header should be updated');
|
|
301
|
+
|
|
302
|
+
const v6Content = readFile(tmpDir, 'jobs/milestone-v6.md');
|
|
303
|
+
const v6Fm = extractFrontmatter(v6Content);
|
|
304
|
+
assert.equal(v6Fm.status, 'in-progress');
|
|
305
|
+
assert.ok(v6Content.includes('**Status:** in-progress'), 'bold-key header should be updated');
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
describe('realistic project fixtures', () => {
|
|
310
|
+
it('handles fc-like project (done ideas, research docs, pending todos, completed jobs)', () => {
|
|
311
|
+
tmpDir = makeGitDir();
|
|
312
|
+
|
|
313
|
+
// fc-like: 7 done ideas, 5 pending ideas, 11 pending todos, 1 resolved todo, 10 completed jobs, 1 research doc
|
|
314
|
+
for (let i = 1; i <= 7; i++) {
|
|
315
|
+
writeFile(tmpDir, `ideas/done/00${i}-done-idea-${i}.md`, makeIdea(i, `Done Idea ${i}`));
|
|
316
|
+
}
|
|
317
|
+
for (let i = 8; i <= 12; i++) {
|
|
318
|
+
writeFile(tmpDir, `ideas/pending/0${i}-pending-idea-${i}.md`, makeIdea(i, `Pending Idea ${i}`));
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
for (let i = 1; i <= 11; i++) {
|
|
322
|
+
writeFile(tmpDir, `todos/pending/todo-${i}.md`, makeTodo(`Todo ${i}`));
|
|
323
|
+
}
|
|
324
|
+
writeFile(tmpDir, 'todos/resolved/todo-resolved.md', makeTodo('Resolved Todo'));
|
|
325
|
+
|
|
326
|
+
for (let i = 1; i <= 10; i++) {
|
|
327
|
+
writeFile(tmpDir, `jobs/completed/milestone-v${i}.md`, makeJob(`v${i} Build`, 'completed'));
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
writeFile(tmpDir, 'docs/ideas/pending/research-doc.md', '# Research\n\nContent.\n');
|
|
331
|
+
|
|
332
|
+
gitCommitAll(tmpDir, 'init');
|
|
333
|
+
|
|
334
|
+
const result = migrateFlatStatus(tmpDir, { apply: true });
|
|
335
|
+
|
|
336
|
+
assert.equal(result.migrated, true);
|
|
337
|
+
// 7 done + 5 pending ideas + 12 todos + 10 jobs + 1 research = 35
|
|
338
|
+
assert.equal(result.filesMoved, 35);
|
|
339
|
+
|
|
340
|
+
// Spot check: done idea has status field
|
|
341
|
+
const doneIdea = readFile(tmpDir, 'ideas/001-done-idea-1.md');
|
|
342
|
+
assert.ok(doneIdea.includes('status: done'), 'done idea should have status: done');
|
|
343
|
+
|
|
344
|
+
// Resolved todo mapped to done
|
|
345
|
+
const resolvedTodo = readFile(tmpDir, 'todos/todo-resolved.md');
|
|
346
|
+
const resolvedFm = extractFrontmatter(resolvedTodo);
|
|
347
|
+
assert.equal(resolvedFm.status, 'done');
|
|
348
|
+
|
|
349
|
+
// Research doc flattened
|
|
350
|
+
assert.ok(fileExists(tmpDir, 'docs/ideas/research-doc.md'));
|
|
351
|
+
assert.ok(!fileExists(tmpDir, 'docs/ideas/pending/research-doc.md'));
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it('handles gsd-like project (mixed todo states, in-progress job)', () => {
|
|
355
|
+
tmpDir = makeGitDir();
|
|
356
|
+
|
|
357
|
+
// gsd-like: 4 done ideas, 5 pending ideas, mixed todos, 1 in-progress job
|
|
358
|
+
for (let i = 1; i <= 4; i++) {
|
|
359
|
+
writeFile(tmpDir, `ideas/done/00${i}-done-${i}.md`, makeIdea(i, `Done ${i}`));
|
|
360
|
+
}
|
|
361
|
+
for (let i = 5; i <= 9; i++) {
|
|
362
|
+
writeFile(tmpDir, `ideas/pending/00${i}-pending-${i}.md`, makeIdea(i, `Pending ${i}`));
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
for (let i = 1; i <= 17; i++) {
|
|
366
|
+
writeFile(tmpDir, `todos/pending/todo-p${i}.md`, makeTodo(`Pending ${i}`));
|
|
367
|
+
}
|
|
368
|
+
for (let i = 1; i <= 4; i++) {
|
|
369
|
+
writeFile(tmpDir, `todos/completed/todo-c${i}.md`, makeTodo(`Completed ${i}`));
|
|
370
|
+
}
|
|
371
|
+
for (let i = 1; i <= 9; i++) {
|
|
372
|
+
writeFile(tmpDir, `todos/done/todo-d${i}.md`, makeTodo(`Done ${i}`));
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
writeFile(tmpDir, 'jobs/in-progress/milestone-v20.md', makeJob('v20 Build', 'in-progress'));
|
|
376
|
+
writeFile(tmpDir, 'jobs/completed/milestone-v19.md', makeJob('v19 Build', 'completed'));
|
|
377
|
+
|
|
378
|
+
gitCommitAll(tmpDir, 'init');
|
|
379
|
+
|
|
380
|
+
const result = migrateFlatStatus(tmpDir, { apply: true });
|
|
381
|
+
|
|
382
|
+
assert.equal(result.migrated, true);
|
|
383
|
+
// 4 done + 5 pending ideas + 17 pending + 4 completed + 9 done todos + 2 jobs = 41
|
|
384
|
+
assert.equal(result.filesMoved, 41);
|
|
385
|
+
|
|
386
|
+
// All todos in flat dir with correct status
|
|
387
|
+
const completedTodo = readFile(tmpDir, 'todos/todo-c1.md');
|
|
388
|
+
const completedFm = extractFrontmatter(completedTodo);
|
|
389
|
+
assert.equal(completedFm.status, 'done', 'completed -> done');
|
|
390
|
+
|
|
391
|
+
const doneTodo = readFile(tmpDir, 'todos/todo-d1.md');
|
|
392
|
+
const doneFm = extractFrontmatter(doneTodo);
|
|
393
|
+
assert.equal(doneFm.status, 'done', 'done -> done');
|
|
394
|
+
});
|
|
395
|
+
});
|
|
396
|
+
});
|
|
@@ -251,7 +251,7 @@ function parseMustHavesBlock(content, blockName) {
|
|
|
251
251
|
|
|
252
252
|
const FRONTMATTER_SCHEMAS = {
|
|
253
253
|
plan: { required: ['phase', 'plan', 'type', 'wave', 'depends_on', 'files_modified', 'autonomous', 'must_haves'] },
|
|
254
|
-
summary: { required: ['phase', 'plan', 'subsystem', 'tags', 'duration', 'completed', '
|
|
254
|
+
summary: { required: ['phase', 'plan', 'subsystem', 'tags', 'duration', 'completed', 'requirements_completed'] },
|
|
255
255
|
verification: { required: ['phase', 'verified', 'status', 'score'] },
|
|
256
256
|
};
|
|
257
257
|
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Governance — Four-eyes principle enforcement for completion gates
|
|
3
|
+
*
|
|
4
|
+
* Provides:
|
|
5
|
+
* - normalizeAuthor(author): lowercased name extraction for comparison
|
|
6
|
+
* - getContributors(cwd): aggregate contributors from milestone phases + quicks
|
|
7
|
+
* - checkFourEyes(contributors, currentUser, mode): four-eyes decision function
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const { extractNameFromAuthor } = require('./identity.cjs');
|
|
13
|
+
const { getMilestonePhaseFilter, getProjectRoot } = require('./core.cjs');
|
|
14
|
+
const { extractFrontmatter } = require('./frontmatter.cjs');
|
|
15
|
+
const { getPlanningRoot } = require('./paths.cjs');
|
|
16
|
+
|
|
17
|
+
// ─── Author Normalization ───────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Normalize an author string for comparison.
|
|
21
|
+
* Extracts the name portion (strips email) and lowercases.
|
|
22
|
+
*
|
|
23
|
+
* @param {string|null|undefined} author - Author string ("Name" or "Name <email>")
|
|
24
|
+
* @returns {string} Lowercased name portion, or '' for null/undefined/empty
|
|
25
|
+
*/
|
|
26
|
+
function normalizeAuthor(author) {
|
|
27
|
+
if (!author) return '';
|
|
28
|
+
return extractNameFromAuthor(author).toLowerCase();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ─── Contributor Aggregation ────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Aggregate unique contributors from milestone phases and milestone-context quick tasks.
|
|
35
|
+
* Reads PLAN.md created_by and SUMMARY.md executed_by from:
|
|
36
|
+
* 1. All phase directories in current milestone
|
|
37
|
+
* 2. Milestone-context quick tasks (detected via STATE.md)
|
|
38
|
+
*
|
|
39
|
+
* @param {string} cwd - Working directory
|
|
40
|
+
* @returns {{ contributors: string[], normalized: Map<string, string> }}
|
|
41
|
+
* contributors: unique raw author strings (for display)
|
|
42
|
+
* normalized: Map of lowercased-name -> first-seen-raw-string (for comparison)
|
|
43
|
+
*/
|
|
44
|
+
function getContributors(cwd) {
|
|
45
|
+
const normalized = new Map(); // lowercased-name -> raw-string
|
|
46
|
+
|
|
47
|
+
function addContributor(rawAuthor) {
|
|
48
|
+
if (!rawAuthor || typeof rawAuthor !== 'string') return;
|
|
49
|
+
const key = normalizeAuthor(rawAuthor);
|
|
50
|
+
if (!key) return;
|
|
51
|
+
if (!normalized.has(key)) {
|
|
52
|
+
normalized.set(key, rawAuthor);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// 1. Scan milestone phase directories
|
|
57
|
+
try {
|
|
58
|
+
const planRoot = getPlanningRoot(cwd);
|
|
59
|
+
const projectRootRel = getProjectRoot(cwd);
|
|
60
|
+
const projectRoot = path.join(planRoot, projectRootRel);
|
|
61
|
+
const phasesDir = path.join(projectRoot, 'phases');
|
|
62
|
+
const isDirInMilestone = getMilestonePhaseFilter(cwd);
|
|
63
|
+
|
|
64
|
+
if (fs.existsSync(phasesDir)) {
|
|
65
|
+
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
|
|
66
|
+
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort();
|
|
67
|
+
|
|
68
|
+
for (const dir of dirs) {
|
|
69
|
+
if (!isDirInMilestone(dir)) continue;
|
|
70
|
+
|
|
71
|
+
const phaseFiles = fs.readdirSync(path.join(phasesDir, dir));
|
|
72
|
+
|
|
73
|
+
// Read SUMMARY.md executed_by
|
|
74
|
+
const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
|
|
75
|
+
for (const s of summaries) {
|
|
76
|
+
try {
|
|
77
|
+
const content = fs.readFileSync(path.join(phasesDir, dir, s), 'utf-8');
|
|
78
|
+
const fm = extractFrontmatter(content);
|
|
79
|
+
addContributor(fm.executed_by);
|
|
80
|
+
} catch { /* skip unreadable files */ }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Read PLAN.md created_by
|
|
84
|
+
const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md');
|
|
85
|
+
for (const p of plans) {
|
|
86
|
+
try {
|
|
87
|
+
const content = fs.readFileSync(path.join(phasesDir, dir, p), 'utf-8');
|
|
88
|
+
const fm = extractFrontmatter(content);
|
|
89
|
+
addContributor(fm.created_by);
|
|
90
|
+
} catch { /* skip unreadable files */ }
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// 2. Scan milestone-context quick tasks via STATE.md
|
|
96
|
+
try {
|
|
97
|
+
const statePath = path.join(projectRoot, 'STATE.md');
|
|
98
|
+
if (fs.existsSync(statePath)) {
|
|
99
|
+
const stateContent = fs.readFileSync(statePath, 'utf-8');
|
|
100
|
+
// Parse quick tasks table for milestone-context entries
|
|
101
|
+
// Directory column format: milestone (slug)
|
|
102
|
+
const milestoneQuickPattern = /\|\s*(\S+)\s*\|[^|]*\|[^|]*\|[^|]*\|[^|]*\|\s*milestone\s*\(([^)]+)\)\s*\|/gi;
|
|
103
|
+
let match;
|
|
104
|
+
while ((match = milestoneQuickPattern.exec(stateContent)) !== null) {
|
|
105
|
+
const quickId = match[1];
|
|
106
|
+
// Find the quick's SUMMARY.md
|
|
107
|
+
const quickBaseDir = path.join(projectRoot, 'quick');
|
|
108
|
+
if (fs.existsSync(quickBaseDir)) {
|
|
109
|
+
try {
|
|
110
|
+
const quickDirs = fs.readdirSync(quickBaseDir);
|
|
111
|
+
const matchingDir = quickDirs.find(d => d.startsWith(quickId));
|
|
112
|
+
if (matchingDir) {
|
|
113
|
+
const quickSummaries = fs.readdirSync(path.join(quickBaseDir, matchingDir))
|
|
114
|
+
.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
|
|
115
|
+
for (const qs of quickSummaries) {
|
|
116
|
+
try {
|
|
117
|
+
const content = fs.readFileSync(path.join(quickBaseDir, matchingDir, qs), 'utf-8');
|
|
118
|
+
const fm = extractFrontmatter(content);
|
|
119
|
+
addContributor(fm.executed_by);
|
|
120
|
+
} catch { /* skip */ }
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
} catch { /* skip quick dir read failure */ }
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
} catch { /* silent skip — STATE.md parse failure per CONTEXT.md */ }
|
|
128
|
+
|
|
129
|
+
} catch { /* silent skip — overall scanning failure */ }
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
contributors: [...normalized.values()],
|
|
133
|
+
normalized,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ─── Four-Eyes Check ────────────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
const VALID_MODES = new Set(['off', 'warn', 'enforce']);
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Check whether the four-eyes principle is satisfied.
|
|
143
|
+
* Pure decision function — does not throw, does not produce output.
|
|
144
|
+
* Callers use the returned fields for display, logging, and gating.
|
|
145
|
+
*
|
|
146
|
+
* @param {string[]} contributors - Raw contributor strings from getContributors().contributors
|
|
147
|
+
* @param {string} currentUser - Current user's author string (from formatAuthorString)
|
|
148
|
+
* @param {string} mode - 'off' | 'warn' | 'enforce' (invalid values treated as 'off')
|
|
149
|
+
* @returns {{ passed: boolean, mode: string, currentUser: string, contributors: string[], matchedUser: string|null, message: string }}
|
|
150
|
+
*/
|
|
151
|
+
function checkFourEyes(contributors, currentUser, mode) {
|
|
152
|
+
// Normalize mode — invalid values fall back to 'off'
|
|
153
|
+
const effectiveMode = VALID_MODES.has(mode) ? mode : 'off';
|
|
154
|
+
|
|
155
|
+
// Base result shape
|
|
156
|
+
const result = {
|
|
157
|
+
passed: true,
|
|
158
|
+
mode: effectiveMode,
|
|
159
|
+
currentUser: currentUser || '',
|
|
160
|
+
contributors: contributors || [],
|
|
161
|
+
matchedUser: null,
|
|
162
|
+
message: '',
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
// Off mode: no check, no output (REQ-06)
|
|
166
|
+
if (effectiveMode === 'off') {
|
|
167
|
+
return result;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Empty contributors: skip check (CTR-05)
|
|
171
|
+
if (!contributors || contributors.length === 0) {
|
|
172
|
+
result.message = 'No contributors found — check skipped';
|
|
173
|
+
return result;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Normalize current user for comparison
|
|
177
|
+
const currentNormalized = normalizeAuthor(currentUser);
|
|
178
|
+
if (!currentNormalized) {
|
|
179
|
+
result.message = 'Current user identity could not be resolved — check skipped';
|
|
180
|
+
return result;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Check if current user is among contributors
|
|
184
|
+
let matchedRaw = null;
|
|
185
|
+
for (const contributor of contributors) {
|
|
186
|
+
if (normalizeAuthor(contributor) === currentNormalized) {
|
|
187
|
+
matchedRaw = contributor;
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (!matchedRaw) {
|
|
193
|
+
// Current user is NOT a contributor — four-eyes satisfied
|
|
194
|
+
result.message = 'Four-eyes principle satisfied — completing user is not a contributor';
|
|
195
|
+
return result;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Current user IS a contributor — fail based on mode
|
|
199
|
+
result.passed = false;
|
|
200
|
+
result.matchedUser = matchedRaw;
|
|
201
|
+
|
|
202
|
+
if (effectiveMode === 'warn') {
|
|
203
|
+
result.message = `Four-eyes warning: ${currentUser} completed while also a contributor. Contributors: ${contributors.join(', ')}`;
|
|
204
|
+
} else if (effectiveMode === 'enforce') {
|
|
205
|
+
result.message = `Four-eyes violation: ${currentUser} is a contributor and cannot complete in enforce mode. Contributors: ${contributors.join(', ')}`;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return result;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
module.exports = { normalizeAuthor, getContributors, checkFourEyes };
|