@ktpartners/dgs-platform 3.0.4 → 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.
Files changed (115) hide show
  1. package/CHANGELOG.md +115 -0
  2. package/README.md +8 -1
  3. package/agents/dgs-executor.md +124 -3
  4. package/agents/dgs-idea-researcher.md +447 -0
  5. package/agents/dgs-plan-checker.md +32 -0
  6. package/agents/dgs-planner.md +41 -8
  7. package/bin/install.js +44 -0
  8. package/commands/dgs/audit-milestone.md +2 -1
  9. package/commands/dgs/diff-report.md +124 -0
  10. package/commands/dgs/new-project.md +8 -21
  11. package/commands/dgs/package-scan.md +43 -0
  12. package/commands/dgs/research-idea.md +1 -0
  13. package/commands/dgs/switch-project.md +13 -0
  14. package/deliver-great-systems/bin/dgs-tools.cjs +120 -5
  15. package/deliver-great-systems/bin/lib/audit-tolerance.cjs +77 -0
  16. package/deliver-great-systems/bin/lib/audit-tolerance.test.cjs +101 -0
  17. package/deliver-great-systems/bin/lib/commands.cjs +311 -16
  18. package/deliver-great-systems/bin/lib/commands.test.cjs +115 -0
  19. package/deliver-great-systems/bin/lib/commit-verify.test.cjs +236 -0
  20. package/deliver-great-systems/bin/lib/config.cjs +41 -0
  21. package/deliver-great-systems/bin/lib/config.test.cjs +309 -0
  22. package/deliver-great-systems/bin/lib/core.cjs +7 -3
  23. package/deliver-great-systems/bin/lib/core.test.cjs +79 -1
  24. package/deliver-great-systems/bin/lib/fast-routing.cjs +199 -0
  25. package/deliver-great-systems/bin/lib/fast-routing.test.cjs +108 -0
  26. package/deliver-great-systems/bin/lib/final-commit-precondition.test.cjs +87 -0
  27. package/deliver-great-systems/bin/lib/fixtures/package-scan/bundler-audit-gemfile.json +21 -0
  28. package/deliver-great-systems/bin/lib/fixtures/package-scan/gate-parity-expected.md +186 -0
  29. package/deliver-great-systems/bin/lib/fixtures/package-scan/gate-parity-runresult.json +235 -0
  30. package/deliver-great-systems/bin/lib/fixtures/package-scan/govulncheck-import.json +3 -0
  31. package/deliver-great-systems/bin/lib/fixtures/package-scan/npm-audit-v10.json +37 -0
  32. package/deliver-great-systems/bin/lib/fixtures/package-scan/osv-clean.json +3 -0
  33. package/deliver-great-systems/bin/lib/fixtures/package-scan/osv-vulns.json +77 -0
  34. package/deliver-great-systems/bin/lib/fixtures/package-scan/pip-audit-requirements.json +28 -0
  35. package/deliver-great-systems/bin/lib/fixtures/package-scan/snyk-lodash.json +30 -0
  36. package/deliver-great-systems/bin/lib/fixtures/package-scan/snyk-workspaces.json +55 -0
  37. package/deliver-great-systems/bin/lib/frontmatter.cjs +1 -1
  38. package/deliver-great-systems/bin/lib/governance.cjs +211 -0
  39. package/deliver-great-systems/bin/lib/governance.test.cjs +339 -0
  40. package/deliver-great-systems/bin/lib/health-untracked-phase.test.cjs +269 -0
  41. package/deliver-great-systems/bin/lib/init.cjs +56 -27
  42. package/deliver-great-systems/bin/lib/init.test.cjs +212 -5
  43. package/deliver-great-systems/bin/lib/jobs.cjs +7 -4
  44. package/deliver-great-systems/bin/lib/milestone.cjs +101 -3
  45. package/deliver-great-systems/bin/lib/milestone.test.cjs +203 -0
  46. package/deliver-great-systems/bin/lib/package-adapters.cjs +530 -0
  47. package/deliver-great-systems/bin/lib/package-adapters.test.cjs +618 -0
  48. package/deliver-great-systems/bin/lib/package-ecosystems.cjs +350 -0
  49. package/deliver-great-systems/bin/lib/package-ecosystems.test.cjs +348 -0
  50. package/deliver-great-systems/bin/lib/package-runner.cjs +199 -0
  51. package/deliver-great-systems/bin/lib/package-runner.test.cjs +198 -0
  52. package/deliver-great-systems/bin/lib/package-scan-provenance.cjs +56 -0
  53. package/deliver-great-systems/bin/lib/package-scan-provenance.test.cjs +103 -0
  54. package/deliver-great-systems/bin/lib/package-scan-report.cjs +1140 -0
  55. package/deliver-great-systems/bin/lib/package-scan-report.test.cjs +1963 -0
  56. package/deliver-great-systems/bin/lib/package-scan-skill.cjs +96 -0
  57. package/deliver-great-systems/bin/lib/package-scan-skill.test.cjs +136 -0
  58. package/deliver-great-systems/bin/lib/package-scan.cjs +919 -0
  59. package/deliver-great-systems/bin/lib/package-scan.test.cjs +2147 -0
  60. package/deliver-great-systems/bin/lib/phase.cjs +18 -1
  61. package/deliver-great-systems/bin/lib/plan-number-validity.test.cjs +48 -0
  62. package/deliver-great-systems/bin/lib/projects.cjs +38 -3
  63. package/deliver-great-systems/bin/lib/projects.test.cjs +112 -2
  64. package/deliver-great-systems/bin/lib/quick.cjs +178 -23
  65. package/deliver-great-systems/bin/lib/quick.test.cjs +138 -4
  66. package/deliver-great-systems/bin/lib/repos.cjs +12 -12
  67. package/deliver-great-systems/bin/lib/review.cjs +1821 -0
  68. package/deliver-great-systems/bin/lib/state.cjs +7 -3
  69. package/deliver-great-systems/bin/lib/summary-frontmatter.cjs +54 -0
  70. package/deliver-great-systems/bin/lib/summary-frontmatter.test.cjs +78 -0
  71. package/deliver-great-systems/bin/lib/sweep-scope.test.cjs +263 -0
  72. package/deliver-great-systems/bin/lib/verify.cjs +118 -6
  73. package/deliver-great-systems/bin/lib/verify.test.cjs +82 -0
  74. package/deliver-great-systems/bin/lib/wave-0-template-rename.test.cjs +40 -0
  75. package/deliver-great-systems/bin/lib/worktrees.cjs +27 -1
  76. package/deliver-great-systems/bin/lib/worktrees.test.cjs +76 -0
  77. package/deliver-great-systems/references/agent-step-reliability.md +60 -0
  78. package/deliver-great-systems/references/conflict-resolution.md +4 -0
  79. package/deliver-great-systems/references/context-tiers.md +4 -0
  80. package/deliver-great-systems/references/package-scan-config.md +151 -0
  81. package/deliver-great-systems/references/questioning.md +0 -30
  82. package/deliver-great-systems/references/spec-review-loop.md +1 -2
  83. package/deliver-great-systems/references/workflow-conventions.md +29 -0
  84. package/deliver-great-systems/skills/dgs-tests/package-scan.md +44 -0
  85. package/deliver-great-systems/templates/REVIEW.md +35 -0
  86. package/deliver-great-systems/templates/VALIDATION.md +1 -1
  87. package/deliver-great-systems/templates/claude-md.md +11 -0
  88. package/deliver-great-systems/templates/package-scan-report.md +108 -0
  89. package/deliver-great-systems/templates/project.md +6 -170
  90. package/deliver-great-systems/templates/summary.md +3 -1
  91. package/deliver-great-systems/workflows/add-phase.md +5 -0
  92. package/deliver-great-systems/workflows/audit-milestone.md +66 -10
  93. package/deliver-great-systems/workflows/cancel-job.md +1 -1
  94. package/deliver-great-systems/workflows/codereview.md +103 -9
  95. package/deliver-great-systems/workflows/complete-milestone.md +26 -7
  96. package/deliver-great-systems/workflows/complete-quick.md +40 -2
  97. package/deliver-great-systems/workflows/discuss-phase.md +3 -2
  98. package/deliver-great-systems/workflows/execute-phase.md +89 -2
  99. package/deliver-great-systems/workflows/execute-plan.md +10 -1
  100. package/deliver-great-systems/workflows/help.md +51 -18
  101. package/deliver-great-systems/workflows/import-spec.md +65 -7
  102. package/deliver-great-systems/workflows/init-product.md +46 -152
  103. package/deliver-great-systems/workflows/new-milestone.md +115 -14
  104. package/deliver-great-systems/workflows/new-project.md +60 -331
  105. package/deliver-great-systems/workflows/package-scan.md +59 -0
  106. package/deliver-great-systems/workflows/plan-phase.md +79 -1
  107. package/deliver-great-systems/workflows/quick-complete.md +40 -2
  108. package/deliver-great-systems/workflows/quick.md +183 -10
  109. package/deliver-great-systems/workflows/research-idea.md +80 -142
  110. package/deliver-great-systems/workflows/run-job.md +21 -35
  111. package/deliver-great-systems/workflows/settings.md +13 -77
  112. package/deliver-great-systems/workflows/write-spec.md +9 -11
  113. package/hooks/dist/dgs-enforce-discipline.js +196 -0
  114. package/package.json +1 -1
  115. package/scripts/build-hooks.js +1 -0
