@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,1963 @@
1
+ /**
2
+ * package-scan-report.test.cjs -- Unit tests for the Phase 151 report writer
3
+ *
4
+ * Covers PKG-06, PKG-07, PKG-20, PKG-21.
5
+ *
6
+ * The module under test -- package-scan-report.cjs -- emits YAML frontmatter
7
+ * (with round-trip validation), renders a markdown body, resolves the report
8
+ * placement path via a three-tier cascade (active phase -> active milestone ->
9
+ * project root), and composes the three in `writePackageScanReport`.
10
+ */
11
+ 'use strict';
12
+ const { test, describe } = require('node:test');
13
+ const assert = require('node:assert/strict');
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+ const os = require('os');
17
+ const { execSync } = require('child_process');
18
+
19
+ // The module under test. Task 1 scaffolds this as a failing require (module
20
+ // does not yet exist); Task 2 lands the implementation and these tests pass.
21
+ const {
22
+ writePackageScanReport,
23
+ _emitYamlFrontmatter,
24
+ _renderBody,
25
+ _resolveReportPath,
26
+ _parseEmittedYaml,
27
+ } = require('./package-scan-report.cjs');
28
+
29
+ const { resetPaths } = require('./paths.cjs');
30
+
31
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
32
+
33
+ function setupPlanningRoot({ config, local, repos } = {}) {
34
+ const tmpDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'pkgscan-report-')));
35
+ execSync('git init -q', { cwd: tmpDir });
36
+ fs.writeFileSync(path.join(tmpDir, 'config.json'), JSON.stringify(config || { mode: 'v2' }, null, 2));
37
+ if (local) fs.writeFileSync(path.join(tmpDir, 'config.local.json'), JSON.stringify(local, null, 2));
38
+ if (repos) fs.writeFileSync(path.join(tmpDir, 'REPOS.md'), repos);
39
+ resetPaths();
40
+ return tmpDir;
41
+ }
42
+
43
+ function cleanup(d) { try { fs.rmSync(d, { recursive: true, force: true }); } catch {} }
44
+
45
+ function makeRunResult({ findings = [], repo_results = [], diagnostics = [], tool_per_target = {} } = {}) {
46
+ return {
47
+ exit_code: 0,
48
+ tool_per_target,
49
+ repo_results,
50
+ findings,
51
+ skipped: [],
52
+ diagnostics,
53
+ };
54
+ }
55
+
56
+ function makeFinding(overrides = {}) {
57
+ return {
58
+ id: overrides.id || 'pkg-001',
59
+ tool: overrides.tool || 'snyk',
60
+ ecosystem: overrides.ecosystem || 'node',
61
+ repo: overrides.repo || 'api',
62
+ manifest_path: overrides.manifest_path === undefined ? null : overrides.manifest_path,
63
+ package_name: overrides.package_name || 'lodash',
64
+ installed_version: overrides.installed_version || '4.17.20',
65
+ vulnerability: overrides.vulnerability || {
66
+ cve: 'CVE-2020-8203',
67
+ title: 'Prototype Pollution in lodash',
68
+ description: null,
69
+ reference_url: 'https://nvd.nist.gov/vuln/detail/CVE-2020-8203',
70
+ },
71
+ severity: overrides.severity === undefined ? 'critical' : overrides.severity,
72
+ cvss_score: overrides.cvss_score === undefined ? 7.4 : overrides.cvss_score,
73
+ cvss_vector: overrides.cvss_vector || null,
74
+ direct_or_transitive: overrides.direct_or_transitive || 'transitive',
75
+ dependency_chain: overrides.dependency_chain === undefined ? ['your-app', 'auth-lib', 'lodash'] : overrides.dependency_chain,
76
+ chain_available: overrides.chain_available === undefined ? true : overrides.chain_available,
77
+ fix_version: overrides.fix_version || '4.17.21',
78
+ remediation: overrides.remediation || 'npm install lodash@^4.17.21 --save',
79
+ licence: overrides.licence || null,
80
+ };
81
+ }
82
+
83
+ function makeFrontmatterPayload(overrides = {}) {
84
+ return Object.assign({
85
+ type: 'package-scan',
86
+ date: '2026-04-17',
87
+ tool: 'snyk',
88
+ repos_scanned: 1,
89
+ critical: 1,
90
+ high: 0,
91
+ medium: 0,
92
+ low: 0,
93
+ duration: 3,
94
+ findings: [
95
+ {
96
+ id: 'pkg-001',
97
+ test_source: 'package-scan',
98
+ gap_type: 'dependency-security',
99
+ severity: 'critical',
100
+ resource_id: 'lodash@4.17.20',
101
+ repo: 'api',
102
+ manifest_path: null,
103
+ title: 'Prototype Pollution in lodash',
104
+ description: null,
105
+ remediation: 'npm install lodash@^4.17.21 --save',
106
+ reference: 'https://nvd.nist.gov/vuln/detail/CVE-2020-8203',
107
+ cve: 'CVE-2020-8203',
108
+ cvss: 7.4,
109
+ dependency_chain: ['your-app', 'auth-lib', 'lodash'],
110
+ chain_available: true,
111
+ direct_or_transitive: 'transitive',
112
+ tool: 'snyk',
113
+ },
114
+ ],
115
+ }, overrides);
116
+ }
117
+
118
+ // ─── _emitYamlFrontmatter — happy path ────────────────────────────────────────
119
+
120
+ describe('_emitYamlFrontmatter — happy path', () => {
121
+ test('emits `type: package-scan` as the first field', () => {
122
+ const emitted = _emitYamlFrontmatter(makeFrontmatterPayload());
123
+ assert.ok(emitted.startsWith('---\n'));
124
+ const afterFence = emitted.slice(4);
125
+ assert.ok(afterFence.startsWith('type: "package-scan"'));
126
+ });
127
+
128
+ test('emits all top-level fields in fixed order', () => {
129
+ const emitted = _emitYamlFrontmatter(makeFrontmatterPayload());
130
+ const expectedOrder = ['type', 'date', 'tool', 'repos_scanned', 'critical', 'high', 'medium', 'low', 'duration', 'findings'];
131
+ let lastIdx = -1;
132
+ for (const key of expectedOrder) {
133
+ const idx = emitted.indexOf('\n' + key + ':');
134
+ assert.ok(idx > lastIdx, `${key} must appear after previous fields (got idx ${idx}, lastIdx ${lastIdx})`);
135
+ lastIdx = idx;
136
+ }
137
+ });
138
+
139
+ test('emits `findings: []` when findings array is empty', () => {
140
+ const emitted = _emitYamlFrontmatter(makeFrontmatterPayload({ findings: [] }));
141
+ assert.match(emitted, /findings: \[\]/);
142
+ });
143
+ });
144
+
145
+ // ─── _emitYamlFrontmatter — adversarial corpus ────────────────────────────────
146
+
147
+ describe('_emitYamlFrontmatter — adversarial corpus', () => {
148
+ const cases = [
149
+ {
150
+ name: 'title with colon',
151
+ mutate: (f) => { f.vulnerability = { ...f.vulnerability, title: 'Prototype Pollution: [CVE-2020-8203]' }; },
152
+ canonical_title: 'Prototype Pollution: [CVE-2020-8203]',
153
+ },
154
+ {
155
+ name: 'title with apostrophe',
156
+ mutate: (f) => { f.vulnerability = { ...f.vulnerability, title: "Denial of Service in Netty's HTTP Parser" }; },
157
+ canonical_title: "Denial of Service in Netty's HTTP Parser",
158
+ },
159
+ {
160
+ name: 'title with & * ! | >',
161
+ mutate: (f) => { f.vulnerability = { ...f.vulnerability, title: 'A & B * C ! D | E > F' }; },
162
+ canonical_title: 'A & B * C ! D | E > F',
163
+ },
164
+ {
165
+ name: 'description with newline (multi-line)',
166
+ mutate: (f) => { f.vulnerability = { ...f.vulnerability, description: 'Line one.\nLine two with indent.' }; },
167
+ },
168
+ {
169
+ name: 'description with backticks',
170
+ mutate: (f) => { f.vulnerability = { ...f.vulnerability, description: 'Call `foo()` for `bar`' }; },
171
+ },
172
+ {
173
+ name: 'reference URL with #fragment',
174
+ mutate: (f) => { f.vulnerability = { ...f.vulnerability, reference_url: 'https://example.com/advisory#details' }; },
175
+ },
176
+ {
177
+ name: 'scoped npm package (@scope/pkg@1.0.0)',
178
+ mutate: (f) => { f.package_name = '@scope/pkg'; f.installed_version = '1.0.0'; },
179
+ },
180
+ {
181
+ name: 'severity null',
182
+ mutate: (f) => { f.severity = null; },
183
+ },
184
+ {
185
+ name: 'manifest_path null',
186
+ mutate: (f) => { f.manifest_path = null; },
187
+ },
188
+ {
189
+ name: 'cvss null, cve null, chain null, chain_available false',
190
+ mutate: (f) => {
191
+ f.cvss_score = null;
192
+ f.vulnerability = { ...f.vulnerability, cve: null };
193
+ f.dependency_chain = null;
194
+ f.chain_available = false;
195
+ },
196
+ },
197
+ ];
198
+
199
+ for (const tc of cases) {
200
+ test(tc.name + ' — emits and round-trips', () => {
201
+ const f = makeFinding();
202
+ tc.mutate(f);
203
+ // Build a canonical findings[] entry matching what the writer produces.
204
+ const canonical = {
205
+ id: f.id,
206
+ test_source: 'package-scan',
207
+ gap_type: 'dependency-security',
208
+ severity: f.severity == null ? 'medium' : String(f.severity).toLowerCase(),
209
+ resource_id: f.installed_version ? `${f.package_name}@${f.installed_version}` : f.package_name,
210
+ repo: f.repo,
211
+ manifest_path: f.manifest_path,
212
+ title: f.vulnerability.title,
213
+ description: f.vulnerability.description,
214
+ remediation: f.remediation,
215
+ reference: f.vulnerability.reference_url,
216
+ cve: f.vulnerability.cve,
217
+ cvss: f.cvss_score,
218
+ dependency_chain: f.dependency_chain,
219
+ chain_available: f.chain_available,
220
+ direct_or_transitive: f.direct_or_transitive,
221
+ tool: f.tool,
222
+ };
223
+ const payload = makeFrontmatterPayload({ findings: [canonical] });
224
+ const emitted = _emitYamlFrontmatter(payload);
225
+ assert.ok(typeof emitted === 'string' && emitted.length > 0);
226
+ const parsed = _parseEmittedYaml(emitted);
227
+ assert.deepEqual(parsed, payload);
228
+ });
229
+ }
230
+ });
231
+
232
+ // ─── _emitYamlFrontmatter — null and number handling ──────────────────────────
233
+
234
+ describe('_emitYamlFrontmatter — null and number handling', () => {
235
+ test('null emits as literal `null`', () => {
236
+ const f = makeFinding({ severity: null, cvss_score: null });
237
+ const canonical = {
238
+ id: 'pkg-001', test_source: 'package-scan', gap_type: 'dependency-security',
239
+ severity: 'medium', resource_id: 'lodash@4.17.20', repo: 'api',
240
+ manifest_path: null, title: 'Title', description: null,
241
+ remediation: null, reference: null, cve: null, cvss: null,
242
+ dependency_chain: null, chain_available: false, direct_or_transitive: null,
243
+ tool: 'snyk',
244
+ };
245
+ const payload = makeFrontmatterPayload({ findings: [canonical] });
246
+ const emitted = _emitYamlFrontmatter(payload);
247
+ assert.match(emitted, /manifest_path: null/);
248
+ assert.match(emitted, /cvss: null/);
249
+ // And not the string form.
250
+ assert.doesNotMatch(emitted, /manifest_path: "null"/);
251
+ });
252
+
253
+ test('integers emit unquoted', () => {
254
+ const payload = makeFrontmatterPayload({ repos_scanned: 42, critical: 2, high: 0 });
255
+ const emitted = _emitYamlFrontmatter(payload);
256
+ assert.match(emitted, /repos_scanned: 42/);
257
+ assert.match(emitted, /critical: 2/);
258
+ assert.match(emitted, /high: 0/);
259
+ });
260
+
261
+ test('floats emit unquoted (e.g., cvss: 7.4)', () => {
262
+ const canonical = {
263
+ id: 'pkg-001', test_source: 'package-scan', gap_type: 'dependency-security',
264
+ severity: 'critical', resource_id: 'lodash@4.17.20', repo: 'api',
265
+ manifest_path: null, title: 'Title', description: null,
266
+ remediation: null, reference: null, cve: null, cvss: 7.4,
267
+ dependency_chain: null, chain_available: false, direct_or_transitive: null,
268
+ tool: 'snyk',
269
+ };
270
+ const payload = makeFrontmatterPayload({ findings: [canonical] });
271
+ const emitted = _emitYamlFrontmatter(payload);
272
+ assert.match(emitted, /cvss: 7\.4/);
273
+ assert.doesNotMatch(emitted, /cvss: "7\.4"/);
274
+ });
275
+
276
+ test('booleans emit as literal true/false', () => {
277
+ const canonical = {
278
+ id: 'pkg-001', test_source: 'package-scan', gap_type: 'dependency-security',
279
+ severity: 'critical', resource_id: 'lodash@4.17.20', repo: 'api',
280
+ manifest_path: null, title: 'T', description: null,
281
+ remediation: null, reference: null, cve: null, cvss: null,
282
+ dependency_chain: null, chain_available: true, direct_or_transitive: null,
283
+ tool: 'snyk',
284
+ };
285
+ const payload = makeFrontmatterPayload({ findings: [canonical] });
286
+ const emitted = _emitYamlFrontmatter(payload);
287
+ assert.match(emitted, /chain_available: true/);
288
+ });
289
+ });
290
+
291
+ // ─── _emitYamlFrontmatter — refuses disallowed input ──────────────────────────
292
+
293
+ describe('_emitYamlFrontmatter — refuses disallowed input', () => {
294
+ test('function value in payload throws', () => {
295
+ const payload = makeFrontmatterPayload();
296
+ payload.tool = () => {};
297
+ assert.throws(() => _emitYamlFrontmatter(payload));
298
+ });
299
+
300
+ test('circular reference throws', () => {
301
+ const payload = makeFrontmatterPayload();
302
+ const finding = payload.findings[0];
303
+ finding.self = finding; // circular
304
+ assert.throws(() => _emitYamlFrontmatter(payload));
305
+ });
306
+
307
+ test('unsupported nested non-finding object throws', () => {
308
+ const payload = makeFrontmatterPayload();
309
+ payload.tool = { nested: { object: 'here' } };
310
+ assert.throws(() => _emitYamlFrontmatter(payload));
311
+ });
312
+ });
313
+
314
+ // ─── _renderBody — summary table ──────────────────────────────────────────────
315
+
316
+ describe('_renderBody — summary table', () => {
317
+ test('single repo with one ecosystem renders one data row', () => {
318
+ const rr = [
319
+ { repo: 'api', ecosystem: 'node', tool_used: 'snyk', outcome: 'ok', findings: [], durationMs: 1000 },
320
+ ];
321
+ const result = makeRunResult({ repo_results: rr, findings: [] });
322
+ const body = _renderBody(result);
323
+ assert.match(body, /\| Repo \| Ecosystem \| Tool \| \.snyk policy \| Critical \| High \| Medium \| Low \| Status \|/);
324
+ assert.match(body, /\| api \| node \| snyk \|/);
325
+ });
326
+
327
+ test('outcome=no_manifests → status "skipped (no manifests)" and em-dash cells', () => {
328
+ const rr = [{ repo: 'api', ecosystem: null, tool_used: null, outcome: 'no_manifests', findings: [], durationMs: 0 }];
329
+ const result = makeRunResult({ repo_results: rr, findings: [] });
330
+ const body = _renderBody(result);
331
+ assert.match(body, /skipped \(no manifests\)/);
332
+ assert.match(body, /—/);
333
+ });
334
+
335
+ test('outcome=tool_failure → status "tool failure"', () => {
336
+ const rr = [{ repo: 'api', ecosystem: 'node', tool_used: 'snyk', outcome: 'tool_failure', findings: [], durationMs: 500 }];
337
+ const result = makeRunResult({ repo_results: rr, findings: [] });
338
+ const body = _renderBody(result);
339
+ assert.match(body, /tool failure/);
340
+ });
341
+
342
+ test('two repos across three ecosystems render three data rows', () => {
343
+ const rr = [
344
+ { repo: 'api', ecosystem: 'node', tool_used: 'snyk', outcome: 'ok', findings: [], durationMs: 10 },
345
+ { repo: 'api', ecosystem: 'python', tool_used: 'snyk', outcome: 'ok', findings: [], durationMs: 10 },
346
+ { repo: 'web', ecosystem: 'node', tool_used: 'osv-scanner', outcome: 'ok', findings: [], durationMs: 10 },
347
+ ];
348
+ const result = makeRunResult({ repo_results: rr, findings: [] });
349
+ const body = _renderBody(result);
350
+ // Find the summary table
351
+ const tableMatch = body.match(/## Summary[\s\S]*?(?=\n##|$)/);
352
+ assert.ok(tableMatch);
353
+ const dataRows = tableMatch[0].split('\n').filter(l => /^\| (api|web) \|/.test(l));
354
+ assert.equal(dataRows.length, 3);
355
+ });
356
+ });
357
+
358
+ // ─── _renderBody — per-severity sections ──────────────────────────────────────
359
+
360
+ describe('_renderBody — per-severity sections', () => {
361
+ test('findings across all severities → sections in Critical → High → Medium → Low order', () => {
362
+ const findings = [
363
+ makeFinding({ id: 'pkg-001', severity: 'critical' }),
364
+ makeFinding({ id: 'pkg-002', severity: 'high' }),
365
+ makeFinding({ id: 'pkg-003', severity: 'medium' }),
366
+ makeFinding({ id: 'pkg-004', severity: 'low' }),
367
+ ];
368
+ const rr = [{ repo: 'api', ecosystem: 'node', tool_used: 'snyk', outcome: 'ok', findings, durationMs: 1 }];
369
+ const result = makeRunResult({ repo_results: rr, findings });
370
+ const body = _renderBody(result);
371
+ const idxCritical = body.indexOf('## Critical');
372
+ const idxHigh = body.indexOf('## High');
373
+ const idxMedium = body.indexOf('## Medium');
374
+ const idxLow = body.indexOf('## Low');
375
+ assert.ok(idxCritical > 0);
376
+ assert.ok(idxHigh > idxCritical);
377
+ assert.ok(idxMedium > idxHigh);
378
+ assert.ok(idxLow > idxMedium);
379
+ });
380
+
381
+ test('empty tier (zero medium findings) → no ## Medium heading', () => {
382
+ const findings = [
383
+ makeFinding({ id: 'pkg-001', severity: 'critical' }),
384
+ ];
385
+ const rr = [{ repo: 'api', ecosystem: 'node', tool_used: 'snyk', outcome: 'ok', findings, durationMs: 1 }];
386
+ const result = makeRunResult({ repo_results: rr, findings });
387
+ const body = _renderBody(result);
388
+ assert.doesNotMatch(body, /## Medium/);
389
+ assert.doesNotMatch(body, /## High/);
390
+ assert.doesNotMatch(body, /## Low/);
391
+ });
392
+
393
+ test('finding block contains CVE / CVSS / Tool / Manifest / Direct/Transitive / Dependency chain / Fix / Reference', () => {
394
+ const findings = [makeFinding({ id: 'pkg-001', severity: 'critical' })];
395
+ const rr = [{ repo: 'api', ecosystem: 'node', tool_used: 'snyk', outcome: 'ok', findings, durationMs: 1 }];
396
+ const result = makeRunResult({ repo_results: rr, findings });
397
+ const body = _renderBody(result);
398
+ assert.match(body, /\*\*CVE:\*\*/);
399
+ assert.match(body, /\*\*CVSS:\*\*/);
400
+ assert.match(body, /\*\*Tool:\*\*/);
401
+ assert.match(body, /\*\*Manifest:\*\*/);
402
+ assert.match(body, /\*\*Direct\/Transitive:\*\*/);
403
+ assert.match(body, /\*\*Dependency chain:\*\*/);
404
+ assert.match(body, /\*\*Fix:\*\*/);
405
+ assert.match(body, /\*\*Reference:\*\*/);
406
+ });
407
+
408
+ test('chain_available false → `unavailable (chain_available: false — recommend Snyk for full chain analysis)`', () => {
409
+ const findings = [makeFinding({ id: 'pkg-001', severity: 'critical', chain_available: false, dependency_chain: null })];
410
+ const rr = [{ repo: 'api', ecosystem: 'node', tool_used: 'osv-scanner', outcome: 'ok', findings, durationMs: 1 }];
411
+ const result = makeRunResult({ repo_results: rr, findings });
412
+ const body = _renderBody(result);
413
+ assert.match(body, /unavailable \(chain_available: false — recommend Snyk for full chain analysis\)/);
414
+ });
415
+
416
+ test('description non-null → rendered as blockquote', () => {
417
+ const findings = [makeFinding({
418
+ id: 'pkg-001',
419
+ severity: 'critical',
420
+ vulnerability: {
421
+ cve: 'CVE-2020-8203',
422
+ title: 'Prototype Pollution in lodash',
423
+ description: 'Multi-line description\nwith details',
424
+ reference_url: 'https://example.com',
425
+ },
426
+ })];
427
+ const rr = [{ repo: 'api', ecosystem: 'node', tool_used: 'snyk', outcome: 'ok', findings, durationMs: 1 }];
428
+ const result = makeRunResult({ repo_results: rr, findings });
429
+ const body = _renderBody(result);
430
+ assert.match(body, /^> Multi-line description/m);
431
+ assert.match(body, /^> with details/m);
432
+ });
433
+ });
434
+
435
+ // ─── _renderBody — special-case sections ──────────────────────────────────────
436
+
437
+ describe('_renderBody — special-case sections', () => {
438
+ test('zero findings + non-empty repo_results → ## Findings clean-scan message', () => {
439
+ const rr = [
440
+ { repo: 'api', ecosystem: 'node', tool_used: 'snyk', outcome: 'ok', findings: [], durationMs: 1 },
441
+ { repo: 'web', ecosystem: 'node', tool_used: 'snyk', outcome: 'ok', findings: [], durationMs: 1 },
442
+ ];
443
+ const result = makeRunResult({ repo_results: rr, findings: [] });
444
+ const body = _renderBody(result);
445
+ assert.match(body, /## Findings/);
446
+ assert.match(body, /No vulnerabilities found across 2 scanned repos\./);
447
+ });
448
+
449
+ test('empty repo_results + non-empty diagnostics → ## No targets scanned', () => {
450
+ const result = makeRunResult({
451
+ repo_results: [],
452
+ findings: [],
453
+ diagnostics: [{ kind: 'tool_unavailable_on_pin', tool: 'snyk', hint: 'Install Snyk CLI' }],
454
+ });
455
+ const body = _renderBody(result);
456
+ assert.match(body, /## No targets scanned/);
457
+ assert.match(body, /tool_unavailable_on_pin/);
458
+ });
459
+
460
+ test('non-empty diagnostics alongside findings → ## Diagnostics section appended', () => {
461
+ const findings = [makeFinding({ id: 'pkg-001', severity: 'critical' })];
462
+ const rr = [{ repo: 'api', ecosystem: 'node', tool_used: 'snyk', outcome: 'ok', findings, durationMs: 1 }];
463
+ const result = makeRunResult({
464
+ repo_results: rr,
465
+ findings,
466
+ diagnostics: [{ kind: 'lockfile_stale', message: 'Lockfile older than manifest' }],
467
+ });
468
+ const body = _renderBody(result);
469
+ assert.match(body, /## Diagnostics/);
470
+ const idxDiag = body.indexOf('## Diagnostics');
471
+ const idxCritical = body.indexOf('## Critical');
472
+ assert.ok(idxDiag > idxCritical, 'Diagnostics section should come after severity sections');
473
+ });
474
+ });
475
+
476
+ // ─── _resolveReportPath — tier 1 (active phase) ───────────────────────────────
477
+
478
+ describe('_resolveReportPath — tier 1 (active phase)', () => {
479
+ let tmpDir;
480
+ test('active_context resolves to existing phase dir → returns phase path', () => {
481
+ tmpDir = setupPlanningRoot({
482
+ local: {
483
+ current_project: 'gsd',
484
+ execution: { active_context: '151-report-generation-and-command-surface' },
485
+ },
486
+ });
487
+ const phaseDir = path.join(tmpDir, 'projects', 'gsd', 'phases', '151-report-generation-and-command-surface');
488
+ fs.mkdirSync(phaseDir, { recursive: true });
489
+ try {
490
+ const resolved = _resolveReportPath(tmpDir, {});
491
+ assert.equal(resolved, path.join(phaseDir, '151-PACKAGE-SCAN.md'));
492
+ } finally {
493
+ cleanup(tmpDir);
494
+ }
495
+ });
496
+
497
+ test('active_context points at a phase that does NOT exist → falls through', () => {
498
+ tmpDir = setupPlanningRoot({
499
+ local: {
500
+ current_project: 'gsd',
501
+ execution: { active_context: '999-phantom-phase' },
502
+ },
503
+ });
504
+ try {
505
+ const resolved = _resolveReportPath(tmpDir, {});
506
+ // Should fall through to tier 2 or 3. In this case no projects/ exists,
507
+ // so tier 2 also can't resolve; tier 3 (project root with timestamp).
508
+ assert.ok(resolved.includes('PACKAGE-SCAN-'));
509
+ } finally {
510
+ cleanup(tmpDir);
511
+ }
512
+ });
513
+ });
514
+
515
+ // ─── _resolveReportPath — tier 2 (active milestone) ───────────────────────────
516
+
517
+ describe('_resolveReportPath — tier 2 (active milestone)', () => {
518
+ let tmpDir;
519
+ test('active_context is milestone slug → returns milestones/{slug}-PACKAGE-SCAN.md', () => {
520
+ tmpDir = setupPlanningRoot({
521
+ local: { execution: { active_context: 'v23.1' } },
522
+ });
523
+ try {
524
+ const resolved = _resolveReportPath(tmpDir, {});
525
+ assert.equal(resolved, path.join(tmpDir, 'milestones', 'v23.1-PACKAGE-SCAN.md'));
526
+ assert.ok(fs.existsSync(path.join(tmpDir, 'milestones')));
527
+ } finally {
528
+ cleanup(tmpDir);
529
+ }
530
+ });
531
+ });
532
+
533
+ // ─── _resolveReportPath — tier 2b (milestone worktree slug) ───────────────────
534
+
535
+ describe('_resolveReportPath — tier 2b (milestone worktree slug)', () => {
536
+ let tmpDir;
537
+ test('active_context is milestone worktree slug with milestone_version → returns milestones/{version}-PACKAGE-SCAN.md', () => {
538
+ tmpDir = setupPlanningRoot({
539
+ local: {
540
+ current_project: 'gsd',
541
+ execution: { active_context: 'my-milestone-slug' },
542
+ projects: {
543
+ gsd: {
544
+ worktrees: {
545
+ 'my-milestone-slug': {
546
+ type: 'milestone',
547
+ mode: null,
548
+ setup_complete: true,
549
+ milestone_version: 'v23.1',
550
+ repos: { 'deliver-great-systems': '/tmp/fake/path' },
551
+ },
552
+ },
553
+ },
554
+ },
555
+ },
556
+ });
557
+ try {
558
+ const resolved = _resolveReportPath(tmpDir, {});
559
+ assert.equal(resolved, path.join(tmpDir, 'milestones', 'v23.1-PACKAGE-SCAN.md'));
560
+ assert.ok(fs.existsSync(path.join(tmpDir, 'milestones')), 'milestones/ directory should be created');
561
+ } finally {
562
+ cleanup(tmpDir);
563
+ }
564
+ });
565
+
566
+ test('worktree entry has milestone_version: null → falls through to tier 3', () => {
567
+ tmpDir = setupPlanningRoot({
568
+ local: {
569
+ current_project: 'gsd',
570
+ execution: { active_context: 'my-milestone-slug' },
571
+ projects: {
572
+ gsd: {
573
+ worktrees: {
574
+ 'my-milestone-slug': {
575
+ type: 'milestone',
576
+ mode: null,
577
+ setup_complete: true,
578
+ milestone_version: null,
579
+ repos: { 'deliver-great-systems': '/tmp/fake/path' },
580
+ },
581
+ },
582
+ },
583
+ },
584
+ },
585
+ });
586
+ try {
587
+ const resolved = _resolveReportPath(tmpDir, {
588
+ now: () => new Date('2026-04-20T10:15:00Z'),
589
+ });
590
+ assert.equal(resolved, path.join(tmpDir, 'PACKAGE-SCAN-2026-04-20-1015.md'));
591
+ } finally {
592
+ cleanup(tmpDir);
593
+ }
594
+ });
595
+
596
+ test('worktree entry missing milestone_version field → falls through to tier 3', () => {
597
+ tmpDir = setupPlanningRoot({
598
+ local: {
599
+ current_project: 'gsd',
600
+ execution: { active_context: 'my-milestone-slug' },
601
+ projects: {
602
+ gsd: {
603
+ worktrees: {
604
+ 'my-milestone-slug': {
605
+ type: 'milestone',
606
+ mode: null,
607
+ setup_complete: true,
608
+ repos: { 'deliver-great-systems': '/tmp/fake/path' },
609
+ },
610
+ },
611
+ },
612
+ },
613
+ },
614
+ });
615
+ try {
616
+ const resolved = _resolveReportPath(tmpDir, {
617
+ now: () => new Date('2026-04-20T10:15:00Z'),
618
+ });
619
+ assert.equal(resolved, path.join(tmpDir, 'PACKAGE-SCAN-2026-04-20-1015.md'));
620
+ } finally {
621
+ cleanup(tmpDir);
622
+ }
623
+ });
624
+
625
+ test('worktree entry is quick-type (not milestone) → falls through to tier 3', () => {
626
+ tmpDir = setupPlanningRoot({
627
+ local: {
628
+ current_project: 'gsd',
629
+ execution: { active_context: 'my-milestone-slug' },
630
+ projects: {
631
+ gsd: {
632
+ worktrees: {
633
+ 'my-milestone-slug': {
634
+ type: 'quick',
635
+ mode: null,
636
+ setup_complete: true,
637
+ milestone_version: 'v23.1',
638
+ repos: { 'deliver-great-systems': '/tmp/fake/path' },
639
+ },
640
+ },
641
+ },
642
+ },
643
+ },
644
+ });
645
+ try {
646
+ const resolved = _resolveReportPath(tmpDir, {
647
+ now: () => new Date('2026-04-20T10:15:00Z'),
648
+ });
649
+ assert.equal(resolved, path.join(tmpDir, 'PACKAGE-SCAN-2026-04-20-1015.md'));
650
+ } finally {
651
+ cleanup(tmpDir);
652
+ }
653
+ });
654
+
655
+ test('milestone_version does not match /^v\\d+(\\.\\d+)?$/ → falls through to tier 3', () => {
656
+ tmpDir = setupPlanningRoot({
657
+ local: {
658
+ current_project: 'gsd',
659
+ execution: { active_context: 'my-milestone-slug' },
660
+ projects: {
661
+ gsd: {
662
+ worktrees: {
663
+ 'my-milestone-slug': {
664
+ type: 'milestone',
665
+ mode: null,
666
+ setup_complete: true,
667
+ milestone_version: 'not-a-version',
668
+ repos: { 'deliver-great-systems': '/tmp/fake/path' },
669
+ },
670
+ },
671
+ },
672
+ },
673
+ },
674
+ });
675
+ try {
676
+ const resolved = _resolveReportPath(tmpDir, {
677
+ now: () => new Date('2026-04-20T10:15:00Z'),
678
+ });
679
+ assert.equal(resolved, path.join(tmpDir, 'PACKAGE-SCAN-2026-04-20-1015.md'));
680
+ } finally {
681
+ cleanup(tmpDir);
682
+ }
683
+ });
684
+ });
685
+
686
+ // ─── _resolveReportPath — tier 3 (project root, no active context) ────────────
687
+
688
+ describe('_resolveReportPath — tier 3 (project root, no active context)', () => {
689
+ let tmpDir;
690
+ test('no active_context → returns project-root/PACKAGE-SCAN-{YYYY-MM-DD-HHmm}.md', () => {
691
+ tmpDir = setupPlanningRoot({});
692
+ try {
693
+ const resolved = _resolveReportPath(tmpDir, {
694
+ now: () => new Date('2026-04-17T14:30:00Z'),
695
+ });
696
+ // Timestamp formatting uses UTC components of the provided Date; HHmm = 1430.
697
+ assert.ok(resolved.includes('2026-04-17-1430'));
698
+ assert.ok(resolved.startsWith(tmpDir));
699
+ } finally {
700
+ cleanup(tmpDir);
701
+ }
702
+ });
703
+ });
704
+
705
+ // ─── writePackageScanReport — end-to-end ──────────────────────────────────────
706
+
707
+ describe('writePackageScanReport — end-to-end', () => {
708
+ let tmpDir;
709
+ test('writes file, returns {path, tool_string, n_findings}, frontmatter round-trips', () => {
710
+ tmpDir = setupPlanningRoot({});
711
+ try {
712
+ const findings = [
713
+ makeFinding({ id: 'pkg-001', severity: 'critical', repo: 'api' }),
714
+ makeFinding({ id: 'pkg-002', severity: 'high', repo: 'api' }),
715
+ makeFinding({ id: 'pkg-003', severity: 'medium', repo: 'web' }),
716
+ ];
717
+ const rr = [
718
+ { repo: 'api', ecosystem: 'node', tool_used: 'snyk', outcome: 'ok', findings: findings.slice(0, 2), durationMs: 2000 },
719
+ { repo: 'web', ecosystem: 'node', tool_used: 'snyk', outcome: 'ok', findings: findings.slice(2), durationMs: 1500 },
720
+ ];
721
+ const runResult = makeRunResult({
722
+ repo_results: rr,
723
+ findings,
724
+ tool_per_target: { api: 'snyk', web: 'snyk' },
725
+ });
726
+ const info = writePackageScanReport(tmpDir, runResult, {
727
+ now: () => new Date('2026-04-17T14:30:00Z'),
728
+ });
729
+ assert.equal(info.tool_string, 'snyk');
730
+ assert.equal(info.n_findings, 3);
731
+ assert.ok(info.path && typeof info.path === 'string');
732
+ assert.ok(fs.existsSync(info.path));
733
+ const content = fs.readFileSync(info.path, 'utf-8');
734
+ assert.ok(content.startsWith('---\ntype: "package-scan"\n'), 'content starts with ---\\ntype: "package-scan"');
735
+ // Extract the frontmatter section up to and including the closing ---
736
+ const closeIdx = content.indexOf('\n---\n', 4);
737
+ assert.ok(closeIdx > 0);
738
+ const frontSection = content.slice(0, closeIdx + 5);
739
+ const parsed = _parseEmittedYaml(frontSection);
740
+ assert.equal(parsed.type, 'package-scan');
741
+ assert.equal(parsed.critical, 1);
742
+ assert.equal(parsed.high, 1);
743
+ assert.equal(parsed.medium, 1);
744
+ assert.equal(parsed.low, 0);
745
+ assert.equal(parsed.findings.length, 3);
746
+ assert.equal(parsed.findings[0].id, 'pkg-001');
747
+ } finally {
748
+ cleanup(tmpDir);
749
+ }
750
+ });
751
+ });
752
+
753
+ // ─── frontmatter — snyk_org (UAT Bug 2) ──────────────────────────────────────
754
+
755
+ describe('frontmatter — snyk_org field (UAT Bug 2)', () => {
756
+ let tmpDir;
757
+
758
+ test('snyk_org emitted in frontmatter when set', () => {
759
+ tmpDir = setupPlanningRoot({});
760
+ try {
761
+ const findings = [makeFinding({ id: 'pkg-001', severity: 'high' })];
762
+ const rr = [{ repo: 'api', ecosystem: 'node', tool_used: 'snyk', outcome: 'ok', findings, durationMs: 100 }];
763
+ const result = makeRunResult({ repo_results: rr, findings });
764
+ result.snyk_org = 'ORG-FRONT-UUID';
765
+ const info = writePackageScanReport(tmpDir, result, { overridePath: path.join(tmpDir, 'rpt.md') });
766
+ const content = fs.readFileSync(info.path, 'utf-8');
767
+ assert.match(content, /snyk_org: "ORG-FRONT-UUID"/);
768
+ // Position check: snyk_org between tool and repos_scanned
769
+ const toolIdx = content.indexOf('tool:');
770
+ const orgIdx = content.indexOf('snyk_org:');
771
+ const reposIdx = content.indexOf('repos_scanned:');
772
+ assert.ok(toolIdx < orgIdx && orgIdx < reposIdx,
773
+ `Expected tool < snyk_org < repos_scanned; got ${toolIdx}, ${orgIdx}, ${reposIdx}`);
774
+ } finally {
775
+ cleanup(tmpDir);
776
+ }
777
+ });
778
+
779
+ test('snyk_org emitted as null when unset', () => {
780
+ tmpDir = setupPlanningRoot({});
781
+ try {
782
+ const findings = [makeFinding({ id: 'pkg-001', severity: 'high' })];
783
+ const rr = [{ repo: 'api', ecosystem: 'node', tool_used: 'snyk', outcome: 'ok', findings, durationMs: 100 }];
784
+ const result = makeRunResult({ repo_results: rr, findings });
785
+ // result.snyk_org intentionally undefined.
786
+ const info = writePackageScanReport(tmpDir, result, { overridePath: path.join(tmpDir, 'rpt.md') });
787
+ const content = fs.readFileSync(info.path, 'utf-8');
788
+ assert.match(content, /snyk_org: null/);
789
+ } finally {
790
+ cleanup(tmpDir);
791
+ }
792
+ });
793
+
794
+ test('snyk_org round-trips through _parseEmittedYaml', () => {
795
+ tmpDir = setupPlanningRoot({});
796
+ try {
797
+ const findings = [makeFinding({ id: 'pkg-001', severity: 'high' })];
798
+ const rr = [{ repo: 'api', ecosystem: 'node', tool_used: 'snyk', outcome: 'ok', findings, durationMs: 100 }];
799
+ const result = makeRunResult({ repo_results: rr, findings });
800
+ result.snyk_org = '5cb2d43f-6064-4fe2-80f8-4a14cef84a5a';
801
+ const info = writePackageScanReport(tmpDir, result, { overridePath: path.join(tmpDir, 'rpt.md') });
802
+ const content = fs.readFileSync(info.path, 'utf-8');
803
+ const closeIdx = content.indexOf('\n---\n', 4);
804
+ const frontSection = content.slice(0, closeIdx + 5);
805
+ const parsed = _parseEmittedYaml(frontSection);
806
+ assert.equal(parsed.snyk_org, '5cb2d43f-6064-4fe2-80f8-4a14cef84a5a');
807
+ } finally {
808
+ cleanup(tmpDir);
809
+ }
810
+ });
811
+ });
812
+
813
+ // ─── writePackageScanReport — round-trip failure mode ─────────────────────────
814
+
815
+ describe('writePackageScanReport — round-trip failure mode', () => {
816
+ let tmpDir;
817
+ test('broken runResult (finding.title is a function) throws + no file written', () => {
818
+ tmpDir = setupPlanningRoot({});
819
+ try {
820
+ const bad = makeFinding({ id: 'pkg-001', severity: 'critical' });
821
+ bad.vulnerability = { cve: null, title: () => {}, description: null, reference_url: null };
822
+ const runResult = makeRunResult({
823
+ repo_results: [{ repo: 'api', ecosystem: 'node', tool_used: 'snyk', outcome: 'ok', findings: [bad], durationMs: 1 }],
824
+ findings: [bad],
825
+ });
826
+ let threw = false;
827
+ let errMsg = '';
828
+ try {
829
+ writePackageScanReport(tmpDir, runResult, {});
830
+ } catch (err) {
831
+ threw = true;
832
+ errMsg = err && err.message ? err.message : '';
833
+ }
834
+ assert.ok(threw, 'expected a throw');
835
+ assert.match(errMsg, /YAML emitter/);
836
+ // No file should have been written in tmpDir or its milestones/.
837
+ const entries = fs.readdirSync(tmpDir);
838
+ const wrote = entries.some(e => /PACKAGE-SCAN/.test(e));
839
+ assert.equal(wrote, false);
840
+ } finally {
841
+ cleanup(tmpDir);
842
+ }
843
+ });
844
+ });
845
+
846
+ // ─── writePackageScanReport — input immutability ──────────────────────────────
847
+
848
+ describe('writePackageScanReport — input immutability', () => {
849
+ let tmpDir;
850
+ test('after a successful call, input runResult is unchanged (by reference + deep-equal)', () => {
851
+ tmpDir = setupPlanningRoot({});
852
+ try {
853
+ const findings = [makeFinding({ id: 'pkg-001', severity: 'critical' })];
854
+ const runResult = makeRunResult({
855
+ repo_results: [{ repo: 'api', ecosystem: 'node', tool_used: 'snyk', outcome: 'ok', findings, durationMs: 1 }],
856
+ findings,
857
+ });
858
+ const snapshot = JSON.parse(JSON.stringify(runResult));
859
+ const info = writePackageScanReport(tmpDir, runResult, {});
860
+ assert.ok(info.path);
861
+ assert.deepEqual(runResult, snapshot);
862
+ } finally {
863
+ cleanup(tmpDir);
864
+ }
865
+ });
866
+ });
867
+
868
+ // ─── frontmatter — tool field aggregation ─────────────────────────────────────
869
+
870
+ describe('frontmatter — tool field aggregation', () => {
871
+ let tmpDir;
872
+ test('all snyk → tool: snyk', () => {
873
+ tmpDir = setupPlanningRoot({});
874
+ try {
875
+ const rr = [
876
+ { repo: 'a', ecosystem: 'node', tool_used: 'snyk', outcome: 'ok', findings: [], durationMs: 1 },
877
+ { repo: 'b', ecosystem: 'node', tool_used: 'snyk', outcome: 'ok', findings: [], durationMs: 1 },
878
+ ];
879
+ const info = writePackageScanReport(tmpDir, makeRunResult({ repo_results: rr, findings: [] }), {});
880
+ assert.equal(info.tool_string, 'snyk');
881
+ } finally {
882
+ cleanup(tmpDir);
883
+ }
884
+ });
885
+
886
+ test('mixed (snyk + npm-audit) → tool: mixed', () => {
887
+ tmpDir = setupPlanningRoot({});
888
+ try {
889
+ const rr = [
890
+ { repo: 'a', ecosystem: 'node', tool_used: 'snyk', outcome: 'ok', findings: [], durationMs: 1 },
891
+ { repo: 'b', ecosystem: 'node', tool_used: 'npm-audit', outcome: 'ok', findings: [], durationMs: 1 },
892
+ ];
893
+ const info = writePackageScanReport(tmpDir, makeRunResult({ repo_results: rr, findings: [] }), {});
894
+ assert.equal(info.tool_string, 'mixed');
895
+ } finally {
896
+ cleanup(tmpDir);
897
+ }
898
+ });
899
+
900
+ test('all null → tool: none', () => {
901
+ tmpDir = setupPlanningRoot({});
902
+ try {
903
+ const rr = [
904
+ { repo: 'a', ecosystem: null, tool_used: null, outcome: 'no_manifests', findings: [], durationMs: 0 },
905
+ ];
906
+ const info = writePackageScanReport(tmpDir, makeRunResult({ repo_results: rr, findings: [] }), {});
907
+ assert.equal(info.tool_string, 'none');
908
+ } finally {
909
+ cleanup(tmpDir);
910
+ }
911
+ });
912
+ });
913
+
914
+ // ─── frontmatter — severity counts ────────────────────────────────────────────
915
+
916
+ describe('frontmatter — severity counts', () => {
917
+ let tmpDir;
918
+ test('null severity counts as medium (conservative bias)', () => {
919
+ tmpDir = setupPlanningRoot({});
920
+ try {
921
+ const findings = [makeFinding({ id: 'pkg-001', severity: null })];
922
+ const rr = [{ repo: 'a', ecosystem: 'node', tool_used: 'snyk', outcome: 'ok', findings, durationMs: 1 }];
923
+ const info = writePackageScanReport(tmpDir, makeRunResult({ repo_results: rr, findings }), {});
924
+ const content = fs.readFileSync(info.path, 'utf-8');
925
+ const closeIdx = content.indexOf('\n---\n', 4);
926
+ const parsed = _parseEmittedYaml(content.slice(0, closeIdx + 5));
927
+ assert.equal(parsed.medium, 1);
928
+ assert.equal(parsed.critical, 0);
929
+ } finally {
930
+ cleanup(tmpDir);
931
+ }
932
+ });
933
+
934
+ test('unknown severity counts as medium (conservative fallback)', () => {
935
+ tmpDir = setupPlanningRoot({});
936
+ try {
937
+ // Phase 152 (PKG-23) gives 'info' a first-class mapping → low. Use a
938
+ // truly unknown token to exercise the fallback-to-medium path.
939
+ const findings = [makeFinding({ id: 'pkg-001', severity: 'unknown-cvss-tier' })];
940
+ const rr = [{ repo: 'a', ecosystem: 'node', tool_used: 'snyk', outcome: 'ok', findings, durationMs: 1 }];
941
+ const info = writePackageScanReport(tmpDir, makeRunResult({ repo_results: rr, findings }), {});
942
+ const content = fs.readFileSync(info.path, 'utf-8');
943
+ const closeIdx = content.indexOf('\n---\n', 4);
944
+ const parsed = _parseEmittedYaml(content.slice(0, closeIdx + 5));
945
+ assert.equal(parsed.medium, 1);
946
+ } finally {
947
+ cleanup(tmpDir);
948
+ }
949
+ });
950
+
951
+ test('npm-audit info severity counts as low (PKG-23 mapping)', () => {
952
+ tmpDir = setupPlanningRoot({});
953
+ try {
954
+ const findings = [makeFinding({ id: 'pkg-001', severity: 'info', tool: 'npm-audit' })];
955
+ const rr = [{ repo: 'a', ecosystem: 'node', tool_used: 'npm-audit', outcome: 'ok', findings, durationMs: 1 }];
956
+ const info = writePackageScanReport(tmpDir, makeRunResult({ repo_results: rr, findings }), {});
957
+ const content = fs.readFileSync(info.path, 'utf-8');
958
+ const closeIdx = content.indexOf('\n---\n', 4);
959
+ const parsed = _parseEmittedYaml(content.slice(0, closeIdx + 5));
960
+ assert.equal(parsed.low, 1);
961
+ assert.equal(parsed.medium, 0);
962
+ } finally {
963
+ cleanup(tmpDir);
964
+ }
965
+ });
966
+
967
+ test('lowercased comparison (CRITICAL counted as critical)', () => {
968
+ tmpDir = setupPlanningRoot({});
969
+ try {
970
+ const findings = [makeFinding({ id: 'pkg-001', severity: 'CRITICAL' })];
971
+ const rr = [{ repo: 'a', ecosystem: 'node', tool_used: 'snyk', outcome: 'ok', findings, durationMs: 1 }];
972
+ const info = writePackageScanReport(tmpDir, makeRunResult({ repo_results: rr, findings }), {});
973
+ const content = fs.readFileSync(info.path, 'utf-8');
974
+ const closeIdx = content.indexOf('\n---\n', 4);
975
+ const parsed = _parseEmittedYaml(content.slice(0, closeIdx + 5));
976
+ assert.equal(parsed.critical, 1);
977
+ } finally {
978
+ cleanup(tmpDir);
979
+ }
980
+ });
981
+
982
+ test('zero counts emitted as 0 (present unconditionally)', () => {
983
+ tmpDir = setupPlanningRoot({});
984
+ try {
985
+ const rr = [{ repo: 'a', ecosystem: 'node', tool_used: 'snyk', outcome: 'ok', findings: [], durationMs: 1 }];
986
+ const info = writePackageScanReport(tmpDir, makeRunResult({ repo_results: rr, findings: [] }), {});
987
+ const content = fs.readFileSync(info.path, 'utf-8');
988
+ assert.match(content, /critical: 0/);
989
+ assert.match(content, /high: 0/);
990
+ assert.match(content, /medium: 0/);
991
+ assert.match(content, /low: 0/);
992
+ } finally {
993
+ cleanup(tmpDir);
994
+ }
995
+ });
996
+ });
997
+
998
+ // ─── frontmatter — repos_scanned count ────────────────────────────────────────
999
+
1000
+ describe('frontmatter — repos_scanned count', () => {
1001
+ let tmpDir;
1002
+ test('excludes outcome=no_manifests', () => {
1003
+ tmpDir = setupPlanningRoot({});
1004
+ try {
1005
+ const rr = [
1006
+ { repo: 'a', ecosystem: 'node', tool_used: 'snyk', outcome: 'ok', findings: [], durationMs: 1 },
1007
+ { repo: 'b', ecosystem: null, tool_used: null, outcome: 'no_manifests', findings: [], durationMs: 0 },
1008
+ ];
1009
+ const info = writePackageScanReport(tmpDir, makeRunResult({ repo_results: rr, findings: [] }), {});
1010
+ const content = fs.readFileSync(info.path, 'utf-8');
1011
+ assert.match(content, /repos_scanned: 1/);
1012
+ } finally {
1013
+ cleanup(tmpDir);
1014
+ }
1015
+ });
1016
+
1017
+ test('includes outcome=tool_failure (attempt counted)', () => {
1018
+ tmpDir = setupPlanningRoot({});
1019
+ try {
1020
+ const rr = [
1021
+ { repo: 'a', ecosystem: 'node', tool_used: 'snyk', outcome: 'ok', findings: [], durationMs: 1 },
1022
+ { repo: 'b', ecosystem: 'node', tool_used: 'snyk', outcome: 'tool_failure', findings: [], durationMs: 500 },
1023
+ ];
1024
+ const info = writePackageScanReport(tmpDir, makeRunResult({ repo_results: rr, findings: [] }), {});
1025
+ const content = fs.readFileSync(info.path, 'utf-8');
1026
+ assert.match(content, /repos_scanned: 2/);
1027
+ } finally {
1028
+ cleanup(tmpDir);
1029
+ }
1030
+ });
1031
+ });
1032
+
1033
+ // ─── Phase 151 Plan 03 — command surface artifacts ───────────────────────────
1034
+
1035
+ describe('Phase 151 Plan 03 — command surface artifacts', () => {
1036
+ // Repo root: from bin/lib/ go up three levels to the deliver-great-systems
1037
+ // repo root (where commands/ and deliver-great-systems/ both live).
1038
+ const repoRoot = path.resolve(__dirname, '..', '..', '..');
1039
+
1040
+ test('commands/dgs/package-scan.md exists with name + workflow reference', () => {
1041
+ const filePath = path.join(repoRoot, 'commands', 'dgs', 'package-scan.md');
1042
+ assert.ok(fs.existsSync(filePath), 'commands/dgs/package-scan.md must exist');
1043
+ const content = fs.readFileSync(filePath, 'utf-8');
1044
+ assert.match(content, /name: dgs:package-scan/);
1045
+ assert.match(content, /@~\/\.claude\/deliver-great-systems\/workflows\/package-scan\.md/);
1046
+ assert.match(content, /<objective>/);
1047
+ assert.match(content, /<process>/);
1048
+ });
1049
+
1050
+ test('deliver-great-systems/workflows/package-scan.md exists with context_tier=none and run-scan step', () => {
1051
+ const filePath = path.join(repoRoot, 'deliver-great-systems', 'workflows', 'package-scan.md');
1052
+ assert.ok(fs.existsSync(filePath), 'workflows/package-scan.md must exist');
1053
+ const content = fs.readFileSync(filePath, 'utf-8');
1054
+ assert.match(content, /<context_tier>none<\/context_tier>/);
1055
+ assert.match(content, /<step name="run-scan"/);
1056
+ assert.match(content, /dgs-tools\.cjs package-scan/);
1057
+ assert.match(content, /<success_criteria>/);
1058
+ });
1059
+
1060
+ test('deliver-great-systems/references/package-scan-config.md documents all testing.packages.* keys', () => {
1061
+ const filePath = path.join(repoRoot, 'deliver-great-systems', 'references', 'package-scan-config.md');
1062
+ assert.ok(fs.existsSync(filePath), 'references/package-scan-config.md must exist');
1063
+ const content = fs.readFileSync(filePath, 'utf-8');
1064
+ for (const key of [
1065
+ 'testing.packages.tool',
1066
+ 'testing.packages.severity_threshold',
1067
+ 'testing.packages.include_dev_dependencies',
1068
+ 'testing.packages.timeout_seconds',
1069
+ 'testing.packages.snyk_token',
1070
+ ]) {
1071
+ assert.match(content, new RegExp(key.replace(/\./g, '\\.')), `must mention ${key}`);
1072
+ }
1073
+ assert.match(content, /config-local-set/);
1074
+ assert.match(content, /dgs-tools config-set/);
1075
+ assert.match(content, /## Report placement/);
1076
+ });
1077
+
1078
+ test('deliver-great-systems/templates/package-scan-report.md shows frontmatter + body skeleton', () => {
1079
+ const filePath = path.join(repoRoot, 'deliver-great-systems', 'templates', 'package-scan-report.md');
1080
+ assert.ok(fs.existsSync(filePath), 'templates/package-scan-report.md must exist');
1081
+ const content = fs.readFileSync(filePath, 'utf-8');
1082
+ assert.match(content, /type: package-scan/);
1083
+ assert.match(content, /findings:/);
1084
+ assert.match(content, /test_source/);
1085
+ assert.match(content, /gap_type/);
1086
+ assert.match(content, /manifest_path/);
1087
+ assert.match(content, /## Summary/);
1088
+ });
1089
+ });
1090
+
1091
+ // ─── Phase 152 Plan 01: severity normalisation (PKG-23) ───────────────────────
1092
+ //
1093
+ // These tests lock the severity/licence normaliser (replacing the Phase 151
1094
+ // stop-gap `_collapseSeverity`), the licence-finding synthesis for Snyk
1095
+ // adapter output, and the canonical-field-ordering invariants from the
1096
+ // test-gate spec §5a.
1097
+ //
1098
+ // The tests depend on module exports added in Task 2:
1099
+ // - SEVERITY_MAP
1100
+ // - LICENCE_SEVERITY_MAP
1101
+ // - _mapLicenceToSeverity
1102
+ // - _canonicalFindingsForAdapter
1103
+ // ─────────────────────────────────────────────────────────────────────────────
1104
+
1105
+ const {
1106
+ SEVERITY_MAP,
1107
+ LICENCE_SEVERITY_MAP,
1108
+ _mapLicenceToSeverity,
1109
+ _canonicalFindingsForAdapter,
1110
+ _mapAdapterFindingToCanonical,
1111
+ } = require('./package-scan-report.cjs');
1112
+
1113
+ describe('severity normalisation (PKG-23)', () => {
1114
+ describe('SEVERITY_MAP — tool-dialect coverage', () => {
1115
+ test('Snyk + bundler-audit tiers map to canonical', () => {
1116
+ assert.strictEqual(SEVERITY_MAP['critical'], 'critical');
1117
+ assert.strictEqual(SEVERITY_MAP['high'], 'high');
1118
+ assert.strictEqual(SEVERITY_MAP['medium'], 'medium');
1119
+ assert.strictEqual(SEVERITY_MAP['low'], 'low');
1120
+ });
1121
+
1122
+ test('OSV database_specific (uppercase) normalises via lowercased key lookup', () => {
1123
+ // Keys in SEVERITY_MAP are ALL lowercased; _collapseSeverity lowers input.
1124
+ assert.strictEqual(SEVERITY_MAP['critical'], 'critical');
1125
+ assert.strictEqual(SEVERITY_MAP['high'], 'high');
1126
+ assert.strictEqual(SEVERITY_MAP['moderate'], 'medium'); // OSV MODERATE -> medium
1127
+ assert.strictEqual(SEVERITY_MAP['medium'], 'medium');
1128
+ assert.strictEqual(SEVERITY_MAP['low'], 'low');
1129
+ });
1130
+
1131
+ test('npm-audit tiers (critical/high/moderate/low/info)', () => {
1132
+ assert.strictEqual(SEVERITY_MAP['critical'], 'critical');
1133
+ assert.strictEqual(SEVERITY_MAP['high'], 'high');
1134
+ assert.strictEqual(SEVERITY_MAP['moderate'], 'medium');
1135
+ assert.strictEqual(SEVERITY_MAP['low'], 'low');
1136
+ assert.strictEqual(SEVERITY_MAP['info'], 'low');
1137
+ });
1138
+
1139
+ test('SEVERITY_MAP is frozen', () => {
1140
+ assert.ok(Object.isFrozen(SEVERITY_MAP), 'SEVERITY_MAP must be Object.freeze()d');
1141
+ });
1142
+
1143
+ test('_collapseSeverity integrates SEVERITY_MAP — null/unknown map to medium (conservative bias)', () => {
1144
+ const { _collapseSeverity } = require('./package-scan-report.cjs');
1145
+ // pip-audit / govulncheck never emit severity:
1146
+ assert.strictEqual(_collapseSeverity(null), 'medium');
1147
+ assert.strictEqual(_collapseSeverity(undefined), 'medium');
1148
+ // Unknown string:
1149
+ assert.strictEqual(_collapseSeverity('unknown-cvss-tier'), 'medium');
1150
+ // Canonical + dialect values:
1151
+ assert.strictEqual(_collapseSeverity('CRITICAL'), 'critical');
1152
+ assert.strictEqual(_collapseSeverity('HIGH'), 'high');
1153
+ assert.strictEqual(_collapseSeverity('MODERATE'), 'medium');
1154
+ assert.strictEqual(_collapseSeverity('moderate'), 'medium');
1155
+ assert.strictEqual(_collapseSeverity('info'), 'low');
1156
+ assert.strictEqual(_collapseSeverity('LOW'), 'low');
1157
+ });
1158
+ });
1159
+
1160
+ describe('LICENCE_SEVERITY_MAP — SPDX coverage', () => {
1161
+ test('LICENCE_SEVERITY_MAP is frozen', () => {
1162
+ assert.ok(Object.isFrozen(LICENCE_SEVERITY_MAP), 'LICENCE_SEVERITY_MAP must be Object.freeze()d');
1163
+ });
1164
+
1165
+ test('GPL-3.0 family → high', () => {
1166
+ assert.strictEqual(LICENCE_SEVERITY_MAP['gpl-3.0'], 'high');
1167
+ assert.strictEqual(LICENCE_SEVERITY_MAP['gpl-3.0-only'], 'high');
1168
+ assert.strictEqual(LICENCE_SEVERITY_MAP['gpl-3.0-or-later'], 'high');
1169
+ });
1170
+
1171
+ test('AGPL-3.0 family → high', () => {
1172
+ assert.strictEqual(LICENCE_SEVERITY_MAP['agpl-3.0'], 'high');
1173
+ assert.strictEqual(LICENCE_SEVERITY_MAP['agpl-3.0-only'], 'high');
1174
+ assert.strictEqual(LICENCE_SEVERITY_MAP['agpl-3.0-or-later'], 'high');
1175
+ });
1176
+
1177
+ test('LGPL family → medium', () => {
1178
+ assert.strictEqual(LICENCE_SEVERITY_MAP['lgpl-2.1'], 'medium');
1179
+ assert.strictEqual(LICENCE_SEVERITY_MAP['lgpl-2.1-only'], 'medium');
1180
+ assert.strictEqual(LICENCE_SEVERITY_MAP['lgpl-2.1-or-later'], 'medium');
1181
+ assert.strictEqual(LICENCE_SEVERITY_MAP['lgpl-3.0'], 'medium');
1182
+ assert.strictEqual(LICENCE_SEVERITY_MAP['lgpl-3.0-only'], 'medium');
1183
+ assert.strictEqual(LICENCE_SEVERITY_MAP['lgpl-3.0-or-later'], 'medium');
1184
+ });
1185
+
1186
+ test('MPL family → medium', () => {
1187
+ assert.strictEqual(LICENCE_SEVERITY_MAP['mpl-1.1'], 'medium');
1188
+ assert.strictEqual(LICENCE_SEVERITY_MAP['mpl-2.0'], 'medium');
1189
+ });
1190
+
1191
+ test('SSPL family → high (PKG-34)', () => {
1192
+ assert.strictEqual(LICENCE_SEVERITY_MAP['sspl-1.0'], 'high');
1193
+ assert.strictEqual(LICENCE_SEVERITY_MAP['sspl-1.0-only'], 'high');
1194
+ });
1195
+ });
1196
+
1197
+ describe('_mapLicenceToSeverity — 8 raw-input permutations', () => {
1198
+ test('GPL-3.0 (canonical) → high', () => {
1199
+ assert.strictEqual(_mapLicenceToSeverity('GPL-3.0'), 'high');
1200
+ });
1201
+
1202
+ test('agpl-3.0-or-later (already lower) → high', () => {
1203
+ assert.strictEqual(_mapLicenceToSeverity('agpl-3.0-or-later'), 'high');
1204
+ });
1205
+
1206
+ test('LGPL-2.1 → medium', () => {
1207
+ assert.strictEqual(_mapLicenceToSeverity('LGPL-2.1'), 'medium');
1208
+ });
1209
+
1210
+ test('MPL-2.0 → medium', () => {
1211
+ assert.strictEqual(_mapLicenceToSeverity('MPL-2.0'), 'medium');
1212
+ });
1213
+
1214
+ test('MIT → null (permissive)', () => {
1215
+ assert.strictEqual(_mapLicenceToSeverity('MIT'), null);
1216
+ });
1217
+
1218
+ test('Apache-2.0 → null (permissive)', () => {
1219
+ assert.strictEqual(_mapLicenceToSeverity('Apache-2.0'), null);
1220
+ });
1221
+
1222
+ test('BSD-3-Clause → null', () => {
1223
+ assert.strictEqual(_mapLicenceToSeverity('BSD-3-Clause'), null);
1224
+ });
1225
+
1226
+ test('ISC → null', () => {
1227
+ assert.strictEqual(_mapLicenceToSeverity('ISC'), null);
1228
+ });
1229
+
1230
+ test('null / undefined → null (no licence field)', () => {
1231
+ assert.strictEqual(_mapLicenceToSeverity(null), null);
1232
+ assert.strictEqual(_mapLicenceToSeverity(undefined), null);
1233
+ });
1234
+ });
1235
+
1236
+ describe('_canonicalFindingsForAdapter — split + ordering + id derivation', () => {
1237
+ test('Snyk finding with licence GPL-3.0 splits into 2 findings (security + licence)', () => {
1238
+ const adapterFinding = makeFinding({
1239
+ id: 'pkg-007',
1240
+ tool: 'snyk',
1241
+ package_name: 'gpl-dep',
1242
+ installed_version: '2.0.0',
1243
+ severity: 'high',
1244
+ licence: 'GPL-3.0',
1245
+ });
1246
+ const out = _canonicalFindingsForAdapter(adapterFinding);
1247
+ assert.ok(Array.isArray(out), 'returns an array');
1248
+ assert.strictEqual(out.length, 2, 'security + licence = 2 findings');
1249
+
1250
+ const [security, licenceFinding] = out;
1251
+ // Security finding (unchanged shape):
1252
+ assert.strictEqual(security.id, 'pkg-007');
1253
+ assert.strictEqual(security.gap_type, 'dependency-security');
1254
+ assert.strictEqual(security.severity, 'high');
1255
+ // Licence finding:
1256
+ assert.strictEqual(licenceFinding.id, 'pkg-007-lic', 'licence id derived as `${id}-lic`');
1257
+ assert.strictEqual(licenceFinding.gap_type, 'dependency-licence');
1258
+ assert.strictEqual(licenceFinding.severity, 'high');
1259
+ assert.strictEqual(licenceFinding.title, 'Restrictive licence: GPL-3.0');
1260
+ assert.strictEqual(
1261
+ licenceFinding.description,
1262
+ 'Package gpl-dep@2.0.0 is licensed under GPL-3.0. Using this dependency may impose copyleft obligations on your project.',
1263
+ );
1264
+ assert.strictEqual(
1265
+ licenceFinding.remediation,
1266
+ 'Review licence compatibility or replace with a permissive-licensed alternative.',
1267
+ );
1268
+ assert.strictEqual(licenceFinding.reference, null);
1269
+ assert.strictEqual(licenceFinding.cve, null);
1270
+ assert.strictEqual(licenceFinding.cvss, null);
1271
+ assert.strictEqual(licenceFinding.dependency_chain, null);
1272
+ assert.strictEqual(licenceFinding.chain_available, false);
1273
+ assert.strictEqual(licenceFinding.tool, 'snyk');
1274
+ assert.strictEqual(licenceFinding.test_source, 'package-scan');
1275
+ });
1276
+
1277
+ test('licence finding resource_id matches security finding (correlation on resource_id)', () => {
1278
+ const adapterFinding = makeFinding({
1279
+ id: 'pkg-003',
1280
+ tool: 'snyk',
1281
+ package_name: 'some-pkg',
1282
+ installed_version: '1.2.3',
1283
+ severity: 'high',
1284
+ licence: 'AGPL-3.0',
1285
+ });
1286
+ const [security, licenceFinding] = _canonicalFindingsForAdapter(adapterFinding);
1287
+ assert.strictEqual(security.resource_id, 'some-pkg@1.2.3');
1288
+ assert.strictEqual(licenceFinding.resource_id, 'some-pkg@1.2.3');
1289
+ });
1290
+
1291
+ test('Snyk finding with MIT licence emits only the security finding (no split)', () => {
1292
+ const adapterFinding = makeFinding({
1293
+ id: 'pkg-008',
1294
+ tool: 'snyk',
1295
+ severity: 'high',
1296
+ licence: 'MIT',
1297
+ });
1298
+ const out = _canonicalFindingsForAdapter(adapterFinding);
1299
+ assert.strictEqual(out.length, 1);
1300
+ assert.strictEqual(out[0].gap_type, 'dependency-security');
1301
+ });
1302
+
1303
+ test('non-Snyk tool with licence GPL-3.0 emits only security finding (licence split is Snyk-only per PKG-30)', () => {
1304
+ const adapterFinding = makeFinding({
1305
+ id: 'pkg-009',
1306
+ tool: 'npm-audit',
1307
+ severity: 'high',
1308
+ licence: 'GPL-3.0',
1309
+ });
1310
+ const out = _canonicalFindingsForAdapter(adapterFinding);
1311
+ assert.strictEqual(out.length, 1);
1312
+ assert.strictEqual(out[0].gap_type, 'dependency-security');
1313
+ });
1314
+
1315
+ test('severity moderate (npm-audit) → medium', () => {
1316
+ const adapterFinding = makeFinding({ severity: 'moderate', tool: 'npm-audit' });
1317
+ const out = _canonicalFindingsForAdapter(adapterFinding);
1318
+ assert.strictEqual(out[0].severity, 'medium');
1319
+ });
1320
+
1321
+ test('severity MODERATE (OSV database_specific) → medium', () => {
1322
+ const adapterFinding = makeFinding({ severity: 'MODERATE', tool: 'osv-scanner' });
1323
+ const out = _canonicalFindingsForAdapter(adapterFinding);
1324
+ assert.strictEqual(out[0].severity, 'medium');
1325
+ });
1326
+
1327
+ test('severity info (npm-audit) → low', () => {
1328
+ const adapterFinding = makeFinding({ severity: 'info', tool: 'npm-audit' });
1329
+ const out = _canonicalFindingsForAdapter(adapterFinding);
1330
+ assert.strictEqual(out[0].severity, 'low');
1331
+ });
1332
+
1333
+ test('severity null (pip-audit) → medium (conservative bias)', () => {
1334
+ const adapterFinding = makeFinding({ severity: null, tool: 'pip-audit' });
1335
+ const out = _canonicalFindingsForAdapter(adapterFinding);
1336
+ assert.strictEqual(out[0].severity, 'medium');
1337
+ });
1338
+
1339
+ test('licence finding inherits manifest_path and direct_or_transitive from security finding', () => {
1340
+ const adapterFinding = makeFinding({
1341
+ id: 'pkg-010',
1342
+ tool: 'snyk',
1343
+ severity: 'high',
1344
+ licence: 'GPL-3.0',
1345
+ manifest_path: 'packages/api/package.json',
1346
+ direct_or_transitive: 'direct',
1347
+ });
1348
+ const [_sec, lic] = _canonicalFindingsForAdapter(adapterFinding);
1349
+ assert.strictEqual(lic.manifest_path, 'packages/api/package.json');
1350
+ assert.strictEqual(lic.direct_or_transitive, 'direct');
1351
+ });
1352
+
1353
+ test('security finding with f.id=null keeps id=null; licence finding id stays null', () => {
1354
+ const adapterFinding = makeFinding({ id: null, tool: 'snyk', licence: 'GPL-3.0' });
1355
+ // adjust makeFinding default (it sets 'pkg-001') by override; here we explicitly set null
1356
+ const out = _canonicalFindingsForAdapter(Object.assign({}, adapterFinding, { id: null }));
1357
+ assert.strictEqual(out[0].id, null);
1358
+ assert.strictEqual(out[1].id, null);
1359
+ });
1360
+ });
1361
+
1362
+ describe('canonical field ordering', () => {
1363
+ test('emitted finding lists keys in the exact FINDING_FIELD_ORDER sequence', () => {
1364
+ const adapterFinding = makeFinding({
1365
+ id: 'pkg-042',
1366
+ tool: 'snyk',
1367
+ severity: 'high',
1368
+ manifest_path: 'packages/api/package.json',
1369
+ licence: null,
1370
+ });
1371
+ const [canonical] = _canonicalFindingsForAdapter(adapterFinding);
1372
+ const payload = makeFrontmatterPayload({ findings: [canonical] });
1373
+ const emitted = _emitYamlFrontmatter(payload);
1374
+ // Extract lines belonging to findings[0]; find the first finding entry
1375
+ // block and check key order line-by-line.
1376
+ const EXPECTED_ORDER = [
1377
+ 'id', 'test_source', 'gap_type', 'severity', 'resource_id',
1378
+ 'repo', 'manifest_path', 'title', 'description', 'remediation',
1379
+ 'reference', 'cve', 'cvss', 'dependency_chain', 'chain_available',
1380
+ 'direct_or_transitive', 'tool',
1381
+ ];
1382
+ const seenKeys = [];
1383
+ for (const line of emitted.split('\n')) {
1384
+ // The first key line starts with ` - id:`; subsequent lines in the
1385
+ // same entry start with ` <key>:`. Stop at the next `---` fence.
1386
+ const m = line.match(/^\s*(?:-\s+)?([a-z_]+):/);
1387
+ if (!m) continue;
1388
+ const key = m[1];
1389
+ if (EXPECTED_ORDER.includes(key) && !seenKeys.includes(key)) {
1390
+ seenKeys.push(key);
1391
+ }
1392
+ }
1393
+ // seenKeys MAY include top-level fields (type, date, tool, etc.) that
1394
+ // collide with finding field names (e.g., `tool`). Collect only the
1395
+ // keys that appear AFTER the `findings:` header.
1396
+ const findingsHeaderIdx = emitted.indexOf('\nfindings:');
1397
+ assert.ok(findingsHeaderIdx >= 0, 'findings: header must exist');
1398
+ const findingsBlock = emitted.slice(findingsHeaderIdx);
1399
+ const findingKeys = [];
1400
+ for (const line of findingsBlock.split('\n')) {
1401
+ const m = line.match(/^\s*(?:-\s+)?([a-z_]+):/);
1402
+ if (!m) continue;
1403
+ const key = m[1];
1404
+ if (EXPECTED_ORDER.includes(key)) findingKeys.push(key);
1405
+ }
1406
+ // The FIRST occurrence of each expected key must appear in the
1407
+ // documented order.
1408
+ const firstIdx = {};
1409
+ for (let i = 0; i < findingKeys.length; i += 1) {
1410
+ if (firstIdx[findingKeys[i]] === undefined) firstIdx[findingKeys[i]] = i;
1411
+ }
1412
+ for (let i = 1; i < EXPECTED_ORDER.length; i += 1) {
1413
+ const prev = EXPECTED_ORDER[i - 1];
1414
+ const curr = EXPECTED_ORDER[i];
1415
+ if (firstIdx[prev] === undefined || firstIdx[curr] === undefined) continue;
1416
+ assert.ok(
1417
+ firstIdx[prev] < firstIdx[curr],
1418
+ `Key ${prev} must appear before ${curr} in emitted frontmatter`,
1419
+ );
1420
+ }
1421
+ });
1422
+ });
1423
+
1424
+ describe('writePackageScanReport — flatMap licence split integration', () => {
1425
+ test('emits 2 findings (security + licence) for a single Snyk GPL finding', () => {
1426
+ const tmpDir = setupPlanningRoot({ config: { mode: 'v2' } });
1427
+ try {
1428
+ const snykFinding = makeFinding({
1429
+ id: 'pkg-001',
1430
+ tool: 'snyk',
1431
+ severity: 'high',
1432
+ package_name: 'gpl-dep',
1433
+ installed_version: '2.0.0',
1434
+ licence: 'GPL-3.0',
1435
+ });
1436
+ const rr = makeRunResult({
1437
+ findings: [snykFinding],
1438
+ repo_results: [{
1439
+ repo: 'api',
1440
+ ecosystem: 'node',
1441
+ tool_used: 'snyk',
1442
+ outcome: 'ok',
1443
+ findings: [snykFinding],
1444
+ durationMs: 1000,
1445
+ }],
1446
+ });
1447
+ const outPath = path.join(tmpDir, 'OUT.md');
1448
+ const res = writePackageScanReport(tmpDir, rr, { overridePath: outPath, now: () => new Date('2026-04-17T00:00:00.000Z') });
1449
+ const content = fs.readFileSync(res.path, 'utf-8');
1450
+ // Extract frontmatter
1451
+ const m = content.match(/^---\n([\s\S]*?)\n---/);
1452
+ assert.ok(m, 'frontmatter present');
1453
+ const parsed = _parseEmittedYaml('---\n' + m[1] + '\n---');
1454
+ assert.strictEqual(parsed.findings.length, 2, 'flatMap produces security + licence');
1455
+ assert.strictEqual(parsed.findings[0].id, 'pkg-001');
1456
+ assert.strictEqual(parsed.findings[0].gap_type, 'dependency-security');
1457
+ assert.strictEqual(parsed.findings[1].id, 'pkg-001-lic');
1458
+ assert.strictEqual(parsed.findings[1].gap_type, 'dependency-licence');
1459
+ assert.strictEqual(parsed.findings[1].severity, 'high');
1460
+ } finally {
1461
+ cleanup(tmpDir);
1462
+ }
1463
+ });
1464
+
1465
+ test('pip-audit null severity → medium tier in the emitted canonical finding', () => {
1466
+ const tmpDir = setupPlanningRoot({ config: { mode: 'v2' } });
1467
+ try {
1468
+ const pipFinding = makeFinding({
1469
+ id: 'pkg-002',
1470
+ tool: 'pip-audit',
1471
+ severity: null,
1472
+ package_name: 'requests',
1473
+ installed_version: '2.25.0',
1474
+ });
1475
+ const rr = makeRunResult({
1476
+ findings: [pipFinding],
1477
+ repo_results: [{
1478
+ repo: 'worker', ecosystem: 'python', tool_used: 'pip-audit',
1479
+ outcome: 'ok', findings: [pipFinding], durationMs: 500,
1480
+ }],
1481
+ });
1482
+ const outPath = path.join(tmpDir, 'OUT.md');
1483
+ const res = writePackageScanReport(tmpDir, rr, { overridePath: outPath, now: () => new Date('2026-04-17T00:00:00.000Z') });
1484
+ const content = fs.readFileSync(res.path, 'utf-8');
1485
+ const m = content.match(/^---\n([\s\S]*?)\n---/);
1486
+ const parsed = _parseEmittedYaml('---\n' + m[1] + '\n---');
1487
+ assert.strictEqual(parsed.findings.length, 1);
1488
+ assert.strictEqual(parsed.findings[0].severity, 'medium');
1489
+ } finally {
1490
+ cleanup(tmpDir);
1491
+ }
1492
+ });
1493
+ });
1494
+ });
1495
+
1496
+ // ─── Phase 153 Plan 02: .snyk policy in report body (PKG-32) ─────────────────
1497
+
1498
+ describe('Phase 153: .snyk policy in report body (PKG-32)', () => {
1499
+ test('Summary table contains ".snyk policy" header', () => {
1500
+ const result = makeRunResult({
1501
+ repo_results: [{ repo: 'api', ecosystem: 'node', tool_used: 'snyk', outcome: 'ok', findings: [], durationMs: 1, has_snyk_policy: true, snyk_suppressions_count: 2 }],
1502
+ findings: [],
1503
+ });
1504
+ const body = _renderBody(result);
1505
+ assert.match(body, /\.snyk policy/);
1506
+ });
1507
+
1508
+ test('Summary row for has_snyk_policy:true contains "| yes |"', () => {
1509
+ const result = makeRunResult({
1510
+ repo_results: [{ repo: 'api', ecosystem: 'node', tool_used: 'snyk', outcome: 'ok', findings: [], durationMs: 1, has_snyk_policy: true, snyk_suppressions_count: 0 }],
1511
+ findings: [],
1512
+ });
1513
+ const body = _renderBody(result);
1514
+ assert.match(body, /\| yes \|/);
1515
+ });
1516
+
1517
+ test('per-repo suppression note when count > 0', () => {
1518
+ const result = makeRunResult({
1519
+ repo_results: [{ repo: 'api', ecosystem: 'node', tool_used: 'snyk', outcome: 'ok', findings: [], durationMs: 1, has_snyk_policy: true, snyk_suppressions_count: 2 }],
1520
+ findings: [],
1521
+ });
1522
+ const body = _renderBody(result);
1523
+ assert.match(body, /\.snyk policy applied: 2 vulnerabilit(y|ies) suppressed/);
1524
+ });
1525
+
1526
+ test('has_snyk_policy:false on every repo => column shows "no" + no suppression line', () => {
1527
+ const result = makeRunResult({
1528
+ repo_results: [
1529
+ { repo: 'api', ecosystem: 'node', tool_used: 'snyk', outcome: 'ok', findings: [], durationMs: 1, has_snyk_policy: false, snyk_suppressions_count: 0 },
1530
+ { repo: 'web', ecosystem: 'node', tool_used: 'snyk', outcome: 'ok', findings: [], durationMs: 1, has_snyk_policy: false, snyk_suppressions_count: 0 },
1531
+ ],
1532
+ findings: [],
1533
+ });
1534
+ const body = _renderBody(result);
1535
+ assert.match(body, /\.snyk policy/);
1536
+ assert.match(body, /\| no \|/);
1537
+ assert.doesNotMatch(body, /\.snyk policy applied:/);
1538
+ });
1539
+ });
1540
+
1541
+ // ─── Phase 153 Plan 05: provenance in canonical + body (PKG-33) ──────────────
1542
+
1543
+ describe('Phase 153: provenance in canonical + body (PKG-33)', () => {
1544
+ const {
1545
+ _mapAdapterFindingToCanonical,
1546
+ _emitYamlFrontmatter,
1547
+ _parseEmittedYaml,
1548
+ } = require('./package-scan-report.cjs');
1549
+ const reportModule = require('./package-scan-report.cjs');
1550
+
1551
+ test('_mapAdapterFindingToCanonical preserves introduced_in_commit + introduced_in_plan', () => {
1552
+ const adapterF = makeFinding({ id: 'pkg-001', severity: 'high' });
1553
+ adapterF.introduced_in_commit = 'abc1234';
1554
+ adapterF.introduced_in_plan = '149-01';
1555
+ const canonical = _mapAdapterFindingToCanonical(adapterF);
1556
+ assert.strictEqual(canonical.introduced_in_commit, 'abc1234');
1557
+ assert.strictEqual(canonical.introduced_in_plan, '149-01');
1558
+ });
1559
+
1560
+ test('FINDING_FIELD_ORDER ends with [..., introduced_in_commit, introduced_in_plan]', () => {
1561
+ // The const isn't directly exported, but the canonical mapping result keys
1562
+ // and the YAML emit order both reflect the order. Round-trip through emit.
1563
+ const payload = makeFrontmatterPayload();
1564
+ payload.findings[0].introduced_in_commit = 'abc1234';
1565
+ payload.findings[0].introduced_in_plan = '149-01';
1566
+ const emitted = _emitYamlFrontmatter(payload);
1567
+ // introduced_in_commit and introduced_in_plan must appear AFTER tool in the emitted yaml.
1568
+ const idxTool = emitted.indexOf('tool: "snyk"');
1569
+ const idxCommit = emitted.indexOf('introduced_in_commit:');
1570
+ const idxPlan = emitted.indexOf('introduced_in_plan:');
1571
+ assert.ok(idxTool > 0);
1572
+ assert.ok(idxCommit > idxTool);
1573
+ assert.ok(idxPlan > idxCommit);
1574
+ });
1575
+
1576
+ test('YAML round-trip preserves introduced_in_* fields', () => {
1577
+ const payload = makeFrontmatterPayload();
1578
+ payload.findings[0].introduced_in_commit = 'abc1234';
1579
+ payload.findings[0].introduced_in_plan = '149-01';
1580
+ const emitted = _emitYamlFrontmatter(payload);
1581
+ const parsed = _parseEmittedYaml(emitted);
1582
+ assert.strictEqual(parsed.findings[0].introduced_in_commit, 'abc1234');
1583
+ assert.strictEqual(parsed.findings[0].introduced_in_plan, '149-01');
1584
+ });
1585
+
1586
+ test('_renderBody includes "Introduced in: commit X (plan Y)" when both fields present', () => {
1587
+ const findings = [makeFinding({ id: 'pkg-001', severity: 'critical' })];
1588
+ findings[0].introduced_in_commit = 'abc1234';
1589
+ findings[0].introduced_in_plan = '149-01';
1590
+ const rr = [{ repo: 'api', ecosystem: 'node', tool_used: 'snyk', outcome: 'ok', findings, durationMs: 1 }];
1591
+ const result = makeRunResult({ repo_results: rr, findings });
1592
+ const body = reportModule._renderBody(result);
1593
+ assert.match(body, /\*\*Introduced in:\*\* commit abc1234 \(plan 149-01\)/);
1594
+ });
1595
+
1596
+ test('_renderBody includes "Introduced in: commit X" when only commit known', () => {
1597
+ const findings = [makeFinding({ id: 'pkg-001', severity: 'critical' })];
1598
+ findings[0].introduced_in_commit = 'abc1234';
1599
+ findings[0].introduced_in_plan = null;
1600
+ const rr = [{ repo: 'api', ecosystem: 'node', tool_used: 'snyk', outcome: 'ok', findings, durationMs: 1 }];
1601
+ const result = makeRunResult({ repo_results: rr, findings });
1602
+ const body = reportModule._renderBody(result);
1603
+ assert.match(body, /\*\*Introduced in:\*\* commit abc1234/);
1604
+ assert.doesNotMatch(body, /\(plan/);
1605
+ });
1606
+
1607
+ test('_renderBody includes "Introduced in: unknown" when neither known', () => {
1608
+ const findings = [makeFinding({ id: 'pkg-001', severity: 'critical' })];
1609
+ findings[0].introduced_in_commit = null;
1610
+ findings[0].introduced_in_plan = null;
1611
+ const rr = [{ repo: 'api', ecosystem: 'node', tool_used: 'snyk', outcome: 'ok', findings, durationMs: 1 }];
1612
+ const result = makeRunResult({ repo_results: rr, findings });
1613
+ const body = reportModule._renderBody(result);
1614
+ assert.match(body, /\*\*Introduced in:\*\* unknown/);
1615
+ });
1616
+ });
1617
+
1618
+ // ─── Phase 153 Plan 03: Licence Compliance section (PKG-30, PKG-34) ──────────
1619
+
1620
+ describe('Phase 153: Licence Compliance section (PKG-30, PKG-34)', () => {
1621
+ const reportModule = require('./package-scan-report.cjs');
1622
+ const { LICENCE_SEVERITY_MAP, _mapLicenceToSeverity, _canonicalFindingsForAdapter } = reportModule;
1623
+
1624
+ test('LICENCE_SEVERITY_MAP[sspl-1.0] => high', () => {
1625
+ assert.strictEqual(LICENCE_SEVERITY_MAP['sspl-1.0'], 'high');
1626
+ });
1627
+
1628
+ test('_mapLicenceToSeverity SSPL-1.0 (case-insensitive) => high', () => {
1629
+ assert.strictEqual(_mapLicenceToSeverity('SSPL-1.0'), 'high');
1630
+ });
1631
+
1632
+ test('_canonicalFindingsForAdapter SSPL => 2 canonical findings, licence severity high', () => {
1633
+ const adapterF = {
1634
+ tool: 'snyk',
1635
+ ecosystem: 'node',
1636
+ repo: 'api',
1637
+ manifest_path: null,
1638
+ package_name: 'mongo-db',
1639
+ installed_version: '5.0',
1640
+ vulnerability: { cve: null, title: 't', description: null, reference_url: null },
1641
+ severity: 'low',
1642
+ cvss_score: null,
1643
+ direct_or_transitive: null,
1644
+ dependency_chain: null,
1645
+ chain_available: false,
1646
+ remediation: null,
1647
+ licence: 'SSPL-1.0',
1648
+ id: 'pkg-001',
1649
+ };
1650
+ const out = _canonicalFindingsForAdapter(adapterF);
1651
+ assert.strictEqual(out.length, 2);
1652
+ assert.strictEqual(out[1].severity, 'high');
1653
+ assert.strictEqual(out[1].title, 'Restrictive licence: SSPL-1.0');
1654
+ });
1655
+
1656
+ test('_renderLicenceComplianceSection (snyk + roster populated) renders table with flag column', () => {
1657
+ const out = reportModule._renderLicenceComplianceSection({
1658
+ tool_agg: 'snyk',
1659
+ licence_roster: [
1660
+ { repo: 'api', package_name: 'lodash', installed_version: '4.17.21', licence: 'MIT' },
1661
+ { repo: 'api', package_name: 'gpl-pkg', installed_version: '1.0', licence: 'GPL-3.0' },
1662
+ ],
1663
+ });
1664
+ assert.match(out, /## Licence Compliance/);
1665
+ assert.match(out, /\| Package \| Version \| Repo \| Licence \| Flag \|/);
1666
+ assert.match(out, /\| gpl-pkg \| 1\.0 \| api \| GPL-3\.0 \| RESTRICTIVE \|/);
1667
+ // lodash row should have an empty flag cell.
1668
+ assert.match(out, /\| lodash \| 4\.17\.21 \| api \| MIT \| \|/);
1669
+ });
1670
+
1671
+ test('_renderLicenceComplianceSection (snyk + empty roster) renders accurate completed note with policy URL (UAT Bug 3)', () => {
1672
+ const out = reportModule._renderLicenceComplianceSection({ tool_agg: 'snyk', licence_roster: [] });
1673
+ assert.match(out, /## Licence Compliance/);
1674
+ // New Snyk-specific completed message.
1675
+ assert.match(out, /Snyk licence scan completed; no restrictive licences flagged/);
1676
+ // References Snyk org-level licence policy config URL.
1677
+ assert.match(out, /app\.snyk\.io.*licen[cs]es?/i);
1678
+ // MUST NOT repeat the non-snyk "use Snyk for full coverage" message.
1679
+ assert.doesNotMatch(out, /use Snyk for full coverage/);
1680
+ // No table rendered (empty roster).
1681
+ assert.doesNotMatch(out, /\| Package \| Version \|/);
1682
+ });
1683
+
1684
+ test('_renderLicenceComplianceSection (non-snyk tool, empty roster) still renders legacy coverage prompt (UAT Bug 3 regression)', () => {
1685
+ const out = reportModule._renderLicenceComplianceSection({ tool_agg: 'osv-scanner', licence_roster: [] });
1686
+ assert.match(out, /Licence scan incomplete -- use Snyk for full coverage/);
1687
+ });
1688
+
1689
+ test('_renderLicenceComplianceSection (non-snyk tool) renders incomplete note even with populated roster', () => {
1690
+ const out = reportModule._renderLicenceComplianceSection({
1691
+ tool_agg: 'osv-scanner',
1692
+ licence_roster: [{ repo: 'api', package_name: 'foo', installed_version: '1.0', licence: 'MIT' }],
1693
+ });
1694
+ assert.match(out, /## Licence Compliance/);
1695
+ assert.match(out, /Licence scan incomplete -- use Snyk for full coverage/);
1696
+ });
1697
+
1698
+ test('_renderLicenceComplianceSection (null inputs) renders incomplete note (defensive)', () => {
1699
+ const out = reportModule._renderLicenceComplianceSection({ tool_agg: null, licence_roster: null });
1700
+ assert.match(out, /Licence scan incomplete -- use Snyk for full coverage/);
1701
+ });
1702
+
1703
+ test('flag mapping: LGPL/MPL => copyleft; AGPL/SSPL => RESTRICTIVE; permissive => blank', () => {
1704
+ const roster = [
1705
+ { repo: 'api', package_name: 'a-lgpl', installed_version: '1.0', licence: 'LGPL-2.1' },
1706
+ { repo: 'api', package_name: 'b-mpl', installed_version: '1.0', licence: 'MPL-2.0' },
1707
+ { repo: 'api', package_name: 'c-agpl', installed_version: '1.0', licence: 'AGPL-3.0' },
1708
+ { repo: 'api', package_name: 'd-sspl', installed_version: '1.0', licence: 'SSPL-1.0' },
1709
+ { repo: 'api', package_name: 'e-apache', installed_version: '1.0', licence: 'Apache-2.0' },
1710
+ { repo: 'api', package_name: 'f-isc', installed_version: '1.0', licence: 'ISC' },
1711
+ { repo: 'api', package_name: 'g-bsd', installed_version: '1.0', licence: 'BSD-3-Clause' },
1712
+ ];
1713
+ const out = reportModule._renderLicenceComplianceSection({ tool_agg: 'snyk', licence_roster: roster });
1714
+ assert.match(out, /a-lgpl.*\| copyleft \|/);
1715
+ assert.match(out, /b-mpl.*\| copyleft \|/);
1716
+ assert.match(out, /c-agpl.*\| RESTRICTIVE \|/);
1717
+ assert.match(out, /d-sspl.*\| RESTRICTIVE \|/);
1718
+ // Apache, ISC, BSD => blank
1719
+ assert.match(out, /e-apache.*\| Apache-2\.0 \| \|/);
1720
+ });
1721
+
1722
+ test('section ends with blank line (no run-on)', () => {
1723
+ const out = reportModule._renderLicenceComplianceSection({ tool_agg: 'snyk', licence_roster: [] });
1724
+ assert.ok(out.endsWith('\n'), 'must end with newline');
1725
+ });
1726
+
1727
+ test('_renderBody emits Licence Compliance AFTER Summary and BEFORE severity sections', () => {
1728
+ const findings = [makeFinding({ id: 'pkg-001', severity: 'critical' })];
1729
+ const rr = [{ repo: 'api', ecosystem: 'node', tool_used: 'snyk', outcome: 'ok', findings, durationMs: 1 }];
1730
+ const result = makeRunResult({ repo_results: rr, findings });
1731
+ result.licence_roster = [
1732
+ { repo: 'api', package_name: 'lodash', installed_version: '4.17.21', licence: 'MIT' },
1733
+ ];
1734
+ const body = reportModule._renderBody(result);
1735
+ const idxSummary = body.indexOf('## Summary');
1736
+ const idxLicence = body.indexOf('## Licence Compliance');
1737
+ const idxCritical = body.indexOf('## Critical');
1738
+ assert.ok(idxSummary >= 0);
1739
+ assert.ok(idxLicence > idxSummary);
1740
+ assert.ok(idxCritical > idxLicence);
1741
+ });
1742
+ });
1743
+
1744
+ // ─── Phase 153 Plan 03: licence_roster on runResult ──────────────────────────
1745
+ // Note: orchestrator-level tests live in package-scan.test.cjs.
1746
+
1747
+ // ─── Phase 153 Plan 04: Cross-repo dedup (PKG-35) ────────────────────────────
1748
+
1749
+ describe('Phase 153: Cross-repo dedup (PKG-35)', () => {
1750
+ const reportModule = require('./package-scan-report.cjs');
1751
+ const { _groupFindingsByPackageVersion, _renderCrossRepoSummary } = reportModule;
1752
+
1753
+ function mkF({ pkg = 'lodash', ver = '4.17.21', repo = 'api', sev = 'high', cve = null, title = '' } = {}) {
1754
+ return { package_name: pkg, installed_version: ver, repo, severity: sev, vulnerability: { cve, title } };
1755
+ }
1756
+
1757
+ test('empty findings => []', () => {
1758
+ assert.deepStrictEqual(_groupFindingsByPackageVersion([]), []);
1759
+ });
1760
+
1761
+ test('single finding => one group with repos:[that repo]', () => {
1762
+ const out = _groupFindingsByPackageVersion([mkF()]);
1763
+ assert.strictEqual(out.length, 1);
1764
+ assert.deepStrictEqual(out[0].repos, ['api']);
1765
+ });
1766
+
1767
+ test('two findings same (pkg, ver, cve) different repos => one group repos length 2', () => {
1768
+ const out = _groupFindingsByPackageVersion([
1769
+ mkF({ repo: 'api', cve: 'CVE-2020-8203' }),
1770
+ mkF({ repo: 'web', cve: 'CVE-2020-8203' }),
1771
+ ]);
1772
+ assert.strictEqual(out.length, 1);
1773
+ assert.strictEqual(out[0].repos.length, 2);
1774
+ });
1775
+
1776
+ test('two findings same pkg different versions => 2 groups', () => {
1777
+ const out = _groupFindingsByPackageVersion([
1778
+ mkF({ ver: '4.17.10' }),
1779
+ mkF({ ver: '4.17.21' }),
1780
+ ]);
1781
+ assert.strictEqual(out.length, 2);
1782
+ });
1783
+
1784
+ test('two findings same pkg same ver different CVE => 2 groups', () => {
1785
+ const out = _groupFindingsByPackageVersion([
1786
+ mkF({ cve: 'CVE-2020-8203' }),
1787
+ mkF({ cve: 'CVE-2021-23337' }),
1788
+ ]);
1789
+ assert.strictEqual(out.length, 2);
1790
+ });
1791
+
1792
+ test('findings without cve use title as fallback grouping key', () => {
1793
+ const out = _groupFindingsByPackageVersion([
1794
+ mkF({ cve: null, title: 'Prototype Pollution' }),
1795
+ mkF({ cve: null, title: 'Prototype Pollution', repo: 'web' }),
1796
+ ]);
1797
+ assert.strictEqual(out.length, 1);
1798
+ assert.strictEqual(out[0].repos.length, 2);
1799
+ });
1800
+
1801
+ test('deterministic ordering: severity desc, then repo-count desc, then name asc', () => {
1802
+ const out = _groupFindingsByPackageVersion([
1803
+ mkF({ pkg: 'a-low', sev: 'high', cve: 'X', repo: 'r1' }),
1804
+ mkF({ pkg: 'b-multi', sev: 'high', cve: 'Y', repo: 'r1' }),
1805
+ mkF({ pkg: 'b-multi', sev: 'high', cve: 'Y', repo: 'r2' }),
1806
+ mkF({ pkg: 'c-crit', sev: 'critical', cve: 'Z', repo: 'r1' }),
1807
+ mkF({ pkg: 'd-crit-multi', sev: 'critical', cve: 'W', repo: 'r1' }),
1808
+ mkF({ pkg: 'd-crit-multi', sev: 'critical', cve: 'W', repo: 'r2' }),
1809
+ ]);
1810
+ // Order: critical+repos=2, critical+repos=1, high+repos=2, high+repos=1
1811
+ assert.strictEqual(out[0].package_name, 'd-crit-multi');
1812
+ assert.strictEqual(out[1].package_name, 'c-crit');
1813
+ assert.strictEqual(out[2].package_name, 'b-multi');
1814
+ assert.strictEqual(out[3].package_name, 'a-low');
1815
+ });
1816
+
1817
+ test('_renderCrossRepoSummary([]) returns empty string', () => {
1818
+ assert.strictEqual(_renderCrossRepoSummary([]), '');
1819
+ });
1820
+
1821
+ test('_renderCrossRepoSummary with multi-repo group renders ## Cross-Repo Summary header + table', () => {
1822
+ const groups = [
1823
+ { package_name: 'lodash', installed_version: '4.17.21', cve: 'CVE-2020-8203', severity: 'high', repos: ['api', 'web'], title: '' },
1824
+ ];
1825
+ const out = _renderCrossRepoSummary(groups);
1826
+ assert.match(out, /## Cross-Repo Summary/);
1827
+ assert.match(out, /\| lodash \| 4\.17\.21 \| CVE-2020-8203 \| high \| api, web \|/);
1828
+ });
1829
+ });
1830
+
1831
+ // ─── Phase 153 Plan 04: Version overlap (PKG-36) ─────────────────────────────
1832
+
1833
+ describe('Phase 153: Version overlap (PKG-36)', () => {
1834
+ const reportModule = require('./package-scan-report.cjs');
1835
+ const { _detectVersionOverlaps, _renderVersionOverlap } = reportModule;
1836
+
1837
+ test('empty inputs => []', () => {
1838
+ assert.deepStrictEqual(_detectVersionOverlaps([], []), []);
1839
+ });
1840
+
1841
+ test('single version single repo => no overlap', () => {
1842
+ const out = _detectVersionOverlaps(
1843
+ [{ package_name: 'lodash', installed_version: '4.17.21', repo: 'api' }],
1844
+ []
1845
+ );
1846
+ assert.deepStrictEqual(out, []);
1847
+ });
1848
+
1849
+ test('two versions across two repos => one overlap entry with sorted versions', () => {
1850
+ const out = _detectVersionOverlaps(
1851
+ [
1852
+ { package_name: 'lodash', installed_version: '4.17.21', repo: 'api' },
1853
+ { package_name: 'lodash', installed_version: '4.17.10', repo: 'web' },
1854
+ ],
1855
+ []
1856
+ );
1857
+ assert.strictEqual(out.length, 1);
1858
+ assert.deepStrictEqual(out[0].versions, ['4.17.10', '4.17.21']);
1859
+ assert.deepStrictEqual(out[0].repos['4.17.21'], ['api']);
1860
+ assert.deepStrictEqual(out[0].repos['4.17.10'], ['web']);
1861
+ });
1862
+
1863
+ test('licence roster supplies a version not in findings => overlap detected', () => {
1864
+ const out = _detectVersionOverlaps(
1865
+ [{ package_name: 'lodash', installed_version: '4.17.21', repo: 'api' }],
1866
+ [{ package_name: 'lodash', installed_version: '4.17.10', repo: 'web' }]
1867
+ );
1868
+ assert.strictEqual(out.length, 1);
1869
+ });
1870
+
1871
+ test('same version multiple repos => not an overlap', () => {
1872
+ const out = _detectVersionOverlaps(
1873
+ [
1874
+ { package_name: 'lodash', installed_version: '4.17.21', repo: 'api' },
1875
+ { package_name: 'lodash', installed_version: '4.17.21', repo: 'web' },
1876
+ ],
1877
+ []
1878
+ );
1879
+ assert.deepStrictEqual(out, []);
1880
+ });
1881
+
1882
+ test('three versions across three repos => versions length 3', () => {
1883
+ const out = _detectVersionOverlaps(
1884
+ [
1885
+ { package_name: 'lodash', installed_version: '4.17.21', repo: 'a' },
1886
+ { package_name: 'lodash', installed_version: '4.17.10', repo: 'b' },
1887
+ { package_name: 'lodash', installed_version: '3.10.1', repo: 'c' },
1888
+ ],
1889
+ []
1890
+ );
1891
+ assert.strictEqual(out.length, 1);
1892
+ assert.strictEqual(out[0].versions.length, 3);
1893
+ });
1894
+
1895
+ test('null roster works via findings-only', () => {
1896
+ const out = _detectVersionOverlaps(
1897
+ [
1898
+ { package_name: 'lodash', installed_version: '4.17.21', repo: 'a' },
1899
+ { package_name: 'lodash', installed_version: '4.17.10', repo: 'b' },
1900
+ ],
1901
+ null
1902
+ );
1903
+ assert.strictEqual(out.length, 1);
1904
+ });
1905
+
1906
+ test('_renderVersionOverlap([]) returns empty string', () => {
1907
+ assert.strictEqual(_renderVersionOverlap([]), '');
1908
+ });
1909
+
1910
+ test('_renderVersionOverlap renders ## Dependency Overlap header + table', () => {
1911
+ const out = _renderVersionOverlap([
1912
+ { package_name: 'lodash', versions: ['4.17.10', '4.17.21'], repos: { '4.17.10': ['web'], '4.17.21': ['api'] } },
1913
+ ]);
1914
+ assert.match(out, /## Dependency Overlap/);
1915
+ assert.match(out, /\| lodash \| 4\.17\.10, 4\.17\.21 \|/);
1916
+ });
1917
+ });
1918
+
1919
+ // ─── Phase 153 Plan 04: body section ordering (PKG-35, PKG-36) ───────────────
1920
+
1921
+ describe('Phase 153: body section ordering (PKG-35, PKG-36)', () => {
1922
+ const reportModule = require('./package-scan-report.cjs');
1923
+
1924
+ test('body order: Licence Compliance => Cross-Repo Summary => Dependency Overlap => Critical', () => {
1925
+ const findings = [
1926
+ { id: 'pkg-001', tool: 'snyk', ecosystem: 'node', repo: 'api', manifest_path: null, package_name: 'lodash', installed_version: '4.17.21', severity: 'critical', vulnerability: { cve: 'CVE-X', title: 't', description: null, reference_url: null }, cvss_score: null, dependency_chain: null, chain_available: false, direct_or_transitive: null, fix_version: null, remediation: null, licence: null },
1927
+ { id: 'pkg-002', tool: 'snyk', ecosystem: 'node', repo: 'web', manifest_path: null, package_name: 'lodash', installed_version: '4.17.21', severity: 'critical', vulnerability: { cve: 'CVE-X', title: 't', description: null, reference_url: null }, cvss_score: null, dependency_chain: null, chain_available: false, direct_or_transitive: null, fix_version: null, remediation: null, licence: null },
1928
+ { id: 'pkg-003', tool: 'snyk', ecosystem: 'node', repo: 'api', manifest_path: null, package_name: 'axios', installed_version: '1.0.0', severity: 'high', vulnerability: { cve: 'CVE-Y', title: 't', description: null, reference_url: null }, cvss_score: null, dependency_chain: null, chain_available: false, direct_or_transitive: null, fix_version: null, remediation: null, licence: null },
1929
+ { id: 'pkg-004', tool: 'snyk', ecosystem: 'node', repo: 'web', manifest_path: null, package_name: 'axios', installed_version: '0.27.0', severity: 'high', vulnerability: { cve: 'CVE-Y', title: 't', description: null, reference_url: null }, cvss_score: null, dependency_chain: null, chain_available: false, direct_or_transitive: null, fix_version: null, remediation: null, licence: null },
1930
+ ];
1931
+ const rr = [
1932
+ { repo: 'api', ecosystem: 'node', tool_used: 'snyk', outcome: 'ok', findings: [findings[0], findings[2]], durationMs: 1 },
1933
+ { repo: 'web', ecosystem: 'node', tool_used: 'snyk', outcome: 'ok', findings: [findings[1], findings[3]], durationMs: 1 },
1934
+ ];
1935
+ const result = makeRunResult({ repo_results: rr, findings });
1936
+ result.licence_roster = [{ repo: 'api', package_name: 'lodash', installed_version: '4.17.21', licence: 'MIT' }];
1937
+ const body = reportModule._renderBody(result);
1938
+ const idxLicence = body.indexOf('## Licence Compliance');
1939
+ const idxCross = body.indexOf('## Cross-Repo Summary');
1940
+ const idxOverlap = body.indexOf('## Dependency Overlap');
1941
+ const idxCritical = body.indexOf('## Critical');
1942
+ assert.ok(idxLicence > 0, 'has Licence Compliance');
1943
+ assert.ok(idxCross > idxLicence, 'Cross-Repo Summary after Licence');
1944
+ assert.ok(idxOverlap > idxCross, 'Dependency Overlap after Cross-Repo Summary');
1945
+ assert.ok(idxCritical > idxOverlap, 'Critical section after Dependency Overlap');
1946
+ });
1947
+
1948
+ test('no shared findings + single versions => no Cross-Repo Summary or Dependency Overlap', () => {
1949
+ const findings = [
1950
+ { id: 'pkg-001', tool: 'snyk', ecosystem: 'node', repo: 'api', manifest_path: null, package_name: 'lodash', installed_version: '4.17.21', severity: 'critical', vulnerability: { cve: 'CVE-X', title: 't', description: null, reference_url: null }, cvss_score: null, dependency_chain: null, chain_available: false, direct_or_transitive: null, fix_version: null, remediation: null, licence: null },
1951
+ ];
1952
+ const rr = [
1953
+ { repo: 'api', ecosystem: 'node', tool_used: 'snyk', outcome: 'ok', findings, durationMs: 1 },
1954
+ ];
1955
+ const result = makeRunResult({ repo_results: rr, findings });
1956
+ const body = reportModule._renderBody(result);
1957
+ assert.doesNotMatch(body, /## Cross-Repo Summary/);
1958
+ assert.doesNotMatch(body, /## Dependency Overlap/);
1959
+ });
1960
+ });
1961
+
1962
+
1963
+