@dzhechkov/harness-cli 0.8.9 → 0.8.10
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/.dz-manifest.json +8 -8
- package/README.md +27 -6
- package/dist/cli.d.ts +11 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +598 -111
- package/dist/cli.js.map +1 -1
- package/package.json +9 -9
- package/sbom.json +7 -7
- package/src/cli.ts +647 -64
package/src/cli.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { isBooleanFlag } from './boolean-flags.js';
|
|
|
13
13
|
import { resolveInstallSpec } from './install-spec.js';
|
|
14
14
|
import { execFile, execFileSync, execSync, spawn, spawnSync, type ChildProcess } from 'node:child_process';
|
|
15
15
|
import { createHash, randomBytes } from 'node:crypto';
|
|
16
|
-
import { homedir, tmpdir } from 'node:os';
|
|
16
|
+
import { homedir, hostname, tmpdir } from 'node:os';
|
|
17
17
|
import { createRequire } from 'node:module';
|
|
18
18
|
import { isDeepStrictEqual } from 'node:util';
|
|
19
19
|
|
|
@@ -30,6 +30,8 @@ import {
|
|
|
30
30
|
runDoctor,
|
|
31
31
|
runInit,
|
|
32
32
|
discoverSkillIds,
|
|
33
|
+
resolveSelection,
|
|
34
|
+
formatSelectRefusal,
|
|
33
35
|
runIntegrationsVerify,
|
|
34
36
|
resolvePackageSkillRoots,
|
|
35
37
|
PACKAGE_SKILL_LAYOUTS,
|
|
@@ -210,6 +212,8 @@ import {
|
|
|
210
212
|
readTailInfo,
|
|
211
213
|
appendChainedLines,
|
|
212
214
|
verifyEventChainText,
|
|
215
|
+
classifyChainDefects,
|
|
216
|
+
CHAINED_JOURNALS,
|
|
213
217
|
buildManifest,
|
|
214
218
|
buildSbom,
|
|
215
219
|
resolveTrustRoot,
|
|
@@ -371,6 +375,11 @@ import {
|
|
|
371
375
|
type EpochOutcome,
|
|
372
376
|
scoreRun,
|
|
373
377
|
readQeGrade,
|
|
378
|
+
scoreReceiptToAggregateRow,
|
|
379
|
+
readScoreAggregateRows,
|
|
380
|
+
dedupeScoreAggregateRows,
|
|
381
|
+
buildScoreAggregateReport,
|
|
382
|
+
renderScoreAggregateReport,
|
|
374
383
|
recapWindow,
|
|
375
384
|
decideHorizon,
|
|
376
385
|
withinWindow,
|
|
@@ -478,6 +487,7 @@ import {
|
|
|
478
487
|
// Mutation gate (feature ha-mutation-gate) — break each named protection, run the suite, require red.
|
|
479
488
|
parseMutationRegistry,
|
|
480
489
|
applyMutationToText,
|
|
490
|
+
attributeBaselineRedness,
|
|
481
491
|
countFailingTests,
|
|
482
492
|
detectSuiteCompletionReceipt,
|
|
483
493
|
detectSuiteReceiptMismatch,
|
|
@@ -487,6 +497,7 @@ import {
|
|
|
487
497
|
mutationGateExitCode,
|
|
488
498
|
summarizeMutationResults,
|
|
489
499
|
renderMutationReport,
|
|
500
|
+
runWithOneInternalRetry,
|
|
490
501
|
TRACE_BUNDLE_LEDGER_PATH,
|
|
491
502
|
TRACE_BUNDLE_SCHEMA,
|
|
492
503
|
TRACE_BUNDLE_RUN_META_FILE,
|
|
@@ -573,7 +584,7 @@ export const DZ_COMMANDS: readonly string[] = [
|
|
|
573
584
|
'epoch-replay', 'score', 'recap', 'cadence', 'qe-rounds', 'restart-advisor', 'tg-post',
|
|
574
585
|
'name-check', 'provenance-check', 'feature-adr-record', 'amendment-check', 'contract-check',
|
|
575
586
|
'feature-adr-checkpoint', 'profile', 'reqe', 'qe-bridge', 'backlog', 'routing',
|
|
576
|
-
'bto-optimize', 'dashboard', 'roam', 'import-ecc',
|
|
587
|
+
'bto-optimize', 'dashboard', 'roam', 'import-ecc', 'chain',
|
|
577
588
|
];
|
|
578
589
|
|
|
579
590
|
const USAGE = `dz - DZ cross-platform harness CLI
|
|
@@ -615,6 +626,7 @@ Usage:
|
|
|
615
626
|
dz epoch-replay --judge <filled-work-order.json> [--out <file>] (blind judge prompts from the filled plans)
|
|
616
627
|
dz epoch-replay --score <judgments.json> --work-order <file> [--slice <name>] [--json] (un-blind against the pre-registered assignment → SUPPORTED only when the two 95% Wilson CIs are DISJOINT, else FALSIFIED / INCONCLUSIVE)
|
|
617
628
|
dz score --slug <feature> [--project <dir>] [--json] (process scorecard for ONE feature-adr run, from its artifacts: ADR confirmation, discrimination, cross-model QE grade, live verification, README-first, learning loop, amendments — descriptive-only, a low score exits 0)
|
|
629
|
+
dz score --all [--project <dir>] [--json] (sweep features/*/.fa-state/score-*.json into the append-only chained scorecards aggregate — descriptive-only, always exits 0)
|
|
618
630
|
dz recap [--day|--week|--month] [--at <ISO date>] [--project <dir>] [--json] (what was done over a window, from records only: deliveries with the grade an independent review STATED — a report naming two grades is reported ambiguous, never guessed — registry publishes, gate verdicts, knowledge reuse. --quarter/--half-year/--year are RECOGNISED and REFUSED with the real span in days: there is one complete quarter and the longest record is 174 days. Every section carries its own data-start date, and "the source was not read" never prints as zero. Contaminated measures — commit count, lines, tokens, learning-event volume, inventory counts, lesson count — are not computed, and the report says so. exit 0 reported / 2 refused)
|
|
619
631
|
dz cadence [--window day|week|month|quarter|halfyear|year] [--json] (the WHAT-SHIPPED aggregator: graded-shipment cadence by ISO week + npm-publish cadence (recap cache) + guard repeat decay on the FIXED rule set + recall reuse; a window deeper than 2× the record is REFUSED with the depth named (ADR: a cadence from one point is scale forgery); exit 0 report / 2 refused-window / 1 usage)
|
|
620
632
|
dz qe-rounds (--slug <feature> | --feature-dir <abs>) [--ceiling <n>] [--project <dir>] [--json] (how many Step-8 review rounds has this feature ALREADY had? Reads what dz qe-bridge already wrote — signoff-<runId>.json and failed-*.json under features/<slug>/.fa-state/qe-bridge — and writes nothing itself, so it can answer for runs already past. A round is a runId, not a file; an attempt with no verdict is counted SEPARATELY and never merged; an unreadable record is NAMED and the count is declared a LOWER BOUND. ONE directory, never a union across checkouts. exit 0 under the ceiling / 1 at-or-over — owner decides, the command does not judge whether the rounds were warranted / 2 NOT ESTABLISHED, which is never "zero rounds")
|
|
@@ -633,7 +645,7 @@ Usage:
|
|
|
633
645
|
dz feature-adr-checkpoint (--slug <feature> | --feature-dir <abs>) --stage <s> --input-hash <h> --result <json> [--artifact a,b] [--json] (record a pipeline stage ONLY after measuring its artifacts on disk; refuses a null result, an absent artifact, or a stage that declares none — the subagent runs a COMMAND instead of hand-writing durable state)
|
|
634
646
|
dz profile [init|show|set|sync] [--json] (WHO the assistant is talking to — per-user store at ~/.dz/profile.json (0600, NEVER in a project), delivered as a marked block in ~/.claude/CLAUDE.md so it loads in EVERY project, dz installed or not. init = five questions (language, register, deep/weak domains as comma lists — "networking (CCIE; NSX)" keeps the parenthetical as the note, Enter skips — teaches y/n with one re-ask, never a silent default); show ALWAYS prints the store path + age + drift verdict + the rendered block; set register|language|teaches <v> or set deep|weak add|rm <tag> [note] — register accepts the owner's own words (профи / профи лайт / просто), an unknown value is REFUSED naming the accepted set; sync re-writes the block (runs automatically after init/set; foreign content byte-for-byte, timestamped backup before every modifying write). The register changes FORM, never FACTS, and governs dialogue only — never ADRs/commits/QE reports; both rules are baked into the rendered block at every level. exit 0 done / 1 no profile or failed / 2 refused input)
|
|
635
647
|
dz reqe [--slug <feature> [--done --report <f>]] [--json] (the re-QE debt ledger: a usage-switched run whose Step-8 QE ran on the coder's OWN family records a debt; list debts, print the cross-family review brief, settle FAIL-CLOSED against a graded report — the settlement lands in 08_qe_report.md)
|
|
636
|
-
dz qe-bridge --family claude --slug <feature> [--coder-family codex|claude] [--model <id>] [--files a,b] [--out <f>] [--timeout <s>] [--allow-same-family] [--json] (the REVERSE QE bridge: run an INDEPENDENT Claude reviewer over a feature's Step-8 artifacts from ANY host — a Codex session included, plain shell, no Claude agent plane needed — and land a PARSED signoff. The reviewer runs ISOLATED: an EMPTY temp cwd plus --safe-mode --strict-mcp-config --tools '' --no-session-persistence, so no CLAUDE.md/skills/plugins/hooks/MCP load, and the verdict is read from the --output-format json RESULT ENVELOPE — text a session customization printed onto the same stdout can never become a signoff. Probes the model before trusting it; sends SCOPED extracts with a loud 200k-char ceiling (never silent truncation); the grade must AGREE across three LAST-anchored channels (terminal marker line, fenced qe-bridge-signoff JSON, the report's own GRADE line) AND the marker must be the FINAL content — empty, gradeless, self-contradicting or miscounted output is one of
|
|
648
|
+
dz qe-bridge --family claude --slug <feature> [--coder-family codex|claude] [--model <id>] [--files a,b] [--out <f>] [--timeout <s>] [--allow-same-family] [--json] (the REVERSE QE bridge: run an INDEPENDENT Claude reviewer over a feature's Step-8 artifacts from ANY host — a Codex session included, plain shell, no Claude agent plane needed — and land a PARSED signoff. The reviewer runs ISOLATED: an EMPTY temp cwd plus --safe-mode --strict-mcp-config --tools '' --no-session-persistence, so no CLAUDE.md/skills/plugins/hooks/MCP load, and the verdict is read from the --output-format json RESULT ENVELOPE — text a session customization printed onto the same stdout can never become a signoff. Probes the model before trusting it; sends SCOPED extracts with a loud 200k-char ceiling (never silent truncation); the grade must AGREE across three LAST-anchored channels (terminal marker line, fenced qe-bridge-signoff JSON, the report's own GRADE line) AND the marker must be the FINAL content — empty, gradeless, self-contradicting or miscounted output is one of 17 NAMED failures with an audit record under features/<slug>/.fa-state/qe-bridge/ (runId, resolved executable + binOverride, prompt sha256, channel offsets, requestedOut, reportWritten, retained raw stdout; 0600 files in a 0700 dir), never a clean review. A --coder-family that contradicts the recorded reqe debt is refused. Writes features/<slug>/08b_reqe_report.md, which dz reqe --done settles unchanged. DISCLOSURE: the extracts you scope are sent to the Claude runtime; the bridge cannot classify secrets. DZ_QE_BRIDGE_CLAUDE_BIN is a TEST SEAM, not a flag. exit 0 signoff parsed (ANY grade — it reports, it does not gate) / 1 named failure / 2 usage)
|
|
637
649
|
dz mutation-gate [--package <dir>] [--registry <file>] [--test-cmd "<cmd>"] [--only <id[,id]>] [--timeout <ms>] [--rebaseline per-entry|final] [--keep-scratch] [--json] (prove each NAMED protection has a test that DISCRIMINATES: copy the package to a scratch dir, verify the baseline suite is green, apply each registry mutation, run the suite, REQUIRE red, restore. The red must be BEHAVIOURAL: a mutation that no longer parses is MUTATION_UNPARSEABLE; a red run whose OWN output reports a test FILE failing to load (node --test file-level not-ok with exitCode, vitest Failed Suites) is MUTATION_LOAD_FATAL — the signal comes from the same run as the failing count, never from a separate isolated import; red output whose shape matches no known runner is INCONCLUSIVE (a runner-coverage gap, loud, never PROVEN); a count far above the entry's bound is OVER_FAILING; a restored tree that does not reproduce green makes the entry INCONCLUSIVE (flaky). Mutation writes are realpath-contained to the scratch copy: a symlink escape or a node_modules/ target is refused (exit 2), the real tree is never written. A mutation that does not apply, a green suite, or an inconclusive run is a FAILURE — never a skip. exit 0 all proven / 1 gate failed / 2 setup error)
|
|
638
650
|
dz backlog add "<idea>" [--effort 1-5] [--proposal <text>] [--dry-run] [--project <dir>] [--json] (capture an idea: semantic dedup against existing ideas via the Brain vector engine (DUPLICATE>=0.92 merges, RELATED links, NEW creates) + GoalMap alignment; --dry-run classifies without writing)
|
|
639
651
|
dz backlog list [--status <s>] [--goal <id>] [--project <dir>] [--json] (list captured ideas, filterable by status/goal)
|
|
@@ -672,6 +684,7 @@ Usage:
|
|
|
672
684
|
dz statusline --fa-record --slug <s> --step "<label>" [--kind <feature-adr|loop>] [--recalled <n>] [--stored <n>] [--mode <m>] (feature-adr: record live per-run learning state → 📐 panel segment)
|
|
673
685
|
dz usage [--json] [--project <dir>] | dz usage --calibrate --session <pct> --weekly <pct> [--model fable=<pct>] [--project <dir>] (ESTIMATE Claude usage from fixed reset windows; optional per-model weekly binding; exit 0 ALWAYS; pct=null when limits unconfigured)
|
|
674
686
|
dz usage --by-stage [--run <runId> | --slug <slug>] [--epsilon <0..1>] [--write <file.jsonl>] [--json] (per-stage cost ledger for ONE feature-adr run + the reconciliation invariant: accounted + unaccounted = run total; verdict BALANCED | DEFECT | INSUFFICIENT_DATA; local transcript ESTIMATES — catches ATTRIBUTION errors, not pricing errors)
|
|
687
|
+
dz chain [--project <dir>] [--json] (verify EVERY hash-chained journal in ONE command: coverage is DERIVED from the CHAINED_JOURNALS registry, never typed, so a journal cannot be given a chain and checked by nobody. An ABSENT journal is NAMED absent, never omitted — omission and cleanliness are indistinguishable in a report. Statuses: ok | healed (defects the current unbroken run has outlived — verdicts over present records are sound) | unchained (present, no chained record yet — legal) | absent | broken | unreadable. Exit 1 on broken/unreadable: a verifier that reports damage and exits 0 is one no automation can act on)
|
|
675
688
|
dz claim-check [paths...] [--json] [--fail-on high|medium|none] [--project <dir>] (enforce the Integrity Rule: flag untagged/overstated accuracy claims; default scan = root README.md + every discovered package's README.md + features/*/08_qe_report.md + docs/**/*.md (historical feature artifacts are NOT scanned — pass paths explicitly); exit 1 only at/above --fail-on, default high)
|
|
676
689
|
dz lint [paths...] [--json] [--config <file>] [--registry <file>] [--project <dir>] (advisory EN/RU prose-style lint; findings exit 0, incomplete input/policy exits 1, usage exits 2)
|
|
677
690
|
dz pretrain [--project <dir>]
|
|
@@ -717,6 +730,17 @@ Workflows: author loop-plan/1 plans with dz workflow init/validate/render; gate
|
|
|
717
730
|
Targets: ${TARGET_NAMES.join(', ')}
|
|
718
731
|
Presets: ${PRESET_NAMES.join(', ')}`;
|
|
719
732
|
|
|
733
|
+
export interface MutationGateRunnerObservation {
|
|
734
|
+
readonly exitCode: number | null;
|
|
735
|
+
readonly output: string;
|
|
736
|
+
readonly failureReason?: string;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
export type MutationGateRunner = (
|
|
740
|
+
command: string,
|
|
741
|
+
options: { readonly cwd: string; readonly timeoutMs: number },
|
|
742
|
+
) => MutationGateRunnerObservation;
|
|
743
|
+
|
|
720
744
|
/** Output sink + working directory — injectable so the CLI is testable. */
|
|
721
745
|
export interface CliIo {
|
|
722
746
|
readonly cwd?: string;
|
|
@@ -761,6 +785,8 @@ export interface CliIo {
|
|
|
761
785
|
* offline, hermetically — mirrors the {@link CliIo.releaseRunner} idiom.
|
|
762
786
|
*/
|
|
763
787
|
readonly installRunner?: (command: string, cwd: string) => void;
|
|
788
|
+
/** Fault seam for proving mutation-gate catches and retries thrown runner internals. */
|
|
789
|
+
readonly mutationGateRunner?: MutationGateRunner;
|
|
764
790
|
}
|
|
765
791
|
|
|
766
792
|
/** Injected subprocess runner used by `dz release` (see {@link CliIo.releaseRunner}). */
|
|
@@ -850,6 +876,12 @@ function discoverSkillsDirs(cwd: string, explicitSkillsDir?: string | undefined)
|
|
|
850
876
|
|
|
851
877
|
/** Outcome of {@link installSkills}. */
|
|
852
878
|
interface InstallSkillsResult {
|
|
879
|
+
/**
|
|
880
|
+
* Set when an explicit `--select` named an id no root provides (backlog 9d15b9b6, PR-A). The
|
|
881
|
+
* caller MUST print it and exit non-zero: `0 skill(s)` is not a success, and by the time this is
|
|
882
|
+
* set nothing has been written yet — the refusal is decided before the first byte.
|
|
883
|
+
*/
|
|
884
|
+
readonly selectRefusal?: string;
|
|
853
885
|
readonly results: { id: string; written: number; skipped: number }[];
|
|
854
886
|
readonly dirsSearched: number;
|
|
855
887
|
readonly written: number;
|
|
@@ -885,11 +917,44 @@ async function installSkills(opts: {
|
|
|
885
917
|
noIntegrations?: boolean;
|
|
886
918
|
noVerify?: boolean;
|
|
887
919
|
allowIntegrations?: string;
|
|
920
|
+
writeErr?: (line: string) => void;
|
|
888
921
|
}): Promise<InstallSkillsResult> {
|
|
889
922
|
const { target, projectRoot, cwd, explicitSkillsDir, select, force, enrich } = opts;
|
|
890
923
|
|
|
891
924
|
const skillsDirs = discoverSkillsDirs(cwd, explicitSkillsDir);
|
|
892
925
|
|
|
926
|
+
// PREFLIGHT (backlog 9d15b9b6, PR-A) — resolve the REQUEST once, before anything is written.
|
|
927
|
+
//
|
|
928
|
+
// Two defects lived in asking each root independently instead of resolving the request: a skill
|
|
929
|
+
// present in two roots was installed TWICE and counted twice (the field report's `2 skill(s)` was
|
|
930
|
+
// one skill installed twice), and a skill present in NO root produced a warning and exit 0 —
|
|
931
|
+
// `0 skill(s)` reading as success. Both are gone once the decision happens here.
|
|
932
|
+
//
|
|
933
|
+
// Placement is load-bearing: an exit 1 that arrives after hooks and memory are written leaves a
|
|
934
|
+
// half-configured project, which is worse than either clean outcome. This runs before the loop
|
|
935
|
+
// below and before every target adapter.
|
|
936
|
+
//
|
|
937
|
+
// Dependency closure is deliberately NOT resolved here — that is PR-B. This preflight fixes the
|
|
938
|
+
// count and the exit contract, and gives that work a base it can trust.
|
|
939
|
+
if (select !== undefined) {
|
|
940
|
+
const roots = skillsDirs.map((dir) => ({ dir, ids: discoverSkillIds(dir) }));
|
|
941
|
+
const resolution = resolveSelection(select, roots);
|
|
942
|
+
for (const shadow of resolution.shadowed) {
|
|
943
|
+
opts.writeErr?.(
|
|
944
|
+
`dz: skill '${shadow.id}' is offered by ${shadow.alsoIn.length + 1} roots; ` +
|
|
945
|
+
`installing from ${shadow.chosen} (earlier root wins). Also present in: ${shadow.alsoIn.join(', ')}`,
|
|
946
|
+
);
|
|
947
|
+
}
|
|
948
|
+
const refusal = formatSelectRefusal(resolution, roots);
|
|
949
|
+
if (refusal !== null) {
|
|
950
|
+
return {
|
|
951
|
+
selectRefusal: refusal,
|
|
952
|
+
results: [], dirsSearched: skillsDirs.length, written: 0, skipped: 0,
|
|
953
|
+
missing: [...resolution.missing], failures: [], applyFailures: [], integrations: [],
|
|
954
|
+
};
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
|
|
893
958
|
// agents-md and gemini are FLATTENING single-file targets: each must aggregate
|
|
894
959
|
// every selected skill from ALL discovered dirs into ONE root file (AGENTS.md /
|
|
895
960
|
// GEMINI.md) in a single merge. A per-dir runInit loop (like the tree targets
|
|
@@ -1014,6 +1079,7 @@ async function cmdInit(options: Map<string, string>, flags: Set<string>, cwd: st
|
|
|
1014
1079
|
cwd,
|
|
1015
1080
|
explicitSkillsDir,
|
|
1016
1081
|
select,
|
|
1082
|
+
writeErr,
|
|
1017
1083
|
force: flags.has('force'),
|
|
1018
1084
|
enrich: flags.has('enrich'),
|
|
1019
1085
|
noHooks: flags.has('no-hooks'),
|
|
@@ -1022,6 +1088,13 @@ async function cmdInit(options: Map<string, string>, flags: Set<string>, cwd: st
|
|
|
1022
1088
|
...(options.get('allow-integrations') !== undefined ? { allowIntegrations: options.get('allow-integrations')! } : {}),
|
|
1023
1089
|
});
|
|
1024
1090
|
|
|
1091
|
+
// PR-A: an explicit --select that named a skill no root provides is a REFUSAL, not a warning.
|
|
1092
|
+
// Printed and returned here, before any target adapter runs — nothing has been written yet.
|
|
1093
|
+
if (r.selectRefusal !== undefined) {
|
|
1094
|
+
writeErr(r.selectRefusal);
|
|
1095
|
+
return 1;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1025
1098
|
// Codex keeps its established user-registry writer, but its result is normalized into the same
|
|
1026
1099
|
// two-outcome contract before JSON/human rendering. A write without a live ready observation is
|
|
1027
1100
|
// a refusal with applied=true, never a second success channel.
|
|
@@ -5471,6 +5544,26 @@ async function cmdSetup(options: Map<string, string>, flags: Set<string>, cwd: s
|
|
|
5471
5544
|
const projectRoot = resolve(cwd, options.get('project') ?? '.');
|
|
5472
5545
|
const presetName = options.get('preset');
|
|
5473
5546
|
|
|
5547
|
+
// PREFLIGHT BEFORE THE FIRST WRITE (backlog 9d15b9b6, PR-A). Step 3 configures the learning
|
|
5548
|
+
// environment and step 4 installs skills, so refusing at step 4 would leave a project that has
|
|
5549
|
+
// memory and hooks but not the skills the operator asked for — a half-configured state worse than
|
|
5550
|
+
// either clean outcome. The request is therefore resolved HERE, before the banner's first step.
|
|
5551
|
+
//
|
|
5552
|
+
// Only an EXPLICIT --select is refused. A preset names skills the package itself ships, so a gap
|
|
5553
|
+
// there is our packaging defect, not the operator's typo, and it is reported by the existing
|
|
5554
|
+
// missing-list rather than by refusing the whole run.
|
|
5555
|
+
const setupSelectRaw = options.get('select');
|
|
5556
|
+
if (setupSelectRaw !== undefined) {
|
|
5557
|
+
const requested = setupSelectRaw.split(',').map((x) => x.trim()).filter((x) => x.length > 0);
|
|
5558
|
+
const roots = discoverSkillsDirs(cwd, options.get('skills-dir')).map((dir) => ({ dir, ids: discoverSkillIds(dir) }));
|
|
5559
|
+
const resolution = resolveSelection(requested, roots);
|
|
5560
|
+
for (const shadow of resolution.shadowed) {
|
|
5561
|
+
writeErr(`dz: skill '${shadow.id}' is offered by ${shadow.alsoIn.length + 1} roots; installing from ${shadow.chosen} (earlier root wins). Also present in: ${shadow.alsoIn.join(', ')}`);
|
|
5562
|
+
}
|
|
5563
|
+
const refusal = formatSelectRefusal(resolution, roots);
|
|
5564
|
+
if (refusal !== null) { writeErr(refusal); return 1; }
|
|
5565
|
+
}
|
|
5566
|
+
|
|
5474
5567
|
write(`\n╔══════════════════════════════════════════════════════╗`);
|
|
5475
5568
|
write(`║ DZ SETUP — Full Environment ║`);
|
|
5476
5569
|
write(`╠══════════════════════════════════════════════════════╣`);
|
|
@@ -5622,7 +5715,7 @@ function cmdPretrain(options: Map<string, string>, cwd: string, write: Write): n
|
|
|
5622
5715
|
return 0;
|
|
5623
5716
|
}
|
|
5624
5717
|
|
|
5625
|
-
function cmdRecommend(options: Map<string, string>, cwd: string, write: Write): number {
|
|
5718
|
+
function cmdRecommend(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
|
|
5626
5719
|
const task = options.get('_positional_0');
|
|
5627
5720
|
if (!task) {
|
|
5628
5721
|
write('dz recommend: task description required');
|
|
@@ -5632,17 +5725,41 @@ function cmdRecommend(options: Map<string, string>, cwd: string, write: Write):
|
|
|
5632
5725
|
|
|
5633
5726
|
const registry = buildRegistry(cwd);
|
|
5634
5727
|
const report = recommend(task, registry, cwd);
|
|
5728
|
+
if (flags.has('json')) {
|
|
5729
|
+
write(JSON.stringify(report, null, 2));
|
|
5730
|
+
return 0;
|
|
5731
|
+
}
|
|
5635
5732
|
|
|
5636
5733
|
write(`\n╔══════════════════════════════════════════════════════════════╗`);
|
|
5637
5734
|
write(`║ DZ RECOMMEND — Task Advisor ║`);
|
|
5638
5735
|
write(`╠══════════════════════════════════════════════════════════════╣`);
|
|
5639
5736
|
write(`║ Task: ${report.task.slice(0, 52).padEnd(52)}║`);
|
|
5640
|
-
|
|
5641
|
-
|
|
5737
|
+
if (report.topicSource === 'task') {
|
|
5738
|
+
write(`║ Topics: ${report.topics.join(', ').slice(0, 50).padEnd(50)}║`);
|
|
5739
|
+
} else if (report.topicSource === 'project-stack') {
|
|
5740
|
+
write(`║ Topics: ${'not matched in the question'.padEnd(50)}║`);
|
|
5741
|
+
} else {
|
|
5742
|
+
write(`║ Topics: ${'not recognized — no recommendations'.padEnd(50)}║`);
|
|
5743
|
+
}
|
|
5642
5744
|
write(`╠══════════════════════════════════════════════════════════════╣`);
|
|
5643
5745
|
|
|
5746
|
+
if (report.topicSource === 'project-stack') {
|
|
5747
|
+
write(`⚠ Тема запроса не распознана — подбор ниже сделан по СТЕКУ ПРОЕКТА, не по вашему вопросу.`);
|
|
5748
|
+
write(` (topic not recognized — recommendations reflect the project stack, not the question)`);
|
|
5749
|
+
write(`PROJECT-STACK SUGGESTIONS`);
|
|
5750
|
+
} else if (report.topicSource === 'none') {
|
|
5751
|
+
write(`Тема запроса не распознана; рекомендаций нет.`);
|
|
5752
|
+
write(`Переформулируйте задачу или используйте dz registry search <слово> / /skill-advisor.`);
|
|
5753
|
+
write(`╚══════════════════════════════════════════════════════════════╝`);
|
|
5754
|
+
return 0;
|
|
5755
|
+
}
|
|
5756
|
+
|
|
5757
|
+
const stackDerived = report.topicSource === 'project-stack';
|
|
5758
|
+
|
|
5644
5759
|
if (report.presets.length > 0) {
|
|
5645
|
-
write(
|
|
5760
|
+
write(stackDerived
|
|
5761
|
+
? `║ PROJECT-STACK PRESETS ║`
|
|
5762
|
+
: `║ RECOMMENDED PRESETS ║`);
|
|
5646
5763
|
for (const p of report.presets) {
|
|
5647
5764
|
const matched = p.matchedSkills.length > 0 ? ` (${p.matchedSkills.slice(0, 3).join(', ')})` : '';
|
|
5648
5765
|
write(`║ ${p.name.padEnd(15)} ${String(p.skills).padStart(2)} skills coverage: ${String(p.coverage).padStart(2)} topics${matched.padEnd(15)}║`);
|
|
@@ -5651,7 +5768,9 @@ function cmdRecommend(options: Map<string, string>, cwd: string, write: Write):
|
|
|
5651
5768
|
}
|
|
5652
5769
|
|
|
5653
5770
|
if (report.skills.length > 0) {
|
|
5654
|
-
write(
|
|
5771
|
+
write(stackDerived
|
|
5772
|
+
? `║ PROJECT-STACK SKILLS (top ${Math.min(report.skills.length, 8)})${' '.repeat(35)}║`
|
|
5773
|
+
: `║ RECOMMENDED SKILLS (top ${Math.min(report.skills.length, 8)})${' '.repeat(35)}║`);
|
|
5655
5774
|
for (const s of report.skills.slice(0, 8)) {
|
|
5656
5775
|
const desc = s.description.length > 35 ? s.description.slice(0, 32) + '...' : s.description;
|
|
5657
5776
|
write(`║ ${s.id.padEnd(24)} ${desc.padEnd(36)}║`);
|
|
@@ -5660,7 +5779,9 @@ function cmdRecommend(options: Map<string, string>, cwd: string, write: Write):
|
|
|
5660
5779
|
}
|
|
5661
5780
|
|
|
5662
5781
|
if (report.toolkits.length > 0) {
|
|
5663
|
-
write(
|
|
5782
|
+
write(stackDerived
|
|
5783
|
+
? `║ PROJECT-STACK PIPELINE (npx toolkits) ║`
|
|
5784
|
+
: `║ FULL PIPELINE (npx toolkits) ║`);
|
|
5664
5785
|
for (const tk of report.toolkits) {
|
|
5665
5786
|
const desc = tk.description.length > 44 ? tk.description.slice(0, 41) + '...' : tk.description;
|
|
5666
5787
|
write(`║ ${tk.name.padEnd(16)} ${desc.padEnd(44)}║`);
|
|
@@ -5671,13 +5792,17 @@ function cmdRecommend(options: Map<string, string>, cwd: string, write: Write):
|
|
|
5671
5792
|
}
|
|
5672
5793
|
|
|
5673
5794
|
write(`╠══════════════════════════════════════════════════════════════╣`);
|
|
5674
|
-
write(
|
|
5795
|
+
write(stackDerived
|
|
5796
|
+
? `║ PROJECT-STACK PLAN ║`
|
|
5797
|
+
: `║ STEP-BY-STEP PLAN ║`);
|
|
5675
5798
|
for (const step of report.plan) {
|
|
5676
5799
|
const line = step.length > 60 ? step.slice(0, 57) + '...' : step;
|
|
5677
5800
|
write(`║ ${line.padEnd(58)}║`);
|
|
5678
5801
|
}
|
|
5679
5802
|
write(`╠══════════════════════════════════════════════════════════════╣`);
|
|
5680
|
-
write(
|
|
5803
|
+
write(stackDerived
|
|
5804
|
+
? `║ PROJECT-STACK QUICK INSTALL ║`
|
|
5805
|
+
: `║ QUICK INSTALL ║`);
|
|
5681
5806
|
const cmd = report.installCommand.length > 58 ? report.installCommand.slice(0, 55) + '...' : report.installCommand;
|
|
5682
5807
|
write(`║ ${cmd.padEnd(58)}║`);
|
|
5683
5808
|
write(`╚══════════════════════════════════════════════════════════════╝`);
|
|
@@ -7426,6 +7551,84 @@ function looksBinaryText(text: string): boolean {
|
|
|
7426
7551
|
* per-file findings (each enriched with its `file`), and applies the exit-code contract.
|
|
7427
7552
|
* `--json` ALWAYS emits valid JSON `{ok, findings, scanned}`, even on the failure path.
|
|
7428
7553
|
*/
|
|
7554
|
+
/**
|
|
7555
|
+
* `dz chain` — verify EVERY hash-chained journal in one command (W0-chain, backlog bc4ee35c).
|
|
7556
|
+
*
|
|
7557
|
+
* The machinery to verify a chain has worked for weeks. What was missing is the ABILITY TO ASK:
|
|
7558
|
+
* verification lived inside two consumers, each carrying its own hardcoded list of which files are
|
|
7559
|
+
* chained, so a journal could be given a chain and still be checked by nobody. Coverage here is
|
|
7560
|
+
* DERIVED from CHAINED_JOURNALS, never typed — adding a journal to the registry adds it to this
|
|
7561
|
+
* report by construction.
|
|
7562
|
+
*
|
|
7563
|
+
* An ABSENT journal is reported as `absent`, not omitted. Omission and cleanliness are
|
|
7564
|
+
* indistinguishable in a report, and that indistinguishability is how the original blind spot
|
|
7565
|
+
* survived; the same reason `broken` exits NON-ZERO rather than merely printing — a verifier that
|
|
7566
|
+
* reports damage and exits 0 is one no automation can act on, and this verb exists to run unattended.
|
|
7567
|
+
*
|
|
7568
|
+
* A journal that exists but carries NO chained records is `unchained`, which is legal (a log may
|
|
7569
|
+
* predate the chain) and therefore does not fail the command. Calling it a defect would train the
|
|
7570
|
+
* reader to ignore the output — the failure mode already measured once on the doctor's own line.
|
|
7571
|
+
*/
|
|
7572
|
+
function cmdChain(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
|
|
7573
|
+
const root = options.get('project') ?? cwd;
|
|
7574
|
+
const journals = CHAINED_JOURNALS.map((journal) => {
|
|
7575
|
+
const path = join(root, journal.rel);
|
|
7576
|
+
if (!existsSync(path)) {
|
|
7577
|
+
return { rel: journal.rel, decides: journal.decides, status: 'absent' as const, chained: 0, defects: 0, detail: 'file not present' };
|
|
7578
|
+
}
|
|
7579
|
+
let text = '';
|
|
7580
|
+
try {
|
|
7581
|
+
text = readFileSync(path, 'utf-8');
|
|
7582
|
+
} catch {
|
|
7583
|
+
// Unreadable is NOT clean. It is the one outcome that must never be quietly folded into
|
|
7584
|
+
// "nothing to report": we did not look, so we know nothing.
|
|
7585
|
+
return { rel: journal.rel, decides: journal.decides, status: 'unreadable' as const, chained: 0, defects: 0, detail: 'file could not be read' };
|
|
7586
|
+
}
|
|
7587
|
+
const v = verifyEventChainText(text);
|
|
7588
|
+
if (v.chained === 0) {
|
|
7589
|
+
return { rel: journal.rel, decides: journal.decides, status: 'unchained' as const, chained: 0, defects: 0, detail: 'present, but no record carries a chain (legal — the log predates chaining)' };
|
|
7590
|
+
}
|
|
7591
|
+
const total = text.split('\n').filter((l) => l.trim() !== '').length;
|
|
7592
|
+
const age = classifyChainDefects(v, total);
|
|
7593
|
+
if (v.ok) {
|
|
7594
|
+
return { rel: journal.rel, decides: journal.decides, status: 'ok' as const, chained: v.chained, defects: 0, detail: `${v.chained} chained record(s), ${v.resets} recorded restart(s)` };
|
|
7595
|
+
}
|
|
7596
|
+
// A break the current unbroken run has already outlived does not make TODAY's records unsound.
|
|
7597
|
+
// Reporting both alike is what made the doctor's equivalent line permanently red for four weeks.
|
|
7598
|
+
const historical = age.inRun.length === 0 && age.runRecords > 0;
|
|
7599
|
+
return {
|
|
7600
|
+
rel: journal.rel,
|
|
7601
|
+
decides: journal.decides,
|
|
7602
|
+
status: historical ? ('healed' as const) : ('broken' as const),
|
|
7603
|
+
chained: v.chained,
|
|
7604
|
+
defects: v.defects.length,
|
|
7605
|
+
detail: historical
|
|
7606
|
+
? `${v.defects.length} defect(s), all BEFORE the current run — the last ${age.runRecords} record(s) are unbroken, so verdicts over those are sound`
|
|
7607
|
+
: `${v.defects.length} defect(s) with NO sound records after them: verdicts computed from this log are unsafe`,
|
|
7608
|
+
};
|
|
7609
|
+
});
|
|
7610
|
+
|
|
7611
|
+
const failed = journals.filter((j) => j.status === 'broken' || j.status === 'unreadable');
|
|
7612
|
+
const ok = failed.length === 0;
|
|
7613
|
+
|
|
7614
|
+
if (flags.has('json')) {
|
|
7615
|
+
write(JSON.stringify({ ok, root, journals }, null, 2));
|
|
7616
|
+
return ok ? 0 : 1;
|
|
7617
|
+
}
|
|
7618
|
+
|
|
7619
|
+
write(`dz chain — ${journals.length} registered journal(s) under ${root}`);
|
|
7620
|
+
write('');
|
|
7621
|
+
const MARK: Record<string, string> = { ok: '\u2713', healed: '\u2713', unchained: '\u00b7', absent: '\u00b7', broken: '\u2717', unreadable: '\u2717' };
|
|
7622
|
+
for (const j of journals) {
|
|
7623
|
+
write(` ${MARK[j.status] ?? '?'} ${j.rel} — ${j.status}`);
|
|
7624
|
+
write(` ${j.detail}`);
|
|
7625
|
+
write(` decides: ${j.decides}`);
|
|
7626
|
+
}
|
|
7627
|
+
write('');
|
|
7628
|
+
write(ok ? ' all registered journals are sound for present verdicts' : ` ${failed.length} journal(s) UNSAFE — see above`);
|
|
7629
|
+
return ok ? 0 : 1;
|
|
7630
|
+
}
|
|
7631
|
+
|
|
7429
7632
|
function cmdClaimCheck(
|
|
7430
7633
|
options: Map<string, string>,
|
|
7431
7634
|
_optionLists: Map<string, string[]>,
|
|
@@ -10098,9 +10301,74 @@ function cmdDiscriminationCheck(options: Map<string, string>, flags: Set<string>
|
|
|
10098
10301
|
const timeoutOpt = Number(options.get('timeout') ?? '300000');
|
|
10099
10302
|
const timeoutMs = Number.isFinite(timeoutOpt) && timeoutOpt > 0 ? timeoutOpt : 300000;
|
|
10100
10303
|
|
|
10101
|
-
|
|
10304
|
+
// Runner honesty (feature instrument-honesty, ADR-001): the runner is selected from the TARGET
|
|
10305
|
+
// package's own scripts.test, never from a global default. The package dir is the nearest
|
|
10306
|
+
// ancestor of the FIRST named test that carries a package.json — walked here, at the seam,
|
|
10307
|
+
// because the pure half deliberately takes the script text as data and never touches the fs.
|
|
10308
|
+
let packageTestScript: string | null = null;
|
|
10309
|
+
let packageDevDependencies: string[] = [];
|
|
10310
|
+
let packageDir = repoRoot;
|
|
10311
|
+
{
|
|
10312
|
+
const firstTest = propertyTests[0]?.file;
|
|
10313
|
+
// QE-1 (instrument-honesty, HIGH): this walk runs on the RAW --test argument, BEFORE the
|
|
10314
|
+
// engine's sanitation — a `../` traversal made it read an arbitrary package.json OUTSIDE the
|
|
10315
|
+
// repo and echo its scripts.test verbatim into the JSON output (MEASURED with a planted
|
|
10316
|
+
// marker file). Containment first: a start point outside the repo root never gets walked,
|
|
10317
|
+
// the script stays null, and the engine's own path sanitation then refuses the test path.
|
|
10318
|
+
const walkStart = firstTest !== undefined ? resolve(cwd, dirname(firstTest)) : undefined;
|
|
10319
|
+
if (firstTest !== undefined && walkStart !== undefined
|
|
10320
|
+
&& (walkStart === resolve(repoRoot) || walkStart.startsWith(resolve(repoRoot) + sep))) {
|
|
10321
|
+
let probe = walkStart;
|
|
10322
|
+
// walk up to the repo root looking for package.json (bounded by the fs root either way)
|
|
10323
|
+
for (;;) {
|
|
10324
|
+
if (existsSync(join(probe, 'package.json'))) { packageDir = probe; break; }
|
|
10325
|
+
const parent = dirname(probe);
|
|
10326
|
+
if (parent === probe || probe === repoRoot) break;
|
|
10327
|
+
probe = parent;
|
|
10328
|
+
}
|
|
10329
|
+
try {
|
|
10330
|
+
const pkg = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf-8')) as {
|
|
10331
|
+
scripts?: Record<string, string>; devDependencies?: Record<string, string>;
|
|
10332
|
+
};
|
|
10333
|
+
packageTestScript = typeof pkg.scripts?.test === 'string' ? pkg.scripts.test : null;
|
|
10334
|
+
packageDevDependencies = Object.keys(pkg.devDependencies ?? {});
|
|
10335
|
+
} catch { /* unreadable package.json → selection falls through to the honest REFUSE */ }
|
|
10336
|
+
}
|
|
10337
|
+
}
|
|
10338
|
+
// The pure half's path sanitation expects a REPO-RELATIVE package dir ('.'-rooted), not an
|
|
10339
|
+
// absolute one — an absolute path is refused as unsafe-package-dir by design.
|
|
10340
|
+
const packageDirRel = relative(repoRoot, packageDir) || '.';
|
|
10341
|
+
const planInput = runnerOpt !== undefined
|
|
10342
|
+
? { baseRef, propertyTests, runner: runnerOpt, packageTestScript, packageDevDependencies, packageDir: packageDirRel }
|
|
10343
|
+
: { baseRef, propertyTests, packageTestScript, packageDevDependencies, packageDir: packageDirRel };
|
|
10344
|
+
const plan = planDiscriminationCheck(planInput);
|
|
10102
10345
|
|
|
10103
10346
|
if (!plan.runnable) {
|
|
10347
|
+
// QE-2 (instrument-honesty, MEDIUM): a runner REFUSE used to be reported through the generic
|
|
10348
|
+
// "no property test to check"/map-a-test framing — the operator-facing surface re-created the
|
|
10349
|
+
// exact "instrument gap misread as test gap" class ADR-001 names as the reason three duplicate
|
|
10350
|
+
// backlog entries existed. The plan's own named reason is the verdict; the generic classify
|
|
10351
|
+
// stays only for the genuinely-empty-target case.
|
|
10352
|
+
const runnerRefusal = typeof plan.reason === 'string' && plan.reason.startsWith('unsupported-runner');
|
|
10353
|
+
if (runnerRefusal) {
|
|
10354
|
+
const refusal = {
|
|
10355
|
+
aggregate: 'CANNOT_ISOLATE',
|
|
10356
|
+
measurementValid: false,
|
|
10357
|
+
primaryAction: plan.primaryAction ?? 'fix-runner-invocation',
|
|
10358
|
+
finding: {
|
|
10359
|
+
severity: 'high',
|
|
10360
|
+
verdict: 'CANNOT_ISOLATE',
|
|
10361
|
+
files: plan.targets.map((t) => t.file),
|
|
10362
|
+
detail: `runner refused: ${plan.reason} — the INSTRUMENT could not run, nothing was measured; `
|
|
10363
|
+
+ `declare scripts.test in the target package (or pass --runner) and re-run. `
|
|
10364
|
+
+ `This is NOT a statement about the tests.`,
|
|
10365
|
+
},
|
|
10366
|
+
};
|
|
10367
|
+
if (flags.has('json')) { write(JSON.stringify({ plan, results: [], perTest: [], ...refusal }, null, 2)); return 0; }
|
|
10368
|
+
write(`discrimination-check: REFUSED (${plan.reason})`);
|
|
10369
|
+
write(` → ${refusal.finding.detail}`);
|
|
10370
|
+
return 0;
|
|
10371
|
+
}
|
|
10104
10372
|
// No safe target to run → this is the existing "property untested" finding (empty propertyTests classify).
|
|
10105
10373
|
const result = classifyDiscrimination({ propertyTests: [], results: [] });
|
|
10106
10374
|
if (flags.has('json')) { write(JSON.stringify({ plan, results: [], ...result }, null, 2)); return 0; }
|
|
@@ -10189,10 +10457,24 @@ function cmdDiscriminationCheck(options: Map<string, string>, flags: Set<string>
|
|
|
10189
10457
|
|
|
10190
10458
|
// t.file + t.name already passed the engine's strict sanitation (no quotes/metacharacters/leading-dash);
|
|
10191
10459
|
// still quote + `--` so a path can never be read as a runner option or split a word.
|
|
10460
|
+
// Runner honesty (ADR-001): the run executes FROM the target package dir with a
|
|
10461
|
+
// package-relative path — a root-cwd `npx vitest run packages/...` loads the ROOT config
|
|
10462
|
+
// (none) and reds unclassifiably, which is exactly the CANNOT_ISOLATE artifact this
|
|
10463
|
+
// feature removes. The plan's own commands encode the same cd; this body mirrors it.
|
|
10464
|
+
const pkgRel = plan.packageDir === '.' ? '' : plan.packageDir;
|
|
10465
|
+
const fileInPkg = pkgRel !== '' && t.file.startsWith(pkgRel + '/') ? t.file.slice(pkgRel.length + 1) : t.file;
|
|
10466
|
+
const execDirBase = pkgRel === '' ? worktree : join(worktree, pkgRel);
|
|
10467
|
+
const execDirTip = pkgRel === '' ? repoRoot : join(repoRoot, pkgRel);
|
|
10192
10468
|
const nameArg = t.name ? ` -t '${t.name}'` : '';
|
|
10193
|
-
|
|
10194
|
-
|
|
10195
|
-
|
|
10469
|
+
// NO `--` before the path: MEASURED 2026-09-02 — `npx vitest run -- 'file'` IGNORES the
|
|
10470
|
+
// filter and runs the whole suite (5269 tests), which is the exact whole-repo artifact
|
|
10471
|
+
// this feature removes (QE ha-intake-archive F5). The path is engine-sanitized (no
|
|
10472
|
+
// leading dash, no metacharacters), so it can never be read as an option.
|
|
10473
|
+
const cmd = `${runner}${nameArg} '${fileInPkg}'`;
|
|
10474
|
+
const base = runCapturedTest(cmd, execDirBase, timeoutMs);
|
|
10475
|
+
// The classifier's targetSeen is a substring probe: the run now prints PACKAGE-relative
|
|
10476
|
+
// paths, so it must be probed with the same form, or every hit reads as target-unseen.
|
|
10477
|
+
const evidence = classifyExecutionEvidence(base.output, base.exitCode, fileInPkg);
|
|
10196
10478
|
const outcome = discriminationOutcomeOf(base.exitCode, evidence);
|
|
10197
10479
|
const row: Record<string, unknown> = t.name !== undefined
|
|
10198
10480
|
? { file: t.file, name: t.name, outcome, evidence }
|
|
@@ -10204,8 +10486,8 @@ function cmdDiscriminationCheck(options: Map<string, string>, flags: Set<string>
|
|
|
10204
10486
|
// base rows per the matrix; running it is cheap and only ever on an already-broken path.
|
|
10205
10487
|
// Do NOT "simplify" this to evidenced-error-only — that silently breaks Confirmation 17.
|
|
10206
10488
|
if (base.exitCode !== null && base.exitCode !== 0 && evidence.failureKind !== 'assertions') {
|
|
10207
|
-
const tip = runCapturedTest(cmd,
|
|
10208
|
-
const tipEvidence = classifyExecutionEvidence(tip.output, tip.exitCode,
|
|
10489
|
+
const tip = runCapturedTest(cmd, execDirTip, timeoutMs);
|
|
10490
|
+
const tipEvidence = classifyExecutionEvidence(tip.output, tip.exitCode, fileInPkg);
|
|
10209
10491
|
row['tipOutcome'] = discriminationOutcomeOf(tip.exitCode, tipEvidence);
|
|
10210
10492
|
row['tipEvidence'] = tipEvidence;
|
|
10211
10493
|
// R15, named honestly: the base run is isolated in a worktree, but the tip runs in the LIVE
|
|
@@ -10329,9 +10611,9 @@ function nameFor(t: { file: string; name?: string }, outcome: 'pass' | 'fail' |
|
|
|
10329
10611
|
*
|
|
10330
10612
|
* Exit codes: 0 every entry PROVEN · 1 the gate ran and failed (undefended / not-applied /
|
|
10331
10613
|
* below-min / unparseable / load-fatal / over-failing / inconclusive entry) · 2 usage or setup
|
|
10332
|
-
* error (missing registry
|
|
10333
|
-
* read as a mutation result — or an entry whose file RESOLVES outside the scratch copy: a
|
|
10614
|
+
* error (missing registry or an entry whose file RESOLVES outside the scratch copy: a
|
|
10334
10615
|
* symlink escape is refused before anything is written, SPEC rule 3).
|
|
10616
|
+
* A RED/no-exit baseline is a measured failing verdict (exit 1), never a usage error.
|
|
10335
10617
|
*/
|
|
10336
10618
|
/**
|
|
10337
10619
|
* Route-a guard for `dz mutation-gate`: parse-check a MUTATED file as its own language BEFORE the
|
|
@@ -10340,7 +10622,14 @@ function nameFor(t: { file: string; name?: string }, outcome: 'pass' | 'fail' |
|
|
|
10340
10622
|
* redness says nothing about the named protection. Returns `{error}` when a parser ran and the
|
|
10341
10623
|
* text does not parse; `{skipped}` (reported loudly, never silently) when no parser is available.
|
|
10342
10624
|
*/
|
|
10343
|
-
|
|
10625
|
+
interface MutationParseCheckResult {
|
|
10626
|
+
readonly error?: string;
|
|
10627
|
+
readonly skipped?: string;
|
|
10628
|
+
readonly internalFailureReason?: string;
|
|
10629
|
+
readonly internalAttempts?: ReturnType<typeof runWithOneInternalRetry>['attempts'];
|
|
10630
|
+
}
|
|
10631
|
+
|
|
10632
|
+
function parseCheckMutatedFile(absFile: string, text: string): MutationParseCheckResult {
|
|
10344
10633
|
interface TsLike {
|
|
10345
10634
|
transpileModule(t: string, o: { reportDiagnostics: boolean; compilerOptions: Record<string, unknown> }): { diagnostics?: { category: number; code: number; messageText: unknown }[] };
|
|
10346
10635
|
flattenDiagnosticMessageText(m: unknown, s: string): string;
|
|
@@ -10364,17 +10653,34 @@ function parseCheckMutatedFile(absFile: string, text: string): { error?: string;
|
|
|
10364
10653
|
try { JSON.parse(text); return {}; } catch (e) { return { error: String((e as Error).message).slice(0, 200) }; }
|
|
10365
10654
|
}
|
|
10366
10655
|
if (ext === '.js' || ext === '.cjs' || ext === '.mjs' || ext === '') {
|
|
10367
|
-
|
|
10368
|
-
|
|
10369
|
-
|
|
10370
|
-
|
|
10371
|
-
|
|
10372
|
-
|
|
10373
|
-
|
|
10374
|
-
|
|
10375
|
-
|
|
10376
|
-
|
|
10656
|
+
const checked = runWithOneInternalRetry<MutationParseCheckResult>(() => {
|
|
10657
|
+
try {
|
|
10658
|
+
// `node --check` on the file IN PLACE, so the nearest package.json decides the module goal.
|
|
10659
|
+
execFileSync(process.execPath, ['--check', absFile], { stdio: 'pipe' });
|
|
10660
|
+
return {};
|
|
10661
|
+
} catch (e) {
|
|
10662
|
+
const err = e as { code?: unknown; status?: unknown; stderr?: Buffer | string; message?: string };
|
|
10663
|
+
// A launched parser that exits non-zero with a SyntaxError is a parse verdict. A child
|
|
10664
|
+
// launch/internal error (EPERM, ENOENT, Node's thrown internal) is runner infrastructure
|
|
10665
|
+
// and must take the bounded retry → INCONCLUSIVE route instead of masquerading as bad JS.
|
|
10666
|
+
if (typeof err.code === 'string' || typeof err.status !== 'number') throw e;
|
|
10667
|
+
const stderrLines = String(err.stderr ?? '').split('\n').map((line) => line.trim()).filter((line) => line !== '');
|
|
10668
|
+
const msg = [...stderrLines].reverse().find((line) => line.includes('Error'))
|
|
10669
|
+
?? stderrLines.at(-1)
|
|
10670
|
+
?? err.message
|
|
10671
|
+
?? 'node --check failed';
|
|
10672
|
+
return { error: msg.slice(0, 200) };
|
|
10673
|
+
}
|
|
10674
|
+
});
|
|
10675
|
+
if (checked.value === null) {
|
|
10676
|
+
return {
|
|
10677
|
+
internalFailureReason: checked.failureReason ?? 'runner-internal-error: persistent after 2/2 attempts',
|
|
10678
|
+
internalAttempts: checked.attempts,
|
|
10679
|
+
};
|
|
10377
10680
|
}
|
|
10681
|
+
return checked.internalRetries === 1
|
|
10682
|
+
? { ...checked.value, internalAttempts: checked.attempts }
|
|
10683
|
+
: checked.value;
|
|
10378
10684
|
}
|
|
10379
10685
|
return { skipped: `no parser for '${ext}' files — parse-check unavailable` };
|
|
10380
10686
|
} catch (e) {
|
|
@@ -10382,7 +10688,13 @@ function parseCheckMutatedFile(absFile: string, text: string): { error?: string;
|
|
|
10382
10688
|
}
|
|
10383
10689
|
}
|
|
10384
10690
|
|
|
10385
|
-
function cmdMutationGate(
|
|
10691
|
+
function cmdMutationGate(
|
|
10692
|
+
options: Map<string, string>,
|
|
10693
|
+
flags: Set<string>,
|
|
10694
|
+
cwd: string,
|
|
10695
|
+
write: Write,
|
|
10696
|
+
injectedRunner?: MutationGateRunner,
|
|
10697
|
+
): number {
|
|
10386
10698
|
const json = flags.has('json');
|
|
10387
10699
|
const fail = (what: string): number => {
|
|
10388
10700
|
write(json ? JSON.stringify({ error: what, exitCode: 2 }) : `dz mutation-gate: ${what}`);
|
|
@@ -10453,6 +10765,11 @@ function cmdMutationGate(options: Map<string, string>, flags: Set<string>, cwd:
|
|
|
10453
10765
|
const results: MutationEntryResult[] = [];
|
|
10454
10766
|
const observations: MutationObservation[] = [];
|
|
10455
10767
|
const warnings: string[] = [];
|
|
10768
|
+
const internalRetries: {
|
|
10769
|
+
readonly phase: 'baseline' | 'parse-check' | 'mutation' | 'rebaseline' | 'final-rebaseline';
|
|
10770
|
+
readonly entryId?: string;
|
|
10771
|
+
readonly attempts: ReturnType<typeof runWithOneInternalRetry>['attempts'];
|
|
10772
|
+
}[] = [];
|
|
10456
10773
|
let baseline: ReturnType<typeof classifyBaseline>;
|
|
10457
10774
|
try {
|
|
10458
10775
|
if (gitTop !== null && gitTop !== pkgDir && resolve(pkgDir).startsWith(resolve(gitTop) + sep)) {
|
|
@@ -10492,7 +10809,11 @@ function cmdMutationGate(options: Map<string, string>, flags: Set<string>, cwd:
|
|
|
10492
10809
|
const realScratchRoot = realpathSync(copyDir);
|
|
10493
10810
|
const requireCompletionReceipt = parsed.registry.requireCompletionReceipt === true;
|
|
10494
10811
|
|
|
10495
|
-
|
|
10812
|
+
type SuiteRun = MutationGateRunnerObservation & { readonly internalAttemptLog?: string };
|
|
10813
|
+
const invokeSuite = (): MutationGateRunnerObservation => {
|
|
10814
|
+
if (injectedRunner !== undefined) {
|
|
10815
|
+
return injectedRunner(testCmd, { cwd: copyDir, timeoutMs: timeout });
|
|
10816
|
+
}
|
|
10496
10817
|
const run = spawnSync(testCmd, {
|
|
10497
10818
|
cwd: copyDir,
|
|
10498
10819
|
shell: true,
|
|
@@ -10504,6 +10825,12 @@ function cmdMutationGate(options: Map<string, string>, flags: Set<string>, cwd:
|
|
|
10504
10825
|
const errorCode = run.error && 'code' in run.error && typeof run.error.code === 'string'
|
|
10505
10826
|
? run.error.code
|
|
10506
10827
|
: undefined;
|
|
10828
|
+
// Node may populate both `error` and a numeric `status` for an internal spawn failure. The
|
|
10829
|
+
// error wins except for the two already-named resource observations: a status alongside
|
|
10830
|
+
// EPERM/Unreachable-code is not a suite verdict and takes the one-retry internal-error path.
|
|
10831
|
+
if (run.error !== undefined && errorCode !== 'ETIMEDOUT' && errorCode !== 'ENOBUFS') {
|
|
10832
|
+
throw run.error;
|
|
10833
|
+
}
|
|
10507
10834
|
const signal = typeof run.signal === 'string' ? run.signal : undefined;
|
|
10508
10835
|
let failureReason: string | undefined;
|
|
10509
10836
|
if (typeof run.status !== 'number') {
|
|
@@ -10515,22 +10842,62 @@ function cmdMutationGate(options: Map<string, string>, flags: Set<string>, cwd:
|
|
|
10515
10842
|
}
|
|
10516
10843
|
return {
|
|
10517
10844
|
exitCode: typeof run.status === 'number' ? run.status : null,
|
|
10518
|
-
// Receipt markers may be on stderr. Preserve both streams even on exit 0; stdout-only
|
|
10519
|
-
// collection would silently lose a green-run marker.
|
|
10520
10845
|
output: `${String(run.stdout ?? '')}\n${String(run.stderr ?? '')}`,
|
|
10521
10846
|
...(failureReason !== undefined ? { failureReason } : {}),
|
|
10522
10847
|
};
|
|
10523
10848
|
};
|
|
10524
10849
|
|
|
10850
|
+
const runSuite = (
|
|
10851
|
+
phase: 'baseline' | 'mutation' | 'rebaseline' | 'final-rebaseline',
|
|
10852
|
+
entryId?: string,
|
|
10853
|
+
): SuiteRun => {
|
|
10854
|
+
const retried = runWithOneInternalRetry(invokeSuite);
|
|
10855
|
+
const loggedAttempts = retried.attempts.map((attempt) => {
|
|
10856
|
+
if (attempt.outcome !== 'completed' || retried.value === null) return attempt;
|
|
10857
|
+
const outcome = retried.value.exitCode === null
|
|
10858
|
+
? `no exit code (${retried.value.failureReason ?? 'unnamed failure'})`
|
|
10859
|
+
: `exit ${retried.value.exitCode}`;
|
|
10860
|
+
return { ...attempt, detail: `attempt ${attempt.attempt}: completed — ${outcome}` };
|
|
10861
|
+
});
|
|
10862
|
+
if (retried.internalRetries === 1) {
|
|
10863
|
+
const record = entryId === undefined
|
|
10864
|
+
? { phase, attempts: loggedAttempts }
|
|
10865
|
+
: { phase, entryId, attempts: loggedAttempts };
|
|
10866
|
+
internalRetries.push(record);
|
|
10867
|
+
if (!json) write(`mutation-gate: internal retry — ${loggedAttempts.map((attempt) => attempt.detail).join('; ')}`);
|
|
10868
|
+
}
|
|
10869
|
+
const internalAttemptLog = retried.internalRetries === 1
|
|
10870
|
+
? loggedAttempts.map((attempt) => attempt.detail).join('; ')
|
|
10871
|
+
: undefined;
|
|
10872
|
+
if (retried.value !== null) {
|
|
10873
|
+
return {
|
|
10874
|
+
...retried.value,
|
|
10875
|
+
...(internalAttemptLog !== undefined ? { internalAttemptLog } : {}),
|
|
10876
|
+
};
|
|
10877
|
+
}
|
|
10878
|
+
return {
|
|
10879
|
+
exitCode: null,
|
|
10880
|
+
output: '',
|
|
10881
|
+
failureReason: retried.failureReason ?? 'runner-internal-error: persistent after 2/2 attempts',
|
|
10882
|
+
...(internalAttemptLog !== undefined ? { internalAttemptLog } : {}),
|
|
10883
|
+
};
|
|
10884
|
+
};
|
|
10885
|
+
|
|
10525
10886
|
// Baseline BEFORE any mutation: a red copy proves nothing, and reading it as a mutation
|
|
10526
10887
|
// result would be this gate shipping the defect class it exists to catch.
|
|
10527
10888
|
if (!json) write(`mutation-gate: baseline suite in scratch copy of ${pkgDir} …`);
|
|
10528
|
-
const base = runSuite();
|
|
10529
|
-
baseline = classifyBaseline(
|
|
10889
|
+
const base = runSuite('baseline');
|
|
10890
|
+
baseline = classifyBaseline(
|
|
10891
|
+
base.exitCode,
|
|
10892
|
+
base.failureReason,
|
|
10893
|
+
base.exitCode !== null && base.exitCode !== 0
|
|
10894
|
+
? attributeBaselineRedness(base.output, entries.map((entry) => entry.file))
|
|
10895
|
+
: undefined,
|
|
10896
|
+
);
|
|
10530
10897
|
if (!baseline.ok) {
|
|
10531
|
-
if (json) { write(JSON.stringify({ packageDir: pkgDir, registryPath, testCommand: testCmd, baseline, results: [], exitCode:
|
|
10898
|
+
if (json) { write(JSON.stringify({ packageDir: pkgDir, registryPath, testCommand: testCmd, baseline, results: [], internalRetries, exitCode: 1 }, null, 2)); return 1; }
|
|
10532
10899
|
write(renderMutationReport([], baseline, pkgDir));
|
|
10533
|
-
return
|
|
10900
|
+
return 1;
|
|
10534
10901
|
}
|
|
10535
10902
|
|
|
10536
10903
|
for (const entry of entries) {
|
|
@@ -10565,8 +10932,10 @@ function cmdMutationGate(options: Map<string, string>, flags: Set<string>, cwd:
|
|
|
10565
10932
|
return fail(`entry '${entry.id}': ${entry.file} resolves to ${realTarget ?? '<unresolvable>'} — OUTSIDE the scratch copy (${realScratchRoot}). A path component is a symlink escaping the scratch tree, so writing the mutation would mutate the REAL working tree (SPEC rule 3). Refused; nothing was written.`);
|
|
10566
10933
|
}
|
|
10567
10934
|
if (!json) write(`mutation-gate: ${entry.id} — mutating ${entry.file}, running suite …`);
|
|
10568
|
-
let run:
|
|
10935
|
+
let run: SuiteRun | null = null;
|
|
10569
10936
|
let parseError: string | undefined;
|
|
10937
|
+
let parseInternalFailureReason: string | undefined;
|
|
10938
|
+
let parseInternalAttemptLog: string | undefined;
|
|
10570
10939
|
try {
|
|
10571
10940
|
writeFileSync(filePath, applied.text);
|
|
10572
10941
|
// Route-a guard: the mutated file must still PARSE — a load failure reddens the whole
|
|
@@ -10576,10 +10945,16 @@ function cmdMutationGate(options: Map<string, string>, flags: Set<string>, cwd:
|
|
|
10576
10945
|
warnings.push(`${entry.id}: parse-check SKIPPED — ${check.skipped}`);
|
|
10577
10946
|
if (!json) write(`mutation-gate: WARNING ${entry.id}: parse-check skipped — ${check.skipped}`);
|
|
10578
10947
|
}
|
|
10948
|
+
if (check.internalAttempts !== undefined) {
|
|
10949
|
+
internalRetries.push({ phase: 'parse-check', entryId: entry.id, attempts: check.internalAttempts });
|
|
10950
|
+
parseInternalAttemptLog = check.internalAttempts.map((attempt) => attempt.detail).join('; ');
|
|
10951
|
+
if (!json) write(`mutation-gate: internal retry — ${parseInternalAttemptLog}`);
|
|
10952
|
+
}
|
|
10953
|
+
parseInternalFailureReason = check.internalFailureReason;
|
|
10579
10954
|
if (check.error !== undefined) {
|
|
10580
10955
|
parseError = check.error; // no suite run: the verdict is MUTATION_UNPARSEABLE regardless
|
|
10581
|
-
} else {
|
|
10582
|
-
run = runSuite();
|
|
10956
|
+
} else if (parseInternalFailureReason === undefined) {
|
|
10957
|
+
run = runSuite('mutation', entry.id);
|
|
10583
10958
|
}
|
|
10584
10959
|
} finally {
|
|
10585
10960
|
writeFileSync(filePath, sourceText); // restore the COPY so the next entry starts pristine
|
|
@@ -10613,13 +10988,26 @@ function cmdMutationGate(options: Map<string, string>, flags: Set<string>, cwd:
|
|
|
10613
10988
|
// those verdicts outrank the rebaseline check, so the extra suite run would buy nothing.
|
|
10614
10989
|
let rebaselineExitCode: number | null | undefined;
|
|
10615
10990
|
let rebaselineFailureReason: string | undefined;
|
|
10991
|
+
let rebaselineAttribution: ReturnType<typeof attributeBaselineRedness> | undefined;
|
|
10992
|
+
let rebaselineInternalAttemptLog: string | undefined;
|
|
10616
10993
|
if (rebaselineMode === 'per-entry' && run !== null && run.exitCode !== null && run.exitCode !== 0
|
|
10617
10994
|
&& fileLoadFailure === undefined && outputUnrecognised === undefined && receiptMismatch === undefined) {
|
|
10618
10995
|
if (!json) write(`mutation-gate: ${entry.id} — re-baselining the restored tree …`);
|
|
10619
|
-
const rebaselineRun = runSuite();
|
|
10996
|
+
const rebaselineRun = runSuite('rebaseline', entry.id);
|
|
10620
10997
|
rebaselineExitCode = rebaselineRun.exitCode;
|
|
10621
10998
|
rebaselineFailureReason = rebaselineRun.failureReason;
|
|
10999
|
+
rebaselineInternalAttemptLog = rebaselineRun.internalAttemptLog;
|
|
11000
|
+
if (rebaselineRun.exitCode !== null && rebaselineRun.exitCode !== 0) {
|
|
11001
|
+
rebaselineAttribution = attributeBaselineRedness(
|
|
11002
|
+
rebaselineRun.output,
|
|
11003
|
+
entries.map((candidate) => candidate.file),
|
|
11004
|
+
);
|
|
11005
|
+
}
|
|
10622
11006
|
}
|
|
11007
|
+
const entryRunFailureReason = run?.failureReason ?? parseInternalFailureReason;
|
|
11008
|
+
const entryInternalAttemptLog = [parseInternalAttemptLog, run?.internalAttemptLog, rebaselineInternalAttemptLog]
|
|
11009
|
+
.filter((log): log is string => log !== undefined)
|
|
11010
|
+
.join('; ');
|
|
10623
11011
|
const obs: MutationObservation = {
|
|
10624
11012
|
entry,
|
|
10625
11013
|
occurrences: 1,
|
|
@@ -10629,9 +11017,11 @@ function cmdMutationGate(options: Map<string, string>, flags: Set<string>, cwd:
|
|
|
10629
11017
|
...(fileLoadFailure !== undefined ? { fileLoadFailure } : {}),
|
|
10630
11018
|
...(outputUnrecognised !== undefined ? { outputUnrecognised } : {}),
|
|
10631
11019
|
...(receiptMismatch !== undefined ? { receiptMismatch } : {}),
|
|
10632
|
-
...(
|
|
11020
|
+
...(entryRunFailureReason !== undefined ? { runFailureReason: entryRunFailureReason } : {}),
|
|
11021
|
+
...(entryInternalAttemptLog !== '' ? { internalAttemptLog: entryInternalAttemptLog } : {}),
|
|
10633
11022
|
...(rebaselineExitCode !== undefined ? { rebaselineExitCode } : {}),
|
|
10634
11023
|
...(rebaselineFailureReason !== undefined ? { rebaselineFailureReason } : {}),
|
|
11024
|
+
...(rebaselineAttribution !== undefined ? { rebaselineAttribution } : {}),
|
|
10635
11025
|
};
|
|
10636
11026
|
observations.push(obs);
|
|
10637
11027
|
results.push(classifyMutationOutcome(obs));
|
|
@@ -10644,7 +11034,7 @@ function cmdMutationGate(options: Map<string, string>, flags: Set<string>, cwd:
|
|
|
10644
11034
|
// MUTATION_LOAD_FATAL / RECEIPT_MISMATCH untouched.
|
|
10645
11035
|
if (rebaselineMode === 'final') {
|
|
10646
11036
|
if (!json) write('mutation-gate: final re-baseline of the restored tree …');
|
|
10647
|
-
const finalRun = runSuite();
|
|
11037
|
+
const finalRun = runSuite('final-rebaseline');
|
|
10648
11038
|
const finalExit = finalRun.exitCode;
|
|
10649
11039
|
if (finalExit !== 0) {
|
|
10650
11040
|
const what = finalExit === null ? `no exit code: ${finalRun.failureReason ?? 'unknown timeout / spawn failure'}` : `exit ${finalExit}`;
|
|
@@ -10654,6 +11044,12 @@ function cmdMutationGate(options: Map<string, string>, flags: Set<string>, cwd:
|
|
|
10654
11044
|
...obs,
|
|
10655
11045
|
rebaselineExitCode: finalExit,
|
|
10656
11046
|
...(finalRun.failureReason !== undefined ? { rebaselineFailureReason: finalRun.failureReason } : {}),
|
|
11047
|
+
...(finalRun.internalAttemptLog !== undefined
|
|
11048
|
+
? { internalAttemptLog: [obs.internalAttemptLog, finalRun.internalAttemptLog].filter((log): log is string => log !== undefined).join('; ') }
|
|
11049
|
+
: {}),
|
|
11050
|
+
...(finalExit !== null && finalExit !== 0
|
|
11051
|
+
? { rebaselineAttribution: attributeBaselineRedness(finalRun.output, entries.map((entry) => entry.file)) }
|
|
11052
|
+
: {}),
|
|
10657
11053
|
}));
|
|
10658
11054
|
results.length = 0;
|
|
10659
11055
|
results.push(...reclassified);
|
|
@@ -10669,7 +11065,7 @@ function cmdMutationGate(options: Map<string, string>, flags: Set<string>, cwd:
|
|
|
10669
11065
|
|
|
10670
11066
|
const exitCode = mutationGateExitCode(results, baseline.ok);
|
|
10671
11067
|
if (json) {
|
|
10672
|
-
write(JSON.stringify({ packageDir: pkgDir, registryPath, testCommand: testCmd, rebaselineMode, baseline, results, summary: summarizeMutationResults(results), warnings, exitCode }, null, 2));
|
|
11068
|
+
write(JSON.stringify({ packageDir: pkgDir, registryPath, testCommand: testCmd, rebaselineMode, baseline, results, summary: summarizeMutationResults(results), warnings, internalRetries, exitCode }, null, 2));
|
|
10673
11069
|
return exitCode;
|
|
10674
11070
|
}
|
|
10675
11071
|
write(renderMutationReport(results, baseline, pkgDir));
|
|
@@ -12382,6 +12778,13 @@ function cmdFeatureAdrRecord(options: Map<string, string>, flags: Set<string>, c
|
|
|
12382
12778
|
targetExists: existsSync(target),
|
|
12383
12779
|
targetHasPair: flags.has('once') && existsSync(target),
|
|
12384
12780
|
timestamp: new Date().toISOString(),
|
|
12781
|
+
// WHO ran it: `--runner <id>` when the caller knows, otherwise this host. The workflow cannot
|
|
12782
|
+
// supply it — it has no host inside its sandbox — so the identity is resolved here, at the one
|
|
12783
|
+
// seam that runs outside. hostname() can throw on an exotic setup; an unresolvable runner stays
|
|
12784
|
+
// ABSENT rather than becoming the string 'unknown', which would later join as if it were one.
|
|
12785
|
+
runnerId: (options.get('runner') ?? '').trim() !== ''
|
|
12786
|
+
? (options.get('runner') ?? '').trim()
|
|
12787
|
+
: (() => { try { return hostname(); } catch { return null; } })(),
|
|
12385
12788
|
});
|
|
12386
12789
|
if (decision.line === null) return emit(decision);
|
|
12387
12790
|
|
|
@@ -13777,6 +14180,145 @@ async function cmdQeBridge(options: Map<string, string>, flags: Set<string>, cwd
|
|
|
13777
14180
|
}
|
|
13778
14181
|
|
|
13779
14182
|
|
|
14183
|
+
interface ScoreReceiptFile {
|
|
14184
|
+
readonly path: string;
|
|
14185
|
+
readonly displayPath: string;
|
|
14186
|
+
readonly qeHash: string;
|
|
14187
|
+
}
|
|
14188
|
+
|
|
14189
|
+
function scoreReceiptFiles(root: string): ScoreReceiptFile[] {
|
|
14190
|
+
const featuresDir = join(root, 'features');
|
|
14191
|
+
let features: Dirent[];
|
|
14192
|
+
try {
|
|
14193
|
+
features = readdirSync(featuresDir, { withFileTypes: true });
|
|
14194
|
+
} catch {
|
|
14195
|
+
return [];
|
|
14196
|
+
}
|
|
14197
|
+
const receipts: ScoreReceiptFile[] = [];
|
|
14198
|
+
for (const feature of features) {
|
|
14199
|
+
if (!feature.isDirectory()) continue;
|
|
14200
|
+
const stateDir = join(featuresDir, feature.name, '.fa-state');
|
|
14201
|
+
let entries: Dirent[];
|
|
14202
|
+
try {
|
|
14203
|
+
if (lstatSync(stateDir).isSymbolicLink()) continue;
|
|
14204
|
+
entries = readdirSync(stateDir, { withFileTypes: true });
|
|
14205
|
+
} catch {
|
|
14206
|
+
continue;
|
|
14207
|
+
}
|
|
14208
|
+
for (const entry of entries) {
|
|
14209
|
+
if (!entry.isFile()) continue;
|
|
14210
|
+
const match = /^score-(.+)\.json$/.exec(entry.name);
|
|
14211
|
+
if (match === null || match[1] === undefined || match[1] === '') continue;
|
|
14212
|
+
const path = join(stateDir, entry.name);
|
|
14213
|
+
receipts.push({ path, displayPath: relative(root, path), qeHash: match[1] });
|
|
14214
|
+
}
|
|
14215
|
+
}
|
|
14216
|
+
return receipts.sort((a, b) => a.displayPath < b.displayPath ? -1 : a.displayPath > b.displayPath ? 1 : 0);
|
|
14217
|
+
}
|
|
14218
|
+
|
|
14219
|
+
function scoreAggregateChainLine(text: string): {
|
|
14220
|
+
readonly line: string;
|
|
14221
|
+
readonly verification: ReturnType<typeof verifyEventChainText> | null;
|
|
14222
|
+
readonly defectAges: ReturnType<typeof classifyChainDefects> | null;
|
|
14223
|
+
} {
|
|
14224
|
+
if (text === '') {
|
|
14225
|
+
return { line: 'chain: NOT_PRESENT — no aggregate evidence file was created', verification: null, defectAges: null };
|
|
14226
|
+
}
|
|
14227
|
+
const verification = verifyEventChainText(text);
|
|
14228
|
+
const defectAges = classifyChainDefects(verification, verification.lines);
|
|
14229
|
+
const kinds = new Map<string, number>();
|
|
14230
|
+
for (const defect of verification.defects) kinds.set(defect.kind, (kinds.get(defect.kind) ?? 0) + 1);
|
|
14231
|
+
const kindText = [...kinds.entries()].map(([kind, count]) => `${kind}: ${count}`).join(' · ');
|
|
14232
|
+
const line =
|
|
14233
|
+
`chain: ${verification.ok ? 'OK' : 'FAILED'} · ${verification.chained} chained · ` +
|
|
14234
|
+
`${verification.resets} recorded restart(s) · before-run defects ${defectAges.beforeRun.length} · ` +
|
|
14235
|
+
`in-run defects ${defectAges.inRun.length} · current run ${defectAges.runRecords} record(s)` +
|
|
14236
|
+
(kindText === '' ? '' : ` · ${kindText}`) +
|
|
14237
|
+
` — ${verification.scope}`;
|
|
14238
|
+
return { line, verification, defectAges };
|
|
14239
|
+
}
|
|
14240
|
+
|
|
14241
|
+
function cmdScoreAll(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
|
|
14242
|
+
const json = flags.has('json');
|
|
14243
|
+
if (options.has('slug')) {
|
|
14244
|
+
write(json
|
|
14245
|
+
? JSON.stringify({ error: '--all and --slug are mutually exclusive', exitCode: 1 })
|
|
14246
|
+
: 'dz score: --all and --slug are mutually exclusive');
|
|
14247
|
+
return 1;
|
|
14248
|
+
}
|
|
14249
|
+
const root = resolve(cwd, options.get('project') ?? '.');
|
|
14250
|
+
const receiptFiles = scoreReceiptFiles(root);
|
|
14251
|
+
if (receiptFiles.length === 0) {
|
|
14252
|
+
const report = buildScoreAggregateReport([], [], 0);
|
|
14253
|
+
const chain = scoreAggregateChainLine('');
|
|
14254
|
+
if (json) write(JSON.stringify({ ...report, chain: null, aggregatePath: '.dz/feature-adr/scorecards.jsonl', exitCode: 0 }, null, 2));
|
|
14255
|
+
else {
|
|
14256
|
+
write(renderScoreAggregateReport(report));
|
|
14257
|
+
write(chain.line);
|
|
14258
|
+
}
|
|
14259
|
+
return 0;
|
|
14260
|
+
}
|
|
14261
|
+
|
|
14262
|
+
const ts = new Date().toISOString();
|
|
14263
|
+
const rows: ReturnType<typeof scoreReceiptToAggregateRow>[] = [];
|
|
14264
|
+
const unreadableReceipts: string[] = [];
|
|
14265
|
+
for (const receipt of receiptFiles) {
|
|
14266
|
+
try {
|
|
14267
|
+
rows.push(scoreReceiptToAggregateRow({
|
|
14268
|
+
content: readFileSync(receipt.path, 'utf8'),
|
|
14269
|
+
qeHash: receipt.qeHash,
|
|
14270
|
+
ts,
|
|
14271
|
+
}));
|
|
14272
|
+
} catch {
|
|
14273
|
+
unreadableReceipts.push(receipt.displayPath);
|
|
14274
|
+
}
|
|
14275
|
+
}
|
|
14276
|
+
|
|
14277
|
+
const storeDir = join(root, '.dz', 'feature-adr');
|
|
14278
|
+
const aggregatePath = join(storeDir, 'scorecards.jsonl');
|
|
14279
|
+
let finalText = '';
|
|
14280
|
+
let finalRows = rows;
|
|
14281
|
+
let appended = 0;
|
|
14282
|
+
let storeError: string | null = null;
|
|
14283
|
+
try {
|
|
14284
|
+
const result = withNamedLockSync(storeDir, 'scorecards', () => {
|
|
14285
|
+
let existingText = '';
|
|
14286
|
+
try {
|
|
14287
|
+
existingText = readFileSync(aggregatePath, 'utf8');
|
|
14288
|
+
} catch (error) {
|
|
14289
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
|
14290
|
+
}
|
|
14291
|
+
const fresh = dedupeScoreAggregateRows(rows, readScoreAggregateRows(existingText));
|
|
14292
|
+
const appendText = appendChainedLines(fresh, readTailInfo(existingText));
|
|
14293
|
+
if (appendText !== '') appendFileSync(aggregatePath, appendText, { encoding: 'utf8', mode: 0o600 });
|
|
14294
|
+
const settledText = existingText + appendText;
|
|
14295
|
+
return { text: settledText, rows: readScoreAggregateRows(settledText), appended: fresh.length };
|
|
14296
|
+
});
|
|
14297
|
+
finalText = result.text;
|
|
14298
|
+
finalRows = result.rows;
|
|
14299
|
+
appended = result.appended;
|
|
14300
|
+
} catch (error) {
|
|
14301
|
+
storeError = error instanceof Error ? error.message : String(error);
|
|
14302
|
+
}
|
|
14303
|
+
|
|
14304
|
+
const report = buildScoreAggregateReport(finalRows, unreadableReceipts, appended);
|
|
14305
|
+
const chain = scoreAggregateChainLine(finalText);
|
|
14306
|
+
if (json) {
|
|
14307
|
+
write(JSON.stringify({
|
|
14308
|
+
...report,
|
|
14309
|
+
aggregatePath: '.dz/feature-adr/scorecards.jsonl',
|
|
14310
|
+
chain: chain.verification === null ? null : { verification: chain.verification, defectAges: chain.defectAges },
|
|
14311
|
+
storeError,
|
|
14312
|
+
exitCode: 0,
|
|
14313
|
+
}, null, 2));
|
|
14314
|
+
} else {
|
|
14315
|
+
write(renderScoreAggregateReport(report));
|
|
14316
|
+
write(chain.line);
|
|
14317
|
+
if (storeError !== null) write(`store error (nothing was claimed appended): ${storeError}`);
|
|
14318
|
+
}
|
|
14319
|
+
return 0;
|
|
14320
|
+
}
|
|
14321
|
+
|
|
13780
14322
|
function cmdScore(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
|
|
13781
14323
|
const json = flags.has('json');
|
|
13782
14324
|
if (flags.has('help')) {
|
|
@@ -13784,13 +14326,14 @@ function cmdScore(options: Map<string, string>, flags: Set<string>, cwd: string,
|
|
|
13784
14326
|
if (json) write(JSON.stringify({ help: usage, exitCode: 0 })); // --json stays ONE document even for help
|
|
13785
14327
|
else {
|
|
13786
14328
|
write(usage);
|
|
14329
|
+
write('dz score --all [--project <dir>] [--json] — sweep immutable score receipts into the append-only chained aggregate');
|
|
13787
14330
|
write(' disciplines: ADR confirmation · discrimination · cross-model QE · live verification · README-first · learning loop · amendments');
|
|
13788
14331
|
write(' descriptive-only, never a gate: a low score exits 0');
|
|
13789
14332
|
}
|
|
13790
14333
|
return 0;
|
|
13791
14334
|
}
|
|
13792
14335
|
for (const flag of flags) {
|
|
13793
|
-
if (!new Set(['json', 'help']).has(flag)) {
|
|
14336
|
+
if (!new Set(['json', 'help', 'all']).has(flag)) {
|
|
13794
14337
|
write(json ? JSON.stringify({ error: `unknown option --${flag}`, exitCode: 1 }) : `dz score: unknown option --${flag}\n allowed: --slug <feature>, --project <dir>, --json`);
|
|
13795
14338
|
return 1;
|
|
13796
14339
|
}
|
|
@@ -13802,6 +14345,7 @@ function cmdScore(options: Map<string, string>, flags: Set<string>, cwd: string,
|
|
|
13802
14345
|
return 1;
|
|
13803
14346
|
}
|
|
13804
14347
|
}
|
|
14348
|
+
if (flags.has('all')) return cmdScoreAll(options, flags, cwd, write);
|
|
13805
14349
|
const slug = options.get('slug') ?? '';
|
|
13806
14350
|
// The delivery-check traversal lesson, upgraded to a WHITELIST: `.` slipped the blacklist and
|
|
13807
14351
|
// silently aggregated the entire features/ tree as one "run" (Codex QE #2).
|
|
@@ -15582,17 +16126,19 @@ function cmdStats(cwd: string, write: Write): number {
|
|
|
15582
16126
|
}
|
|
15583
16127
|
const dirs = readdirSync(baseDir, { withFileTypes: true }).filter((e) => e.isDirectory());
|
|
15584
16128
|
const packages = dirs.length;
|
|
15585
|
-
|
|
15586
|
-
|
|
15587
|
-
|
|
15588
|
-
|
|
15589
|
-
|
|
15590
|
-
|
|
15591
|
-
|
|
15592
|
-
|
|
15593
|
-
|
|
15594
|
-
|
|
15595
|
-
|
|
16129
|
+
// Backlog e160aeee. This used to walk the tree ITSELF, and was wrong in two independent ways:
|
|
16130
|
+
// it counted only packages whose NAME starts with `skills-` (health-advisor, p-replicator,
|
|
16131
|
+
// keysarium and trip-planner were therefore invisible), and it knew only ONE of the three skill
|
|
16132
|
+
// layouts. Result: 203 here against 250 from `dz registry` on the same tree — two counters of one
|
|
16133
|
+
// quantity, each unable to refute the other because neither knew the other existed.
|
|
16134
|
+
//
|
|
16135
|
+
// The fix is structural, not arithmetic: there is now ONE enumerator, and both commands ask it.
|
|
16136
|
+
// Pinned by test/stats-registry-parity.test.ts, whose red half is this exact divergence.
|
|
16137
|
+
// The registry already PUBLISHES these totals; recomputing them from `entries` here would be a
|
|
16138
|
+
// third implementation of the same count, which is the very defect being fixed.
|
|
16139
|
+
const registry = buildRegistry(cwd);
|
|
16140
|
+
const totalSkills = registry.totalSkills;
|
|
16141
|
+
const skillPacks = registry.totalPacks;
|
|
15596
16142
|
const targets = TARGET_NAMES.length;
|
|
15597
16143
|
const presets = PRESET_NAMES.length;
|
|
15598
16144
|
write(`dz stats — DZ Harness Hub`);
|
|
@@ -16065,7 +16611,31 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
|
|
|
16065
16611
|
return version === 'unknown' ? 1 : 0;
|
|
16066
16612
|
}
|
|
16067
16613
|
|
|
16068
|
-
|
|
16614
|
+
// `-h` is the most-typed help flag and is NOT a command: before the unknown-command contract
|
|
16615
|
+
// landed it fell through to the switch and still printed usage; afterwards it would have died
|
|
16616
|
+
// with exit 2 and an empty stdout (measured regression, cross-model QE M1). It belongs beside
|
|
16617
|
+
// `-v` above — an argv-level flag, resolved before command dispatch.
|
|
16618
|
+
if (argv[0] === '-h') {
|
|
16619
|
+
write(USAGE);
|
|
16620
|
+
return 0;
|
|
16621
|
+
}
|
|
16622
|
+
// A bare `--typo` leaves the command empty, so the usage branch reported SUCCESS on a misspelled
|
|
16623
|
+
// FLAG exactly as it used to on a misspelled VERB (cross-model QE M2): `dz --frobnicate` exited 0
|
|
16624
|
+
// with 30 KB of usage. The refusal is deliberately narrowed to the no-command case, because the
|
|
16625
|
+
// warn-don't-refuse decision above is measured and still stands: with a command present, an
|
|
16626
|
+
// unrecognised name may simply be missing from KNOWN_CLI_FLAGS and refusing would break working
|
|
16627
|
+
// invocations. With NO command there is nothing the flag could belong to, so it is a usage error.
|
|
16628
|
+
if (command === '') {
|
|
16629
|
+
const strayNames = unknownFlagNotice(
|
|
16630
|
+
[...flags, ...options.keys()].filter((k) => !k.startsWith('_positional_')),
|
|
16631
|
+
KNOWN_CLI_FLAGS,
|
|
16632
|
+
).map((n) => n.name);
|
|
16633
|
+
if (strayNames.length > 0) {
|
|
16634
|
+
writeErr(`dz: unknown option --${strayNames[0]} — run 'dz help' for usage`);
|
|
16635
|
+
return 2;
|
|
16636
|
+
}
|
|
16637
|
+
}
|
|
16638
|
+
if (command === '' || command === 'help' || (flags.has('help') && DZ_COMMANDS.includes(command))) {
|
|
16069
16639
|
write(USAGE);
|
|
16070
16640
|
return 0;
|
|
16071
16641
|
}
|
|
@@ -16134,6 +16704,8 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
|
|
|
16134
16704
|
return cmdStatusline(options, flags, cwd, write, readStdin);
|
|
16135
16705
|
case 'usage':
|
|
16136
16706
|
return cmdUsage(options, optionLists, flags, cwd, write);
|
|
16707
|
+
case 'chain':
|
|
16708
|
+
return cmdChain(options, flags, cwd, write);
|
|
16137
16709
|
case 'claim-check':
|
|
16138
16710
|
return cmdClaimCheck(options, optionLists, flags, cwd, write);
|
|
16139
16711
|
case 'lint':
|
|
@@ -16155,7 +16727,7 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
|
|
|
16155
16727
|
case 'diff':
|
|
16156
16728
|
return cmdDiff(options, cwd, write);
|
|
16157
16729
|
case 'recommend':
|
|
16158
|
-
return cmdRecommend(options, cwd, write);
|
|
16730
|
+
return cmdRecommend(options, flags, cwd, write);
|
|
16159
16731
|
case 'upgrade':
|
|
16160
16732
|
return cmdUpgrade(options, flags, cwd, write, writeErr);
|
|
16161
16733
|
case 'auto-canonicalize':
|
|
@@ -16204,8 +16776,20 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
|
|
|
16204
16776
|
return cmdChallenge(options, flags, cwd, write);
|
|
16205
16777
|
case 'discrimination-check':
|
|
16206
16778
|
return cmdDiscriminationCheck(options, flags, cwd, write);
|
|
16207
|
-
case 'mutation-gate':
|
|
16208
|
-
|
|
16779
|
+
case 'mutation-gate': {
|
|
16780
|
+
try {
|
|
16781
|
+
return cmdMutationGate(options, flags, cwd, write, io.mutationGateRunner);
|
|
16782
|
+
} catch (error) {
|
|
16783
|
+
const raw = error instanceof Error ? error.message : String(error);
|
|
16784
|
+
const head = Array.from(raw.split(/\r?\n/, 1)[0]?.trim() || 'unknown internal error').slice(0, 160).join('');
|
|
16785
|
+
if (flags.has('json')) {
|
|
16786
|
+
write(JSON.stringify({ verdict: 'INCONCLUSIVE', reason: 'runner-internal-error', error: head, exitCode: 1 }));
|
|
16787
|
+
} else {
|
|
16788
|
+
write(`mutation-gate: INTERNAL ERROR (${head}) — verdict INCONCLUSIVE, exit 1`);
|
|
16789
|
+
}
|
|
16790
|
+
return 1;
|
|
16791
|
+
}
|
|
16792
|
+
}
|
|
16209
16793
|
case 'delivery-check':
|
|
16210
16794
|
return cmdDeliveryCheck(options, flags, cwd, write);
|
|
16211
16795
|
case 'skills-verify':
|
|
@@ -16259,9 +16843,8 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
|
|
|
16259
16843
|
case 'import-ecc':
|
|
16260
16844
|
return await cmdImportEcc(options, flags, cwd, write);
|
|
16261
16845
|
default:
|
|
16262
|
-
|
|
16263
|
-
|
|
16264
|
-
return 1;
|
|
16846
|
+
writeErr(`dz: unknown command ${JSON.stringify(command)} — run 'dz help' for the command list`);
|
|
16847
|
+
return 2;
|
|
16265
16848
|
}
|
|
16266
16849
|
} catch (error) {
|
|
16267
16850
|
// stderr, not stdout: an uncaught failure is a diagnostic, and routing it through
|