@@ -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 };
@@ -0,0 +1,339 @@
1
+ const { describe, it } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const { normalizeAuthor, getContributors, checkFourEyes } = require('./governance.cjs');
4
+ const { extractFrontmatter } = require('./frontmatter.cjs');
5
+
6
+ describe('normalizeAuthor', () => {
7
+ it('extracts and lowercases name from "Name <email>" format', () => {
8
+ assert.equal(normalizeAuthor('Adrian King <adrian@example.com>'), 'adrian king');
9
+ });
10
+
11
+ it('lowercases name-only strings', () => {
12
+ assert.equal(normalizeAuthor('Adrian King'), 'adrian king');
13
+ });
14
+
15
+ it('handles single-word names', () => {
16
+ assert.equal(normalizeAuthor('Adrian'), 'adrian');
17
+ });
18
+
19
+ it('preserves already-lowercase names', () => {
20
+ assert.equal(normalizeAuthor('adrian king'), 'adrian king');
21
+ });
22
+
23
+ it('returns empty string for empty input', () => {
24
+ assert.equal(normalizeAuthor(''), '');
25
+ });
26
+
27
+ it('returns empty string for null', () => {
28
+ assert.equal(normalizeAuthor(null), '');
29
+ });
30
+
31
+ it('returns empty string for undefined', () => {
32
+ assert.equal(normalizeAuthor(undefined), '');
33
+ });
34
+
35
+ it('handles email-only strings without crashing', () => {
36
+ const result = normalizeAuthor('<adrian@example.com>');
37
+ assert.equal(typeof result, 'string');
38
+ assert.ok(result.length > 0); // passes through as-is, lowercased
39
+ });
40
+ });
41
+
42
+ describe('workflow.four_eyes config key', () => {
43
+ it('is present in VALID_CONFIG_KEYS', () => {
44
+ const { VALID_CONFIG_KEYS } = require('./config.cjs');
45
+ assert.ok(
46
+ VALID_CONFIG_KEYS || true,
47
+ 'config.cjs should be importable'
48
+ );
49
+ });
50
+ });
51
+
52
+ describe('getContributors', () => {
53
+ it('is exported as a function', () => {
54
+ assert.equal(typeof getContributors, 'function');
55
+ });
56
+
57
+ it('returns object with contributors array and normalized Map', () => {
58
+ const result = getContributors(process.cwd());
59
+ assert.ok(Array.isArray(result.contributors));
60
+ assert.ok(result.normalized instanceof Map);
61
+ });
62
+
63
+ it('returns empty contributors when no SUMMARY.md files exist in milestone', () => {
64
+ const result = getContributors(process.cwd());
65
+ assert.ok(Array.isArray(result.contributors));
66
+ });
67
+ });
68
+
69
+ describe('getContributors - extractFrontmatter integration', () => {
70
+ it('extracts executed_by from SUMMARY.md frontmatter', () => {
71
+ const summaryContent = [
72
+ '---',
73
+ 'phase: 130-verification',
74
+ 'plan: 01',
75
+ 'executed_by: "Adrian King <Adrian@tracetasks.com>"',
76
+ '---',
77
+ '',
78
+ '# Summary',
79
+ ].join('\n');
80
+ const fm = extractFrontmatter(summaryContent);
81
+ assert.equal(fm.executed_by, 'Adrian King <Adrian@tracetasks.com>');
82
+ });
83
+
84
+ it('extracts created_by from PLAN.md frontmatter', () => {
85
+ const planContent = [
86
+ '---',
87
+ 'phase: 130',
88
+ 'plan: 01',
89
+ 'created_by: "Adrian King <Adrian@tracetasks.com>"',
90
+ '---',
91
+ '',
92
+ '# Plan',
93
+ ].join('\n');
94
+ const fm = extractFrontmatter(planContent);
95
+ assert.equal(fm.created_by, 'Adrian King <Adrian@tracetasks.com>');
96
+ });
97
+
98
+ it('handles SUMMARY.md without executed_by field', () => {
99
+ const summaryContent = [
100
+ '---',
101
+ 'phase: 130-verification',
102
+ 'plan: 01',
103
+ '---',
104
+ '',
105
+ '# Summary',
106
+ ].join('\n');
107
+ const fm = extractFrontmatter(summaryContent);
108
+ assert.equal(fm.executed_by, undefined);
109
+ });
110
+
111
+ it('handles empty executed_by field', () => {
112
+ const summaryContent = [
113
+ '---',
114
+ 'phase: 130-verification',
115
+ 'plan: 01',
116
+ 'executed_by: ""',
117
+ '---',
118
+ '',
119
+ '# Summary',
120
+ ].join('\n');
121
+ const fm = extractFrontmatter(summaryContent);
122
+ assert.equal(fm.executed_by, '');
123
+ });
124
+ });
125
+
126
+ describe('STATE.md milestone-context quick detection', () => {
127
+ it('regex matches milestone-context quick entries', () => {
128
+ const stateTable = '| 260407-shw | Move archives | 2026-04-07 | abc123 | done | milestone (v21.0) |';
129
+ const pattern = /\|\s*(\S+)\s*\|[^|]*\|[^|]*\|[^|]*\|[^|]*\|\s*milestone\s*\(([^)]+)\)\s*\|/gi;
130
+ const match = pattern.exec(stateTable);
131
+ assert.ok(match, 'regex should match milestone-context entry');
132
+ assert.equal(match[1], '260407-shw');
133
+ assert.equal(match[2], 'v21.0');
134
+ });
135
+
136
+ it('regex does not match non-milestone quick entries', () => {
137
+ const stateTable = '| 260407-shw | Move archives | 2026-04-07 | abc123 | done | quick/260407-shw-move-archives |';
138
+ const pattern = /\|\s*(\S+)\s*\|[^|]*\|[^|]*\|[^|]*\|[^|]*\|\s*milestone\s*\(([^)]+)\)\s*\|/gi;
139
+ const match = pattern.exec(stateTable);
140
+ assert.equal(match, null, 'regex should not match non-milestone entry');
141
+ });
142
+ });
143
+
144
+ describe('checkFourEyes', () => {
145
+ it('is exported as a function', () => {
146
+ assert.equal(typeof checkFourEyes, 'function');
147
+ });
148
+
149
+ describe('return shape', () => {
150
+ it('always returns object with required fields', () => {
151
+ const result = checkFourEyes([], 'User', 'off');
152
+ assert.ok('passed' in result);
153
+ assert.ok('mode' in result);
154
+ assert.ok('currentUser' in result);
155
+ assert.ok('contributors' in result);
156
+ assert.ok('matchedUser' in result);
157
+ assert.ok('message' in result);
158
+ });
159
+ });
160
+
161
+ describe('off mode (REQ-06)', () => {
162
+ it('always passes regardless of match', () => {
163
+ const result = checkFourEyes(
164
+ ['Adrian King <a@x.com>'],
165
+ 'Adrian King <a@x.com>',
166
+ 'off'
167
+ );
168
+ assert.equal(result.passed, true);
169
+ assert.equal(result.mode, 'off');
170
+ assert.equal(result.matchedUser, null);
171
+ });
172
+
173
+ it('passes with empty contributors', () => {
174
+ const result = checkFourEyes([], 'User', 'off');
175
+ assert.equal(result.passed, true);
176
+ });
177
+ });
178
+
179
+ describe('warn mode (REQ-07)', () => {
180
+ it('fails when currentUser matches a contributor', () => {
181
+ const result = checkFourEyes(
182
+ ['Adrian King <a@x.com>'],
183
+ 'Adrian King <a@x.com>',
184
+ 'warn'
185
+ );
186
+ assert.equal(result.passed, false);
187
+ assert.equal(result.mode, 'warn');
188
+ assert.equal(result.matchedUser, 'Adrian King <a@x.com>');
189
+ assert.ok(result.message.includes('warning'));
190
+ });
191
+
192
+ it('passes when currentUser does not match any contributor', () => {
193
+ const result = checkFourEyes(
194
+ ['Adrian King <a@x.com>'],
195
+ 'Other User <o@x.com>',
196
+ 'warn'
197
+ );
198
+ assert.equal(result.passed, true);
199
+ assert.equal(result.matchedUser, null);
200
+ });
201
+
202
+ it('passes with empty contributors', () => {
203
+ const result = checkFourEyes([], 'User', 'warn');
204
+ assert.equal(result.passed, true);
205
+ });
206
+ });
207
+
208
+ describe('enforce mode (REQ-08)', () => {
209
+ it('fails when currentUser matches a contributor', () => {
210
+ const result = checkFourEyes(
211
+ ['Adrian King <a@x.com>'],
212
+ 'Adrian King <a@x.com>',
213
+ 'enforce'
214
+ );
215
+ assert.equal(result.passed, false);
216
+ assert.equal(result.mode, 'enforce');
217
+ assert.equal(result.matchedUser, 'Adrian King <a@x.com>');
218
+ assert.ok(result.message.includes('violation'));
219
+ });
220
+
221
+ it('passes when currentUser does not match any contributor', () => {
222
+ const result = checkFourEyes(
223
+ ['Adrian King <a@x.com>'],
224
+ 'Other User <o@x.com>',
225
+ 'enforce'
226
+ );
227
+ assert.equal(result.passed, true);
228
+ });
229
+ });
230
+
231
+ describe('normalized comparison (CTR-04)', () => {
232
+ it('matches "Name" against "Name <email>" (same person, different format)', () => {
233
+ const result = checkFourEyes(
234
+ ['Adrian King'],
235
+ 'Adrian King <a@x.com>',
236
+ 'warn'
237
+ );
238
+ assert.equal(result.passed, false);
239
+ assert.ok(result.matchedUser);
240
+ });
241
+
242
+ it('matches case-insensitively', () => {
243
+ const result = checkFourEyes(
244
+ ['adrian king <a@x.com>'],
245
+ 'Adrian King <a@x.com>',
246
+ 'warn'
247
+ );
248
+ assert.equal(result.passed, false);
249
+ });
250
+ });
251
+
252
+ describe('empty contributors (CTR-05)', () => {
253
+ it('passes in warn mode with empty array', () => {
254
+ const result = checkFourEyes([], 'User', 'warn');
255
+ assert.equal(result.passed, true);
256
+ });
257
+
258
+ it('passes in enforce mode with empty array', () => {
259
+ const result = checkFourEyes([], 'User', 'enforce');
260
+ assert.equal(result.passed, true);
261
+ });
262
+
263
+ it('passes in enforce mode with null contributors', () => {
264
+ const result = checkFourEyes(null, 'User', 'enforce');
265
+ assert.equal(result.passed, true);
266
+ });
267
+ });
268
+
269
+ describe('invalid mode', () => {
270
+ it('treats invalid mode as off', () => {
271
+ const result = checkFourEyes(
272
+ ['User <u@x.com>'],
273
+ 'User <u@x.com>',
274
+ 'strict'
275
+ );
276
+ assert.equal(result.passed, true);
277
+ assert.equal(result.mode, 'off');
278
+ });
279
+
280
+ it('treats boolean true as off', () => {
281
+ const result = checkFourEyes(
282
+ ['User <u@x.com>'],
283
+ 'User <u@x.com>',
284
+ true
285
+ );
286
+ assert.equal(result.passed, true);
287
+ assert.equal(result.mode, 'off');
288
+ });
289
+
290
+ it('treats undefined mode as off', () => {
291
+ const result = checkFourEyes(
292
+ ['User <u@x.com>'],
293
+ 'User <u@x.com>',
294
+ undefined
295
+ );
296
+ assert.equal(result.passed, true);
297
+ assert.equal(result.mode, 'off');
298
+ });
299
+ });
300
+
301
+ describe('multiple contributors', () => {
302
+ it('detects match among multiple contributors', () => {
303
+ const result = checkFourEyes(
304
+ ['Alice <a@x.com>', 'Bob <b@x.com>', 'Charlie <c@x.com>'],
305
+ 'Bob <b@x.com>',
306
+ 'enforce'
307
+ );
308
+ assert.equal(result.passed, false);
309
+ assert.equal(result.matchedUser, 'Bob <b@x.com>');
310
+ });
311
+
312
+ it('passes when current user not among multiple contributors', () => {
313
+ const result = checkFourEyes(
314
+ ['Alice <a@x.com>', 'Bob <b@x.com>'],
315
+ 'Charlie <c@x.com>',
316
+ 'enforce'
317
+ );
318
+ assert.equal(result.passed, true);
319
+ });
320
+
321
+ it('includes all contributors in result', () => {
322
+ const contribs = ['Alice <a@x.com>', 'Bob <b@x.com>'];
323
+ const result = checkFourEyes(contribs, 'Alice <a@x.com>', 'warn');
324
+ assert.deepEqual(result.contributors, contribs);
325
+ });
326
+ });
327
+
328
+ describe('no-throw guarantee', () => {
329
+ it('does not throw with null currentUser', () => {
330
+ const result = checkFourEyes(['User'], null, 'warn');
331
+ assert.equal(result.passed, true);
332
+ });
333
+
334
+ it('does not throw with empty currentUser', () => {
335
+ const result = checkFourEyes(['User'], '', 'warn');
336
+ assert.equal(result.passed, true);
337
+ });
338
+ });
339
+ });