dsh-standards 0.0.3 → 1.0.1
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/40-checker/README.md +251 -0
- package/40-checker/bin/dsh-levels.mjs +15 -0
- package/40-checker/bin/dsh-plugin-verify.mjs +8 -0
- package/40-checker/fixtures/bad/index.mjs +12 -0
- package/40-checker/fixtures/bad/package.json +15 -0
- package/40-checker/fixtures/bad-manifest/index.js +1 -0
- package/40-checker/fixtures/bad-manifest/package.json +5 -0
- package/40-checker/fixtures/bundle/cordis.patch.yml +3 -0
- package/40-checker/fixtures/bundle/package.json +15 -0
- package/40-checker/fixtures/bundle/server.mjs +7 -0
- package/40-checker/fixtures/bundle-v0/cordis.patch.yml +3 -0
- package/40-checker/fixtures/bundle-v0/package.json +21 -0
- package/40-checker/fixtures/bundle-v0/server.mjs +7 -0
- package/40-checker/fixtures/client-missing/client.js +1 -0
- package/40-checker/fixtures/client-missing/index.js +1 -0
- package/40-checker/fixtures/client-missing/package.json +6 -0
- package/40-checker/fixtures/flow-config/index.js +9 -0
- package/40-checker/fixtures/flow-config/package.json +10 -0
- package/40-checker/fixtures/fs-write/index.mjs +7 -0
- package/40-checker/fixtures/fs-write/package.json +10 -0
- package/40-checker/fixtures/good/index.mjs +10 -0
- package/40-checker/fixtures/good/package.json +19 -0
- package/40-checker/fixtures/hang/index.mjs +5 -0
- package/40-checker/fixtures/hang/package.json +10 -0
- package/40-checker/fixtures/known-answer/expected.json +18 -0
- package/40-checker/fixtures/market-index-real.json +18 -0
- package/40-checker/fixtures/market-index.json +18 -0
- package/40-checker/fixtures/negative/bad-entry/package.json +1 -0
- package/40-checker/fixtures/negative/no-entry/package.json +1 -0
- package/40-checker/fixtures/negative/no-patch/package.json +1 -0
- package/40-checker/fixtures/negative/root-missing/cordis.patch.yml +2 -0
- package/40-checker/fixtures/negative/root-missing/package.json +1 -0
- package/40-checker/fixtures/negative/workspace/package.json +1 -0
- package/40-checker/package.json +22 -0
- package/40-checker/samples/README.md +8 -0
- package/40-checker/samples/entry18.json +139 -0
- package/40-checker/samples/frame.json +485 -0
- package/40-checker/scripts/apply-overlay.mjs +59 -0
- package/40-checker/scripts/attestation-check.mjs +151 -0
- package/40-checker/scripts/bundle-smoke.mjs +126 -0
- package/40-checker/scripts/claim-check.mjs +145 -0
- package/40-checker/scripts/clause-map.mjs +94 -0
- package/40-checker/scripts/collect-fixtures.mjs +44 -0
- package/40-checker/scripts/dashboard.mjs +85 -0
- package/40-checker/scripts/dco-check.mjs +147 -0
- package/40-checker/scripts/dependabot-check.mjs +40 -0
- package/40-checker/scripts/doc-commands-check.mjs +92 -0
- package/40-checker/scripts/entry-scan.mjs +190 -0
- package/40-checker/scripts/entry-validate.mjs +291 -0
- package/40-checker/scripts/entry18-attribution.mjs +66 -0
- package/40-checker/scripts/entry18-ci.mjs +685 -0
- package/40-checker/scripts/entry18-realweb-summary.mjs +71 -0
- package/40-checker/scripts/entry18-web.mjs +307 -0
- package/40-checker/scripts/evidence-chain.mjs +100 -0
- package/40-checker/scripts/flow-index.mjs +82 -0
- package/40-checker/scripts/flow-sample-ci.mjs +529 -0
- package/40-checker/scripts/governance-check.mjs +210 -0
- package/40-checker/scripts/host-farm.mjs +165 -0
- package/40-checker/scripts/journey-config-observe.mjs +109 -0
- package/40-checker/scripts/journey-disclosure-shape.mjs +83 -0
- package/40-checker/scripts/journey-runtime.mjs +178 -0
- package/40-checker/scripts/journey.mjs +76 -0
- package/40-checker/scripts/label-drift.mjs +74 -0
- package/40-checker/scripts/label-sync.mjs +87 -0
- package/40-checker/scripts/label-verify.mjs +90 -0
- package/40-checker/scripts/landlock-exec.c +101 -0
- package/40-checker/scripts/level-audit.mjs +336 -0
- package/40-checker/scripts/m1-sidecar.mjs +126 -0
- package/40-checker/scripts/m2-runner.mjs +213 -0
- package/40-checker/scripts/round-robin.mjs +88 -0
- package/40-checker/scripts/sample-frame.mjs +72 -0
- package/40-checker/scripts/sample20-ci.mjs +25 -0
- package/40-checker/scripts/sample60-ci.mjs +25 -0
- package/40-checker/scripts/stale-check.mjs +59 -0
- package/40-checker/scripts/surveillance.mjs +88 -0
- package/40-checker/scripts/web-probe.mjs +504 -0
- package/40-checker/src/cli.mjs +300 -0
- package/40-checker/src/client-shape.mjs +37 -0
- package/40-checker/src/declarations.mjs +242 -0
- package/40-checker/src/index.mjs +8 -0
- package/40-checker/src/install-spec.mjs +67 -0
- package/40-checker/src/label.mjs +130 -0
- package/40-checker/src/manifest.mjs +381 -0
- package/40-checker/src/permissions.mjs +60 -0
- package/40-checker/src/report.mjs +204 -0
- package/40-checker/src/result.mjs +60 -0
- package/40-checker/src/runtime.mjs +299 -0
- package/40-checker/src/semver.mjs +194 -0
- package/40-checker/src/util.mjs +148 -0
- package/40-checker/test/attestation.test.mjs +59 -0
- package/40-checker/test/checks.test.mjs +172 -0
- package/40-checker/test/claim.test.mjs +28 -0
- package/40-checker/test/client-shape.test.mjs +35 -0
- package/40-checker/test/dco.test.mjs +51 -0
- package/40-checker/test/entry-validate.test.mjs +172 -0
- package/40-checker/test/governance.test.mjs +76 -0
- package/40-checker/test/label.test.mjs +97 -0
- package/40-checker/test/level-audit.test.mjs +99 -0
- package/40-checker/test/levels.test.mjs +31 -0
- package/40-checker/test/ops.test.mjs +117 -0
- package/40-checker/test/permissions.test.mjs +40 -0
- package/40-checker/test/semver.test.mjs +32 -0
- package/40-checker/test/verify.test.mjs +57 -0
- package/CHANGELOG.md +24 -0
- package/LICENSE +28 -0
- package/LICENSE-APACHE +190 -0
- package/LICENSE-CC-BY +23 -0
- package/NOTICE +14 -0
- package/NOTICE.md +11 -0
- package/README.md +146 -3
- package/bin/dsh-levels.mjs +4 -0
- package/bin/dsh-standards.mjs +19 -0
- package/docs/BOOST-FIXTURES-2026-11-02.md +38 -0
- package/docs/DEVELOPER-JOURNEY.md +40 -0
- package/docs/HOST-J3-BUG-REPORT-DRAFT.md +27 -0
- package/docs/HOST-J3-CONFIG-REPRO-2026-11-02.md +36 -0
- package/docs/NAMING-FALLBACK.md +11 -0
- package/docs/fork-policy.md +12 -0
- package/docs/full-test-plan-2026-11-02.md +207 -0
- package/docs/incident.md +37 -0
- package/docs/onboarding.md +27 -0
- package/docs/privacy.md +12 -0
- package/docs/release.md +16 -0
- package/docs/scope-security.md +62 -0
- package/docs/status-taxonomy.md +54 -0
- package/docs/sunset.md +44 -0
- package/docs/translating.md +12 -0
- package/package.json +58 -12
- package/spec/README.md +27 -0
- package/spec/boost/README.md +8 -0
- package/spec/clauses.json +253 -0
- package/spec/entry/README.md +9 -0
- package/spec/flow/README.md +8 -0
- package/spec/levels.json +371 -0
- package/spec/levels.schema.json +45 -0
- package/spec/reference-suite.json +35 -0
- package/spec/schemas/attestation.schema.json +284 -0
- package/spec/schemas/evidence.schema.json +331 -0
- package/spec/schemas/report.schema.json +64 -0
|
@@ -0,0 +1,685 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* entry18-ci.mjs — reproducible base- or web-layer G0–G3 runtime evidence for
|
|
4
|
+
* the 18 ENTRY packages.
|
|
5
|
+
*
|
|
6
|
+
* One invocation serves one CI matrix shard (`--only <id>`), or all shards in
|
|
7
|
+
* sequence on a developer machine (`--all`). The script is zero-dependency:
|
|
8
|
+
* it only shells out to `npm`, `git`, `tar` and `node`, which exist on the
|
|
9
|
+
* GitHub Actions Ubuntu runner.
|
|
10
|
+
*
|
|
11
|
+
* Farm layout (a real directory, deleted after every package):
|
|
12
|
+
*
|
|
13
|
+
* <farm>/node_modules/<entry> symlink -> <dsh>/node_modules/<entry>
|
|
14
|
+
* <farm>/plugins/<id> published tarball or git clone
|
|
15
|
+
* <farm>/node_modules/<pkg-name> symlink -> ../plugins/<id>
|
|
16
|
+
*
|
|
17
|
+
* Keeping `<farm>/node_modules` a real directory matters: symlinking the whole
|
|
18
|
+
* directory makes npm write into the global DSH install.
|
|
19
|
+
*
|
|
20
|
+
* Install policy: the copied package.json is first stripped of devDependencies
|
|
21
|
+
* and scripts (published dev-only `workspace:*` specs and dev peer pins break a
|
|
22
|
+
* standalone install), then `npm install --omit=dev --ignore-scripts
|
|
23
|
+
* --legacy-peer-deps --no-package-lock --no-audit --no-fund` runs. No install
|
|
24
|
+
* script from a third-party package is ever executed.
|
|
25
|
+
*
|
|
26
|
+
* Usage:
|
|
27
|
+
* node 40-checker/scripts/entry18-ci.mjs --only <id>
|
|
28
|
+
* node 40-checker/scripts/entry18-ci.mjs --all
|
|
29
|
+
* node 40-checker/scripts/entry18-ci.mjs --summary
|
|
30
|
+
*
|
|
31
|
+
* Options:
|
|
32
|
+
* --only <id> run one shard (id from samples/entry18.json)
|
|
33
|
+
* --all run every shard sequentially
|
|
34
|
+
* --summary do not boot; aggregate existing rows
|
|
35
|
+
* --out <dir> result directory (default: 40-checker/results/entry/18/fullbase)
|
|
36
|
+
* --farm <dir> farm directory (default: <tmp>/dsh-entry18-farm-<pid>)
|
|
37
|
+
* --dsh-root <dir> installed @deepseek-ai/dsh package root
|
|
38
|
+
* (default: `<npm root -g>/@deepseek-ai/dsh`)
|
|
39
|
+
* --web [patch] also apply the web layer (default: @deepseek-ai/dsh-web-app/cordis.patch.yml)
|
|
40
|
+
* --expected-dsh-version <v> fail before booting when the installed version differs
|
|
41
|
+
* --fixture <mode> auto | published-tarball | git-clone (default: auto)
|
|
42
|
+
* --smoke-timeout <ms> bundle-smoke boot budget (default: 150000)
|
|
43
|
+
* --install-timeout <ms> npm install budget (default: 300000)
|
|
44
|
+
* --fetch-timeout <ms> npm pack / git clone budget (default: 300000)
|
|
45
|
+
* --keep-farm keep the farm after the shard (debug only)
|
|
46
|
+
* --verbose echo captured command output to stdout
|
|
47
|
+
*
|
|
48
|
+
* Exit codes:
|
|
49
|
+
* 0 a JSON row was written (verified / partial / failed)
|
|
50
|
+
* 2 bad usage
|
|
51
|
+
* 3 the row could not be produced (preflight or runner error)
|
|
52
|
+
*
|
|
53
|
+
* The script never upgrades a boot result: `status` is either the G3 state
|
|
54
|
+
* reported by bundle-smoke or "error". Only a real all-rows-ACTIVE boot can
|
|
55
|
+
* produce "verified".
|
|
56
|
+
*/
|
|
57
|
+
import fs from 'node:fs'
|
|
58
|
+
import os from 'node:os'
|
|
59
|
+
import path from 'node:path'
|
|
60
|
+
import { spawnSync } from 'node:child_process'
|
|
61
|
+
import { fileURLToPath } from 'node:url'
|
|
62
|
+
|
|
63
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url))
|
|
64
|
+
const STD = path.resolve(HERE, '..', '..')
|
|
65
|
+
const MANIFEST_PATH = path.join(STD, '40-checker', 'samples', 'entry18.json')
|
|
66
|
+
const SMOKE_SCRIPT = path.join(STD, '40-checker', 'scripts', 'bundle-smoke.mjs')
|
|
67
|
+
const DEFAULT_OUT = path.join(STD, '40-checker', 'results', 'entry', '18', 'fullbase')
|
|
68
|
+
const HOST_ENTRY = path.join('@deepseek-ai', 'dsh-app-boot', 'lib', 'index.js')
|
|
69
|
+
const BASE_PATCH = path.join('@deepseek-ai', 'dsh-base', 'cordis.patch.yml')
|
|
70
|
+
const WEB_PATCH = path.join('@deepseek-ai', 'dsh-web-app', 'cordis.patch.yml')
|
|
71
|
+
const FARM_MARKER = '.entry18-farm'
|
|
72
|
+
const MAX_LOG_BYTES = 4 * 1024 * 1024
|
|
73
|
+
const NPM = process.platform === 'win32' ? 'npm.cmd' : 'npm'
|
|
74
|
+
|
|
75
|
+
function usage() {
|
|
76
|
+
console.log(`usage: node 40-checker/scripts/entry18-ci.mjs --only <id> | --all | --summary [options]
|
|
77
|
+
|
|
78
|
+
--only <id> run one shard from 40-checker/samples/entry18.json
|
|
79
|
+
--all run every shard sequentially
|
|
80
|
+
--summary aggregate existing <id>.json rows (no boot)
|
|
81
|
+
--out <dir> result dir (default: 40-checker/results/entry/18/fullbase)
|
|
82
|
+
--farm <dir> farm dir (default: <tmp>/dsh-entry18-farm-<pid>)
|
|
83
|
+
--dsh-root <dir> @deepseek-ai/dsh package root (default: <npm root -g>/@deepseek-ai/dsh)
|
|
84
|
+
--web [patch] also apply the web layer (default: @deepseek-ai/dsh-web-app/cordis.patch.yml)
|
|
85
|
+
--expected-dsh-version <v> refuse to run on a different installed DSH version
|
|
86
|
+
--fixture <mode> auto | published-tarball | git-clone
|
|
87
|
+
--smoke-timeout <ms> bundle-smoke boot budget (default 150000)
|
|
88
|
+
--install-timeout <ms> npm install budget (default 300000)
|
|
89
|
+
--fetch-timeout <ms> npm pack / git clone budget (default 300000)
|
|
90
|
+
--keep-farm keep the farm (debug)
|
|
91
|
+
--verbose echo command output`)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function parseArgs(argv) {
|
|
95
|
+
const options = {
|
|
96
|
+
only: null, all: false, summary: false,
|
|
97
|
+
out: DEFAULT_OUT, farm: null, dshRoot: null,
|
|
98
|
+
expectedDshVersion: null, fixture: 'auto', web: null,
|
|
99
|
+
smokeTimeout: 150_000, installTimeout: 300_000, fetchTimeout: 300_000,
|
|
100
|
+
keepFarm: false, verbose: false,
|
|
101
|
+
}
|
|
102
|
+
const numeric = { '--smoke-timeout': 'smokeTimeout', '--install-timeout': 'installTimeout', '--fetch-timeout': 'fetchTimeout' }
|
|
103
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
104
|
+
const arg = argv[i]
|
|
105
|
+
const next = () => {
|
|
106
|
+
const value = argv[++i]
|
|
107
|
+
if (value === undefined) throw new Error(`${arg} needs a value`)
|
|
108
|
+
return value
|
|
109
|
+
}
|
|
110
|
+
if (arg === '--only') options.only = next()
|
|
111
|
+
else if (arg === '--all') options.all = true
|
|
112
|
+
else if (arg === '--summary') options.summary = true
|
|
113
|
+
else if (arg === '--out') options.out = path.resolve(next())
|
|
114
|
+
else if (arg === '--farm') options.farm = path.resolve(next())
|
|
115
|
+
else if (arg === '--dsh-root') options.dshRoot = path.resolve(next())
|
|
116
|
+
else if (arg === '--web') {
|
|
117
|
+
const value = argv[i + 1]
|
|
118
|
+
if (value !== undefined && !value.startsWith('-')) {
|
|
119
|
+
options.web = path.resolve(value)
|
|
120
|
+
i += 1
|
|
121
|
+
} else {
|
|
122
|
+
options.web = 'auto'
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
else if (arg === '--expected-dsh-version') options.expectedDshVersion = next()
|
|
126
|
+
else if (arg === '--fixture') options.fixture = next()
|
|
127
|
+
else if (arg === '--keep-farm') options.keepFarm = true
|
|
128
|
+
else if (arg === '--verbose') options.verbose = true
|
|
129
|
+
else if (arg === '--help' || arg === '-h') { usage(); process.exit(0) }
|
|
130
|
+
else if (numeric[arg]) {
|
|
131
|
+
const value = Number(next())
|
|
132
|
+
if (!Number.isFinite(value) || value < 1000) throw new Error(`${arg} must be >= 1000`)
|
|
133
|
+
options[numeric[arg]] = value
|
|
134
|
+
} else throw new Error(`unknown option: ${arg}`)
|
|
135
|
+
}
|
|
136
|
+
if (['auto', 'published-tarball', 'git-clone'].includes(options.fixture) === false) throw new Error(`--fixture must be auto, published-tarball or git-clone`)
|
|
137
|
+
const selected = [options.only !== null, options.all, options.summary].filter(Boolean).length
|
|
138
|
+
if (selected === 0) { usage(); process.exit(2) }
|
|
139
|
+
if (selected > 1) throw new Error('choose exactly one of --only, --all, --summary')
|
|
140
|
+
return options
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function tail(text, limit = 600) {
|
|
144
|
+
const clean = String(text ?? '').trim()
|
|
145
|
+
return clean.length <= limit ? clean : `…${clean.slice(-limit)}`
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function run(command, args, { cwd, timeout, env } = {}) {
|
|
149
|
+
const startedAt = Date.now()
|
|
150
|
+
const result = spawnSync(command, args, {
|
|
151
|
+
cwd,
|
|
152
|
+
timeout,
|
|
153
|
+
encoding: 'utf8',
|
|
154
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
155
|
+
killSignal: 'SIGKILL',
|
|
156
|
+
env: {
|
|
157
|
+
...process.env,
|
|
158
|
+
NO_COLOR: '1',
|
|
159
|
+
npm_config_update_notifier: 'false',
|
|
160
|
+
// Bound registry waits so one unresponsive metadata request cannot wedge
|
|
161
|
+
// a shard past its job timeout.
|
|
162
|
+
npm_config_fetch_timeout: '60000',
|
|
163
|
+
npm_config_fetch_retries: '2',
|
|
164
|
+
...env,
|
|
165
|
+
},
|
|
166
|
+
})
|
|
167
|
+
return {
|
|
168
|
+
command,
|
|
169
|
+
args,
|
|
170
|
+
status: result.status,
|
|
171
|
+
signal: result.signal,
|
|
172
|
+
stdout: result.stdout ?? '',
|
|
173
|
+
stderr: result.stderr ?? '',
|
|
174
|
+
timedOut: result.error?.code === 'ETIMEDOUT',
|
|
175
|
+
error: result.error ? result.error.message : null,
|
|
176
|
+
elapsedMs: Math.max(0, Date.now() - startedAt),
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function describeRun(result) {
|
|
181
|
+
if (result.timedOut) return `timed out after ${result.elapsedMs}ms`
|
|
182
|
+
if (result.error) return result.error
|
|
183
|
+
return `exit ${result.status ?? `signal ${result.signal}`}`
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function logSection(lines, label, result) {
|
|
187
|
+
lines.push(`[${label}] $ ${result.command} ${result.args.join(' ')}`)
|
|
188
|
+
lines.push(`[${label}] ${describeRun(result)} (${result.elapsedMs}ms)`)
|
|
189
|
+
if (result.stdout.trim()) lines.push(`[${label}:stdout]\n${result.stdout.trimEnd()}`)
|
|
190
|
+
if (result.stderr.trim()) lines.push(`[${label}:stderr]\n${result.stderr.trimEnd()}`)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function writeAtomic(file, content) {
|
|
194
|
+
const tmp = `${file}.tmp-${process.pid}`
|
|
195
|
+
fs.writeFileSync(tmp, content)
|
|
196
|
+
fs.renameSync(tmp, file)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function writeJson(file, value) {
|
|
200
|
+
writeAtomic(file, `${JSON.stringify(value, null, 2)}\n`)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function readJson(file) {
|
|
204
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Global installs may keep the package's dependencies nested under the package
|
|
208
|
+
// (the Windows 0.2.1 layout) or hoist them next to it (a local `npm install`
|
|
209
|
+
// layout, and usually `npm install -g` on Linux). Resolve both, never assume.
|
|
210
|
+
function inspectDshRoot(dir) {
|
|
211
|
+
try {
|
|
212
|
+
const manifest = readJson(path.join(dir, 'package.json'))
|
|
213
|
+
if (typeof manifest.version !== 'string') return null
|
|
214
|
+
const candidates = [
|
|
215
|
+
path.join(dir, 'node_modules'),
|
|
216
|
+
path.dirname(path.dirname(dir)),
|
|
217
|
+
]
|
|
218
|
+
for (const depsRoot of candidates) {
|
|
219
|
+
const appBoot = path.join(depsRoot, HOST_ENTRY)
|
|
220
|
+
const basePatch = path.join(depsRoot, BASE_PATCH)
|
|
221
|
+
if (fs.existsSync(appBoot) && fs.existsSync(basePatch)) {
|
|
222
|
+
return { dir, version: manifest.version, depsRoot, appBoot, basePatch }
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return null
|
|
226
|
+
} catch {
|
|
227
|
+
return null
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function resolveDshRoot(explicit) {
|
|
232
|
+
if (explicit) {
|
|
233
|
+
const layout = inspectDshRoot(explicit)
|
|
234
|
+
if (!layout) throw new Error(`--dsh-root ${explicit} is not an installed @deepseek-ai/dsh package root (or its dependencies are not resolvable)`)
|
|
235
|
+
return layout
|
|
236
|
+
}
|
|
237
|
+
const npmRoot = run(NPM, ['root', '-g'], { timeout: 60_000 })
|
|
238
|
+
if (npmRoot.status !== 0) throw new Error(`npm root -g failed: ${tail(npmRoot.stderr)}`)
|
|
239
|
+
const candidate = path.join(npmRoot.stdout.trim(), '@deepseek-ai', 'dsh')
|
|
240
|
+
const layout = inspectDshRoot(candidate)
|
|
241
|
+
if (!layout) {
|
|
242
|
+
throw new Error(`cannot find @deepseek-ai/dsh under ${npmRoot.stdout.trim()}; install it globally or pass --dsh-root`)
|
|
243
|
+
}
|
|
244
|
+
return layout
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function assertFarmOwned(dir) {
|
|
248
|
+
if (!fs.existsSync(dir)) return
|
|
249
|
+
if (!fs.existsSync(path.join(dir, FARM_MARKER))) {
|
|
250
|
+
throw new Error(`refusing to delete ${dir}: it exists but has no ${FARM_MARKER} marker`)
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function removeFarm(dir) {
|
|
255
|
+
assertFarmOwned(dir)
|
|
256
|
+
fs.rmSync(dir, { recursive: true, force: true })
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function prepareFarm(dir, depsRoot) {
|
|
260
|
+
assertFarmOwned(dir)
|
|
261
|
+
fs.rmSync(dir, { recursive: true, force: true })
|
|
262
|
+
fs.mkdirSync(path.join(dir, 'node_modules'), { recursive: true })
|
|
263
|
+
fs.mkdirSync(path.join(dir, 'plugins'), { recursive: true })
|
|
264
|
+
fs.writeFileSync(path.join(dir, FARM_MARKER), `dsh-entry18 farm\ncreatedAt=${new Date().toISOString()}\n`)
|
|
265
|
+
const source = depsRoot
|
|
266
|
+
let linked = 0
|
|
267
|
+
for (const entry of fs.readdirSync(source)) {
|
|
268
|
+
const target = path.join(source, entry)
|
|
269
|
+
const destination = path.join(dir, 'node_modules', entry)
|
|
270
|
+
const isDirectory = fs.statSync(target).isDirectory()
|
|
271
|
+
try {
|
|
272
|
+
if (process.platform === 'win32') fs.symlinkSync(target, destination, isDirectory ? 'junction' : 'file')
|
|
273
|
+
else fs.symlinkSync(target, destination, isDirectory ? 'dir' : 'file')
|
|
274
|
+
} catch (error) {
|
|
275
|
+
throw new Error(`cannot link ${entry} into the farm: ${error.message}`)
|
|
276
|
+
}
|
|
277
|
+
linked += 1
|
|
278
|
+
}
|
|
279
|
+
return linked
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function ensureScopeDir(farmDir, scope) {
|
|
283
|
+
const destination = path.join(farmDir, 'node_modules', scope)
|
|
284
|
+
let stat
|
|
285
|
+
try { stat = fs.lstatSync(destination) } catch { fs.mkdirSync(destination, { recursive: true }); return }
|
|
286
|
+
if (!stat.isSymbolicLink()) return
|
|
287
|
+
// A host scope was linked as one symlink. Turn it into a real directory so
|
|
288
|
+
// the sample can join that scope without writing through into the host.
|
|
289
|
+
const target = path.resolve(path.dirname(destination), fs.readlinkSync(destination))
|
|
290
|
+
fs.unlinkSync(destination)
|
|
291
|
+
fs.mkdirSync(destination)
|
|
292
|
+
for (const entry of fs.readdirSync(target)) {
|
|
293
|
+
const source = path.join(target, entry)
|
|
294
|
+
const link = path.join(destination, entry)
|
|
295
|
+
if (process.platform === 'win32') fs.symlinkSync(source, link, fs.statSync(source).isDirectory() ? 'junction' : 'file')
|
|
296
|
+
else fs.symlinkSync(source, link)
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function linkPlugin(farmDir, packageDir, name) {
|
|
301
|
+
const parts = name.split('/')
|
|
302
|
+
if (parts.length === 2) ensureScopeDir(farmDir, parts[0])
|
|
303
|
+
const parent = path.join(farmDir, 'node_modules', ...parts.slice(0, -1))
|
|
304
|
+
fs.mkdirSync(parent, { recursive: true })
|
|
305
|
+
const destination = path.join(parent, parts.at(-1))
|
|
306
|
+
try { fs.unlinkSync(destination) } catch { /* absent */ }
|
|
307
|
+
const target = process.platform === 'win32' ? packageDir : path.relative(parent, packageDir)
|
|
308
|
+
fs.symlinkSync(target, destination, process.platform === 'win32' ? 'junction' : 'dir')
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function npmView(name, version) {
|
|
312
|
+
const result = run(NPM, ['view', `${name}@${version}`, 'version', 'repository.url', '--json'], { timeout: 120_000 })
|
|
313
|
+
if (result.status !== 0) return null
|
|
314
|
+
try {
|
|
315
|
+
const parsed = JSON.parse(result.stdout)
|
|
316
|
+
const rows = Array.isArray(parsed) ? parsed : [parsed]
|
|
317
|
+
const row = rows.at(-1)
|
|
318
|
+
// `npm view <pkg>@<v> version repository.url --json` returns a bare string
|
|
319
|
+
// when only the version field exists (no repository metadata), not an object.
|
|
320
|
+
if (typeof row === 'string') return row === version ? { version: row, repository: null } : null
|
|
321
|
+
if (!row || typeof row !== 'object') return null
|
|
322
|
+
if (row.version !== version) return null
|
|
323
|
+
return { version: row.version, repository: row['repository.url'] ?? null }
|
|
324
|
+
} catch {
|
|
325
|
+
return null
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function normalizeRepo(value) {
|
|
330
|
+
if (!value) return null
|
|
331
|
+
let url = String(value).trim()
|
|
332
|
+
url = url.replace(/^git\+/, '').replace(/^git@([^:]+):/, 'https://$1/').replace(/\.git$/, '').replace(/\/+$/, '')
|
|
333
|
+
const match = url.match(/github\.com[/:]([^/]+)\/([^/]+)$/i)
|
|
334
|
+
return match ? `${match[1]}/${match[2]}`.toLowerCase() : null
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function fixturePlan(row, mode) {
|
|
338
|
+
if (row.source === 'npm') {
|
|
339
|
+
return { kind: 'published-tarball', reason: 'source=npm: the registry tarball is the installable artifact' }
|
|
340
|
+
}
|
|
341
|
+
if (mode === 'published-tarball') return { kind: 'published-tarball', reason: 'forced by --fixture published-tarball' }
|
|
342
|
+
if (mode === 'git-clone') return { kind: 'git-clone', reason: 'forced by --fixture git-clone' }
|
|
343
|
+
const published = npmView(row.name, row.version)
|
|
344
|
+
if (!published) return { kind: 'git-clone', reason: `no published npm tarball at ${row.name}@${row.version}` }
|
|
345
|
+
const repo = normalizeRepo(published.repository)
|
|
346
|
+
if (!repo) return { kind: 'published-tarball', reason: `published tarball exists and has no GitHub repository field to contradict ${row.spec}` }
|
|
347
|
+
if (repo === row.spec.toLowerCase()) {
|
|
348
|
+
return { kind: 'published-tarball', reason: `published tarball repository matches ${row.spec}`, publishedRepository: published.repository }
|
|
349
|
+
}
|
|
350
|
+
return {
|
|
351
|
+
kind: 'git-clone',
|
|
352
|
+
reason: `published tarball repository ${repo} does not match ${row.spec}`,
|
|
353
|
+
publishedRepository: published.repository,
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function fetchTarball(row, farmDir, tmpDir, options, lines) {
|
|
358
|
+
const destination = path.join(farmDir, 'plugins', row.id)
|
|
359
|
+
fs.mkdirSync(destination, { recursive: true })
|
|
360
|
+
const spec = `${row.name}@${row.version}`
|
|
361
|
+
const pack = run(NPM, ['pack', spec, '--pack-destination', tmpDir, '--json'], { timeout: options.fetchTimeout })
|
|
362
|
+
logSection(lines, 'npm-pack', pack)
|
|
363
|
+
if (pack.status !== 0) throw new Error(`npm pack ${spec} failed: ${tail(pack.stderr || pack.stdout)}`)
|
|
364
|
+
let filename
|
|
365
|
+
try {
|
|
366
|
+
const parsed = JSON.parse(pack.stdout)
|
|
367
|
+
filename = (Array.isArray(parsed) ? parsed.at(-1) : parsed)?.filename
|
|
368
|
+
} catch {
|
|
369
|
+
throw new Error(`npm pack ${spec} returned unparseable JSON`)
|
|
370
|
+
}
|
|
371
|
+
if (!filename) throw new Error(`npm pack ${spec} did not report a tarball filename`)
|
|
372
|
+
const tarball = path.join(tmpDir, filename)
|
|
373
|
+
if (!fs.existsSync(tarball)) throw new Error(`npm pack ${spec} reported ${filename} but the file is missing`)
|
|
374
|
+
const extract = run('tar', ['-xzf', tarball, '-C', destination, '--strip-components=1'], { timeout: options.fetchTimeout })
|
|
375
|
+
logSection(lines, 'tar-extract', extract)
|
|
376
|
+
if (extract.status !== 0) throw new Error(`tar extraction of ${filename} failed: ${tail(extract.stderr)}`)
|
|
377
|
+
return { kind: 'published-tarball', spec, tarball: filename, packageDir: destination }
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function cloneRepo(row, farmDir, options, lines) {
|
|
381
|
+
const destination = path.join(farmDir, 'plugins', row.id)
|
|
382
|
+
const url = `https://github.com/${row.spec}.git`
|
|
383
|
+
const clone = run('git', ['clone', '--depth', '1', url, destination], { timeout: options.fetchTimeout })
|
|
384
|
+
logSection(lines, 'git-clone', clone)
|
|
385
|
+
if (clone.status !== 0) throw new Error(`git clone ${row.spec} failed: ${tail(clone.stderr)}`)
|
|
386
|
+
return { kind: 'git-clone', spec: row.spec, url, packageDir: destination }
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function extractSmoke(stdout) {
|
|
390
|
+
const marker = '"schema": "dsh-bundle-smoke/1"'
|
|
391
|
+
const markerIndex = stdout.lastIndexOf(marker)
|
|
392
|
+
if (markerIndex < 0) return null
|
|
393
|
+
const start = stdout.lastIndexOf('{', markerIndex)
|
|
394
|
+
if (start < 0) return null
|
|
395
|
+
try { return JSON.parse(stdout.slice(start).trim()) } catch { return null }
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function writeLog(outDir, id, lines) {
|
|
399
|
+
let text = `${lines.join('\n')}\n`
|
|
400
|
+
if (Buffer.byteLength(text) > MAX_LOG_BYTES) {
|
|
401
|
+
text = `${text.slice(0, MAX_LOG_BYTES)}\n…[truncated to ${MAX_LOG_BYTES} bytes]…\n`
|
|
402
|
+
}
|
|
403
|
+
fs.writeFileSync(path.join(outDir, `${id}.log`), text)
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function baseRow(row, context) {
|
|
407
|
+
return {
|
|
408
|
+
schema: context.webPatch ? 'dsh-entry18-web/1' : 'dsh-entry18-fullbase/1',
|
|
409
|
+
id: row.id,
|
|
410
|
+
name: row.name,
|
|
411
|
+
version: row.version,
|
|
412
|
+
source: row.source,
|
|
413
|
+
spec: row.spec,
|
|
414
|
+
profile: context.webPatch ? 'web' : 'base',
|
|
415
|
+
base: { layer: 'dsh-base/cordis.patch.yml', webLayer: Boolean(context.webPatch), webLayerPath: context.webPatch ?? null },
|
|
416
|
+
environment: process.env.GITHUB_ACTIONS === 'true' ? 'github-actions' : 'local',
|
|
417
|
+
dshVersion: context.dshVersion,
|
|
418
|
+
dshVersionExpected: context.options.expectedDshVersion,
|
|
419
|
+
host: {
|
|
420
|
+
packageRoot: context.layout.dir,
|
|
421
|
+
depsRoot: context.layout.depsRoot,
|
|
422
|
+
appBoot: context.layout.appBoot,
|
|
423
|
+
basePatch: context.layout.basePatch,
|
|
424
|
+
webPatch: context.webPatch ?? null,
|
|
425
|
+
},
|
|
426
|
+
startedAt: context.startedAt,
|
|
427
|
+
finishedAt: null,
|
|
428
|
+
elapsedMs: null,
|
|
429
|
+
fixture: 'none',
|
|
430
|
+
artifact: null,
|
|
431
|
+
installScripts: 'skipped',
|
|
432
|
+
installCommand: 'npm install --omit=dev --ignore-scripts --legacy-peer-deps --no-package-lock --no-audit --no-fund',
|
|
433
|
+
manifestRewrite: 'none',
|
|
434
|
+
install: null,
|
|
435
|
+
status: 'error',
|
|
436
|
+
verdict: null,
|
|
437
|
+
g3State: null,
|
|
438
|
+
g3Rows: null,
|
|
439
|
+
g3Active: null,
|
|
440
|
+
compatibility: null,
|
|
441
|
+
exitCode: null,
|
|
442
|
+
smoke: null,
|
|
443
|
+
error: null,
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function runShard(row, options, context) {
|
|
448
|
+
const startedAt = context.startedAt
|
|
449
|
+
const lines = []
|
|
450
|
+
lines.push(`# entry18 ${context.webPatch ? 'web' : 'fullbase'} shard: ${row.id}`)
|
|
451
|
+
lines.push(`# name=${row.name} version=${row.version} source=${row.source} spec=${row.spec}`)
|
|
452
|
+
lines.push(`# dshVersion=${context.dshVersion} profile=${context.webPatch ? 'web' : 'base'} webLayer=${Boolean(context.webPatch)} environment=${process.env.GITHUB_ACTIONS === 'true' ? 'github-actions' : 'local'}`)
|
|
453
|
+
lines.push(`# startedAt=${startedAt}`)
|
|
454
|
+
const record = baseRow(row, context)
|
|
455
|
+
// A shard that dies before writing must not leave an older row behind for
|
|
456
|
+
// `--summary` or a CI artifact upload to pick up as fresh evidence.
|
|
457
|
+
for (const extension of ['json', 'log']) {
|
|
458
|
+
fs.rmSync(path.join(options.out, `${row.id}.${extension}`), { force: true })
|
|
459
|
+
}
|
|
460
|
+
const farmDir = options.farm ?? path.join(os.tmpdir(), `dsh-entry18-farm-${process.pid}`)
|
|
461
|
+
let artifactTmp = null
|
|
462
|
+
try {
|
|
463
|
+
const linked = prepareFarm(farmDir, context.layout.depsRoot)
|
|
464
|
+
lines.push(`[farm] ${farmDir} (${linked} host entries linked, marker=${FARM_MARKER})`)
|
|
465
|
+
artifactTmp = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-entry18-artifact-'))
|
|
466
|
+
const plan = fixturePlan(row, options.fixture)
|
|
467
|
+
lines.push(`[fixture] plan=${plan.kind} reason=${plan.reason}`)
|
|
468
|
+
const fetched = plan.kind === 'published-tarball'
|
|
469
|
+
? fetchTarball(row, farmDir, artifactTmp, options, lines)
|
|
470
|
+
: cloneRepo(row, farmDir, options, lines)
|
|
471
|
+
const packageDir = fetched.packageDir
|
|
472
|
+
const manifest = readJson(path.join(packageDir, 'package.json'))
|
|
473
|
+
const versionMatches = manifest.version === row.version
|
|
474
|
+
const nameMatches = manifest.name === row.name
|
|
475
|
+
record.fixture = fetched.kind
|
|
476
|
+
record.artifact = {
|
|
477
|
+
requested: row.source === 'npm' ? `${row.name}@${row.version}` : row.spec,
|
|
478
|
+
resolved: fetched.kind,
|
|
479
|
+
reason: plan.reason,
|
|
480
|
+
manifestName: manifest.name,
|
|
481
|
+
manifestVersion: manifest.version,
|
|
482
|
+
versionMatches,
|
|
483
|
+
nameMatches,
|
|
484
|
+
publishedRepository: plan.publishedRepository ?? null,
|
|
485
|
+
tarball: fetched.tarball ?? null,
|
|
486
|
+
repository: fetched.url ?? null,
|
|
487
|
+
}
|
|
488
|
+
if (!nameMatches) throw new Error(`artifact package name ${manifest.name} does not match ${row.name}`)
|
|
489
|
+
if (!versionMatches) lines.push(`[fixture] WARNING manifest version ${manifest.version} != requested ${row.version}`)
|
|
490
|
+
if (!manifest.dsh?.bundle?.patch) throw new Error('artifact manifest has no dsh.bundle.patch')
|
|
491
|
+
lines.push(`[fixture] packageDir=${packageDir} manifest=${manifest.name}@${manifest.version} bundle=${JSON.stringify(manifest.dsh.bundle.patch)}`)
|
|
492
|
+
|
|
493
|
+
// Dev-only `workspace:*` specs and dev peer pins make a published package
|
|
494
|
+
// unresolvable when installed standalone (EUNSUPPORTEDPROTOCOL/ERESOLVE).
|
|
495
|
+
// Runtime needs neither, so strip them before installing. The rewrite is
|
|
496
|
+
// recorded in the row; the shipped tarball itself is not modified.
|
|
497
|
+
const rewriteKeys = []
|
|
498
|
+
if (manifest.devDependencies) rewriteKeys.push('devDependencies')
|
|
499
|
+
if (manifest.scripts) rewriteKeys.push('scripts')
|
|
500
|
+
if (rewriteKeys.length > 0) {
|
|
501
|
+
delete manifest.devDependencies
|
|
502
|
+
delete manifest.scripts
|
|
503
|
+
fs.writeFileSync(path.join(packageDir, 'package.json'), `${JSON.stringify(manifest, null, 2)}\n`)
|
|
504
|
+
record.manifestRewrite = `removed ${rewriteKeys.join('+')}`
|
|
505
|
+
}
|
|
506
|
+
lines.push(`[manifest] rewrite=${record.manifestRewrite}`)
|
|
507
|
+
|
|
508
|
+
const installArgs = ['install', '--omit=dev', '--ignore-scripts', '--legacy-peer-deps', '--no-package-lock', '--no-audit', '--no-fund']
|
|
509
|
+
const install = run(NPM, installArgs, { cwd: packageDir, timeout: options.installTimeout })
|
|
510
|
+
record.install = {
|
|
511
|
+
command: `npm ${installArgs.join(' ')}`,
|
|
512
|
+
status: install.status === 0 ? 'ok' : 'failed',
|
|
513
|
+
elapsedMs: install.elapsedMs,
|
|
514
|
+
scripts: 'skipped',
|
|
515
|
+
}
|
|
516
|
+
logSection(lines, 'npm-install', install)
|
|
517
|
+
if (install.status !== 0) throw new Error(`npm install in ${path.basename(packageDir)} failed: ${tail(install.stderr || install.stdout)}`)
|
|
518
|
+
|
|
519
|
+
linkPlugin(farmDir, packageDir, row.name)
|
|
520
|
+
lines.push(`[link] ${path.join('node_modules', row.name)} -> ${packageDir}`)
|
|
521
|
+
|
|
522
|
+
const smokeArgs = [
|
|
523
|
+
SMOKE_SCRIPT,
|
|
524
|
+
'--package', packageDir,
|
|
525
|
+
'--host', context.layout.appBoot,
|
|
526
|
+
'--base', context.layout.basePatch,
|
|
527
|
+
...(context.webPatch ? ['--web', context.webPatch] : []),
|
|
528
|
+
'--bare', `${farmDir}${path.sep}`,
|
|
529
|
+
'--timeout', String(options.smokeTimeout),
|
|
530
|
+
'--json',
|
|
531
|
+
]
|
|
532
|
+
const smokeRun = run(process.execPath, smokeArgs, { timeout: options.smokeTimeout + 120_000 })
|
|
533
|
+
logSection(lines, 'bundle-smoke', smokeRun)
|
|
534
|
+
record.exitCode = smokeRun.status
|
|
535
|
+
const smoke = extractSmoke(smokeRun.stdout)
|
|
536
|
+
if (!smoke) {
|
|
537
|
+
const reason = smokeRun.timedOut
|
|
538
|
+
? `bundle-smoke exceeded its ${options.smokeTimeout + 120_000}ms process budget`
|
|
539
|
+
: tail(smokeRun.stderr || smokeRun.stdout, 400) || describeRun(smokeRun)
|
|
540
|
+
record.error = { stage: smokeRun.timedOut ? 'smoke-timeout' : 'smoke', message: reason }
|
|
541
|
+
record.status = 'error'
|
|
542
|
+
record.verdict = 'error'
|
|
543
|
+
record.finishedAt = new Date().toISOString()
|
|
544
|
+
return record
|
|
545
|
+
}
|
|
546
|
+
record.smoke = smoke
|
|
547
|
+
record.g3State = smoke.g3?.state ?? null
|
|
548
|
+
record.g3Rows = Array.isArray(smoke.g3?.rows) ? smoke.g3.rows.length : null
|
|
549
|
+
record.g3Active = smoke.g3?.active ?? null
|
|
550
|
+
record.compatibility = smoke.g2?.compatibility ?? null
|
|
551
|
+
record.status = ['verified', 'partial', 'failed'].includes(smoke.g3?.state) ? smoke.g3.state : 'error'
|
|
552
|
+
// G2 is reported, not enforced, by bundle-smoke: a package can boot while
|
|
553
|
+
// the host version gate would reject it. Keep that distinction visible.
|
|
554
|
+
record.verdict = record.status === 'verified'
|
|
555
|
+
? (record.compatibility === 'reject' ? 'verified-gate-reject' : 'verified')
|
|
556
|
+
: record.status
|
|
557
|
+
if (record.status === 'error') record.error = { stage: 'smoke-result', message: `unexpected g3.state ${JSON.stringify(smoke.g3?.state)}` }
|
|
558
|
+
if (record.status === 'failed') record.error = { stage: 'g3-failed', message: 'target rows did not activate' }
|
|
559
|
+
record.finishedAt = new Date().toISOString()
|
|
560
|
+
return record
|
|
561
|
+
} catch (error) {
|
|
562
|
+
record.status = 'error'
|
|
563
|
+
record.verdict = 'error'
|
|
564
|
+
record.error = { stage: record.fixture === 'none' ? 'fixture' : 'setup', message: error.message }
|
|
565
|
+
record.finishedAt = new Date().toISOString()
|
|
566
|
+
lines.push(`[error] ${error.message}`)
|
|
567
|
+
return record
|
|
568
|
+
} finally {
|
|
569
|
+
record.elapsedMs = Date.now() - Date.parse(startedAt)
|
|
570
|
+
if (!record.finishedAt) record.finishedAt = new Date().toISOString()
|
|
571
|
+
if (!options.keepFarm) {
|
|
572
|
+
try { removeFarm(farmDir) } catch (error) { lines.push(`[farm] cleanup failed: ${error.message}`) }
|
|
573
|
+
}
|
|
574
|
+
if (artifactTmp) {
|
|
575
|
+
try { fs.rmSync(artifactTmp, { recursive: true, force: true }) } catch { /* best effort */ }
|
|
576
|
+
}
|
|
577
|
+
try { writeLog(options.out, row.id, lines) } catch { /* log is evidence, not a gate */ }
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function summarize(manifest, outDir, targetDshVersion, requestedProfile = 'base') {
|
|
582
|
+
const rows = manifest.rows.map((row) => {
|
|
583
|
+
const file = path.join(outDir, `${row.id}.json`)
|
|
584
|
+
if (!fs.existsSync(file)) {
|
|
585
|
+
return { id: row.id, name: row.name, version: row.version, source: row.source, profile: null, status: 'missing', fixture: null, g3State: null, targetRows: null, active: null, elapsedMs: null, error: null }
|
|
586
|
+
}
|
|
587
|
+
const record = readJson(file)
|
|
588
|
+
return {
|
|
589
|
+
id: row.id,
|
|
590
|
+
name: row.name,
|
|
591
|
+
version: row.version,
|
|
592
|
+
source: row.source,
|
|
593
|
+
profile: record.profile ?? null,
|
|
594
|
+
status: record.status,
|
|
595
|
+
verdict: record.verdict ?? record.status,
|
|
596
|
+
fixture: record.fixture,
|
|
597
|
+
g3State: record.g3State,
|
|
598
|
+
targetRows: record.g3Rows,
|
|
599
|
+
active: record.g3Active,
|
|
600
|
+
elapsedMs: record.elapsedMs,
|
|
601
|
+
dshVersion: record.dshVersion,
|
|
602
|
+
error: record.error,
|
|
603
|
+
}
|
|
604
|
+
})
|
|
605
|
+
const counts = { verified: 0, partial: 0, failed: 0, error: 0, missing: 0 }
|
|
606
|
+
const verdicts = {}
|
|
607
|
+
for (const row of rows) {
|
|
608
|
+
counts[row.status] = (counts[row.status] ?? 0) + 1
|
|
609
|
+
verdicts[row.verdict ?? row.status] = (verdicts[row.verdict ?? row.status] ?? 0) + 1
|
|
610
|
+
}
|
|
611
|
+
const dshVersions = [...new Set(rows.map((row) => row.dshVersion).filter(Boolean))]
|
|
612
|
+
const profiles = new Set(rows.map((row) => row.profile).filter(Boolean))
|
|
613
|
+
const profile = profiles.has('web') ? 'web' : profiles.has('base') ? 'base' : requestedProfile
|
|
614
|
+
return {
|
|
615
|
+
schema: profile === 'web' ? 'dsh-entry18-web-summary/1' : 'dsh-entry18-fullbase-summary/1',
|
|
616
|
+
generatedAt: new Date().toISOString(),
|
|
617
|
+
profile,
|
|
618
|
+
note: profile === 'web'
|
|
619
|
+
? 'Aggregates the <id>.json rows present in this directory with the web layer; a missing row is not a pass.'
|
|
620
|
+
: 'Aggregates the <id>.json rows present in this directory. Run the entry18-runtime workflow to fill all 18 shards.',
|
|
621
|
+
targetDshVersion,
|
|
622
|
+
dshVersions,
|
|
623
|
+
count: rows.length,
|
|
624
|
+
present: rows.length - counts.missing,
|
|
625
|
+
missing: counts.missing,
|
|
626
|
+
status: counts,
|
|
627
|
+
verdicts,
|
|
628
|
+
rows,
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
async function main() {
|
|
633
|
+
const options = parseArgs(process.argv.slice(2))
|
|
634
|
+
const manifest = readJson(MANIFEST_PATH)
|
|
635
|
+
if (manifest.rows.length !== 18) throw new Error(`manifest has ${manifest.rows.length} rows, expected 18`)
|
|
636
|
+
if (options.summary) {
|
|
637
|
+
fs.mkdirSync(options.out, { recursive: true })
|
|
638
|
+
const summary = summarize(manifest, options.out, manifest.targetDshVersion, options.web ? 'web' : 'base')
|
|
639
|
+
writeJson(path.join(options.out, 'summary.json'), summary)
|
|
640
|
+
console.log(`summary: ${summary.present}/${summary.count} rows present, status=${JSON.stringify(summary.status)}, verdicts=${JSON.stringify(summary.verdicts)}`)
|
|
641
|
+
return 0
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
const layout = resolveDshRoot(options.dshRoot)
|
|
645
|
+
const dshVersion = layout.version
|
|
646
|
+
if (options.expectedDshVersion && dshVersion !== options.expectedDshVersion) {
|
|
647
|
+
throw new Error(`installed DSH is ${dshVersion}, expected ${options.expectedDshVersion}`)
|
|
648
|
+
}
|
|
649
|
+
const webPatch = options.web
|
|
650
|
+
? (options.web === 'auto' ? path.join(layout.depsRoot, WEB_PATCH) : options.web)
|
|
651
|
+
: null
|
|
652
|
+
if (webPatch && !fs.existsSync(webPatch)) throw new Error(`web patch not found: ${webPatch}`)
|
|
653
|
+
const selected = options.all
|
|
654
|
+
? manifest.rows
|
|
655
|
+
: [manifest.rows.find((row) => row.id === options.only) ?? (() => { throw new Error(`unknown id: ${options.only}`) })()]
|
|
656
|
+
fs.mkdirSync(options.out, { recursive: true })
|
|
657
|
+
console.log(`entry18 ${webPatch ? 'web' : 'fullbase'}: ${selected.length} shard(s), dsh ${dshVersion} at ${layout.dir}`)
|
|
658
|
+
console.log(`deps root: ${layout.depsRoot}`)
|
|
659
|
+
console.log(`base: ${layout.basePatch}`)
|
|
660
|
+
console.log(webPatch ? `web layer: ${webPatch}` : 'web layer: none')
|
|
661
|
+
|
|
662
|
+
let exitCode = 0
|
|
663
|
+
for (const row of selected) {
|
|
664
|
+
const record = runShard(row, options, { layout, dshVersion, options, webPatch, startedAt: new Date().toISOString() })
|
|
665
|
+
writeJson(path.join(options.out, `${row.id}.json`), record)
|
|
666
|
+
const verdict = record.status === 'error'
|
|
667
|
+
? `error(${record.error?.stage}: ${record.error?.message})`
|
|
668
|
+
: `${record.status} fixture=${record.fixture} rows=${record.g3Active}/${record.g3Rows}`
|
|
669
|
+
console.log(`[${row.id}] ${verdict} ${record.elapsedMs}ms -> ${path.join(options.out, `${row.id}.json`)}`)
|
|
670
|
+
if (record.status === 'error') exitCode = 3
|
|
671
|
+
}
|
|
672
|
+
if (options.all) {
|
|
673
|
+
const summary = summarize(manifest, options.out, manifest.targetDshVersion, webPatch ? 'web' : 'base')
|
|
674
|
+
writeJson(path.join(options.out, 'summary.json'), summary)
|
|
675
|
+
console.log(`summary: ${summary.present}/${summary.count} rows, status=${JSON.stringify(summary.status)}, verdicts=${JSON.stringify(summary.verdicts)}`)
|
|
676
|
+
}
|
|
677
|
+
return exitCode
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
try {
|
|
681
|
+
process.exitCode = await main()
|
|
682
|
+
} catch (error) {
|
|
683
|
+
console.error(`entry18-ci: ${error.message}`)
|
|
684
|
+
process.exitCode = 3
|
|
685
|
+
}
|