@dzhechkov/harness-cli 0.3.226 → 0.3.228
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/README.md +40 -3
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +310 -23
- package/dist/cli.js.map +1 -1
- package/package.json +2 -2
- package/src/cli.ts +258 -26
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dzhechkov/harness-cli",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.228",
|
|
4
4
|
"description": "The dz CLI — install AI skills for Claude Code, Codex, OpenCode, Hermes, OpenClaude, GitHub Copilot. 35 commands, 13 presets, 6 platform targets.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
"@dzhechkov/skills-reverse-engineering": "^0.1.0",
|
|
56
56
|
"@dzhechkov/skills-presentation-storyteller": "^0.1.0",
|
|
57
57
|
"@dzhechkov/skills-website-cloner": "^0.1.0",
|
|
58
|
-
"@dzhechkov/harness-core": "0.3.
|
|
58
|
+
"@dzhechkov/harness-core": "0.3.121"
|
|
59
59
|
},
|
|
60
60
|
"devDependencies": {
|
|
61
61
|
"@types/node": "^25.6.0",
|
package/src/cli.ts
CHANGED
|
@@ -4,11 +4,11 @@
|
|
|
4
4
|
* @packageDocumentation
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, renameSync, rmdirSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, rmdirSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
8
8
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
9
9
|
import { fileURLToPath } from 'node:url';
|
|
10
10
|
import { execSync } from 'node:child_process';
|
|
11
|
-
import { homedir } from 'node:os';
|
|
11
|
+
import { homedir, tmpdir } from 'node:os';
|
|
12
12
|
import { createRequire } from 'node:module';
|
|
13
13
|
|
|
14
14
|
import {
|
|
@@ -101,6 +101,7 @@ import {
|
|
|
101
101
|
buildSbom,
|
|
102
102
|
resolveTrustRoot,
|
|
103
103
|
decideVerifyPolicy,
|
|
104
|
+
generateSigningKeypair,
|
|
104
105
|
decideProvenance,
|
|
105
106
|
isInsideTree,
|
|
106
107
|
signManifest,
|
|
@@ -142,6 +143,8 @@ import {
|
|
|
142
143
|
readExistingForScaffold,
|
|
143
144
|
assembleChallengeContext,
|
|
144
145
|
buildChallengeBrief,
|
|
146
|
+
planDiscriminationCheck,
|
|
147
|
+
classifyDiscrimination,
|
|
145
148
|
pickAdversaryModel,
|
|
146
149
|
CHALLENGE_QUESTIONS,
|
|
147
150
|
loadOutcomes,
|
|
@@ -3228,10 +3231,73 @@ function reportPackVerification(
|
|
|
3228
3231
|
}
|
|
3229
3232
|
|
|
3230
3233
|
function cmdSign(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
|
|
3234
|
+
// `dz sign --init` — one-time keygen. Writes the PRIVATE key OUTSIDE the repo (default ~/.dz/keys/dz.key,
|
|
3235
|
+
// mode 0600) and prints the PUBLIC key for the operator to commit as keys/dz.pub. The private key never
|
|
3236
|
+
// touches the repo or a tarball (the hard invariant — enforced by assertKeyOutsideTree).
|
|
3237
|
+
if (flags.has('init')) {
|
|
3238
|
+
const outPath = resolve(cwd, options.get('out') ?? join(homedir(), '.dz', 'keys', 'dz.key'));
|
|
3239
|
+
// Resolve symlinks BEFORE the containment check: an --out (or a parent dir) that is a symlink pointing
|
|
3240
|
+
// INTO the repo would otherwise slip the private key inside the tree past a string-only guard — including a
|
|
3241
|
+
// DANGLING symlink whose target does not exist yet (existsSync follows the link and reports false, so the
|
|
3242
|
+
// symlink itself must be resolved via lstat/readlink, not existsSync).
|
|
3243
|
+
const realTarget = ((): string => {
|
|
3244
|
+
// walk up to the deepest path that exists as ANY entry (file, dir, or even a dangling symlink).
|
|
3245
|
+
let anc = outPath;
|
|
3246
|
+
const tail: string[] = [];
|
|
3247
|
+
for (;;) {
|
|
3248
|
+
try { lstatSync(anc); break; } catch { /* not present */ }
|
|
3249
|
+
const parent = dirname(anc);
|
|
3250
|
+
if (parent === anc) return outPath; // reached root without an existing entry
|
|
3251
|
+
tail.unshift(basename(anc));
|
|
3252
|
+
anc = parent;
|
|
3253
|
+
}
|
|
3254
|
+
let base: string;
|
|
3255
|
+
try {
|
|
3256
|
+
base = realpathSync(anc); // resolves dirs/files and any RESOLVABLE symlink chain
|
|
3257
|
+
} catch {
|
|
3258
|
+
// `anc` exists but realpath threw → a dangling symlink: resolve its immediate target by hand.
|
|
3259
|
+
try { base = resolve(dirname(anc), readlinkSync(anc)); } catch { return outPath; }
|
|
3260
|
+
}
|
|
3261
|
+
return tail.length ? join(base, ...tail) : base;
|
|
3262
|
+
})();
|
|
3263
|
+
try {
|
|
3264
|
+
assertKeyOutsideTree(realTarget, cwd);
|
|
3265
|
+
} catch (err) {
|
|
3266
|
+
write(`dz sign --init: ${(err as Error).message}`);
|
|
3267
|
+
write(' choose an --out path (and parent) OUTSIDE the repository (e.g. ~/.dz/keys/dz.key)');
|
|
3268
|
+
return 1;
|
|
3269
|
+
}
|
|
3270
|
+
if (existsSync(outPath) && !flags.has('force')) {
|
|
3271
|
+
write(`dz sign --init: ${outPath} already exists — refusing to overwrite (pass --force to replace)`);
|
|
3272
|
+
write(' overwriting a signing key orphans every pack signed with the old one.');
|
|
3273
|
+
return 1;
|
|
3274
|
+
}
|
|
3275
|
+
const { privateKey, publicKey } = generateSigningKeypair();
|
|
3276
|
+
try {
|
|
3277
|
+
mkdirSync(dirname(outPath), { recursive: true });
|
|
3278
|
+
// Unlink an existing file first: writeFileSync's `mode` is ignored when the file already exists, so a
|
|
3279
|
+
// pre-existing 0644 key would stay world-readable after --force. Removing it forces a fresh 0600 create.
|
|
3280
|
+
if (existsSync(outPath)) rmSync(outPath, { force: true });
|
|
3281
|
+
writeFileSync(outPath, privateKey, { mode: 0o600 });
|
|
3282
|
+
chmodSync(outPath, 0o600); // belt-and-suspenders: guarantee 0600 regardless of umask/prior state
|
|
3283
|
+
} catch (err) {
|
|
3284
|
+
write(`dz sign --init: could not write the private key to ${outPath}: ${(err as Error).message}`);
|
|
3285
|
+
return 1;
|
|
3286
|
+
}
|
|
3287
|
+
write(`dz sign --init: Ed25519 keypair generated.`);
|
|
3288
|
+
write(` private key → ${outPath} (mode 0600, OUTSIDE the repo — never commit it)`);
|
|
3289
|
+
write(` public key → commit the block below as ${TRUST_ROOT_REL}:`);
|
|
3290
|
+
write('');
|
|
3291
|
+
write(publicKey.trimEnd());
|
|
3292
|
+
write('');
|
|
3293
|
+
write(` then: dz sign --pack <dir> --key ${outPath} (sign a pack)`);
|
|
3294
|
+
return 0;
|
|
3295
|
+
}
|
|
3296
|
+
|
|
3231
3297
|
const pack = options.get('pack');
|
|
3232
3298
|
const key = options.get('key');
|
|
3233
3299
|
if (!pack || !key) {
|
|
3234
|
-
write('dz sign: --pack <dir> and --key <path> are both required');
|
|
3300
|
+
write('dz sign: --pack <dir> and --key <path> are both required (or: dz sign --init to generate a keypair)');
|
|
3235
3301
|
write(' the key path MUST be outside the repository working tree (a leaked signing key is not revertible)');
|
|
3236
3302
|
return 1;
|
|
3237
3303
|
}
|
|
@@ -3283,6 +3349,37 @@ function cmdVerifyPack(options: Map<string, string>, flags: Set<string>, cwd: st
|
|
|
3283
3349
|
return 1;
|
|
3284
3350
|
}
|
|
3285
3351
|
|
|
3352
|
+
/**
|
|
3353
|
+
* `dz sbom` — emit a CycloneDX 1.5 SBOM (Software Bill of Materials) for a pack, standalone (no signing).
|
|
3354
|
+
* The same inventory `dz sign` writes as `sbom.json`, available on its own for audit/procurement. Components
|
|
3355
|
+
* are the pack's shipped files with their SHA-256 — a file-level bill of materials for the pack.
|
|
3356
|
+
* --pack <dir> the pack to inventory (required)
|
|
3357
|
+
* --out <file> write here (default: print to stdout)
|
|
3358
|
+
* --json (implied) SPDX/CycloneDX is already JSON
|
|
3359
|
+
*/
|
|
3360
|
+
function cmdSbom(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
|
|
3361
|
+
const pack = options.get('pack');
|
|
3362
|
+
if (!pack) { write('dz sbom: --pack <dir> is required'); return 1; }
|
|
3363
|
+
const packDir = resolve(cwd, pack);
|
|
3364
|
+
if (!existsSync(packDir)) { write(`dz sbom: no such pack: ${packDir}`); return 1; }
|
|
3365
|
+
|
|
3366
|
+
const files = packFiles(packDir);
|
|
3367
|
+
if (files.length === 0) { write('dz sbom: the pack contains no files'); return 1; }
|
|
3368
|
+
const manifest = buildManifest(packDir, basename(packDir), files);
|
|
3369
|
+
const sbom = buildSbom(manifest);
|
|
3370
|
+
const out = JSON.stringify(sbom, null, 2);
|
|
3371
|
+
|
|
3372
|
+
const outOpt = options.get('out');
|
|
3373
|
+
if (outOpt !== undefined) {
|
|
3374
|
+
const outPath = resolve(cwd, outOpt);
|
|
3375
|
+
try { writeFileSync(outPath, out + '\n'); } catch (err) { write(`dz sbom: could not write ${outPath}: ${(err as Error).message}`); return 1; }
|
|
3376
|
+
write(`dz sbom: ${sbom.components.length} component(s) → ${outPath} (CycloneDX ${sbom.specVersion})`);
|
|
3377
|
+
return 0;
|
|
3378
|
+
}
|
|
3379
|
+
write(out);
|
|
3380
|
+
return 0;
|
|
3381
|
+
}
|
|
3382
|
+
|
|
3286
3383
|
function cmdPublish(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
|
|
3287
3384
|
// Reject unknown flags/options so a typo (e.g. `--dry-rum`) can NEVER be
|
|
3288
3385
|
// silently swallowed and flip the command into live-publish mode.
|
|
@@ -4060,33 +4157,40 @@ function cmdDriftCheck(options: Map<string, string>, flags: Set<string>, cwd: st
|
|
|
4060
4157
|
write(`allowlisted (accepted drift): ${r.allowlisted.map((d) => d.name).join(', ')}`);
|
|
4061
4158
|
}
|
|
4062
4159
|
write(`DRIFTED (unexpected, byte-differences between copies): ${r.drifted.length}`);
|
|
4160
|
+
let driftExit = 0;
|
|
4063
4161
|
if (r.drifted.length === 0) {
|
|
4064
4162
|
write('✓ no unexpected intra-monorepo skill drift');
|
|
4065
|
-
|
|
4066
|
-
|
|
4067
|
-
|
|
4068
|
-
|
|
4069
|
-
|
|
4070
|
-
|
|
4071
|
-
|
|
4072
|
-
|
|
4073
|
-
|
|
4074
|
-
|
|
4075
|
-
|
|
4076
|
-
|
|
4163
|
+
} else {
|
|
4164
|
+
write('');
|
|
4165
|
+
write('skill'.padEnd(34) + 'copies drift/total');
|
|
4166
|
+
for (const d of r.drifted) {
|
|
4167
|
+
write(
|
|
4168
|
+
d.name.padEnd(34) +
|
|
4169
|
+
String(d.copies).padStart(4) +
|
|
4170
|
+
' ' +
|
|
4171
|
+
`${d.driftFiles}/${d.totalFiles}` +
|
|
4172
|
+
(d.missingFiles ? ` (+${d.missingFiles} missing)` : ''),
|
|
4173
|
+
);
|
|
4174
|
+
}
|
|
4175
|
+
write('');
|
|
4176
|
+
write('→ fix each: heal drift, then commit. Remediation per skill:');
|
|
4177
|
+
for (const d of r.drifted) {
|
|
4178
|
+
const hasMeta = existsSync(join(root, 'packages', '@dzhechkov', 'skills-meta', d.name));
|
|
4179
|
+
write(
|
|
4180
|
+
hasMeta
|
|
4181
|
+
? ` dz sync-canonical ${d.name}`
|
|
4182
|
+
: ` dz sync-canonical ${d.name} --from <a-known-good-copy> (no skills-meta canonical)`,
|
|
4183
|
+
);
|
|
4184
|
+
}
|
|
4185
|
+
write(' (accept a drift intentionally: add its name to .dz/drift-allowlist.json with a reason)');
|
|
4186
|
+
driftExit = 1; // EXIT CODE 1 = the CI gate trips
|
|
4077
4187
|
}
|
|
4188
|
+
// Supplementary CI check: installed-pack signatures. A TAMPERED pack trips the gate (fatal); an unsigned
|
|
4189
|
+
// pack or a missing trust root is reported, not fatal — the same warn/block posture as `dz doctor` (the
|
|
4190
|
+
// primary signature gate). drift-check surfaces it so the CI drift view also flags a tampered pack.
|
|
4078
4191
|
write('');
|
|
4079
|
-
|
|
4080
|
-
|
|
4081
|
-
const hasMeta = existsSync(join(root, 'packages', '@dzhechkov', 'skills-meta', d.name));
|
|
4082
|
-
write(
|
|
4083
|
-
hasMeta
|
|
4084
|
-
? ` dz sync-canonical ${d.name}`
|
|
4085
|
-
: ` dz sync-canonical ${d.name} --from <a-known-good-copy> (no skills-meta canonical)`,
|
|
4086
|
-
);
|
|
4087
|
-
}
|
|
4088
|
-
write(' (accept a drift intentionally: add its name to .dz/drift-allowlist.json with a reason)');
|
|
4089
|
-
return 1; // EXIT CODE 1 = the CI gate trips
|
|
4192
|
+
const sigFatal = reportPackVerification(root, options.get('pubkey'), flags.has('require-signing'), write);
|
|
4193
|
+
return driftExit || sigFatal;
|
|
4090
4194
|
}
|
|
4091
4195
|
|
|
4092
4196
|
/**
|
|
@@ -4573,6 +4677,130 @@ function cmdChallenge(options: Map<string, string>, flags: Set<string>, cwd: str
|
|
|
4573
4677
|
return 0;
|
|
4574
4678
|
}
|
|
4575
4679
|
|
|
4680
|
+
/**
|
|
4681
|
+
* `dz discrimination-check` — the §42 test-discrimination gate (feature learned from cve-bench/evaluate.mjs).
|
|
4682
|
+
* feature-adr Step-8 already asserts the ADR safety property HAS a test; this asserts that test DISCRIMINATES:
|
|
4683
|
+
* run the property test(s) in an isolated git worktree at the pre-feature base (default HEAD, since the feature
|
|
4684
|
+
* diff is uncommitted mid-pipeline). They MUST go red without the feature diff — a green is a false green.
|
|
4685
|
+
*
|
|
4686
|
+
* --test <a.test.ts[,b.test.ts]> the property test file(s) mapped from the ADR Confirmation (comma list)
|
|
4687
|
+
* --base <ref> the base ref to fail against (default HEAD)
|
|
4688
|
+
* --name '<filter>' optional -t test-name filter applied to every target
|
|
4689
|
+
* --runner '<cmd>' test runner (default `npx vitest run`)
|
|
4690
|
+
* --json machine-readable {plan, results, verdict, finding}
|
|
4691
|
+
*
|
|
4692
|
+
* NEVER auto-aborts: a non-discriminating (false-green) test is reported as a HIGH finding for the owner to
|
|
4693
|
+
* decide (dz's rule — a false gate kills trust). Exit code is 0 on a clean run regardless of verdict; 2 only on
|
|
4694
|
+
* a usage/setup error, so a caller distinguishes "gate ran" from "gate could not run".
|
|
4695
|
+
*/
|
|
4696
|
+
function cmdDiscriminationCheck(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
|
|
4697
|
+
let repoRoot = cwd;
|
|
4698
|
+
try { repoRoot = execSync('git rev-parse --show-toplevel', { cwd, encoding: 'utf-8' }).trim() || cwd; } catch { /* not git */ }
|
|
4699
|
+
|
|
4700
|
+
const testArg = options.get('test');
|
|
4701
|
+
if (testArg === undefined || testArg.trim() === '') {
|
|
4702
|
+
write('dz discrimination-check: pass --test <property-test.ts[,...]> (the test(s) the ADR Confirmation names).');
|
|
4703
|
+
return 2;
|
|
4704
|
+
}
|
|
4705
|
+
const nameFilter = options.get('name');
|
|
4706
|
+
const propertyTests = testArg.split(',').map((s) => s.trim()).filter(Boolean).map((file) =>
|
|
4707
|
+
nameFilter !== undefined && nameFilter.trim() !== '' ? { file, name: nameFilter.trim() } : { file });
|
|
4708
|
+
const baseRef = options.get('base') ?? 'HEAD';
|
|
4709
|
+
const runnerOpt = options.get('runner');
|
|
4710
|
+
|
|
4711
|
+
const plan = planDiscriminationCheck(runnerOpt !== undefined ? { baseRef, propertyTests, runner: runnerOpt } : { baseRef, propertyTests });
|
|
4712
|
+
|
|
4713
|
+
if (!plan.runnable) {
|
|
4714
|
+
// No safe target to run → this is the existing "property untested" finding (empty propertyTests classify).
|
|
4715
|
+
const result = classifyDiscrimination({ propertyTests: [], results: [] });
|
|
4716
|
+
if (flags.has('json')) { write(JSON.stringify({ plan, results: [], ...result }, null, 2)); return 0; }
|
|
4717
|
+
write(`discrimination-check: NOT RUN (${plan.reason ?? 'unknown'})`);
|
|
4718
|
+
if (plan.rejected.length) write(' rejected: ' + plan.rejected.map((r) => `${r.file} (${r.reason})`).join(', '));
|
|
4719
|
+
write(` → ${result.finding?.detail ?? 'no property test to check'}`);
|
|
4720
|
+
return 0;
|
|
4721
|
+
}
|
|
4722
|
+
|
|
4723
|
+
// Execute the plan in a temp worktree WE own; substitute {{WORKTREE}} and always clean up.
|
|
4724
|
+
// `git worktree add` must CREATE the path, so compute a fresh non-existent one (do NOT mkdtemp it).
|
|
4725
|
+
const worktree = join(mkdtempSync(join(tmpdir(), 'dz-disc-')), 'wt');
|
|
4726
|
+
const results: { file: string; name?: string; outcome: 'pass' | 'fail' | 'error' }[] = [];
|
|
4727
|
+
try {
|
|
4728
|
+
// 1) add the detached worktree at base (git creates `worktree`; its parent already exists).
|
|
4729
|
+
const addCmd = plan.commands[0]!.replace(/\{\{WORKTREE\}\}/g, worktree);
|
|
4730
|
+
execSync(addCmd, { cwd: repoRoot, stdio: 'pipe', encoding: 'utf-8' });
|
|
4731
|
+
|
|
4732
|
+
// 1b) a fresh worktree has NO node_modules — without this, every test fails to load (runner + deps
|
|
4733
|
+
// unresolvable) and the gate collapses to always-VIA_ERROR, blind to false greens. Absolute-path
|
|
4734
|
+
// symlinks point back at the main checkout's already-installed trees, robust across pnpm's layout.
|
|
4735
|
+
const linkNodeModules = (relDir: string): void => {
|
|
4736
|
+
const srcNm = join(repoRoot, relDir, 'node_modules');
|
|
4737
|
+
if (!existsSync(srcNm)) return;
|
|
4738
|
+
const dstNm = join(worktree, relDir, 'node_modules');
|
|
4739
|
+
if (existsSync(dstNm)) return;
|
|
4740
|
+
try { mkdirSync(dirname(dstNm), { recursive: true }); symlinkSync(srcNm, dstNm, 'dir'); } catch { /* best effort */ }
|
|
4741
|
+
};
|
|
4742
|
+
linkNodeModules('.'); // root (hoisted deps + .bin)
|
|
4743
|
+
const pkgDirs = new Set<string>();
|
|
4744
|
+
for (const t of plan.targets) {
|
|
4745
|
+
let d = dirname(t.file);
|
|
4746
|
+
while (d && d !== '.' && d !== sep) {
|
|
4747
|
+
if (existsSync(join(repoRoot, d, 'package.json'))) { pkgDirs.add(d); break; }
|
|
4748
|
+
d = dirname(d);
|
|
4749
|
+
}
|
|
4750
|
+
}
|
|
4751
|
+
for (const d of pkgDirs) linkNodeModules(d);
|
|
4752
|
+
|
|
4753
|
+
// 2) copy each property test into the base worktree, then 3) run it and record pass/fail/error per target.
|
|
4754
|
+
for (const t of plan.targets) {
|
|
4755
|
+
try {
|
|
4756
|
+
const src = resolve(repoRoot, t.file);
|
|
4757
|
+
// containment guard (defense in depth beyond planDiscriminationCheck's path sanitation).
|
|
4758
|
+
if (!resolve(src).startsWith(resolve(repoRoot) + sep)) { results.push(nameFor(t, 'error')); continue; }
|
|
4759
|
+
const dst = join(worktree, t.file);
|
|
4760
|
+
mkdirSync(dirname(dst), { recursive: true });
|
|
4761
|
+
writeFileSync(dst, readFileSync(src));
|
|
4762
|
+
} catch { results.push(nameFor(t, 'error')); continue; }
|
|
4763
|
+
|
|
4764
|
+
const runner = (runnerOpt !== undefined && plan.commands.some((c) => c.includes(runnerOpt))) ? runnerOpt : 'npx vitest run';
|
|
4765
|
+
// t.file + t.name already passed the engine's strict sanitation (no quotes/metacharacters/leading-dash);
|
|
4766
|
+
// still quote + `--` so a path can never be read as a runner option or split a word.
|
|
4767
|
+
const nameArg = t.name ? ` -t '${t.name}'` : '';
|
|
4768
|
+
try {
|
|
4769
|
+
execSync(`${runner}${nameArg} -- '${t.file}'`, { cwd: worktree, stdio: 'pipe', encoding: 'utf-8' });
|
|
4770
|
+
results.push(nameFor(t, 'pass')); // exit 0 → test PASSED at base → false green
|
|
4771
|
+
} catch (e) {
|
|
4772
|
+
// vitest exits non-zero on failure AND on load/compile error. Distinguish: a load error usually names
|
|
4773
|
+
// "Cannot find module"/"Failed to load"/"No test files"; otherwise treat as an assertion failure (red).
|
|
4774
|
+
const out = String((e as { stdout?: string; stderr?: string }).stdout ?? '') + String((e as { stderr?: string }).stderr ?? '');
|
|
4775
|
+
const isLoadError = /cannot find module|failed to load|no test (files )?found|error: cannot|transform failed|esbuild/i.test(out);
|
|
4776
|
+
results.push(nameFor(t, isLoadError ? 'error' : 'fail'));
|
|
4777
|
+
}
|
|
4778
|
+
}
|
|
4779
|
+
} catch (e) {
|
|
4780
|
+
if (flags.has('json')) { write(JSON.stringify({ plan, error: 'worktree-setup-failed', detail: String((e as Error).message).slice(0, 300) }, null, 2)); }
|
|
4781
|
+
else write(`discrimination-check: could not create worktree at ${baseRef}: ${String((e as Error).message).slice(0, 200)}`);
|
|
4782
|
+
return 2;
|
|
4783
|
+
} finally {
|
|
4784
|
+
try { execSync(`git worktree remove --force ${worktree}`, { cwd: repoRoot, stdio: 'pipe' }); } catch { /* fall through to rm */ }
|
|
4785
|
+
// remove the whole mkdtemp parent (worktree is `<mkdtemp>/wt`), so nothing leaks under tmp even on error.
|
|
4786
|
+
try { rmSync(dirname(worktree), { recursive: true, force: true }); } catch { /* best effort */ }
|
|
4787
|
+
try { execSync('git worktree prune', { cwd: repoRoot, stdio: 'pipe' }); } catch { /* best effort */ }
|
|
4788
|
+
}
|
|
4789
|
+
|
|
4790
|
+
const result = classifyDiscrimination({ propertyTests, results });
|
|
4791
|
+
if (flags.has('json')) { write(JSON.stringify({ plan, results, ...result }, null, 2)); return 0; }
|
|
4792
|
+
|
|
4793
|
+
write(`discrimination-check @ ${baseRef} — verdict: ${result.aggregate}`);
|
|
4794
|
+
for (const p of result.perTest) write(` ${p.verdict === 'NON_DISCRIMINATING' ? '✗' : '✓'} ${p.file}${p.name ? ` (${p.name})` : ''}: ${p.verdict}`);
|
|
4795
|
+
if (result.finding) write(`\n [${result.finding.severity}] ${result.finding.title}\n ${result.finding.detail}`);
|
|
4796
|
+
return 0;
|
|
4797
|
+
}
|
|
4798
|
+
|
|
4799
|
+
/** small helper: build a result row, omitting `name` when absent (exactOptionalPropertyTypes). */
|
|
4800
|
+
function nameFor(t: { file: string; name?: string }, outcome: 'pass' | 'fail' | 'error'): { file: string; name?: string; outcome: 'pass' | 'fail' | 'error' } {
|
|
4801
|
+
return t.name !== undefined ? { file: t.file, name: t.name, outcome } : { file: t.file, outcome };
|
|
4802
|
+
}
|
|
4803
|
+
|
|
4576
4804
|
/**
|
|
4577
4805
|
* `dz routing` — inspect the learned cost-optimal routing outcome store (feature learned-cost-routing). Shows
|
|
4578
4806
|
* what `args.models[stage]='auto-cost'` currently believes per (stage, complexity-tier, model): gated
|
|
@@ -4939,6 +5167,8 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
|
|
|
4939
5167
|
return cmdClaimCheck(options, optionLists, flags, cwd, write);
|
|
4940
5168
|
case 'sign':
|
|
4941
5169
|
return cmdSign(options, flags, cwd, write);
|
|
5170
|
+
case 'sbom':
|
|
5171
|
+
return cmdSbom(options, flags, cwd, write);
|
|
4942
5172
|
case 'verify-pack':
|
|
4943
5173
|
return cmdVerifyPack(options, flags, cwd, write);
|
|
4944
5174
|
case 'setup':
|
|
@@ -4987,6 +5217,8 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
|
|
|
4987
5217
|
return cmdFeatureAdrSetup(options, flags, cwd, write);
|
|
4988
5218
|
case 'challenge':
|
|
4989
5219
|
return cmdChallenge(options, flags, cwd, write);
|
|
5220
|
+
case 'discrimination-check':
|
|
5221
|
+
return cmdDiscriminationCheck(options, flags, cwd, write);
|
|
4990
5222
|
case 'routing':
|
|
4991
5223
|
return cmdRouting(options, flags, cwd, write);
|
|
4992
5224
|
case 'bto-optimize':
|