@ktpartners/dgs-platform 2.9.0 → 3.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +197 -0
- package/README.md +34 -2
- package/agents/dgs-executor.md +124 -3
- package/agents/dgs-idea-researcher.md +447 -0
- package/agents/dgs-plan-checker.md +61 -3
- package/agents/dgs-planner.md +51 -8
- package/bin/install.js +44 -0
- package/commands/dgs/abandon-quick.md +28 -0
- package/commands/dgs/add-tests.md +2 -2
- package/commands/dgs/audit-milestone.md +4 -3
- package/commands/dgs/capture-principle.md +11 -11
- package/commands/dgs/cleanup.md +2 -2
- package/commands/dgs/complete-milestone.md +11 -11
- package/commands/dgs/complete-quick.md +28 -0
- package/commands/dgs/create-milestone-job.md +2 -2
- package/commands/dgs/debug.md +3 -3
- package/commands/dgs/develop-idea.md +1 -1
- package/commands/dgs/diff-report.md +124 -0
- package/commands/dgs/fast.md +3 -1
- package/commands/dgs/health.md +1 -1
- package/commands/dgs/map-codebase.md +6 -6
- package/commands/dgs/new-milestone.md +5 -5
- package/commands/dgs/new-project.md +8 -21
- package/commands/dgs/package-scan.md +43 -0
- package/commands/dgs/plan-milestone-gaps.md +1 -1
- package/commands/dgs/progress.md +3 -3
- package/commands/dgs/quick-abandon.md +8 -0
- package/commands/dgs/quick-complete.md +8 -0
- package/commands/dgs/quick.md +10 -3
- package/commands/dgs/research-idea.md +3 -2
- package/commands/dgs/research-phase.md +3 -3
- package/commands/dgs/switch-project.md +14 -1
- package/commands/dgs/write-spec.md +3 -3
- package/deliver-great-systems/bin/dgs-tools.cjs +401 -32
- package/deliver-great-systems/bin/lib/audit-tolerance.cjs +77 -0
- package/deliver-great-systems/bin/lib/audit-tolerance.test.cjs +101 -0
- package/deliver-great-systems/bin/lib/commands.cjs +626 -46
- package/deliver-great-systems/bin/lib/commands.test.cjs +451 -0
- package/deliver-great-systems/bin/lib/commit-verify.test.cjs +236 -0
- package/deliver-great-systems/bin/lib/config.cjs +80 -6
- package/deliver-great-systems/bin/lib/config.test.cjs +309 -0
- package/deliver-great-systems/bin/lib/context.cjs +120 -0
- package/deliver-great-systems/bin/lib/core.cjs +35 -14
- package/deliver-great-systems/bin/lib/core.test.cjs +79 -1
- package/deliver-great-systems/bin/lib/execution.cjs +49 -17
- package/deliver-great-systems/bin/lib/fast-routing.cjs +199 -0
- package/deliver-great-systems/bin/lib/fast-routing.test.cjs +108 -0
- package/deliver-great-systems/bin/lib/final-commit-precondition.test.cjs +87 -0
- package/deliver-great-systems/bin/lib/fixtures/package-scan/bundler-audit-gemfile.json +21 -0
- package/deliver-great-systems/bin/lib/fixtures/package-scan/gate-parity-expected.md +186 -0
- package/deliver-great-systems/bin/lib/fixtures/package-scan/gate-parity-runresult.json +235 -0
- package/deliver-great-systems/bin/lib/fixtures/package-scan/govulncheck-import.json +3 -0
- package/deliver-great-systems/bin/lib/fixtures/package-scan/npm-audit-v10.json +37 -0
- package/deliver-great-systems/bin/lib/fixtures/package-scan/osv-clean.json +3 -0
- package/deliver-great-systems/bin/lib/fixtures/package-scan/osv-vulns.json +77 -0
- package/deliver-great-systems/bin/lib/fixtures/package-scan/pip-audit-requirements.json +28 -0
- package/deliver-great-systems/bin/lib/fixtures/package-scan/snyk-lodash.json +30 -0
- package/deliver-great-systems/bin/lib/fixtures/package-scan/snyk-workspaces.json +55 -0
- package/deliver-great-systems/bin/lib/flat-migration.test.cjs +396 -0
- package/deliver-great-systems/bin/lib/frontmatter.cjs +1 -1
- package/deliver-great-systems/bin/lib/governance.cjs +211 -0
- package/deliver-great-systems/bin/lib/governance.test.cjs +339 -0
- package/deliver-great-systems/bin/lib/health-untracked-phase.test.cjs +269 -0
- package/deliver-great-systems/bin/lib/ideas.cjs +206 -91
- package/deliver-great-systems/bin/lib/ideas.test.cjs +244 -1
- package/deliver-great-systems/bin/lib/init.cjs +357 -61
- package/deliver-great-systems/bin/lib/init.test.cjs +625 -8
- package/deliver-great-systems/bin/lib/jobs.cjs +131 -25
- package/deliver-great-systems/bin/lib/jobs.test.cjs +193 -74
- package/deliver-great-systems/bin/lib/migration.cjs +409 -1
- package/deliver-great-systems/bin/lib/migration.test.cjs +158 -1
- package/deliver-great-systems/bin/lib/milestone.cjs +154 -31
- package/deliver-great-systems/bin/lib/milestone.test.cjs +203 -0
- package/deliver-great-systems/bin/lib/package-adapters.cjs +530 -0
- package/deliver-great-systems/bin/lib/package-adapters.test.cjs +618 -0
- package/deliver-great-systems/bin/lib/package-ecosystems.cjs +350 -0
- package/deliver-great-systems/bin/lib/package-ecosystems.test.cjs +348 -0
- package/deliver-great-systems/bin/lib/package-runner.cjs +199 -0
- package/deliver-great-systems/bin/lib/package-runner.test.cjs +198 -0
- package/deliver-great-systems/bin/lib/package-scan-provenance.cjs +56 -0
- package/deliver-great-systems/bin/lib/package-scan-provenance.test.cjs +103 -0
- package/deliver-great-systems/bin/lib/package-scan-report.cjs +1140 -0
- package/deliver-great-systems/bin/lib/package-scan-report.test.cjs +1963 -0
- package/deliver-great-systems/bin/lib/package-scan-skill.cjs +96 -0
- package/deliver-great-systems/bin/lib/package-scan-skill.test.cjs +136 -0
- package/deliver-great-systems/bin/lib/package-scan.cjs +919 -0
- package/deliver-great-systems/bin/lib/package-scan.test.cjs +2147 -0
- package/deliver-great-systems/bin/lib/phase.cjs +146 -3
- package/deliver-great-systems/bin/lib/phase.test.cjs +420 -0
- package/deliver-great-systems/bin/lib/plan-number-validity.test.cjs +48 -0
- package/deliver-great-systems/bin/lib/projects.cjs +65 -10
- package/deliver-great-systems/bin/lib/projects.test.cjs +198 -2
- package/deliver-great-systems/bin/lib/quick.cjs +739 -0
- package/deliver-great-systems/bin/lib/quick.test.cjs +730 -0
- package/deliver-great-systems/bin/lib/repos.cjs +37 -13
- package/deliver-great-systems/bin/lib/review.cjs +1821 -0
- package/deliver-great-systems/bin/lib/roadmap.cjs +34 -13
- package/deliver-great-systems/bin/lib/specs.cjs +3 -81
- package/deliver-great-systems/bin/lib/state-transition-gate.test.cjs +160 -0
- package/deliver-great-systems/bin/lib/state.cjs +147 -55
- package/deliver-great-systems/bin/lib/summary-frontmatter.cjs +54 -0
- package/deliver-great-systems/bin/lib/summary-frontmatter.test.cjs +78 -0
- package/deliver-great-systems/bin/lib/sweep-scope.test.cjs +263 -0
- package/deliver-great-systems/bin/lib/sync.cjs +75 -0
- package/deliver-great-systems/bin/lib/verify.cjs +198 -7
- package/deliver-great-systems/bin/lib/verify.test.cjs +82 -0
- package/deliver-great-systems/bin/lib/wave-0-template-rename.test.cjs +40 -0
- package/deliver-great-systems/bin/lib/worktrees.cjs +790 -0
- package/deliver-great-systems/bin/lib/worktrees.test.cjs +963 -0
- package/deliver-great-systems/references/agent-step-reliability.md +60 -0
- package/deliver-great-systems/references/conflict-resolution.md +4 -0
- package/deliver-great-systems/references/context-tiers.md +4 -0
- package/deliver-great-systems/references/package-scan-config.md +151 -0
- package/deliver-great-systems/references/questioning.md +0 -30
- package/deliver-great-systems/references/spec-review-loop.md +1 -2
- package/deliver-great-systems/references/workflow-conventions.md +29 -0
- package/deliver-great-systems/skills/dgs-tests/package-scan.md +44 -0
- package/deliver-great-systems/templates/REVIEW.md +35 -0
- package/deliver-great-systems/templates/VALIDATION.md +1 -1
- package/deliver-great-systems/templates/claude-md.md +27 -0
- package/deliver-great-systems/templates/package-scan-report.md +108 -0
- package/deliver-great-systems/templates/project.md +6 -170
- package/deliver-great-systems/templates/summary.md +3 -1
- package/deliver-great-systems/workflows/abandon-quick.md +89 -0
- package/deliver-great-systems/workflows/add-idea.md +3 -3
- package/deliver-great-systems/workflows/add-phase.md +5 -0
- package/deliver-great-systems/workflows/add-tests.md +14 -0
- package/deliver-great-systems/workflows/add-todo.md +1 -0
- package/deliver-great-systems/workflows/approve-spec.md +25 -4
- package/deliver-great-systems/workflows/audit-milestone.md +66 -10
- package/deliver-great-systems/workflows/audit-phase.md +15 -5
- package/deliver-great-systems/workflows/cancel-job.md +2 -2
- package/deliver-great-systems/workflows/check-todos.md +2 -3
- package/deliver-great-systems/workflows/codereview.md +103 -9
- package/deliver-great-systems/workflows/complete-milestone.md +218 -24
- package/deliver-great-systems/workflows/complete-quick.md +106 -0
- package/deliver-great-systems/workflows/consolidate-ideas.md +1 -1
- package/deliver-great-systems/workflows/create-milestone-job.md +4 -4
- package/deliver-great-systems/workflows/develop-idea.md +11 -11
- package/deliver-great-systems/workflows/diagnose-issues.md +14 -0
- package/deliver-great-systems/workflows/discuss-idea.md +1 -1
- package/deliver-great-systems/workflows/discuss-phase.md +3 -2
- package/deliver-great-systems/workflows/execute-phase.md +209 -33
- package/deliver-great-systems/workflows/execute-plan.md +22 -22
- package/deliver-great-systems/workflows/help.md +53 -20
- package/deliver-great-systems/workflows/import-spec.md +65 -7
- package/deliver-great-systems/workflows/init-product.md +45 -167
- package/deliver-great-systems/workflows/new-milestone.md +140 -33
- package/deliver-great-systems/workflows/new-project.md +60 -331
- package/deliver-great-systems/workflows/package-scan.md +59 -0
- package/deliver-great-systems/workflows/plan-phase.md +79 -1
- package/deliver-great-systems/workflows/progress-all.md +133 -0
- package/deliver-great-systems/workflows/quick-abandon.md +89 -0
- package/deliver-great-systems/workflows/quick-complete.md +106 -0
- package/deliver-great-systems/workflows/quick.md +328 -26
- package/deliver-great-systems/workflows/refine-spec.md +1 -1
- package/deliver-great-systems/workflows/research-idea.md +77 -139
- package/deliver-great-systems/workflows/resume-project.md +2 -2
- package/deliver-great-systems/workflows/run-job.md +29 -43
- package/deliver-great-systems/workflows/settings.md +13 -77
- package/deliver-great-systems/workflows/validate-phase.md +39 -1
- package/deliver-great-systems/workflows/verify-work.md +14 -0
- package/deliver-great-systems/workflows/write-spec.md +11 -13
- package/hooks/dist/dgs-enforce-discipline.js +196 -0
- package/package.json +1 -1
- package/scripts/build-hooks.js +1 -0
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* package-ecosystems.test.cjs -- Unit tests for ecosystem detection
|
|
3
|
+
*
|
|
4
|
+
* Covers PKG-08 (manifest detection), PKG-09 (empty-result), PKG-10 (provenance),
|
|
5
|
+
* PKG-31 (monorepo workspace expansion with manifest_path).
|
|
6
|
+
*/
|
|
7
|
+
'use strict';
|
|
8
|
+
const { test, describe, beforeEach, afterEach } = require('node:test');
|
|
9
|
+
const assert = require('node:assert');
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const os = require('os');
|
|
13
|
+
const { detectEcosystems, ECOSYSTEM_MANIFESTS } = require('./package-ecosystems.cjs');
|
|
14
|
+
|
|
15
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
function writeFile(dir, rel, content) {
|
|
18
|
+
const abs = path.join(dir, rel);
|
|
19
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
20
|
+
fs.writeFileSync(abs, content);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function makeNodeRepo(tmp, opts = {}) {
|
|
24
|
+
const pkg = Object.assign({ name: 'simple', version: '1.0.0' }, opts.pkgExtra || {});
|
|
25
|
+
writeFile(tmp, 'package.json', JSON.stringify(pkg, null, 2));
|
|
26
|
+
if (opts.packageLock !== false) {
|
|
27
|
+
writeFile(tmp, 'package-lock.json', '{}');
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function makeWorkspaceRepo(tmp, { workspaces, rootDeps, useYarnField, extraWsFiles }) {
|
|
32
|
+
const rootPkg = {
|
|
33
|
+
name: 'monorepo',
|
|
34
|
+
version: '1.0.0',
|
|
35
|
+
private: true,
|
|
36
|
+
};
|
|
37
|
+
if (useYarnField) {
|
|
38
|
+
// Yarn-style plain array (same as npm)
|
|
39
|
+
rootPkg.workspaces = workspaces;
|
|
40
|
+
} else {
|
|
41
|
+
rootPkg.workspaces = workspaces;
|
|
42
|
+
}
|
|
43
|
+
if (rootDeps) {
|
|
44
|
+
rootPkg.dependencies = { ...rootDeps };
|
|
45
|
+
}
|
|
46
|
+
writeFile(tmp, 'package.json', JSON.stringify(rootPkg, null, 2));
|
|
47
|
+
const wsDirs = Array.isArray(workspaces) ? workspaces : (workspaces && workspaces.packages) || [];
|
|
48
|
+
for (const wsPath of wsDirs) {
|
|
49
|
+
const wsName = wsPath.replace(/\*/g, 'mock');
|
|
50
|
+
// For glob patterns like `packages/*`, create `packages/api/...` and `packages/web/...`
|
|
51
|
+
if (wsPath.endsWith('/*')) {
|
|
52
|
+
const baseDir = wsPath.replace(/\/\*$/, '');
|
|
53
|
+
writeFile(tmp, `${baseDir}/api/package.json`, JSON.stringify({ name: 'api', version: '1.0.0' }));
|
|
54
|
+
writeFile(tmp, `${baseDir}/web/package.json`, JSON.stringify({ name: 'web', version: '1.0.0' }));
|
|
55
|
+
} else {
|
|
56
|
+
writeFile(tmp, `${wsPath}/package.json`, JSON.stringify({ name: wsName, version: '1.0.0' }));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (extraWsFiles) {
|
|
60
|
+
for (const [rel, body] of Object.entries(extraWsFiles)) {
|
|
61
|
+
writeFile(tmp, rel, body);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function makeMavenRepo(tmp, { modules, rootDeps, missing }) {
|
|
67
|
+
const moduleXml = (modules || []).map(m => ` <module>${m}</module>`).join('\n');
|
|
68
|
+
const depsXml = rootDeps
|
|
69
|
+
? `
|
|
70
|
+
<dependencies>
|
|
71
|
+
<dependency>
|
|
72
|
+
<groupId>org.example</groupId>
|
|
73
|
+
<artifactId>lib</artifactId>
|
|
74
|
+
<version>1.0.0</version>
|
|
75
|
+
</dependency>
|
|
76
|
+
</dependencies>`
|
|
77
|
+
: '';
|
|
78
|
+
const modulesBlock = modules && modules.length ? `
|
|
79
|
+
<modules>
|
|
80
|
+
${moduleXml}
|
|
81
|
+
</modules>` : '';
|
|
82
|
+
const rootPom = `<?xml version="1.0" encoding="UTF-8"?>
|
|
83
|
+
<project>
|
|
84
|
+
<modelVersion>4.0.0</modelVersion>
|
|
85
|
+
<groupId>org.example</groupId>
|
|
86
|
+
<artifactId>parent</artifactId>
|
|
87
|
+
<version>1.0.0</version>
|
|
88
|
+
<packaging>pom</packaging>${modulesBlock}${depsXml}
|
|
89
|
+
</project>`;
|
|
90
|
+
writeFile(tmp, 'pom.xml', rootPom);
|
|
91
|
+
for (const m of (modules || [])) {
|
|
92
|
+
if (missing && missing.includes(m)) continue;
|
|
93
|
+
const childPom = `<?xml version="1.0" encoding="UTF-8"?>
|
|
94
|
+
<project>
|
|
95
|
+
<modelVersion>4.0.0</modelVersion>
|
|
96
|
+
<artifactId>${m}</artifactId>
|
|
97
|
+
</project>`;
|
|
98
|
+
writeFile(tmp, `${m}/pom.xml`, childPom);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function makeGoWorkspaceRepo(tmp, { modules, includeRootGoMod }) {
|
|
103
|
+
const useBlock = modules.length > 0
|
|
104
|
+
? `use (\n${modules.map(m => ` ${m}`).join('\n')}\n)\n`
|
|
105
|
+
: '';
|
|
106
|
+
writeFile(tmp, 'go.work', `go 1.21\n\n${useBlock}`);
|
|
107
|
+
if (includeRootGoMod) {
|
|
108
|
+
writeFile(tmp, 'go.mod', 'module example.com/root\n');
|
|
109
|
+
}
|
|
110
|
+
for (const m of modules) {
|
|
111
|
+
const dir = m.replace(/^\.\//, '');
|
|
112
|
+
writeFile(tmp, `${dir}/go.mod`, `module example.com/${dir.replace(/\//g, '-')}\n`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const ALLOWED_ECOSYSTEMS = new Set(['node', 'python', 'go', 'ruby', 'java']);
|
|
117
|
+
|
|
118
|
+
// ─── Tests ───────────────────────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
describe('detectEcosystems - simple repos (PKG-08)', () => {
|
|
121
|
+
let tmpDir;
|
|
122
|
+
beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dgs-ecos-')); });
|
|
123
|
+
afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); });
|
|
124
|
+
|
|
125
|
+
test('detects node repo with package.json + package-lock.json', () => {
|
|
126
|
+
makeNodeRepo(tmpDir);
|
|
127
|
+
assert.deepStrictEqual(detectEcosystems(tmpDir), [
|
|
128
|
+
{ ecosystem: 'node', manifest_path: null },
|
|
129
|
+
]);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('detects node repo with yarn.lock', () => {
|
|
133
|
+
writeFile(tmpDir, 'package.json', JSON.stringify({ name: 'x', version: '1.0.0' }));
|
|
134
|
+
writeFile(tmpDir, 'yarn.lock', '# yarn\n');
|
|
135
|
+
assert.deepStrictEqual(detectEcosystems(tmpDir), [
|
|
136
|
+
{ ecosystem: 'node', manifest_path: null },
|
|
137
|
+
]);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('detects python repo with requirements.txt', () => {
|
|
141
|
+
writeFile(tmpDir, 'requirements.txt', 'requests==2.20.0\n');
|
|
142
|
+
assert.deepStrictEqual(detectEcosystems(tmpDir), [
|
|
143
|
+
{ ecosystem: 'python', manifest_path: null },
|
|
144
|
+
]);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('detects python repo with poetry.lock', () => {
|
|
148
|
+
writeFile(tmpDir, 'pyproject.toml', '[tool.poetry]\nname="x"\n');
|
|
149
|
+
writeFile(tmpDir, 'poetry.lock', '# generated\n');
|
|
150
|
+
assert.deepStrictEqual(detectEcosystems(tmpDir), [
|
|
151
|
+
{ ecosystem: 'python', manifest_path: null },
|
|
152
|
+
]);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('detects go repo with go.mod', () => {
|
|
156
|
+
writeFile(tmpDir, 'go.mod', 'module example.com/x\n');
|
|
157
|
+
assert.deepStrictEqual(detectEcosystems(tmpDir), [
|
|
158
|
+
{ ecosystem: 'go', manifest_path: null },
|
|
159
|
+
]);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('detects ruby repo with Gemfile.lock', () => {
|
|
163
|
+
writeFile(tmpDir, 'Gemfile', "source 'https://rubygems.org'\n");
|
|
164
|
+
writeFile(tmpDir, 'Gemfile.lock', 'GEM\n');
|
|
165
|
+
assert.deepStrictEqual(detectEcosystems(tmpDir), [
|
|
166
|
+
{ ecosystem: 'ruby', manifest_path: null },
|
|
167
|
+
]);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('detects java repo with pom.xml', () => {
|
|
171
|
+
writeFile(tmpDir, 'pom.xml', '<?xml version="1.0"?>\n<project><modelVersion>4.0.0</modelVersion></project>\n');
|
|
172
|
+
assert.deepStrictEqual(detectEcosystems(tmpDir), [
|
|
173
|
+
{ ecosystem: 'java', manifest_path: null },
|
|
174
|
+
]);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test('mixed node + python at root emits two entries, both manifest_path:null', () => {
|
|
178
|
+
makeNodeRepo(tmpDir);
|
|
179
|
+
writeFile(tmpDir, 'requirements.txt', 'requests==2.20.0\n');
|
|
180
|
+
const result = detectEcosystems(tmpDir);
|
|
181
|
+
assert.strictEqual(result.length, 2);
|
|
182
|
+
assert.deepStrictEqual(result, [
|
|
183
|
+
{ ecosystem: 'node', manifest_path: null },
|
|
184
|
+
{ ecosystem: 'python', manifest_path: null },
|
|
185
|
+
]);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
describe('detectEcosystems - empty case (PKG-09)', () => {
|
|
190
|
+
let tmpDir;
|
|
191
|
+
beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dgs-ecos-')); });
|
|
192
|
+
afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); });
|
|
193
|
+
|
|
194
|
+
test('repo with no recognised manifest returns []', () => {
|
|
195
|
+
writeFile(tmpDir, 'README.md', '# readme');
|
|
196
|
+
writeFile(tmpDir, 'Makefile', 'all:\n\ttrue\n');
|
|
197
|
+
assert.deepStrictEqual(detectEcosystems(tmpDir), []);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test('empty directory returns []', () => {
|
|
201
|
+
assert.deepStrictEqual(detectEcosystems(tmpDir), []);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('repo with only src/pyproject.toml (PKG-42 deferred) returns []', () => {
|
|
205
|
+
writeFile(tmpDir, 'src/pyproject.toml', '[project]\nname="x"\n');
|
|
206
|
+
assert.deepStrictEqual(detectEcosystems(tmpDir), []);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
describe('detectEcosystems - ecosystem provenance (PKG-10)', () => {
|
|
211
|
+
let tmpDir;
|
|
212
|
+
beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dgs-ecos-')); });
|
|
213
|
+
afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); });
|
|
214
|
+
|
|
215
|
+
test('every entry has exactly one ecosystem field drawn from the allow-list', () => {
|
|
216
|
+
makeNodeRepo(tmpDir);
|
|
217
|
+
writeFile(tmpDir, 'go.mod', 'module example.com/x\n');
|
|
218
|
+
writeFile(tmpDir, 'Gemfile', "source 'https://rubygems.org'\n");
|
|
219
|
+
writeFile(tmpDir, 'pom.xml', '<?xml version="1.0"?>\n<project><modelVersion>4.0.0</modelVersion></project>\n');
|
|
220
|
+
writeFile(tmpDir, 'requirements.txt', 'x\n');
|
|
221
|
+
const result = detectEcosystems(tmpDir);
|
|
222
|
+
assert.ok(result.length >= 5);
|
|
223
|
+
for (const entry of result) {
|
|
224
|
+
assert.ok(ALLOWED_ECOSYSTEMS.has(entry.ecosystem), `bad ecosystem: ${entry.ecosystem}`);
|
|
225
|
+
assert.ok('manifest_path' in entry);
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
describe('detectEcosystems - monorepo workspaces (PKG-31)', () => {
|
|
231
|
+
let tmpDir;
|
|
232
|
+
beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dgs-ecos-')); });
|
|
233
|
+
afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); });
|
|
234
|
+
|
|
235
|
+
test('npm workspaces via packages/*/package.json glob emits one entry per workspace', () => {
|
|
236
|
+
makeWorkspaceRepo(tmpDir, { workspaces: ['packages/*'] });
|
|
237
|
+
const result = detectEcosystems(tmpDir);
|
|
238
|
+
const wsEntries = result.filter(e => e.manifest_path);
|
|
239
|
+
const paths = wsEntries.map(e => e.manifest_path).sort();
|
|
240
|
+
assert.deepStrictEqual(paths, ['packages/api/package.json', 'packages/web/package.json']);
|
|
241
|
+
for (const e of wsEntries) assert.strictEqual(e.ecosystem, 'node');
|
|
242
|
+
// No root entry because no root deps
|
|
243
|
+
assert.strictEqual(result.length, 2);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test('pnpm workspaces declared in pnpm-workspace.yaml emits one entry per declared workspace', () => {
|
|
247
|
+
writeFile(tmpDir, 'package.json', JSON.stringify({ name: 'root', private: true }));
|
|
248
|
+
writeFile(tmpDir, 'pnpm-workspace.yaml', "packages:\n - 'apps/*'\n");
|
|
249
|
+
writeFile(tmpDir, 'apps/api/package.json', JSON.stringify({ name: 'api' }));
|
|
250
|
+
writeFile(tmpDir, 'apps/web/package.json', JSON.stringify({ name: 'web' }));
|
|
251
|
+
const result = detectEcosystems(tmpDir);
|
|
252
|
+
const paths = result.map(e => e.manifest_path).filter(Boolean).sort();
|
|
253
|
+
assert.deepStrictEqual(paths, ['apps/api/package.json', 'apps/web/package.json']);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test('Yarn workspaces declared in root package.json workspaces field emits one entry per workspace', () => {
|
|
257
|
+
// Yarn array form is identical to npm — a plain array under `workspaces`.
|
|
258
|
+
makeWorkspaceRepo(tmpDir, { workspaces: ['services/*'], useYarnField: true });
|
|
259
|
+
const result = detectEcosystems(tmpDir);
|
|
260
|
+
const paths = result.map(e => e.manifest_path).filter(Boolean).sort();
|
|
261
|
+
assert.deepStrictEqual(paths, ['services/api/package.json', 'services/web/package.json']);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test('npm workspace root with root-level dependencies emits root entry + workspace entries', () => {
|
|
265
|
+
makeWorkspaceRepo(tmpDir, { workspaces: ['packages/*'], rootDeps: { lodash: '^4' } });
|
|
266
|
+
const result = detectEcosystems(tmpDir);
|
|
267
|
+
const paths = result.map(e => e.manifest_path);
|
|
268
|
+
assert.ok(paths.includes('package.json'), 'root package.json entry expected');
|
|
269
|
+
assert.ok(paths.includes('packages/api/package.json'));
|
|
270
|
+
assert.ok(paths.includes('packages/web/package.json'));
|
|
271
|
+
assert.strictEqual(result.length, 3);
|
|
272
|
+
// Root entry LAST in its ecosystem group
|
|
273
|
+
assert.strictEqual(result[result.length - 1].manifest_path, 'package.json');
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test('npm workspace root without root-level dependencies emits workspace entries only', () => {
|
|
277
|
+
makeWorkspaceRepo(tmpDir, { workspaces: ['packages/*'] });
|
|
278
|
+
const result = detectEcosystems(tmpDir);
|
|
279
|
+
const paths = result.map(e => e.manifest_path);
|
|
280
|
+
assert.ok(!paths.includes('package.json'));
|
|
281
|
+
assert.strictEqual(result.length, 2);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test('Maven <modules> declares api, web -> emits api/pom.xml + web/pom.xml entries', () => {
|
|
285
|
+
makeMavenRepo(tmpDir, { modules: ['api', 'web'] });
|
|
286
|
+
const result = detectEcosystems(tmpDir);
|
|
287
|
+
const paths = result.map(e => e.manifest_path).sort((a, b) => (a === null) - (b === null) || a.localeCompare(b));
|
|
288
|
+
assert.deepStrictEqual(
|
|
289
|
+
result.map(e => e.manifest_path),
|
|
290
|
+
['api/pom.xml', 'web/pom.xml']
|
|
291
|
+
);
|
|
292
|
+
assert.strictEqual(result.length, 2);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test('Maven <modules> plus root <dependencies> -> emits root pom.xml entry alongside workspace entries', () => {
|
|
296
|
+
makeMavenRepo(tmpDir, { modules: ['api', 'web'], rootDeps: true });
|
|
297
|
+
const result = detectEcosystems(tmpDir);
|
|
298
|
+
const paths = result.map(e => e.manifest_path);
|
|
299
|
+
assert.ok(paths.includes('api/pom.xml'));
|
|
300
|
+
assert.ok(paths.includes('web/pom.xml'));
|
|
301
|
+
assert.ok(paths.includes('pom.xml'));
|
|
302
|
+
assert.strictEqual(paths[paths.length - 1], 'pom.xml'); // root entry LAST
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test('Maven <modules> declaring a module not present on disk is skipped without throwing', () => {
|
|
306
|
+
makeMavenRepo(tmpDir, { modules: ['api', 'ghost'], missing: ['ghost'] });
|
|
307
|
+
const result = detectEcosystems(tmpDir);
|
|
308
|
+
const paths = result.map(e => e.manifest_path);
|
|
309
|
+
assert.deepStrictEqual(paths, ['api/pom.xml']);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test('Go workspace with use (./api ./worker) emits api/go.mod + worker/go.mod, no root entry', () => {
|
|
313
|
+
makeGoWorkspaceRepo(tmpDir, { modules: ['./api', './worker'], includeRootGoMod: true });
|
|
314
|
+
const result = detectEcosystems(tmpDir);
|
|
315
|
+
assert.strictEqual(result.length, 2);
|
|
316
|
+
const paths = result.map(e => e.manifest_path).sort();
|
|
317
|
+
assert.deepStrictEqual(paths, ['api/go.mod', 'worker/go.mod']);
|
|
318
|
+
// No root entry even when root has go.mod
|
|
319
|
+
assert.ok(!paths.some(p => p === null || p === 'go.mod'));
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test('Gradle-only repo with build.gradle.kts emits single { java, manifest_path: null }', () => {
|
|
323
|
+
writeFile(tmpDir, 'build.gradle.kts', '// gradle\n');
|
|
324
|
+
writeFile(tmpDir, 'settings.gradle.kts', 'include("api", "web")\n'); // should be IGNORED (PKG-41 deferred)
|
|
325
|
+
writeFile(tmpDir, 'api/build.gradle.kts', '// subproject\n');
|
|
326
|
+
const result = detectEcosystems(tmpDir);
|
|
327
|
+
assert.deepStrictEqual(result, [{ ecosystem: 'java', manifest_path: null }]);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test('manifest_path is always repo-relative POSIX (forward slashes)', () => {
|
|
331
|
+
makeWorkspaceRepo(tmpDir, { workspaces: ['packages/*'] });
|
|
332
|
+
const result = detectEcosystems(tmpDir);
|
|
333
|
+
for (const e of result) {
|
|
334
|
+
if (e.manifest_path) {
|
|
335
|
+
assert.ok(!e.manifest_path.includes('\\'), `backslash in manifest_path: ${e.manifest_path}`);
|
|
336
|
+
assert.ok(!path.isAbsolute(e.manifest_path), `absolute path: ${e.manifest_path}`);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
describe('detectEcosystems - module exports', () => {
|
|
343
|
+
test('ECOSYSTEM_MANIFESTS is exported and has all 5 ecosystems', () => {
|
|
344
|
+
assert.ok(Array.isArray(ECOSYSTEM_MANIFESTS));
|
|
345
|
+
const ecos = ECOSYSTEM_MANIFESTS.map(e => e.eco).sort();
|
|
346
|
+
assert.deepStrictEqual(ecos, ['go', 'java', 'node', 'python', 'ruby']);
|
|
347
|
+
});
|
|
348
|
+
});
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* package-runner.cjs -- Exit-code-aware external security tool runner
|
|
3
|
+
*
|
|
4
|
+
* Wraps spawnSync with a 50 MB maxBuffer and a per-tool FINDINGS_EXIT_CODES
|
|
5
|
+
* allow-list, distinguishing ok / tool_failure / missing / timeout /
|
|
6
|
+
* no_native_tool_for_ecosystem outcomes. Modelled on core.execGit but
|
|
7
|
+
* extended for the additional outcomes security tools require.
|
|
8
|
+
*
|
|
9
|
+
* Exports:
|
|
10
|
+
* runTool(cwd, toolKey, argv, opts) -- returns structured outcome
|
|
11
|
+
* FINDINGS_EXIT_CODES -- per-tool findings-exit allow-list
|
|
12
|
+
* ECOSYSTEM_OVERRIDES -- tool-for-ecosystem forcing (yarn -> osv)
|
|
13
|
+
* DEFAULT_TIMEOUT_MS, DEFAULT_MAX_BUFFER
|
|
14
|
+
*
|
|
15
|
+
* Covers roadmap requirement PKG-11 exit-code layer. Does NOT own JSON
|
|
16
|
+
* structural validation (adapters do that). Does NOT read config
|
|
17
|
+
* (orchestrator passes timeoutMs via opts).
|
|
18
|
+
*
|
|
19
|
+
* SPAWNSYNC ONLY: the shell-based sync helpers in child_process have a 1 MiB
|
|
20
|
+
* maxBuffer default that silently truncates multi-MB JSON at brace boundaries.
|
|
21
|
+
* This module uses spawnSync with an explicit 50 MB maxBuffer. Enforced by
|
|
22
|
+
* test: the corresponding invariant is gated in package-runner.test.cjs.
|
|
23
|
+
*/
|
|
24
|
+
'use strict';
|
|
25
|
+
const { spawnSync } = require('child_process');
|
|
26
|
+
|
|
27
|
+
const DEFAULT_TIMEOUT_MS = 300_000;
|
|
28
|
+
const DEFAULT_MAX_BUFFER = 50 * 1024 * 1024;
|
|
29
|
+
|
|
30
|
+
const FINDINGS_EXIT_CODES = Object.freeze({
|
|
31
|
+
snyk: Object.freeze([1]),
|
|
32
|
+
'npm-audit': Object.freeze([1]),
|
|
33
|
+
'osv-scanner': Object.freeze([1, 128]),
|
|
34
|
+
'pip-audit': Object.freeze([1]),
|
|
35
|
+
govulncheck: Object.freeze([]),
|
|
36
|
+
'bundler-audit': Object.freeze([1]),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const ECOSYSTEM_OVERRIDES = Object.freeze({
|
|
40
|
+
yarn: Object.freeze({ force_tool: 'osv', reason: 'Yarn Berry NDJSON schema instability' }),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// ─── Internal helpers ─────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Classify a process exit code against a tool's FINDINGS_EXIT_CODES allow-list.
|
|
47
|
+
*
|
|
48
|
+
* The three branches, in priority order:
|
|
49
|
+
* - 127 -> missing (shell convention for "command not found")
|
|
50
|
+
* - 0 -> ok (success, nothing to report)
|
|
51
|
+
* - allow-list hit -> ok (tool-specific findings-present exit code)
|
|
52
|
+
* - otherwise -> tool_failure
|
|
53
|
+
*
|
|
54
|
+
* Extracted as a pure helper so tests can exercise the classifier directly
|
|
55
|
+
* once the runner lands (not part of the public API — kept private).
|
|
56
|
+
*
|
|
57
|
+
* @param {number} exitCode
|
|
58
|
+
* @param {string} toolKey
|
|
59
|
+
* @returns {'ok'|'missing'|'tool_failure'}
|
|
60
|
+
*/
|
|
61
|
+
function _classifyExitCode(exitCode, toolKey) {
|
|
62
|
+
if (exitCode === 127) return 'missing';
|
|
63
|
+
if (exitCode === 0) return 'ok';
|
|
64
|
+
const allowed = FINDINGS_EXIT_CODES[toolKey] || [];
|
|
65
|
+
if (allowed.includes(exitCode)) return 'ok';
|
|
66
|
+
return 'tool_failure';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Detect whether a spawnSync result represents a missing-binary failure.
|
|
71
|
+
* node surfaces ENOENT via `result.error.code === 'ENOENT'` on POSIX and
|
|
72
|
+
* via `result.error.message` containing `ENOENT` on Windows; both are
|
|
73
|
+
* accepted here.
|
|
74
|
+
*/
|
|
75
|
+
function _isMissingBinary(result) {
|
|
76
|
+
if (!result || !result.error) return false;
|
|
77
|
+
if (result.error.code === 'ENOENT') return true;
|
|
78
|
+
const msg = result.error.message || '';
|
|
79
|
+
return /ENOENT/.test(msg);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Detect whether a spawnSync result represents a timeout. spawnSync signals
|
|
84
|
+
* this by setting `signal === 'SIGTERM'` once the timeout expires. The
|
|
85
|
+
* 100 ms slack absorbs clock-skew on slower CI runners.
|
|
86
|
+
*/
|
|
87
|
+
function _isTimeout(result, duration, timeoutMs) {
|
|
88
|
+
return result.signal === 'SIGTERM' && duration >= timeoutMs - 100;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Run an external security tool with structured outcome classification.
|
|
93
|
+
*
|
|
94
|
+
* @param {string} cwd - Working directory for the child process
|
|
95
|
+
* @param {string|null} toolKey - One of: 'snyk' | 'npm-audit' | 'osv-scanner' |
|
|
96
|
+
* 'pip-audit' | 'govulncheck' | 'bundler-audit' | null.
|
|
97
|
+
* Passing null triggers the `no_native_tool_for_ecosystem` outcome
|
|
98
|
+
* without invoking spawnSync.
|
|
99
|
+
* @param {string[]} argv - Full argv array: argv[0] is the binary, argv[1..] are args.
|
|
100
|
+
* @param {{ timeoutMs?: number, maxBuffer?: number, env?: object, expectJson?: boolean }} [opts]
|
|
101
|
+
* @returns {{
|
|
102
|
+
* exitCode: number,
|
|
103
|
+
* stdout: string,
|
|
104
|
+
* stderr: string,
|
|
105
|
+
* timedOut: boolean,
|
|
106
|
+
* duration: number,
|
|
107
|
+
* outcome: 'ok'|'tool_failure'|'missing'|'timeout'|'no_native_tool_for_ecosystem',
|
|
108
|
+
* parsed?: any
|
|
109
|
+
* }}
|
|
110
|
+
*/
|
|
111
|
+
function runTool(cwd, toolKey, argv, opts = {}) {
|
|
112
|
+
if (toolKey == null) {
|
|
113
|
+
return {
|
|
114
|
+
exitCode: -1,
|
|
115
|
+
stdout: '',
|
|
116
|
+
stderr: '',
|
|
117
|
+
timedOut: false,
|
|
118
|
+
duration: 0,
|
|
119
|
+
outcome: 'no_native_tool_for_ecosystem',
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const {
|
|
124
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
125
|
+
maxBuffer = DEFAULT_MAX_BUFFER,
|
|
126
|
+
env = {},
|
|
127
|
+
expectJson = false,
|
|
128
|
+
} = opts;
|
|
129
|
+
|
|
130
|
+
const started = Date.now();
|
|
131
|
+
const result = spawnSync(argv[0], argv.slice(1), {
|
|
132
|
+
cwd,
|
|
133
|
+
encoding: 'utf-8',
|
|
134
|
+
timeout: timeoutMs,
|
|
135
|
+
maxBuffer,
|
|
136
|
+
env: { ...process.env, ...env },
|
|
137
|
+
});
|
|
138
|
+
const duration = Date.now() - started;
|
|
139
|
+
|
|
140
|
+
// Missing-binary detection (ENOENT or spawn ENOENT)
|
|
141
|
+
if (_isMissingBinary(result)) {
|
|
142
|
+
return {
|
|
143
|
+
exitCode: 127,
|
|
144
|
+
stdout: '',
|
|
145
|
+
stderr: result.error.message || '',
|
|
146
|
+
timedOut: false,
|
|
147
|
+
duration,
|
|
148
|
+
outcome: 'missing',
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Timeout detection (SIGTERM issued by spawnSync when timeout expires)
|
|
153
|
+
if (_isTimeout(result, duration, timeoutMs)) {
|
|
154
|
+
return {
|
|
155
|
+
exitCode: 124,
|
|
156
|
+
stdout: result.stdout || '',
|
|
157
|
+
stderr: result.stderr || '',
|
|
158
|
+
timedOut: true,
|
|
159
|
+
duration,
|
|
160
|
+
outcome: 'timeout',
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const exitCode = result.status != null ? result.status : 1;
|
|
165
|
+
const stdout = result.stdout != null ? result.stdout : '';
|
|
166
|
+
const stderr = result.stderr != null ? result.stderr : '';
|
|
167
|
+
|
|
168
|
+
// Classify outcome via the pure helper.
|
|
169
|
+
const outcome = _classifyExitCode(exitCode, toolKey);
|
|
170
|
+
|
|
171
|
+
const out = {
|
|
172
|
+
exitCode,
|
|
173
|
+
stdout,
|
|
174
|
+
stderr,
|
|
175
|
+
timedOut: false,
|
|
176
|
+
duration,
|
|
177
|
+
outcome,
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
// Optional JSON parse on ok outcomes
|
|
181
|
+
if (expectJson && outcome === 'ok' && stdout) {
|
|
182
|
+
try {
|
|
183
|
+
out.parsed = JSON.parse(stdout);
|
|
184
|
+
} catch (e) {
|
|
185
|
+
out.outcome = 'tool_failure';
|
|
186
|
+
out.stderr = (stderr ? stderr + '\n' : '') + `[JSON parse error]: ${e.message}`;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return out;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
module.exports = {
|
|
194
|
+
runTool,
|
|
195
|
+
FINDINGS_EXIT_CODES,
|
|
196
|
+
ECOSYSTEM_OVERRIDES,
|
|
197
|
+
DEFAULT_TIMEOUT_MS,
|
|
198
|
+
DEFAULT_MAX_BUFFER,
|
|
199
|
+
};
|