@ktpartners/dgs-platform 2.9.0 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. package/CHANGELOG.md +197 -0
  2. package/README.md +34 -2
  3. package/agents/dgs-executor.md +124 -3
  4. package/agents/dgs-idea-researcher.md +447 -0
  5. package/agents/dgs-plan-checker.md +61 -3
  6. package/agents/dgs-planner.md +51 -8
  7. package/bin/install.js +44 -0
  8. package/commands/dgs/abandon-quick.md +28 -0
  9. package/commands/dgs/add-tests.md +2 -2
  10. package/commands/dgs/audit-milestone.md +4 -3
  11. package/commands/dgs/capture-principle.md +11 -11
  12. package/commands/dgs/cleanup.md +2 -2
  13. package/commands/dgs/complete-milestone.md +11 -11
  14. package/commands/dgs/complete-quick.md +28 -0
  15. package/commands/dgs/create-milestone-job.md +2 -2
  16. package/commands/dgs/debug.md +3 -3
  17. package/commands/dgs/develop-idea.md +1 -1
  18. package/commands/dgs/diff-report.md +124 -0
  19. package/commands/dgs/fast.md +3 -1
  20. package/commands/dgs/health.md +1 -1
  21. package/commands/dgs/map-codebase.md +6 -6
  22. package/commands/dgs/new-milestone.md +5 -5
  23. package/commands/dgs/new-project.md +8 -21
  24. package/commands/dgs/package-scan.md +43 -0
  25. package/commands/dgs/plan-milestone-gaps.md +1 -1
  26. package/commands/dgs/progress.md +3 -3
  27. package/commands/dgs/quick-abandon.md +8 -0
  28. package/commands/dgs/quick-complete.md +8 -0
  29. package/commands/dgs/quick.md +10 -3
  30. package/commands/dgs/research-idea.md +3 -2
  31. package/commands/dgs/research-phase.md +3 -3
  32. package/commands/dgs/switch-project.md +14 -1
  33. package/commands/dgs/write-spec.md +3 -3
  34. package/deliver-great-systems/bin/dgs-tools.cjs +401 -32
  35. package/deliver-great-systems/bin/lib/audit-tolerance.cjs +77 -0
  36. package/deliver-great-systems/bin/lib/audit-tolerance.test.cjs +101 -0
  37. package/deliver-great-systems/bin/lib/commands.cjs +626 -46
  38. package/deliver-great-systems/bin/lib/commands.test.cjs +451 -0
  39. package/deliver-great-systems/bin/lib/commit-verify.test.cjs +236 -0
  40. package/deliver-great-systems/bin/lib/config.cjs +80 -6
  41. package/deliver-great-systems/bin/lib/config.test.cjs +309 -0
  42. package/deliver-great-systems/bin/lib/context.cjs +120 -0
  43. package/deliver-great-systems/bin/lib/core.cjs +35 -14
  44. package/deliver-great-systems/bin/lib/core.test.cjs +79 -1
  45. package/deliver-great-systems/bin/lib/execution.cjs +49 -17
  46. package/deliver-great-systems/bin/lib/fast-routing.cjs +199 -0
  47. package/deliver-great-systems/bin/lib/fast-routing.test.cjs +108 -0
  48. package/deliver-great-systems/bin/lib/final-commit-precondition.test.cjs +87 -0
  49. package/deliver-great-systems/bin/lib/fixtures/package-scan/bundler-audit-gemfile.json +21 -0
  50. package/deliver-great-systems/bin/lib/fixtures/package-scan/gate-parity-expected.md +186 -0
  51. package/deliver-great-systems/bin/lib/fixtures/package-scan/gate-parity-runresult.json +235 -0
  52. package/deliver-great-systems/bin/lib/fixtures/package-scan/govulncheck-import.json +3 -0
  53. package/deliver-great-systems/bin/lib/fixtures/package-scan/npm-audit-v10.json +37 -0
  54. package/deliver-great-systems/bin/lib/fixtures/package-scan/osv-clean.json +3 -0
  55. package/deliver-great-systems/bin/lib/fixtures/package-scan/osv-vulns.json +77 -0
  56. package/deliver-great-systems/bin/lib/fixtures/package-scan/pip-audit-requirements.json +28 -0
  57. package/deliver-great-systems/bin/lib/fixtures/package-scan/snyk-lodash.json +30 -0
  58. package/deliver-great-systems/bin/lib/fixtures/package-scan/snyk-workspaces.json +55 -0
  59. package/deliver-great-systems/bin/lib/flat-migration.test.cjs +396 -0
  60. package/deliver-great-systems/bin/lib/frontmatter.cjs +1 -1
  61. package/deliver-great-systems/bin/lib/governance.cjs +211 -0
  62. package/deliver-great-systems/bin/lib/governance.test.cjs +339 -0
  63. package/deliver-great-systems/bin/lib/health-untracked-phase.test.cjs +269 -0
  64. package/deliver-great-systems/bin/lib/ideas.cjs +206 -91
  65. package/deliver-great-systems/bin/lib/ideas.test.cjs +244 -1
  66. package/deliver-great-systems/bin/lib/init.cjs +357 -61
  67. package/deliver-great-systems/bin/lib/init.test.cjs +625 -8
  68. package/deliver-great-systems/bin/lib/jobs.cjs +131 -25
  69. package/deliver-great-systems/bin/lib/jobs.test.cjs +193 -74
  70. package/deliver-great-systems/bin/lib/migration.cjs +409 -1
  71. package/deliver-great-systems/bin/lib/migration.test.cjs +158 -1
  72. package/deliver-great-systems/bin/lib/milestone.cjs +154 -31
  73. package/deliver-great-systems/bin/lib/milestone.test.cjs +203 -0
  74. package/deliver-great-systems/bin/lib/package-adapters.cjs +530 -0
  75. package/deliver-great-systems/bin/lib/package-adapters.test.cjs +618 -0
  76. package/deliver-great-systems/bin/lib/package-ecosystems.cjs +350 -0
  77. package/deliver-great-systems/bin/lib/package-ecosystems.test.cjs +348 -0
  78. package/deliver-great-systems/bin/lib/package-runner.cjs +199 -0
  79. package/deliver-great-systems/bin/lib/package-runner.test.cjs +198 -0
  80. package/deliver-great-systems/bin/lib/package-scan-provenance.cjs +56 -0
  81. package/deliver-great-systems/bin/lib/package-scan-provenance.test.cjs +103 -0
  82. package/deliver-great-systems/bin/lib/package-scan-report.cjs +1140 -0
  83. package/deliver-great-systems/bin/lib/package-scan-report.test.cjs +1963 -0
  84. package/deliver-great-systems/bin/lib/package-scan-skill.cjs +96 -0
  85. package/deliver-great-systems/bin/lib/package-scan-skill.test.cjs +136 -0
  86. package/deliver-great-systems/bin/lib/package-scan.cjs +919 -0
  87. package/deliver-great-systems/bin/lib/package-scan.test.cjs +2147 -0
  88. package/deliver-great-systems/bin/lib/phase.cjs +146 -3
  89. package/deliver-great-systems/bin/lib/phase.test.cjs +420 -0
  90. package/deliver-great-systems/bin/lib/plan-number-validity.test.cjs +48 -0
  91. package/deliver-great-systems/bin/lib/projects.cjs +65 -10
  92. package/deliver-great-systems/bin/lib/projects.test.cjs +198 -2
  93. package/deliver-great-systems/bin/lib/quick.cjs +739 -0
  94. package/deliver-great-systems/bin/lib/quick.test.cjs +730 -0
  95. package/deliver-great-systems/bin/lib/repos.cjs +37 -13
  96. package/deliver-great-systems/bin/lib/review.cjs +1821 -0
  97. package/deliver-great-systems/bin/lib/roadmap.cjs +34 -13
  98. package/deliver-great-systems/bin/lib/specs.cjs +3 -81
  99. package/deliver-great-systems/bin/lib/state-transition-gate.test.cjs +160 -0
  100. package/deliver-great-systems/bin/lib/state.cjs +147 -55
  101. package/deliver-great-systems/bin/lib/summary-frontmatter.cjs +54 -0
  102. package/deliver-great-systems/bin/lib/summary-frontmatter.test.cjs +78 -0
  103. package/deliver-great-systems/bin/lib/sweep-scope.test.cjs +263 -0
  104. package/deliver-great-systems/bin/lib/sync.cjs +75 -0
  105. package/deliver-great-systems/bin/lib/verify.cjs +198 -7
  106. package/deliver-great-systems/bin/lib/verify.test.cjs +82 -0
  107. package/deliver-great-systems/bin/lib/wave-0-template-rename.test.cjs +40 -0
  108. package/deliver-great-systems/bin/lib/worktrees.cjs +790 -0
  109. package/deliver-great-systems/bin/lib/worktrees.test.cjs +963 -0
  110. package/deliver-great-systems/references/agent-step-reliability.md +60 -0
  111. package/deliver-great-systems/references/conflict-resolution.md +4 -0
  112. package/deliver-great-systems/references/context-tiers.md +4 -0
  113. package/deliver-great-systems/references/package-scan-config.md +151 -0
  114. package/deliver-great-systems/references/questioning.md +0 -30
  115. package/deliver-great-systems/references/spec-review-loop.md +1 -2
  116. package/deliver-great-systems/references/workflow-conventions.md +29 -0
  117. package/deliver-great-systems/skills/dgs-tests/package-scan.md +44 -0
  118. package/deliver-great-systems/templates/REVIEW.md +35 -0
  119. package/deliver-great-systems/templates/VALIDATION.md +1 -1
  120. package/deliver-great-systems/templates/claude-md.md +27 -0
  121. package/deliver-great-systems/templates/package-scan-report.md +108 -0
  122. package/deliver-great-systems/templates/project.md +6 -170
  123. package/deliver-great-systems/templates/summary.md +3 -1
  124. package/deliver-great-systems/workflows/abandon-quick.md +89 -0
  125. package/deliver-great-systems/workflows/add-idea.md +3 -3
  126. package/deliver-great-systems/workflows/add-phase.md +5 -0
  127. package/deliver-great-systems/workflows/add-tests.md +14 -0
  128. package/deliver-great-systems/workflows/add-todo.md +1 -0
  129. package/deliver-great-systems/workflows/approve-spec.md +25 -4
  130. package/deliver-great-systems/workflows/audit-milestone.md +66 -10
  131. package/deliver-great-systems/workflows/audit-phase.md +15 -5
  132. package/deliver-great-systems/workflows/cancel-job.md +2 -2
  133. package/deliver-great-systems/workflows/check-todos.md +2 -3
  134. package/deliver-great-systems/workflows/codereview.md +103 -9
  135. package/deliver-great-systems/workflows/complete-milestone.md +218 -24
  136. package/deliver-great-systems/workflows/complete-quick.md +106 -0
  137. package/deliver-great-systems/workflows/consolidate-ideas.md +1 -1
  138. package/deliver-great-systems/workflows/create-milestone-job.md +4 -4
  139. package/deliver-great-systems/workflows/develop-idea.md +11 -11
  140. package/deliver-great-systems/workflows/diagnose-issues.md +14 -0
  141. package/deliver-great-systems/workflows/discuss-idea.md +1 -1
  142. package/deliver-great-systems/workflows/discuss-phase.md +3 -2
  143. package/deliver-great-systems/workflows/execute-phase.md +209 -33
  144. package/deliver-great-systems/workflows/execute-plan.md +22 -22
  145. package/deliver-great-systems/workflows/help.md +53 -20
  146. package/deliver-great-systems/workflows/import-spec.md +65 -7
  147. package/deliver-great-systems/workflows/init-product.md +45 -167
  148. package/deliver-great-systems/workflows/new-milestone.md +140 -33
  149. package/deliver-great-systems/workflows/new-project.md +60 -331
  150. package/deliver-great-systems/workflows/package-scan.md +59 -0
  151. package/deliver-great-systems/workflows/plan-phase.md +79 -1
  152. package/deliver-great-systems/workflows/progress-all.md +133 -0
  153. package/deliver-great-systems/workflows/quick-abandon.md +89 -0
  154. package/deliver-great-systems/workflows/quick-complete.md +106 -0
  155. package/deliver-great-systems/workflows/quick.md +328 -26
  156. package/deliver-great-systems/workflows/refine-spec.md +1 -1
  157. package/deliver-great-systems/workflows/research-idea.md +77 -139
  158. package/deliver-great-systems/workflows/resume-project.md +2 -2
  159. package/deliver-great-systems/workflows/run-job.md +29 -43
  160. package/deliver-great-systems/workflows/settings.md +13 -77
  161. package/deliver-great-systems/workflows/validate-phase.md +39 -1
  162. package/deliver-great-systems/workflows/verify-work.md +14 -0
  163. package/deliver-great-systems/workflows/write-spec.md +11 -13
  164. package/hooks/dist/dgs-enforce-discipline.js +196 -0
  165. package/package.json +1 -1
  166. package/scripts/build-hooks.js +1 -0
@@ -0,0 +1,1140 @@
1
+ /**
2
+ * package-scan-report.cjs -- Phase 151 report writer for /dgs:package-scan
3
+ *
4
+ * Consumes Phase 150's frozen `runScan` result shape and emits a markdown
5
+ * report with YAML frontmatter (programmatic consumption) + a human-readable
6
+ * body. Chooses the report's on-disk location via a three-tier cascade:
7
+ * active phase -> active milestone -> project root with timestamp.
8
+ *
9
+ * Exports:
10
+ * writePackageScanReport(cwd, runResult, opts) -- composes emit + render + resolve + write
11
+ * _emitYamlFrontmatter(payload) -- hand-rolled YAML emitter (round-trip-validated)
12
+ * _parseEmittedYaml(text) -- minimal parser (subset the emitter produces)
13
+ * _renderBody(runResult) -- pure markdown emitter
14
+ * _resolveReportPath(cwd, opts) -- cascade resolver
15
+ *
16
+ * Covers roadmap requirements: PKG-06, PKG-07, PKG-20, PKG-21.
17
+ *
18
+ * Pure module: no module-level state, no side effects except (a) the file
19
+ * write inside writePackageScanReport and (b) mkdirSync for the milestones/
20
+ * or project-root parent dir during path resolution (acceptable per research
21
+ * hard rule 7).
22
+ */
23
+ 'use strict';
24
+ const fs = require('fs');
25
+ const path = require('path');
26
+ const { getPlanningRoot } = require('./paths.cjs');
27
+ const { getLocalConfigPath } = require('./config.cjs');
28
+
29
+ // ─── Canonical frontmatter constants ─────────────────────────────────────────
30
+ // Every findings[] entry emitted by this module carries these two constant
31
+ // fields so downstream consumers (Phase 152 skill plugin, Phase 153 gap
32
+ // planner) can detect + route on them without re-parsing severity strings.
33
+ //
34
+ // test_source: "package-scan"
35
+ // gap_type: "dependency-security"
36
+ const CANONICAL_TEST_SOURCE = 'package-scan';
37
+ const CANONICAL_GAP_TYPE = 'dependency-security';
38
+
39
+ // ─── Severity / licence normalisation (PKG-22 / PKG-23) ──────────────────────
40
+ // SEVERITY_MAP covers every raw severity string the six adapters emit. Keys
41
+ // are lowercased at lookup time; the map's own keys are pre-lowercased. The
42
+ // map resolves adapter dialects to exactly one canonical tier:
43
+ // critical|high|medium|low. Unknown or null inputs collapse to medium (see
44
+ // _collapseSeverity below) — a conservative default that prefers false
45
+ // positives over silently dropping a finding.
46
+ //
47
+ // Tool dialects covered (via lowercased lookup):
48
+ // - Snyk: critical | high | medium | low (canonical tiers, pass-through)
49
+ // - Bundler-Audit: High | Medium | Low (lowercases to canonical)
50
+ // - OSV: CRITICAL | HIGH | MODERATE | MEDIUM | LOW (database_specific.severity)
51
+ // - npm audit: critical | high | moderate | low | info
52
+ // - pip-audit: null (never emits severity)
53
+ // - govulncheck: null (never emits severity)
54
+ const SEVERITY_MAP = Object.freeze({
55
+ critical: 'critical',
56
+ high: 'high',
57
+ medium: 'medium',
58
+ low: 'low',
59
+ moderate: 'medium', // npm-audit + OSV MODERATE (lowered)
60
+ info: 'low', // npm-audit info-level
61
+ });
62
+
63
+ // LICENCE_SEVERITY_MAP maps SPDX identifiers (lowercased) to the canonical
64
+ // severity tier. Used by _mapLicenceToSeverity for licence-finding synthesis
65
+ // when the Snyk adapter surfaces a `licence` field. Permissive licences (MIT,
66
+ // Apache-2.0, BSD-*, ISC, etc.) are intentionally absent from the map — the
67
+ // helper returns null for them and no licence finding is synthesised.
68
+ const LICENCE_SEVERITY_MAP = Object.freeze({
69
+ 'gpl-3.0': 'high',
70
+ 'gpl-3.0-only': 'high',
71
+ 'gpl-3.0-or-later': 'high',
72
+ 'agpl-3.0': 'high',
73
+ 'agpl-3.0-only': 'high',
74
+ 'agpl-3.0-or-later': 'high',
75
+ 'lgpl-2.1': 'medium',
76
+ 'lgpl-2.1-only': 'medium',
77
+ 'lgpl-2.1-or-later': 'medium',
78
+ 'lgpl-3.0': 'medium',
79
+ 'lgpl-3.0-only': 'medium',
80
+ 'lgpl-3.0-or-later': 'medium',
81
+ 'mpl-1.1': 'medium',
82
+ 'mpl-2.0': 'medium',
83
+ // Phase 153 PKG-34: SSPL classified as restrictive (high).
84
+ 'sspl-1.0': 'high',
85
+ 'sspl-1.0-only': 'high',
86
+ });
87
+
88
+ // ─── YAML emitter ────────────────────────────────────────────────────────────
89
+
90
+ const TOP_LEVEL_ORDER = Object.freeze([
91
+ // UAT Bug 2: snyk_org emitted between 'tool' and 'repos_scanned' so the
92
+ // resolved Snyk org UUID is auditable in every report frontmatter.
93
+ 'type', 'date', 'tool', 'snyk_org', 'repos_scanned',
94
+ 'critical', 'high', 'medium', 'low',
95
+ 'duration', 'findings',
96
+ ]);
97
+
98
+ const FINDING_FIELD_ORDER = Object.freeze([
99
+ 'id', 'test_source', 'gap_type', 'severity', 'resource_id',
100
+ 'repo', 'manifest_path', 'title', 'description', 'remediation',
101
+ 'reference', 'cve', 'cvss', 'dependency_chain', 'chain_available',
102
+ 'direct_or_transitive', 'tool',
103
+ // Phase 153 PKG-33: optional plan provenance fields.
104
+ 'introduced_in_commit', 'introduced_in_plan',
105
+ ]);
106
+
107
+ const STRING_FINDING_FIELDS = new Set([
108
+ 'id', 'test_source', 'gap_type', 'severity', 'resource_id',
109
+ 'repo', 'manifest_path', 'title', 'description', 'remediation',
110
+ 'reference', 'cve', 'direct_or_transitive', 'tool',
111
+ // Phase 153 PKG-33: provenance fields are nullable strings.
112
+ 'introduced_in_commit', 'introduced_in_plan',
113
+ ]);
114
+
115
+ /**
116
+ * Emit a YAML string for a finding-array member. All non-null keys emit in
117
+ * FINDING_FIELD_ORDER. String scalars are double-quoted and escaped; newlines
118
+ * switch to block-scalar `|-` form. Null emits literal `null`; numbers/booleans
119
+ * unquoted. `dependency_chain` emits as either `null` or a nested block
120
+ * sequence. Throws on any disallowed value (function, circular, non-finding
121
+ * nested object).
122
+ */
123
+ function _emitFindingEntry(entry, visited) {
124
+ if (!entry || typeof entry !== 'object') {
125
+ throw new Error('Finding must be a non-null object');
126
+ }
127
+ if (visited.has(entry)) throw new Error('Circular reference in finding');
128
+ visited.add(entry);
129
+
130
+ // First field uses the ` - ` prefix; remaining indent is 4 spaces.
131
+ const lines = [];
132
+ let first = true;
133
+ for (const key of FINDING_FIELD_ORDER) {
134
+ if (!(key in entry)) continue;
135
+ const value = entry[key];
136
+ const indent = first ? ' - ' : ' ';
137
+ if (key === 'dependency_chain') {
138
+ if (value === null || value === undefined) {
139
+ lines.push(indent + 'dependency_chain: null');
140
+ } else if (Array.isArray(value)) {
141
+ if (value.length === 0) {
142
+ lines.push(indent + 'dependency_chain: []');
143
+ } else {
144
+ lines.push(indent + 'dependency_chain:');
145
+ for (const link of value) {
146
+ if (typeof link !== 'string') {
147
+ throw new Error('dependency_chain elements must be strings');
148
+ }
149
+ lines.push(' - ' + _emitStringScalarInline(link));
150
+ }
151
+ }
152
+ } else {
153
+ throw new Error('dependency_chain must be null or an array');
154
+ }
155
+ first = false;
156
+ continue;
157
+ }
158
+ if (STRING_FINDING_FIELDS.has(key)) {
159
+ if (value === null || value === undefined) {
160
+ lines.push(indent + key + ': null');
161
+ } else if (typeof value === 'string') {
162
+ const rendered = _emitStringScalar(value, first ? 4 : 4);
163
+ if (rendered.block) {
164
+ lines.push(indent + key + ': |-');
165
+ for (const bl of rendered.lines) {
166
+ lines.push(' ' + bl);
167
+ }
168
+ } else {
169
+ lines.push(indent + key + ': ' + rendered.inline);
170
+ }
171
+ } else {
172
+ throw new Error('Field ' + key + ' must be a string or null, got ' + typeof value);
173
+ }
174
+ first = false;
175
+ continue;
176
+ }
177
+ // Number / boolean fields (cvss, chain_available).
178
+ if (key === 'cvss') {
179
+ if (value === null || value === undefined) {
180
+ lines.push(indent + 'cvss: null');
181
+ } else if (typeof value === 'number' && Number.isFinite(value)) {
182
+ lines.push(indent + 'cvss: ' + String(value));
183
+ } else {
184
+ throw new Error('cvss must be number or null, got ' + typeof value);
185
+ }
186
+ } else if (key === 'chain_available') {
187
+ if (typeof value !== 'boolean') {
188
+ throw new Error('chain_available must be boolean, got ' + typeof value);
189
+ }
190
+ lines.push(indent + 'chain_available: ' + String(value));
191
+ }
192
+ first = false;
193
+ }
194
+ visited.delete(entry);
195
+ return lines.join('\n');
196
+ }
197
+
198
+ /**
199
+ * Emit a string scalar inline (double-quoted). For use inside block-sequence
200
+ * entries where the value is known to be a single-line string.
201
+ */
202
+ function _emitStringScalarInline(value) {
203
+ const rendered = _emitStringScalar(value, 4);
204
+ if (rendered.block) {
205
+ // Extremely unusual for a chain element; block form not supported here.
206
+ throw new Error('Block-scalar form unsupported in inline string context');
207
+ }
208
+ return rendered.inline;
209
+ }
210
+
211
+ /**
212
+ * Emit a string scalar. Returns either `{block: false, inline: '"..."'}` for
213
+ * single-line strings (double-quoted, with `"` and `\` escaped) or
214
+ * `{block: true, lines: ['line1', 'line2', ...]}` for multi-line strings
215
+ * (caller writes `|-` header + 2-space further indent).
216
+ */
217
+ function _emitStringScalar(value) {
218
+ const s = String(value);
219
+ if (s.indexOf('\n') === -1) {
220
+ let escaped = '';
221
+ for (const ch of s) {
222
+ if (ch === '\\') escaped += '\\\\';
223
+ else if (ch === '"') escaped += '\\"';
224
+ else escaped += ch;
225
+ }
226
+ return { block: false, inline: '"' + escaped + '"' };
227
+ }
228
+ const rawLines = s.split('\n');
229
+ return { block: true, lines: rawLines };
230
+ }
231
+
232
+ /**
233
+ * Emit YAML frontmatter for the full payload (top-level fields + findings[]).
234
+ * Performs round-trip validation after emitting and throws if the parsed
235
+ * output does not deep-equal the input payload.
236
+ */
237
+ function _emitYamlFrontmatter(payload) {
238
+ if (!payload || typeof payload !== 'object') {
239
+ throw new Error('Frontmatter payload must be a non-null object');
240
+ }
241
+ const lines = ['---'];
242
+ for (const key of TOP_LEVEL_ORDER) {
243
+ if (!(key in payload)) continue;
244
+ const value = payload[key];
245
+ if (key === 'findings') {
246
+ if (!Array.isArray(value)) {
247
+ throw new Error('findings must be an array');
248
+ }
249
+ if (value.length === 0) {
250
+ lines.push('findings: []');
251
+ } else {
252
+ lines.push('findings:');
253
+ const visited = new WeakSet();
254
+ for (const entry of value) {
255
+ lines.push(_emitFindingEntry(entry, visited));
256
+ }
257
+ }
258
+ continue;
259
+ }
260
+ if (value === null || value === undefined) {
261
+ lines.push(key + ': null');
262
+ continue;
263
+ }
264
+ if (typeof value === 'string') {
265
+ const rendered = _emitStringScalar(value);
266
+ if (rendered.block) {
267
+ lines.push(key + ': |-');
268
+ for (const bl of rendered.lines) {
269
+ lines.push(' ' + bl);
270
+ }
271
+ } else {
272
+ lines.push(key + ': ' + rendered.inline);
273
+ }
274
+ continue;
275
+ }
276
+ if (typeof value === 'number' && Number.isFinite(value)) {
277
+ lines.push(key + ': ' + String(value));
278
+ continue;
279
+ }
280
+ if (typeof value === 'boolean') {
281
+ lines.push(key + ': ' + String(value));
282
+ continue;
283
+ }
284
+ if (typeof value === 'function') {
285
+ throw new Error('Function values are not allowed in frontmatter payload (field: ' + key + ')');
286
+ }
287
+ if (typeof value === 'object') {
288
+ throw new Error('Unsupported nested object for field: ' + key);
289
+ }
290
+ throw new Error('Unsupported value type ' + typeof value + ' for field: ' + key);
291
+ }
292
+ lines.push('---');
293
+ const emitted = lines.join('\n') + '\n';
294
+
295
+ // Round-trip validation
296
+ let parsed;
297
+ try {
298
+ parsed = _parseEmittedYaml(emitted);
299
+ } catch (err) {
300
+ throw new Error('YAML emitter round-trip failed: parse error — ' + (err && err.message ? err.message : String(err)));
301
+ }
302
+ if (!_deepEqual(parsed, payload)) {
303
+ throw new Error('YAML emitter round-trip failed: parsed output does not match input payload');
304
+ }
305
+ return emitted;
306
+ }
307
+
308
+ // ─── YAML parser (minimal, matches emitter subset) ───────────────────────────
309
+
310
+ /**
311
+ * Parse the YAML subset produced by _emitYamlFrontmatter. Handles:
312
+ * - top-level `key: <scalar>` with scalars being double-quoted strings,
313
+ * `null`, integers, floats, booleans, or `[]` (empty sequence).
314
+ * - block-scalar `|-` with 2-space-indented content lines.
315
+ * - `findings:` header followed by block-sequence entries (` - key: value`
316
+ * then ` key2: value2` or ` key2: |-\n line1\n line2`).
317
+ */
318
+ function _parseEmittedYaml(text) {
319
+ if (typeof text !== 'string') throw new Error('text must be a string');
320
+ // Strip leading / trailing --- fences.
321
+ let body = text;
322
+ if (body.startsWith('---\n')) body = body.slice(4);
323
+ const closeIdx = body.indexOf('\n---');
324
+ if (closeIdx !== -1) body = body.slice(0, closeIdx);
325
+ const rawLines = body.split('\n');
326
+ // Remove trailing empty line if present.
327
+ if (rawLines.length > 0 && rawLines[rawLines.length - 1] === '') rawLines.pop();
328
+
329
+ const out = {};
330
+ let i = 0;
331
+ while (i < rawLines.length) {
332
+ const line = rawLines[i];
333
+ // Top-level lines: no leading whitespace.
334
+ if (/^[A-Za-z_]/.test(line)) {
335
+ const colonIdx = line.indexOf(':');
336
+ if (colonIdx === -1) throw new Error('Malformed line: ' + line);
337
+ const key = line.slice(0, colonIdx);
338
+ const rest = line.slice(colonIdx + 1);
339
+ if (key === 'findings') {
340
+ // Sequence
341
+ if (rest.trim() === '[]') {
342
+ out.findings = [];
343
+ i += 1;
344
+ continue;
345
+ }
346
+ if (rest.trim() !== '') {
347
+ throw new Error('findings: expected [] or block sequence, got ' + rest);
348
+ }
349
+ // Parse block sequence entries
350
+ const entries = [];
351
+ i += 1;
352
+ while (i < rawLines.length && rawLines[i].startsWith(' -')) {
353
+ const entry = {};
354
+ // First line of entry
355
+ const firstLine = rawLines[i];
356
+ const firstRest = firstLine.slice(4); // strip " - "
357
+ const fcolon = firstRest.indexOf(':');
358
+ if (fcolon === -1) throw new Error('Malformed entry first line: ' + firstLine);
359
+ const fkey = firstRest.slice(0, fcolon);
360
+ const fvalRaw = firstRest.slice(fcolon + 1);
361
+ const firstResult = _parseScalarOrBlock(fvalRaw, rawLines, i + 1, ' ');
362
+ entry[fkey] = firstResult.value;
363
+ i = firstResult.nextIndex;
364
+ // Remaining lines for this entry start with " " (4 spaces) and are
365
+ // not the next sequence entry.
366
+ while (i < rawLines.length && rawLines[i].startsWith(' ') && !rawLines[i].startsWith(' - ')) {
367
+ const l = rawLines[i];
368
+ const inner = l.slice(4);
369
+ if (inner.startsWith('- ')) {
370
+ // Part of a nested sequence handled by _parseScalarOrBlock earlier.
371
+ i += 1;
372
+ continue;
373
+ }
374
+ const ccolon = inner.indexOf(':');
375
+ if (ccolon === -1) {
376
+ i += 1;
377
+ continue;
378
+ }
379
+ const ckey = inner.slice(0, ccolon);
380
+ const cvalRaw = inner.slice(ccolon + 1);
381
+ const parsed = _parseScalarOrBlock(cvalRaw, rawLines, i + 1, ' ');
382
+ entry[ckey] = parsed.value;
383
+ i = parsed.nextIndex;
384
+ }
385
+ entries.push(entry);
386
+ }
387
+ out.findings = entries;
388
+ continue;
389
+ }
390
+ // Regular top-level key
391
+ const parsed = _parseScalarOrBlock(rest, rawLines, i + 1, ' ');
392
+ out[key] = parsed.value;
393
+ i = parsed.nextIndex;
394
+ continue;
395
+ }
396
+ // Unexpected line structure; skip.
397
+ i += 1;
398
+ }
399
+ return out;
400
+ }
401
+
402
+ /**
403
+ * Parse a scalar from `rest` (the slice after `key:`). If rest starts with
404
+ * ` |-`, collect indented continuation lines at `blockIndent` as a multi-line
405
+ * string. Returns `{value, nextIndex}` where nextIndex is the index of the
406
+ * first line AFTER this value's lines.
407
+ */
408
+ function _parseScalarOrBlock(rest, rawLines, continueIdx, blockIndent) {
409
+ const trimmed = rest.trim();
410
+ if (trimmed === '|-') {
411
+ const bodyLines = [];
412
+ let i = continueIdx;
413
+ while (i < rawLines.length && rawLines[i].startsWith(blockIndent)) {
414
+ bodyLines.push(rawLines[i].slice(blockIndent.length));
415
+ i += 1;
416
+ }
417
+ return { value: bodyLines.join('\n'), nextIndex: i };
418
+ }
419
+ if (trimmed === '') {
420
+ // Possibly a nested sequence (e.g., dependency_chain:)
421
+ const seqIndent = blockIndent;
422
+ const seq = [];
423
+ let i = continueIdx;
424
+ while (i < rawLines.length && rawLines[i].startsWith(seqIndent + '- ')) {
425
+ const itemRaw = rawLines[i].slice(seqIndent.length + 2);
426
+ seq.push(_parseInlineScalar(itemRaw));
427
+ i += 1;
428
+ }
429
+ return { value: seq, nextIndex: i };
430
+ }
431
+ if (trimmed === '[]') {
432
+ return { value: [], nextIndex: continueIdx };
433
+ }
434
+ return { value: _parseInlineScalar(trimmed), nextIndex: continueIdx };
435
+ }
436
+
437
+ function _parseInlineScalar(s) {
438
+ if (s === 'null') return null;
439
+ if (s === 'true') return true;
440
+ if (s === 'false') return false;
441
+ if (s.length >= 2 && s[0] === '"' && s[s.length - 1] === '"') {
442
+ // unescape
443
+ const inner = s.slice(1, -1);
444
+ let out = '';
445
+ for (let i = 0; i < inner.length; i += 1) {
446
+ const ch = inner[i];
447
+ if (ch === '\\' && i + 1 < inner.length) {
448
+ const next = inner[i + 1];
449
+ if (next === '"') { out += '"'; i += 1; continue; }
450
+ if (next === '\\') { out += '\\'; i += 1; continue; }
451
+ out += ch;
452
+ } else {
453
+ out += ch;
454
+ }
455
+ }
456
+ return out;
457
+ }
458
+ // Number?
459
+ if (/^-?\d+$/.test(s)) return parseInt(s, 10);
460
+ if (/^-?\d+\.\d+$/.test(s)) return parseFloat(s);
461
+ // Fallthrough: treat as bare string (should not happen for emitter output).
462
+ return s;
463
+ }
464
+
465
+ // ─── Deep equality (used for round-trip validation) ──────────────────────────
466
+
467
+ function _deepEqual(a, b) {
468
+ if (a === b) return true;
469
+ if (a === null || b === null) return a === b;
470
+ if (typeof a !== typeof b) return false;
471
+ if (typeof a !== 'object') return a === b;
472
+ if (Array.isArray(a)) {
473
+ if (!Array.isArray(b)) return false;
474
+ if (a.length !== b.length) return false;
475
+ for (let i = 0; i < a.length; i += 1) {
476
+ if (!_deepEqual(a[i], b[i])) return false;
477
+ }
478
+ return true;
479
+ }
480
+ if (Array.isArray(b)) return false;
481
+ const aKeys = Object.keys(a);
482
+ const bKeys = Object.keys(b);
483
+ if (aKeys.length !== bKeys.length) return false;
484
+ for (const k of aKeys) {
485
+ if (!Object.prototype.hasOwnProperty.call(b, k)) return false;
486
+ if (!_deepEqual(a[k], b[k])) return false;
487
+ }
488
+ return true;
489
+ }
490
+
491
+ // ─── Body renderer ───────────────────────────────────────────────────────────
492
+
493
+ function _statusLabel(outcome) {
494
+ switch (outcome) {
495
+ case 'ok': return 'ok';
496
+ case 'no_manifests': return 'skipped (no manifests)';
497
+ case 'tool_failure': return 'tool failure';
498
+ case 'timeout': return 'timeout';
499
+ case 'no_native_tool_for_ecosystem': return 'no native tool';
500
+ case 'skipped': return 'skipped';
501
+ default: return outcome || 'unknown';
502
+ }
503
+ }
504
+
505
+ function _collapseSeverity(raw) {
506
+ if (raw === null || raw === undefined) return 'medium';
507
+ const key = String(raw).toLowerCase();
508
+ return Object.prototype.hasOwnProperty.call(SEVERITY_MAP, key) ? SEVERITY_MAP[key] : 'medium';
509
+ }
510
+
511
+ /**
512
+ * Map a raw SPDX licence identifier to a canonical severity tier. Returns null
513
+ * for permissive licences (MIT, Apache-2.0, BSD-*, ISC, etc.) or when the
514
+ * licence field is null/undefined. Used by _canonicalFindingsForAdapter to
515
+ * decide whether a Snyk finding should emit an extra licence-violation
516
+ * finding (PKG-23).
517
+ */
518
+ function _mapLicenceToSeverity(licence) {
519
+ if (licence === null || licence === undefined) return null;
520
+ const key = String(licence).toLowerCase();
521
+ return Object.prototype.hasOwnProperty.call(LICENCE_SEVERITY_MAP, key)
522
+ ? LICENCE_SEVERITY_MAP[key]
523
+ : null;
524
+ }
525
+
526
+ function _renderBody(runResult) {
527
+ const lines = ['# Package Scan Report', ''];
528
+ const rr = Array.isArray(runResult.repo_results) ? runResult.repo_results : [];
529
+ const findings = Array.isArray(runResult.findings) ? runResult.findings : [];
530
+ const diagnostics = Array.isArray(runResult.diagnostics) ? runResult.diagnostics : [];
531
+
532
+ // Empty repo_results case.
533
+ if (rr.length === 0) {
534
+ lines.push('## No targets scanned', '');
535
+ if (diagnostics.length > 0) {
536
+ for (const d of diagnostics) {
537
+ const msg = d.message || d.hint || d.kind || 'unknown diagnostic';
538
+ lines.push(`- **${d.kind || 'diagnostic'}:** ${msg}`);
539
+ }
540
+ } else {
541
+ lines.push('- No repos registered and product root has no manifests.');
542
+ }
543
+ lines.push('');
544
+ return lines.join('\n');
545
+ }
546
+
547
+ // Summary table — Phase 153 PKG-32 adds the ".snyk policy" column.
548
+ lines.push('## Summary', '');
549
+ lines.push('| Repo | Ecosystem | Tool | .snyk policy | Critical | High | Medium | Low | Status |');
550
+ lines.push('|------|-----------|------|--------------|----------|------|--------|-----|--------|');
551
+ // Group findings by (repo, ecosystem) so we know per-row severity counts.
552
+ for (const row of rr) {
553
+ const repo = row.repo;
554
+ const eco = row.ecosystem || '—';
555
+ const tool = row.tool_used || '—';
556
+ const snykPolicy = row.has_snyk_policy === true ? 'yes'
557
+ : row.has_snyk_policy === false ? 'no' : '—';
558
+ const isOk = row.outcome === 'ok';
559
+ let critical = '—', high = '—', medium = '—', low = '—';
560
+ if (isOk) {
561
+ let c = 0, h = 0, m = 0, l = 0;
562
+ const rowFindings = Array.isArray(row.findings) ? row.findings : [];
563
+ for (const f of rowFindings) {
564
+ const s = _collapseSeverity(f.severity);
565
+ if (s === 'critical') c += 1;
566
+ else if (s === 'high') h += 1;
567
+ else if (s === 'medium') m += 1;
568
+ else if (s === 'low') l += 1;
569
+ }
570
+ critical = String(c); high = String(h); medium = String(m); low = String(l);
571
+ }
572
+ lines.push(`| ${repo} | ${eco} | ${tool} | ${snykPolicy} | ${critical} | ${high} | ${medium} | ${low} | ${_statusLabel(row.outcome)} |`);
573
+ }
574
+ lines.push('');
575
+
576
+ // Phase 153 PKG-32: per-repo suppression notes (when .snyk policy applied AND count > 0).
577
+ for (const row of rr) {
578
+ if (row.has_snyk_policy === true
579
+ && typeof row.snyk_suppressions_count === 'number'
580
+ && row.snyk_suppressions_count > 0) {
581
+ const n = row.snyk_suppressions_count;
582
+ const word = n === 1 ? 'vulnerability' : 'vulnerabilities';
583
+ lines.push(`> ${row.repo}: .snyk policy applied: ${n} ${word} suppressed.`);
584
+ lines.push('');
585
+ }
586
+ }
587
+
588
+ // Phase 153 PKG-30/34: Licence Compliance section (always rendered).
589
+ const aggToolForLicence = _aggregateToolString(runResult);
590
+ const licenceRoster = Array.isArray(runResult.licence_roster) ? runResult.licence_roster : [];
591
+ if (findings.length > 0 || licenceRoster.length > 0 || aggToolForLicence !== 'none') {
592
+ lines.push(_renderLicenceComplianceSection({ tool_agg: aggToolForLicence, licence_roster: licenceRoster }));
593
+ }
594
+
595
+ // Phase 153 PKG-35: cross-repo dedup (body-only; YAML findings stay one-per-repo).
596
+ const groups = _groupFindingsByPackageVersion(findings);
597
+ const crossRepoSection = _renderCrossRepoSummary(groups);
598
+ if (crossRepoSection) lines.push(crossRepoSection);
599
+ // Phase 153 PKG-36: dependency version overlap.
600
+ const overlaps = _detectVersionOverlaps(findings, runResult.licence_roster);
601
+ const overlapSection = _renderVersionOverlap(overlaps);
602
+ if (overlapSection) lines.push(overlapSection);
603
+
604
+ // Severity sections (only when findings exist)
605
+ if (findings.length === 0) {
606
+ lines.push('## Findings', '');
607
+ lines.push(`No vulnerabilities found across ${rr.length} scanned repos.`);
608
+ lines.push('');
609
+ } else {
610
+ const severityOrder = ['critical', 'high', 'medium', 'low'];
611
+ const byTier = { critical: [], high: [], medium: [], low: [] };
612
+ for (const f of findings) {
613
+ const s = _collapseSeverity(f.severity);
614
+ byTier[s].push(f);
615
+ }
616
+ const titleCase = { critical: 'Critical', high: 'High', medium: 'Medium', low: 'Low' };
617
+ for (const tier of severityOrder) {
618
+ const tierFindings = byTier[tier];
619
+ if (tierFindings.length === 0) continue;
620
+ lines.push(`## ${titleCase[tier]}`, '');
621
+ for (const f of tierFindings) {
622
+ const title = (f.vulnerability && f.vulnerability.title) || 'Untitled';
623
+ const pkg = f.package_name || 'unknown';
624
+ const ver = f.installed_version || '';
625
+ const headerSuffix = ver ? `${pkg}@${ver}` : pkg;
626
+ lines.push(`### ${f.repo}: ${headerSuffix} — ${title}`);
627
+ const cve = (f.vulnerability && f.vulnerability.cve) || null;
628
+ const cvss = (typeof f.cvss_score === 'number') ? f.cvss_score : null;
629
+ const tool = f.tool || 'unknown';
630
+ const manifest = f.manifest_path ? '`' + f.manifest_path + '`' : 'repo root';
631
+ const direct = f.direct_or_transitive || 'unknown';
632
+ let chainLine;
633
+ if (f.chain_available && Array.isArray(f.dependency_chain) && f.dependency_chain.length > 0) {
634
+ chainLine = f.dependency_chain.map(_chainEntryToString).join(' → ');
635
+ } else {
636
+ chainLine = 'unavailable (chain_available: false — recommend Snyk for full chain analysis)';
637
+ }
638
+ const fix = f.remediation || 'no upgrade path available — manual review required';
639
+ const ref = (f.vulnerability && f.vulnerability.reference_url) || 'unavailable';
640
+ lines.push(`- **CVE:** ${cve || 'unavailable'}`);
641
+ lines.push(`- **CVSS:** ${cvss !== null ? String(cvss) : 'unavailable'}`);
642
+ lines.push(`- **Tool:** ${tool}`);
643
+ lines.push(`- **Manifest:** ${manifest}`);
644
+ lines.push(`- **Direct/Transitive:** ${direct}`);
645
+ lines.push(`- **Dependency chain:** ${chainLine}`);
646
+ lines.push(`- **Fix:** ${fix}`);
647
+ lines.push(`- **Reference:** ${ref}`);
648
+ // Phase 153 PKG-33: provenance line.
649
+ let introLine;
650
+ if (f.introduced_in_commit && f.introduced_in_plan) {
651
+ introLine = `- **Introduced in:** commit ${f.introduced_in_commit} (plan ${f.introduced_in_plan})`;
652
+ } else if (f.introduced_in_commit) {
653
+ introLine = `- **Introduced in:** commit ${f.introduced_in_commit}`;
654
+ } else {
655
+ introLine = `- **Introduced in:** unknown`;
656
+ }
657
+ lines.push(introLine);
658
+ const descr = f.vulnerability && f.vulnerability.description;
659
+ if (descr !== null && descr !== undefined && String(descr).length > 0) {
660
+ lines.push('');
661
+ for (const dl of String(descr).split('\n')) {
662
+ lines.push('> ' + dl);
663
+ }
664
+ }
665
+ lines.push('');
666
+ }
667
+ }
668
+ }
669
+
670
+ // Diagnostics appendix (only when findings exist + diagnostics populated)
671
+ if (findings.length > 0 && diagnostics.length > 0) {
672
+ lines.push('## Diagnostics', '');
673
+ for (const d of diagnostics) {
674
+ const msg = d.message || d.hint || d.kind || 'unknown';
675
+ lines.push(`- **${d.kind || 'diagnostic'}:** ${msg}`);
676
+ }
677
+ lines.push('');
678
+ }
679
+
680
+ // Pitfall 17 one-line warning (when aggregated tool is snyk).
681
+ const aggTool = _aggregateToolString(runResult);
682
+ if (aggTool === 'snyk') {
683
+ lines.push('> Note: scan output may include package names and tool URLs. Review before pushing.');
684
+ lines.push('');
685
+ }
686
+
687
+ return lines.join('\n');
688
+ }
689
+
690
+ // ─── Phase 153 Plan 04: Cross-repo dedup + version overlap (PKG-35, PKG-36) ─
691
+
692
+ function _severityRankLocal(tier) {
693
+ const RANK = { critical: 4, high: 3, medium: 2, low: 1 };
694
+ if (tier === null || tier === undefined) return 0;
695
+ return RANK[String(tier).toLowerCase()] || 0;
696
+ }
697
+
698
+ function _groupFindingsByPackageVersion(findings) {
699
+ if (!Array.isArray(findings)) return [];
700
+ const map = new Map();
701
+ for (const f of findings) {
702
+ const pkg = f.package_name || 'unknown';
703
+ const ver = f.installed_version || '';
704
+ const vuln = f.vulnerability || {};
705
+ const cveKey = vuln.cve || null;
706
+ const titleKey = vuln.title || '';
707
+ const key = `${pkg}\u0000${ver}\u0000${cveKey || titleKey}`;
708
+ if (!map.has(key)) {
709
+ map.set(key, {
710
+ package_name: pkg,
711
+ installed_version: ver,
712
+ cve: cveKey,
713
+ title: vuln.title || '',
714
+ severity: _collapseSeverity(f.severity),
715
+ repos: [],
716
+ });
717
+ }
718
+ const group = map.get(key);
719
+ if (!group.repos.includes(f.repo)) group.repos.push(f.repo);
720
+ const prevRank = _severityRankLocal(group.severity);
721
+ const curRank = _severityRankLocal(_collapseSeverity(f.severity));
722
+ if (curRank > prevRank) group.severity = _collapseSeverity(f.severity);
723
+ }
724
+ const out = Array.from(map.values());
725
+ out.sort((a, b) => {
726
+ const sa = _severityRankLocal(a.severity);
727
+ const sb = _severityRankLocal(b.severity);
728
+ if (sa !== sb) return sb - sa;
729
+ if (a.repos.length !== b.repos.length) return b.repos.length - a.repos.length;
730
+ return String(a.package_name).localeCompare(String(b.package_name));
731
+ });
732
+ return out;
733
+ }
734
+
735
+ function _detectVersionOverlaps(findings, licenceRoster) {
736
+ const fArr = Array.isArray(findings) ? findings : [];
737
+ const rArr = Array.isArray(licenceRoster) ? licenceRoster : [];
738
+ const byPkg = new Map(); // package_name -> Map(version -> Set(repo))
739
+ const addEntry = (pkg, ver, repo) => {
740
+ if (!pkg || ver === undefined || ver === null || ver === '') return;
741
+ if (!byPkg.has(pkg)) byPkg.set(pkg, new Map());
742
+ const versionMap = byPkg.get(pkg);
743
+ if (!versionMap.has(ver)) versionMap.set(ver, new Set());
744
+ if (repo) versionMap.get(ver).add(repo);
745
+ };
746
+ for (const f of fArr) addEntry(f.package_name, f.installed_version, f.repo);
747
+ for (const e of rArr) addEntry(e.package_name, e.installed_version, e.repo);
748
+ const out = [];
749
+ for (const [pkg, versionMap] of byPkg.entries()) {
750
+ if (versionMap.size < 2) continue;
751
+ const versions = Array.from(versionMap.keys()).sort();
752
+ const repos = {};
753
+ for (const v of versions) repos[v] = Array.from(versionMap.get(v)).sort();
754
+ out.push({ package_name: pkg, versions, repos });
755
+ }
756
+ out.sort((a, b) => String(a.package_name).localeCompare(String(b.package_name)));
757
+ return out;
758
+ }
759
+
760
+ function _renderCrossRepoSummary(groups) {
761
+ const multi = Array.isArray(groups) ? groups.filter(g => g.repos && g.repos.length >= 2) : [];
762
+ if (multi.length === 0) return '';
763
+ const lines = ['## Cross-Repo Summary', ''];
764
+ lines.push('| Package | Version | CVE | Severity | Repos |');
765
+ lines.push('|---------|---------|-----|----------|-------|');
766
+ for (const g of multi) {
767
+ const cve = g.cve || '—';
768
+ const repos = g.repos.slice().sort().join(', ');
769
+ lines.push(`| ${g.package_name} | ${g.installed_version || '—'} | ${cve} | ${g.severity} | ${repos} |`);
770
+ }
771
+ lines.push('');
772
+ return lines.join('\n');
773
+ }
774
+
775
+ function _renderVersionOverlap(overlaps) {
776
+ const arr = Array.isArray(overlaps) ? overlaps : [];
777
+ if (arr.length === 0) return '';
778
+ const lines = ['## Dependency Overlap', ''];
779
+ lines.push('| Package | Versions | Repos |');
780
+ lines.push('|---------|----------|-------|');
781
+ for (const o of arr) {
782
+ const versions = o.versions.join(', ');
783
+ const reposParts = [];
784
+ for (const v of o.versions) {
785
+ const repos = (o.repos && o.repos[v]) ? o.repos[v].join(', ') : '';
786
+ reposParts.push(`${v}: ${repos}`);
787
+ }
788
+ lines.push(`| ${o.package_name} | ${versions} | ${reposParts.join(' / ')} |`);
789
+ }
790
+ lines.push('');
791
+ return lines.join('\n');
792
+ }
793
+
794
+ // ─── Phase 153 Plan 03: Licence Compliance section (PKG-30, PKG-34) ─────────
795
+
796
+ /**
797
+ * PKG-30, PKG-34: render the dedicated Licence Compliance section.
798
+ * @param {{ tool_agg: string|null, licence_roster: Array|null }} args
799
+ * @returns {string} Markdown fragment ending with a blank line.
800
+ */
801
+ function _renderLicenceComplianceSection(args) {
802
+ const lines = ['## Licence Compliance', ''];
803
+ const tool = args && args.tool_agg;
804
+ const roster = (args && Array.isArray(args.licence_roster)) ? args.licence_roster : [];
805
+ if (tool !== 'snyk') {
806
+ // Non-snyk (or no tool aggregated) — suggest Snyk for licence coverage.
807
+ lines.push('> Licence scan incomplete -- use Snyk for full coverage.');
808
+ lines.push('');
809
+ return lines.join('\n');
810
+ }
811
+ if (roster.length === 0) {
812
+ // UAT Bug 3: Snyk IS the tool but roster is empty. This is NOT
813
+ // "incomplete coverage" — the scan ran and surfaced no restrictive
814
+ // licences. Point the user at Snyk org-level licence policy configuration
815
+ // for expanded scope rather than repeating the non-snyk "install Snyk"
816
+ // prompt, which was misleading during live UAT.
817
+ lines.push('> Snyk licence scan completed; no restrictive licences flagged.');
818
+ lines.push('> To expand licence coverage, configure org-level licence policies at https://app.snyk.io/org/licenses.');
819
+ lines.push('');
820
+ return lines.join('\n');
821
+ }
822
+ lines.push('| Package | Version | Repo | Licence | Flag |');
823
+ lines.push('|---------|---------|------|---------|------|');
824
+ // Deterministic ordering: restrictive first, copyleft next, permissive last; secondary by package name.
825
+ const sorted = roster.slice().sort((a, b) => {
826
+ const fa = _licenceRosterFlag(a.licence);
827
+ const fb = _licenceRosterFlag(b.licence);
828
+ const rank = (flag) => flag === 'RESTRICTIVE' ? 2 : (flag === 'copyleft' ? 1 : 0);
829
+ if (rank(fb) !== rank(fa)) return rank(fb) - rank(fa);
830
+ return String(a.package_name).localeCompare(String(b.package_name));
831
+ });
832
+ for (const e of sorted) {
833
+ const flag = _licenceRosterFlag(e.licence);
834
+ const licence = e.licence === null || e.licence === undefined ? 'unknown' : String(e.licence);
835
+ lines.push(`| ${e.package_name} | ${e.installed_version} | ${e.repo || '—'} | ${licence} | ${flag} |`);
836
+ }
837
+ lines.push('');
838
+ return lines.join('\n');
839
+ }
840
+
841
+ function _licenceRosterFlag(licence) {
842
+ if (licence === null || licence === undefined) return '';
843
+ const key = String(licence).toLowerCase();
844
+ if (/^gpl[-]?/.test(key) || /^agpl[-]?/.test(key) || /^sspl[-]?/.test(key)) return 'RESTRICTIVE';
845
+ if (/^lgpl[-]?/.test(key) || /^mpl[-]?/.test(key)) return 'copyleft';
846
+ return '';
847
+ }
848
+
849
+ // ─── Path resolution ─────────────────────────────────────────────────────────
850
+
851
+ function _pad2(n) { return String(n).padStart(2, '0'); }
852
+
853
+ function _formatTimestamp(dateObj) {
854
+ const y = dateObj.getUTCFullYear();
855
+ const mo = _pad2(dateObj.getUTCMonth() + 1);
856
+ const d = _pad2(dateObj.getUTCDate());
857
+ const hh = _pad2(dateObj.getUTCHours());
858
+ const mm = _pad2(dateObj.getUTCMinutes());
859
+ return `${y}-${mo}-${d}-${hh}${mm}`;
860
+ }
861
+
862
+ function _resolveReportPath(cwd, opts) {
863
+ opts = opts || {};
864
+ const planningRoot = getPlanningRoot(cwd);
865
+ let activeContext = null;
866
+ try {
867
+ const localPath = getLocalConfigPath(cwd);
868
+ if (fs.existsSync(localPath)) {
869
+ const local = JSON.parse(fs.readFileSync(localPath, 'utf-8'));
870
+ activeContext = (local.execution && local.execution.active_context) || null;
871
+ }
872
+ } catch {
873
+ activeContext = null;
874
+ }
875
+
876
+ if (typeof activeContext === 'string' && activeContext.length > 0) {
877
+ // Tier 1: active phase. Look for a projects/*/phases/ directory whose
878
+ // basename matches activeContext (or whose number prefix matches).
879
+ const projectsDir = path.join(planningRoot, 'projects');
880
+ if (fs.existsSync(projectsDir) && fs.statSync(projectsDir).isDirectory()) {
881
+ const projects = fs.readdirSync(projectsDir, { withFileTypes: true });
882
+ for (const pEnt of projects) {
883
+ if (!pEnt.isDirectory()) continue;
884
+ const phasesDir = path.join(projectsDir, pEnt.name, 'phases');
885
+ if (!fs.existsSync(phasesDir)) continue;
886
+ const phaseEntries = fs.readdirSync(phasesDir, { withFileTypes: true });
887
+ for (const phEnt of phaseEntries) {
888
+ if (!phEnt.isDirectory()) continue;
889
+ if (phEnt.name === activeContext) {
890
+ const m = phEnt.name.match(/^(\d+)/);
891
+ const prefix = m ? m[1] : phEnt.name;
892
+ return path.join(phasesDir, phEnt.name, prefix + '-PACKAGE-SCAN.md');
893
+ }
894
+ }
895
+ }
896
+ }
897
+ // Tier 2: milestone slug (e.g., `v23.1`, `v2.0`, etc.).
898
+ if (/^v\d+(\.\d+)?$/.test(activeContext)) {
899
+ const milestonesDir = path.join(planningRoot, 'milestones');
900
+ fs.mkdirSync(milestonesDir, { recursive: true });
901
+ return path.join(milestonesDir, activeContext + '-PACKAGE-SCAN.md');
902
+ }
903
+ // Tier 2b: activeContext is a worktree slug whose entry carries a milestone_version.
904
+ // Enables `package-scan` to land reports under `milestones/<version>-PACKAGE-SCAN.md`
905
+ // when the active context is a milestone worktree (e.g. `package-dependency-scanning`).
906
+ try {
907
+ const localPath2 = getLocalConfigPath(cwd);
908
+ if (fs.existsSync(localPath2)) {
909
+ const local2 = JSON.parse(fs.readFileSync(localPath2, 'utf-8'));
910
+ const currentProject = local2.current_project;
911
+ const wt = currentProject
912
+ && local2.projects
913
+ && local2.projects[currentProject]
914
+ && local2.projects[currentProject].worktrees
915
+ && local2.projects[currentProject].worktrees[activeContext];
916
+ if (wt
917
+ && wt.type === 'milestone'
918
+ && typeof wt.milestone_version === 'string'
919
+ && /^v\d+(\.\d+)?$/.test(wt.milestone_version)) {
920
+ const milestonesDir = path.join(planningRoot, 'milestones');
921
+ fs.mkdirSync(milestonesDir, { recursive: true });
922
+ return path.join(milestonesDir, wt.milestone_version + '-PACKAGE-SCAN.md');
923
+ }
924
+ }
925
+ } catch { /* fall through to tier 3 */ }
926
+ // Tier 2 fallback: active_context did not match a phase dir, but is not a
927
+ // milestone-slug pattern either. Fall through to tier 3.
928
+ }
929
+ // Tier 3: project-root timestamped.
930
+ const now = (opts.now ? opts.now() : new Date());
931
+ const stamp = _formatTimestamp(now instanceof Date ? now : new Date(now));
932
+ return path.join(planningRoot, `PACKAGE-SCAN-${stamp}.md`);
933
+ }
934
+
935
+ // ─── Composition helpers ─────────────────────────────────────────────────────
936
+
937
+ function _aggregateToolString(runResult) {
938
+ const tools = new Set();
939
+ let hasNull = false;
940
+ const rr = Array.isArray(runResult.repo_results) ? runResult.repo_results : [];
941
+ for (const row of rr) {
942
+ if (row.tool_used === null || row.tool_used === undefined) {
943
+ hasNull = true;
944
+ } else {
945
+ tools.add(row.tool_used);
946
+ }
947
+ }
948
+ if (tools.size === 0) return 'none';
949
+ if (tools.size === 1) return Array.from(tools)[0];
950
+ return 'mixed';
951
+ }
952
+
953
+ function _countSeverity(findings, tier) {
954
+ let n = 0;
955
+ for (const f of findings) {
956
+ if (_collapseSeverity(f.severity) === tier) n += 1;
957
+ }
958
+ return n;
959
+ }
960
+
961
+ /**
962
+ * Normalise a dependency_chain entry to a string. Adapter output may use either
963
+ * plain strings (e.g., from govulncheck) or `{name, version}` objects (Snyk
964
+ * _parseSnykFromEntry). Non-string entries render as `name@version` or just
965
+ * `name` if version is empty.
966
+ */
967
+ function _chainEntryToString(entry) {
968
+ if (typeof entry === 'string') return entry;
969
+ if (entry && typeof entry === 'object') {
970
+ const name = entry.name || entry.package || '';
971
+ const ver = entry.version || '';
972
+ return ver ? `${name}@${ver}` : name;
973
+ }
974
+ return String(entry);
975
+ }
976
+
977
+ function _normaliseChain(chain) {
978
+ if (chain === null || chain === undefined) return null;
979
+ if (!Array.isArray(chain)) return null;
980
+ return chain.map(_chainEntryToString);
981
+ }
982
+
983
+ function _mapAdapterFindingToCanonical(f) {
984
+ const pkg = f.package_name || '';
985
+ const ver = f.installed_version || '';
986
+ const resource_id = ver ? `${pkg}@${ver}` : pkg;
987
+ const vuln = f.vulnerability || {};
988
+ const severity = _collapseSeverity(f.severity);
989
+ return {
990
+ id: f.id,
991
+ test_source: CANONICAL_TEST_SOURCE,
992
+ gap_type: CANONICAL_GAP_TYPE,
993
+ severity,
994
+ resource_id,
995
+ repo: f.repo,
996
+ manifest_path: f.manifest_path === undefined ? null : f.manifest_path,
997
+ title: vuln.title || '',
998
+ description: vuln.description === undefined ? null : vuln.description,
999
+ remediation: f.remediation === undefined ? null : f.remediation,
1000
+ reference: vuln.reference_url === undefined ? null : vuln.reference_url,
1001
+ cve: vuln.cve === undefined ? null : vuln.cve,
1002
+ cvss: typeof f.cvss_score === 'number' ? f.cvss_score : null,
1003
+ dependency_chain: _normaliseChain(f.dependency_chain === undefined ? null : f.dependency_chain),
1004
+ chain_available: !!f.chain_available,
1005
+ direct_or_transitive: f.direct_or_transitive === undefined ? null : f.direct_or_transitive,
1006
+ tool: f.tool,
1007
+ // Phase 153 PKG-33: plan provenance (optional; null when unknown).
1008
+ introduced_in_commit: f.introduced_in_commit === undefined ? null : f.introduced_in_commit,
1009
+ introduced_in_plan: f.introduced_in_plan === undefined ? null : f.introduced_in_plan,
1010
+ };
1011
+ }
1012
+
1013
+ /**
1014
+ * Map one adapter finding to 1 or 2 canonical findings.
1015
+ *
1016
+ * - Always emits the security finding (unchanged shape from _mapAdapterFindingToCanonical).
1017
+ * - When f.tool === 'snyk' AND f.licence maps to a non-null canonical tier,
1018
+ * appends a licence finding with gap_type: 'dependency-licence'. The
1019
+ * licence finding's id is `${security.id}-lic` (derived, collision-free)
1020
+ * when the security finding has a non-null id, else null.
1021
+ *
1022
+ * Licence synthesis is Snyk-only on purpose: PKG-30 pins licence detection to
1023
+ * Snyk's pass-through licence field — other tools don't reliably emit licence
1024
+ * data. The future test-gate gap planner correlates licence + security
1025
+ * findings on `resource_id` (same package@version for both halves).
1026
+ */
1027
+ function _canonicalFindingsForAdapter(f) {
1028
+ const security = _mapAdapterFindingToCanonical(f);
1029
+ if (f.tool !== 'snyk') return [security];
1030
+ const licence = f.licence === undefined ? null : f.licence;
1031
+ const licenceSeverity = _mapLicenceToSeverity(licence);
1032
+ if (licenceSeverity === null) return [security];
1033
+ const pkg = f.package_name || 'unknown';
1034
+ const ver = f.installed_version || 'unknown';
1035
+ const licenceId = security.id === null || security.id === undefined
1036
+ ? null
1037
+ : `${security.id}-lic`;
1038
+ const licenceFinding = {
1039
+ id: licenceId,
1040
+ test_source: CANONICAL_TEST_SOURCE,
1041
+ gap_type: 'dependency-licence',
1042
+ severity: licenceSeverity,
1043
+ resource_id: security.resource_id,
1044
+ repo: f.repo,
1045
+ manifest_path: f.manifest_path === undefined ? null : f.manifest_path,
1046
+ title: `Restrictive licence: ${licence}`,
1047
+ description: `Package ${pkg}@${ver} is licensed under ${licence}. Using this dependency may impose copyleft obligations on your project.`,
1048
+ remediation: 'Review licence compatibility or replace with a permissive-licensed alternative.',
1049
+ reference: null,
1050
+ cve: null,
1051
+ cvss: null,
1052
+ dependency_chain: null,
1053
+ chain_available: false,
1054
+ direct_or_transitive: f.direct_or_transitive === undefined ? null : f.direct_or_transitive,
1055
+ tool: 'snyk',
1056
+ // Phase 153 PKG-33: licence finding inherits provenance from the security finding (same package, same manifest).
1057
+ introduced_in_commit: security.introduced_in_commit,
1058
+ introduced_in_plan: security.introduced_in_plan,
1059
+ };
1060
+ return [security, licenceFinding];
1061
+ }
1062
+
1063
+ // ─── Public: writePackageScanReport ──────────────────────────────────────────
1064
+
1065
+ function writePackageScanReport(cwd, runResult, opts) {
1066
+ opts = opts || {};
1067
+ if (!runResult || typeof runResult !== 'object') {
1068
+ throw new Error('runResult must be a non-null object');
1069
+ }
1070
+
1071
+ const now = opts.now ? opts.now() : new Date();
1072
+ const nowDate = now instanceof Date ? now : new Date(now);
1073
+
1074
+ const rr = Array.isArray(runResult.repo_results) ? runResult.repo_results : [];
1075
+ const findings = Array.isArray(runResult.findings) ? runResult.findings : [];
1076
+
1077
+ const toolString = _aggregateToolString(runResult);
1078
+ const repos_scanned = rr.filter(r => r.outcome !== 'no_manifests').length;
1079
+ const duration = Math.round(rr.reduce((s, r) => s + (r.durationMs || 0), 0) / 1000);
1080
+
1081
+ const payload = {
1082
+ type: 'package-scan',
1083
+ date: nowDate.toISOString().slice(0, 10),
1084
+ tool: toolString,
1085
+ // UAT Bug 2: forward snyk_org from runScan result. Null when unset.
1086
+ snyk_org: (runResult.snyk_org === undefined ? null : runResult.snyk_org),
1087
+ repos_scanned,
1088
+ critical: _countSeverity(findings, 'critical'),
1089
+ high: _countSeverity(findings, 'high'),
1090
+ medium: _countSeverity(findings, 'medium'),
1091
+ low: _countSeverity(findings, 'low'),
1092
+ duration,
1093
+ findings: findings.flatMap(_canonicalFindingsForAdapter),
1094
+ };
1095
+
1096
+ let frontmatter;
1097
+ try {
1098
+ frontmatter = _emitYamlFrontmatter(payload);
1099
+ } catch (err) {
1100
+ throw new Error('YAML emitter: ' + (err && err.message ? err.message : String(err)));
1101
+ }
1102
+
1103
+ const body = _renderBody(runResult);
1104
+ const reportPath = opts.overridePath || _resolveReportPath(cwd, { now: opts.now });
1105
+ fs.mkdirSync(path.dirname(reportPath), { recursive: true });
1106
+ const content = frontmatter + '\n' + body + '\n';
1107
+ fs.writeFileSync(reportPath, content);
1108
+
1109
+ return {
1110
+ path: reportPath,
1111
+ tool_string: toolString,
1112
+ n_findings: findings.length,
1113
+ };
1114
+ }
1115
+
1116
+ module.exports = {
1117
+ writePackageScanReport,
1118
+ _emitYamlFrontmatter,
1119
+ _parseEmittedYaml,
1120
+ _renderBody,
1121
+ _resolveReportPath,
1122
+ // Internal helpers exported for symmetry with tests.
1123
+ _aggregateToolString,
1124
+ _countSeverity,
1125
+ _collapseSeverity,
1126
+ _mapAdapterFindingToCanonical,
1127
+ _canonicalFindingsForAdapter,
1128
+ _mapLicenceToSeverity,
1129
+ // Phase 152: canonical normaliser tables exported for test-gate consumers.
1130
+ SEVERITY_MAP,
1131
+ LICENCE_SEVERITY_MAP,
1132
+ // Phase 153 PKG-30/34: Licence Compliance section renderer.
1133
+ _renderLicenceComplianceSection,
1134
+ _licenceRosterFlag,
1135
+ // Phase 153 PKG-35/36: cross-repo dedup + version overlap.
1136
+ _groupFindingsByPackageVersion,
1137
+ _detectVersionOverlaps,
1138
+ _renderCrossRepoSummary,
1139
+ _renderVersionOverlap,
1140
+ };