@dzhechkov/harness-cli 0.8.24 → 0.8.26
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 +12 -12
- package/README.md +323 -27
- package/dist/cli.d.ts +44 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +910 -193
- package/dist/cli.js.map +1 -1
- package/dist/known-flags.d.ts.map +1 -1
- package/dist/known-flags.js +7 -0
- package/dist/known-flags.js.map +1 -1
- package/package.json +2 -2
- package/sbom.json +11 -11
- package/src/cli.ts +893 -145
- package/src/known-flags.ts +7 -0
package/dist/cli.js
CHANGED
|
@@ -3,8 +3,16 @@
|
|
|
3
3
|
*
|
|
4
4
|
* @packageDocumentation
|
|
5
5
|
*/
|
|
6
|
-
import {
|
|
7
|
-
|
|
6
|
+
import { parseNpmPackInventory } from '@dzhechkov/harness-core';
|
|
7
|
+
// Fix-round 1 (Codex HIGH-1c, feature recall-short-terms): the ONE place `dz recall` prints an
|
|
8
|
+
// empty result must name WHY — via the shared helper, not by re-deriving the decision. Routed
|
|
9
|
+
// through harness-core's re-export (lead correction) rather than a new direct dependency on
|
|
10
|
+
// `@dzhechkov/memory`: a new package-graph edge is a publishing-surface change outside this
|
|
11
|
+
// feature's scope, and harness-core already depends on memory.
|
|
12
|
+
import { noSearchableTermsReason } from '@dzhechkov/harness-core';
|
|
13
|
+
import { appendFileSync, chmodSync, closeSync, constants as fsConstants, cpSync, existsSync, fstatSync, fsyncSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, readdirSync, readlinkSync, realpathSync, renameSync, rmdirSync, rmSync, statSync, symlinkSync, unlinkSync, writeFileSync, writeSync } from 'node:fs';
|
|
14
|
+
import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep, posix as nodePosixPath } from 'node:path';
|
|
15
|
+
const posixNormalize = nodePosixPath.normalize;
|
|
8
16
|
import { fileURLToPath } from 'node:url';
|
|
9
17
|
import { request as httpsRequest } from 'node:https';
|
|
10
18
|
import { KNOWN_CLI_FLAGS } from './known-flags.js';
|
|
@@ -13,15 +21,15 @@ import { resolveInstallSpec } from './install-spec.js';
|
|
|
13
21
|
import { dispatchedCommands, documentedCommands } from './command-inventory.js';
|
|
14
22
|
import { execFile, execFileSync, execSync, spawn, spawnSync } from 'node:child_process';
|
|
15
23
|
import { createHash, randomBytes } from 'node:crypto';
|
|
16
|
-
import { homedir, hostname, tmpdir } from 'node:os';
|
|
24
|
+
import { cpus, homedir, hostname, tmpdir } from 'node:os';
|
|
17
25
|
import { createRequire } from 'node:module';
|
|
18
26
|
import { isDeepStrictEqual } from 'node:util';
|
|
19
27
|
import { JOURNAL_KINDS, formatLine, parseLine, selectWindow, appendWitnessed } from '@dzhechkov/harness-core';
|
|
20
28
|
import { appendRunEvent, readRunRegistry, liveParents, liveness, probePid, settleDeadRuns, planRegistryArchive, planWorktreeCleanup, renderCleanupPlan, worktreeRemovalsToApply } from '@dzhechkov/harness-core';
|
|
21
29
|
import { openRound, closeRound, listRounds, parseCodexTokens, classifyRoundExecOutcome, buildRoundExecRow, } from '@dzhechkov/harness-core';
|
|
22
|
-
import { createSkill, getSkillInfo, listSkillsDetailed, formatSkillLoadFailures, formatSkillApplyFailures, resolveTargetName, formatTargetProblem, formatTargetAliasNote, TARGET_NAMES_SORTED, runDoctor, runInit, discoverSkillIds, resolveSelection, formatSelectRefusal, runIntegrationsVerify, resolvePackageSkillRoots, PACKAGE_SKILL_LAYOUTS, benchmarkSkill, benchmarkSkills, scanMcp, reconcileCapabilities, RECONCILE_BANNER, buildRegistry, discoverSkillPackDirs, discoverVerifiablePackDirs, checkUpstream, compareSkills, checkAllUpstream, sweepSkillDrift, syncCanonicalSkill, checkUpgrades, discoverPackages, discoverSourcePackages, fetchAllDownloads, filterByCategory, pretrain, recommend, generatePlugin, publishPackages, runSetup, memoryBackendSourceLabel, runMigrate, searchRegistry, runSync, runVerify, runInitAgentsMd, runInitGeminiMd, runSyncAgentsPolicy, runSyncCodexHooks, resolveCodexHome, withNamedLockSync,
|
|
30
|
+
import { createSkill, getSkillInfo, listSkillsDetailed, formatSkillLoadFailures, formatSkillApplyFailures, resolveTargetName, formatTargetProblem, formatTargetAliasNote, TARGET_NAMES_SORTED, runDoctor, runInit, discoverSkillIds, loadSkillFromDir, resolveSelection, formatSelectRefusal, runIntegrationsVerify, resolvePackageSkillRoots, PACKAGE_SKILL_LAYOUTS, benchmarkSkill, benchmarkSkills, scanMcp, reconcileCapabilities, RECONCILE_BANNER, buildRegistry, discoverSkillPackDirs, discoverVerifiablePackDirs, checkUpstream, compareSkills, checkAllUpstream, sweepSkillDrift, syncCanonicalSkill, checkUpgrades, discoverPackages, discoverSourcePackages, fetchAllDownloads, filterByCategory, pretrain, recommend, generatePlugin, publishPackages, runSetup, memoryBackendSourceLabel, runMigrate, searchRegistry, runSync, runVerify, runInitAgentsMd, runInitGeminiMd, runSyncAgentsPolicy, runSyncCodexHooks, resolveCodexHome, withNamedLockSync,
|
|
23
31
|
// dz workflow run (feature dz-workflow-run): the pure scheduler + the dispatch adapters.
|
|
24
|
-
TRACE_RUNID_RE, WF_RUN_OWNER_HOST, preflight, runWorkflow, makeClaudePDispatcher, makeCodexExecDispatcher, NamedLockTimeoutError, NamedLockCompromisedError, POLICY_SOURCES, detectPolicyDrift, hasPolicyFence, TARGET_NAMES, buildParityMatrix, computeParity, PARITY_FEATURES, downgradeForStaleEvidence, findStaleTranscriptEvidence, TARGET_CAPABILITIES, TARGET_SHORT_LABELS, applyLegStatus, applyLegReasonMessage, resolveAgentdbPath, WORKFLOW_TEMPLATES_RETIRED_MESSAGE, parsePlan, isParseErrors, validatePlan, normalizePlan, planDigest, toTraceProjection, renderPlan, mergeRender, lint, lintExitCode, LOOP_BLOBS, parseTrace, assembleTimeline, runInvariants, deriveAttestation, stampAttestation, corroborate, NOT_WITNESSED, renderTimelineHtml, importEcc, recordPattern, recordLessonForms, normalizeLessonForms, resolveLearningBackend, storeStats, consolidateSessions, pruneNoisePatterns, lessonDeltaReport, removePatternsByIds, snapshotStore, recallHybrid, teachGuard, mirrorPatternsToVector, mirrorEntriesToVector, patternVectorEntry, readMemoryLearningConfig, promotePatterns, quarantineExpiryCandidates, pruneQuarantinePatterns, clearAgentdbQuarantine, vectorMirrorEnabled, vectorTierStatus, resolveVectorEngine, reindexVectorStore, harmonizeVectorStore, importRvfCheckpoint, renderFeatureAdrPhaseLine, statuslineData, countLearningStoreRowsReadonly, readStoreMark, writeStoreMark, resetStoreMark, checkStoreHealth, storeGuardPath, storeSnapshotPath, writeFeatureAdrState, writeFeatureAdrStateDetailed, CHECKPOINT_STAGES, estimateEta, extractStageSamples, formatEta, parseCheckpointLines, segmentRun, computeSpendReport, deriveCostLedger, planLedgerBackfill, listCostLedgerRuns, resolveLedgerRunId, AMBIGUOUS, stampCheckpointLine, LEDGER_FILL_SOURCE, renderCostLedger, verifyCostLedgerReport, writeCostLedgerJsonl, COST_LEDGER_SCOPE, spendReport, claimCheck, summarize, BUNDLED_SLOP_REGISTRY_URL, DEFAULT_SLOP_CONFIG, parseSlopRegistry, slopLint, validateSlopLintConfig, queryBookKnowledge, loadStorePatternsSync, patternRecordId, patternIdentityOf, mergeLessonMatchedForms, SWARM_BRIEF_CONTRACT, checkSwarmBrief, visibleText, loadStoreRecords, findExactLesson, recordToPattern, bundleSkills, brainHome, brainAgentdbPath, listPreReindexSnapshots, rotatePreReindexSnapshots, scanSnapshotDir, listBrain, bookKbPath, promoteProjectToBrain, updateBrainSource, queryBrain, groundPrompt, expandKu, reindexBrainVectors, buildPrimer, exportBrainSlice, importBrainSlice, registerKusToBrain, RECALL_USAGE_LOG_RELATIVE, RECALL_USAGE_LOG_MAX_BYTES, parseRecallUsageLog, buildRecallUsageReport, EVENT_CHAIN_TAIL_BYTES, EMPTY_LOG_TAIL, readTailInfo, appendChainedLines, verifyEventChainText, classifyChainDefects, CHAINED_JOURNALS, buildManifest, buildSbom, resolveTrustRoot, decideVerifyPolicy, generateSigningKeypair, appendTransition, evaluateGuard, resolveRules, auditRecord, guardExitCode, DEFAULT_RULES, parsePnpmLockImporters, scannableStubPath,
|
|
32
|
+
TRACE_RUNID_RE, WF_RUN_OWNER_HOST, preflight, runWorkflow, makeClaudePDispatcher, makeCodexExecDispatcher, NamedLockTimeoutError, NamedLockCompromisedError, POLICY_SOURCES, detectPolicyDrift, hasPolicyFence, TARGET_NAMES, buildParityMatrix, computeParity, PARITY_FEATURES, downgradeForStaleEvidence, findStaleTranscriptEvidence, TARGET_CAPABILITIES, TARGET_SHORT_LABELS, applyLegStatus, applyLegReasonMessage, probeApplyLeg, resolveAgentdbPath, WORKFLOW_TEMPLATES_RETIRED_MESSAGE, parsePlan, isParseErrors, validatePlan, normalizePlan, planDigest, toTraceProjection, renderPlan, mergeRender, lint, lintExitCode, LOOP_BLOBS, parseTrace, assembleTimeline, runInvariants, deriveAttestation, stampAttestation, corroborate, NOT_WITNESSED, renderTimelineHtml, importEcc, recordPattern, recordLessonForms, normalizeLessonForms, resolveLearningBackend, storeStats, consolidateSessions, pruneNoisePatterns, lessonDeltaReport, removePatternsByIds, snapshotStore, recallHybrid, teachGuard, mirrorPatternsToVector, mirrorEntriesToVector, patternVectorEntry, readMemoryLearningConfig, promotePatterns, quarantineExpiryCandidates, pruneQuarantinePatterns, clearAgentdbQuarantine, vectorMirrorEnabled, vectorTierStatus, resolveVectorEngine, reindexVectorStore, harmonizeVectorStore, importRvfCheckpoint, renderFeatureAdrPhaseLine, statuslineData, countLearningStoreRowsReadonly, readStoreMark, writeStoreMark, resetStoreMark, checkStoreHealth, storeGuardPath, storeSnapshotPath, writeFeatureAdrState, writeFeatureAdrStateDetailed, CHECKPOINT_STAGES, estimateEta, extractStageSamples, formatEta, parseCheckpointLines, segmentRun, computeSpendReport, deriveCostLedger, planLedgerBackfill, listCostLedgerRuns, resolveLedgerRunId, AMBIGUOUS, stampCheckpointLine, LEDGER_FILL_SOURCE, renderCostLedger, verifyCostLedgerReport, writeCostLedgerJsonl, COST_LEDGER_SCOPE, spendReport, claimCheck, summarize, BUNDLED_SLOP_REGISTRY_URL, DEFAULT_SLOP_CONFIG, parseSlopRegistry, slopLint, validateSlopLintConfig, queryBookKnowledge, loadStorePatternsSync, patternRecordId, patternIdentityOf, mergeLessonMatchedForms, SWARM_BRIEF_CONTRACT, checkSwarmBrief, visibleText, loadStoreRecords, findExactLesson, recordToPattern, bundleSkills, brainHome, brainAgentdbPath, listPreReindexSnapshots, rotatePreReindexSnapshots, scanSnapshotDir, listBrain, bookKbPath, promoteProjectToBrain, updateBrainSource, queryBrain, groundPrompt, expandKu, reindexBrainVectors, buildPrimer, exportBrainSlice, importBrainSlice, registerKusToBrain, RECALL_USAGE_LOG_RELATIVE, RECALL_USAGE_LOG_MAX_BYTES, parseRecallUsageLog, buildRecallUsageReport, EVENT_CHAIN_TAIL_BYTES, EMPTY_LOG_TAIL, readTailInfo, appendChainedLines, verifyEventChainText, classifyChainDefects, CHAINED_JOURNALS, buildManifest, buildSbom, resolveTrustRoot, decideVerifyPolicy, generateSigningKeypair, appendTransition, evaluateGuard, resolveRules, auditRecord, guardExitCode, DEFAULT_RULES, parsePnpmLockImporters, scannableStubPath,
|
|
25
33
|
// guard-promotion (feature guard-promotion, scout idea #1)
|
|
26
34
|
assembleCandidates, renderPromotionReport, renderPromotionAdr, normalizePromotionState, nextPromotionState, recordPromotionRunEvidence, isLessonRuleContentAnchor, isOffsetIsoTimestamp, globMatch, promotionAdrRelPath, DEFAULT_WINDOW_DAYS, DEFAULT_PERIODS, MAX_CONTENT_FETCHES, BUILTIN_COVERAGE, decideProvenance, isInsideTree, signManifest, verifyManifest, hashPackBytes, rewriteWorkspaceSpecs, detectSiblingDrift, planPackedInstallSmoke, judgePackedInstallSmoke, listPackFiles, listSignablePackFiles, assertKeyOutsideTree, decidePublishGate, collectPackageFacts, planReleaseGates, selectAffectedPackages, classifyGateExecutions, buildFailureIssue, buildReleaseNotes, releaseTagName, firstOutputLine, formatPublishError, MANIFEST_NAME, SBOM_NAME, buildArchitectureMap, renderMapHuman, findArchitectureDrift, renderDriftReport, scanWorkspacePackages, loadSubsystemManifest, loadProductVision, checkFeatureAgainstArchitecture, renderArchCheck, planProjectSkills, guidanceForStage, renderInjectionReport, analyzeCorpus, renderRakeReport, renderCriticSection, rakeAsLesson, rakeReward, DEFAULT_RAKE_THRESHOLDS, streamSessionEvents, findLatestTranscript, resolveScanTailTranscript, detectProcessRakes, buildRetro, renderRetro, retroLessonText, PROCESS_SIGNATURES, RETRO_DOMAIN, runRetroTailScan, scanForSetup, buildSetupPlan, scaffoldFromSpec, renderScaffoldPreview, readExistingForScaffold, assembleChallengeContext, buildChallengeBrief, planDiscriminationCheck, classifyDiscrimination, classifyExecutionEvidence, pickAdversaryModel, CHALLENGE_QUESTIONS, loadOutcomes, renderOutcomes, statsForKey, selectAutoCost, recordProvisional, finalizeOutcome, harvestStageOutcomes, recommendModels, planFeed, unfedRuns, GRADE_SUCCESS_FLOOR, COST_LADDER, splitScenarios, budgetPlan, selectWinner, proseScopeOk, renderProseDiff, readScenarioIds, DEFAULT_MAX_JUDGE_RUNS, collectDeliveryFacts, planDeliveryCheck, renderDeliveryBrief, classifyDelivery, isUsablePlaneResult, renderDeliveryReview, scanSkillsLayout, declaredPluginSurface, parseInitFacts, verifyRegistration, buildContentProbePrompt, classifyContentProbe, renderContentProbe, findNonRegistrableSkillDirs, assembleCompoundingReport, buildDeadwoodReport, compactCmdUsageIfNeeded, measureCmdUsageDepthDays, recordCommandInvocation, resolveCmdUsageRoot, renderDeadwoodReport, CMD_USAGE_LOG_RELATIVE, banditStats, narrowBanditReport, renderBanditHealth,
|
|
27
35
|
// Cold-vs-warm EPOCH RUNNER (feature epoch-replay) — orchestrates + scores, never calls a model.
|
|
@@ -31,7 +39,7 @@ readBacklogConfig, readIdeas, writeIdeas, ideaId, dedupIdea, readGoalMap, readGo
|
|
|
31
39
|
// qe-bridge (feature qe-bridge-claude, ADR-001): the pure half of the reverse QE bridge.
|
|
32
40
|
KNOWN_CLAUDE, isSafeClaudeId, claudeProbeArgs, claudeReviewArgs, interpretClaudeProbe, modelFamily, buildBridgePrompt, parseBridgeOutput, buildBridgeFailureRecord, buildBridgeSignoffRecord, renderBridgeReport, isSafeSlug, hasUnsafePathChars, hasDotDotSegment, buildReqeBrief, settleReqeDebt, renderReqeList, REQE_SCOPE,
|
|
33
41
|
// Mutation gate (feature ha-mutation-gate) — break each named protection, run the suite, require red.
|
|
34
|
-
REGISTRY_SELFCHECK_TESTS, buildMutationTestCommand, parseMutationRegistry, applyMutationToText, attributeBaselineRedness, countFailingTests, detectSuiteCompletionReceipt, detectSuiteReceiptMismatch, classifyBaseline, classifyRunFailure, classifyMutationOutcome, mutationGateExitCode, summarizeMutationResults, renderMutationReport, runWithOneInternalRetry, TRACE_BUNDLE_LEDGER_PATH, TRACE_BUNDLE_SCHEMA, TRACE_BUNDLE_RUN_META_FILE, buildBundle, serializeBundle, parseBundle, planImport, decideCheckpointWrite, amendmentSection, amendmentSectionCount, amendmentDeclarationAmbiguity, planSaysNoAmendments, parseAmendments, resolveAmendments, decideAmendmentOutcome, amendmentVerdictLine, amendmentsMissingFromPlan, AMENDMENT_VACUITY_NOTE, extractContractChecklist, readFeatureTier, parseContractVerdictReport, verifyContractVerdicts, decideSignableSet, signableSetLine, decideRecordWrite, decideReadBack, recordVerdictLine, buildCadenceReport, tgVisibleSha256, CADENCE_WINDOW_DAYS, readQeRounds, QE_ROUNDS_DEFAULT_CEILING, adviseRestart, describeStoreLocation, storeLocationLine, resolveTeachTarget, teachReasonPhrase, readTeachToConfig, TeachTargetError, mergeStoreHits, sameStore, globalStoreRoot, storeCountLabel,
|
|
42
|
+
REGISTRY_SELFCHECK_TESTS, buildMutationTestCommand, parseMutationRegistry, registryEntriesAddedSince, applyMutationToText, attributeBaselineRedness, countFailingTests, detectSuiteCompletionReceipt, detectSuiteReceiptMismatch, classifyBaseline, classifyRunFailure, classifyMutationOutcome, mutationGateExitCode, summarizeMutationResults, renderMutationReport, runWithOneInternalRetry, TRACE_BUNDLE_LEDGER_PATH, TRACE_BUNDLE_SCHEMA, TRACE_BUNDLE_RUN_META_FILE, buildBundle, serializeBundle, parseBundle, planImport, decideCheckpointWrite, amendmentSection, amendmentSectionCount, amendmentDeclarationAmbiguity, planSaysNoAmendments, parseAmendments, resolveAmendments, decideAmendmentOutcome, amendmentVerdictLine, amendmentsMissingFromPlan, AMENDMENT_VACUITY_NOTE, extractContractChecklist, readFeatureTier, parseContractVerdictReport, verifyContractVerdicts, decideSignableSet, signableSetLine, decideRecordWrite, decideReadBack, recordVerdictLine, buildCadenceReport, tgVisibleSha256, CADENCE_WINDOW_DAYS, readQeRounds, QE_ROUNDS_DEFAULT_CEILING, adviseRestart, describeStoreLocation, storeLocationLine, resolveTeachTarget, teachReasonPhrase, readTeachToConfig, TeachTargetError, mergeStoreHits, sameStore, globalStoreRoot, storeCountLabel,
|
|
35
43
|
// operator-profile (ADR-001): per-user 0600 store + marked block in ~/.claude/CLAUDE.md
|
|
36
44
|
renderProfileBlock, readProfile, writeProfile, syncProfileBlock, checkProfileDrift, parseRegister, registerOwnerWord, profileAgeDays, parseDomainList, domainListText, parseYesNo, REGISTERS, } from '@dzhechkov/harness-core';
|
|
37
45
|
import { getPreset, PRESET_NAMES } from '@dzhechkov/harness-presets';
|
|
@@ -141,7 +149,7 @@ Usage:
|
|
|
141
149
|
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)
|
|
142
150
|
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)
|
|
143
151
|
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)
|
|
144
|
-
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)
|
|
152
|
+
dz mutation-gate [--package <dir>] [--registry <file>] [--test-cmd "<cmd>"] [--only <id[,id]>] [--touched <path[,path]>] [--added-since <git-ref>] [--timeout <ms>] [--max-workers <n>] [--rebaseline per-entry|final] [--keep-scratch] [--json] (prove each NAMED protection has a test that DISCRIMINATES: '--max-workers' resolves flag > the registry's own 'maxWorkers' field > 'min(4, max(1, floor(cpus/2)))' (an invalid flag value — 0, negative, fractional, non-numeric — is a usage error, exit 2, never a silent default; fix-round 1), injects '--maxWorkers=<n>' right after 'vitest run' inside its own compound-command segment (unless that segment already names the flag; fix-round 1 — scoped detection, not a whole-command substring check) and sets 'VITEST_MAX_WORKERS=<n>' in the env regardless — an uncapped full-suite baseline/mutant run at vitest's default worker count (= cpu cores) has measured load 62-358 and <2GB free on an 8-core/16GB box under embedding-daemon tests, killing full overnight gate runs (0bb74d66); printed as 'mutation-gate: workers: <n> (<flag|registry|default>)', or 'mutation-gate: workers: n/a — test command is not vitest' when the command is not recognised as vitest. '--touched' selects entries whose 'file' matches one of the given paths, accepted in ANY of package-relative, './'-prefixed, absolute-inside-the-package, repo-relative, or backslash-separated form — all normalized to package-relative POSIX before matching (fix-round 1, AM-1); a path that resolves OUTSIDE the package is counted, never silently dropped, as '<K> outside package' in the 'selected N of M' line; '--added-since <ref>' selects entries whose id is not present in the registry as it read at that ref ('git show <ref>:<registry path>'): a registry genuinely ABSENT at that ref means every current entry counts as added (said explicitly); an unresolvable ref, any OTHER git failure, or an invalid/malformed base registry at that ref is a usage error (exit 2), never folded into "absent" (AM-3) — a feature scopes the gate to its own touched files and any entries it just added instead of the whole registry (MEASURED: an unscoped run over 358 entries on this repo's core package ran 30-40 minutes and hit the timeout wall, INCONCLUSIVE every time). The two selectors UNION and the result INTERSECTS with '--only' when both are given; an empty selection prints 'selected 0 of M entries (…)' and exits 0 — never a silent skip; '--json' always carries a 'selection' object ({selected, total, touched, addedSince, base, outsidePackage}) on every scoped run (AM-4). 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)
|
|
145
153
|
dz backlog add "<idea>" [--effort 1-5] [--proposal <text>] [--dry-run] [--allow-cold-start] [--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)
|
|
146
154
|
dz backlog list [--status <s>] [--goal <id>] [--project <dir>] [--json] (list captured ideas, filterable by status/goal)
|
|
147
155
|
dz backlog show <id> [--project <dir>] [--json] (full record for one idea)
|
|
@@ -1807,6 +1815,43 @@ async function cmdInstall(options, flags, cwd, write, writeErr, installRunner) {
|
|
|
1807
1815
|
if (root.layout === 'npx-template' && root.hasCompanionAssets) {
|
|
1808
1816
|
write(` note: ${pkg} also ships commands/hooks/agents — \`npx -y ${pkg} init\` installs the full kit.`);
|
|
1809
1817
|
}
|
|
1818
|
+
// Junk-skip summary (feature skills-walk-symlinks-and-junk, FR-2/AC-3). `report.skills[].skipped`
|
|
1819
|
+
// above is a WRITE outcome (an existing file not overwritten without --force) — a different
|
|
1820
|
+
// question from "did this skill directory contain build/cache junk that never became an asset at
|
|
1821
|
+
// all". Re-walk each discovered skill (a cheap second READ — `loadSkillFromDir` already did this
|
|
1822
|
+
// once inside `runInit`'s `adapter.compile`, this adds no write) to surface that count without
|
|
1823
|
+
// reshaping `InitReport`. Silent at N=0 (AC-3): most packages ship no junk and must print nothing.
|
|
1824
|
+
// fix-round 1 MEDIUM-4: this counts ENTRIES, not files. A skipped junk DIRECTORY (e.g.
|
|
1825
|
+
// `__pycache__`) is exactly one entry here even though it may hold many files underneath —
|
|
1826
|
+
// `walkFiles` never descends into a skipped junk directory to count those (see skills.ts),
|
|
1827
|
+
// so a per-file count would be a number this code cannot honestly produce. The wording and
|
|
1828
|
+
// the trailing `/` on directory paths say so, instead of implying "file" for something that
|
|
1829
|
+
// may be a whole tree.
|
|
1830
|
+
let junkSkippedCount = 0;
|
|
1831
|
+
const junkSkippedPaths = [];
|
|
1832
|
+
for (const id of discoverSkillIds(root.dir)) {
|
|
1833
|
+
let skill;
|
|
1834
|
+
try {
|
|
1835
|
+
skill = loadSkillFromDir(root.dir, id);
|
|
1836
|
+
}
|
|
1837
|
+
catch {
|
|
1838
|
+
continue; // unparseable skills are reported separately via report.failures below
|
|
1839
|
+
}
|
|
1840
|
+
for (const entry of skill.skipped ?? []) {
|
|
1841
|
+
if (!entry.reason.startsWith('junk'))
|
|
1842
|
+
continue; // broken-symlink/cycle/escape skips are a different concern (FR-1/AM-8)
|
|
1843
|
+
junkSkippedCount += 1;
|
|
1844
|
+
const shownPath = relative(pkgDir, entry.path).split('\\').join('/');
|
|
1845
|
+
const isDir = entry.reason.startsWith('junk directory');
|
|
1846
|
+
junkSkippedPaths.push(isDir ? `${shownPath}/` : shownPath);
|
|
1847
|
+
}
|
|
1848
|
+
}
|
|
1849
|
+
if (junkSkippedCount > 0) {
|
|
1850
|
+
const shown = junkSkippedPaths.slice(0, 5).join(', ');
|
|
1851
|
+
const more = junkSkippedPaths.length > 5 ? `, … (+${junkSkippedPaths.length - 5} more)` : '';
|
|
1852
|
+
const noun = junkSkippedCount === 1 ? 'entry' : 'entries';
|
|
1853
|
+
write(`skills: skipped ${junkSkippedCount} junk ${noun} (${shown}${more})`);
|
|
1854
|
+
}
|
|
1810
1855
|
// Skip-and-collect at install time (D1 / the report's D2 amendment): the offending
|
|
1811
1856
|
// SKILL.md came out of the DOWNLOADED TARBALL, so the path is rendered relative to
|
|
1812
1857
|
// the package root (a `node_modules/**` absolute path is not actionable) and the
|
|
@@ -4243,7 +4288,19 @@ async function cmdRecall(options, flags, cwd, write, writeErr, classMatcher) {
|
|
|
4243
4288
|
}
|
|
4244
4289
|
if (hits.length === 0) {
|
|
4245
4290
|
write(`dz recall "${shownQuery}"`);
|
|
4246
|
-
|
|
4291
|
+
// Fix-round 1 (Codex HIGH-1c): a query that tokenizes to literally nothing (pure
|
|
4292
|
+
// punctuation/whitespace) is a DIFFERENT empty result than a query with real terms that
|
|
4293
|
+
// simply matched no record — FR-3 requires the reason to be named, not folded into the same
|
|
4294
|
+
// generic "no matching patterns" line. `noSearchableTermsReason` is the single source of
|
|
4295
|
+
// truth for the decision (memory/src/tokenize.ts); this print site calls it rather than
|
|
4296
|
+
// re-deriving "no searchable terms" from the query text itself.
|
|
4297
|
+
const noTermsReason = noSearchableTermsReason(query);
|
|
4298
|
+
if (noTermsReason !== undefined) {
|
|
4299
|
+
write(` no searchable terms in "${shownQuery}" (only punctuation/whitespace) — reason: ${noTermsReason}`);
|
|
4300
|
+
}
|
|
4301
|
+
else {
|
|
4302
|
+
write(` No matching patterns (teach some with \`dz teach\`, or consolidate sessions).`);
|
|
4303
|
+
}
|
|
4247
4304
|
// The domain note must print here too (Codex QE #10): a --domain run with zero hits
|
|
4248
4305
|
// silently said nothing about the domain, so the reader could not tell whether the
|
|
4249
4306
|
// boost had been applied and found nothing, or had not run at all.
|
|
@@ -6236,7 +6293,85 @@ function mirrorFailureMessage(error) {
|
|
|
6236
6293
|
function packedInstallScratchRoot() {
|
|
6237
6294
|
return existsSync('/var/tmp') ? '/var/tmp' : tmpdir();
|
|
6238
6295
|
}
|
|
6239
|
-
|
|
6296
|
+
/**
|
|
6297
|
+
* FR-1 (feature release-smoke-staged-pack): stage every target's `package.json` exactly like a
|
|
6298
|
+
* live publish packs it — `workspace:*` sibling specs rewritten to the exact sibling version
|
|
6299
|
+
* (`rewriteWorkspaceSpecs`), `scripts.prepublishOnly` dropped — run `fn`, then ALWAYS restore the
|
|
6300
|
+
* original bytes in a `finally`, whatever `fn` does or throws. A restore failure is reported
|
|
6301
|
+
* through `write` (with the path), never swallowed — the "absence of a receipt is not success"
|
|
6302
|
+
* rule this file follows everywhere else.
|
|
6303
|
+
*
|
|
6304
|
+
* `cmdPublish`'s dry-run preview and `cmdRelease`'s packed-install-smoke `pack` steps both go
|
|
6305
|
+
* through this ONE helper, so the two doors that ask "what would the registry receive?" pack the
|
|
6306
|
+
* exact same bytes (MEASURED 2026-09-13 16:05: `dz release` packed the live `workspace:*`
|
|
6307
|
+
* package.json and its smoke install died with EUNSUPPORTEDPROTOCOL — `dz publish`'s preview
|
|
6308
|
+
* already staged around this and release never got that).
|
|
6309
|
+
*/
|
|
6310
|
+
function withStagedPackageJson(targets, workspaceVersions, write, fn, label = 'dz') {
|
|
6311
|
+
// Lead edits after Codex review (2026-09-13, findings 1/5/6): every write — the staged text and
|
|
6312
|
+
// the restore — goes through a sibling temp file + rename, so a reader never sees a truncated
|
|
6313
|
+
// package.json; a restore that FAILS is an error the caller must see (thrown after fn, or attached
|
|
6314
|
+
// to fn's own error), never a warning that lets a run "succeed" on a damaged tree; and the
|
|
6315
|
+
// diagnostic keeps the calling command's name (`label`).
|
|
6316
|
+
const atomicWrite = (path, text) => {
|
|
6317
|
+
// Codex round 2: an EXCLUSIVE, randomized sibling temp — never a shared pid-named file
|
|
6318
|
+
const tmp = `${path}.${process.pid}.${randomBytes(6).toString('hex')}.staged.tmp`;
|
|
6319
|
+
writeFileSync(tmp, text, { flag: 'wx' });
|
|
6320
|
+
renameSync(tmp, path);
|
|
6321
|
+
};
|
|
6322
|
+
const stagedOriginals = [];
|
|
6323
|
+
let fnError;
|
|
6324
|
+
let fnThrew = false;
|
|
6325
|
+
try {
|
|
6326
|
+
for (const p of targets) {
|
|
6327
|
+
const pkgJsonPath = join(p.dir, 'package.json');
|
|
6328
|
+
const original = readFileSync(pkgJsonPath, 'utf-8');
|
|
6329
|
+
const rewritten = JSON.parse(rewriteWorkspaceSpecs(original, workspaceVersions));
|
|
6330
|
+
const scripts = rewritten['scripts'];
|
|
6331
|
+
if (scripts !== null && typeof scripts === 'object' && !Array.isArray(scripts))
|
|
6332
|
+
delete scripts['prepublishOnly'];
|
|
6333
|
+
stagedOriginals.push({ path: pkgJsonPath, text: original });
|
|
6334
|
+
atomicWrite(pkgJsonPath, JSON.stringify(rewritten, null, 2) + '\n');
|
|
6335
|
+
}
|
|
6336
|
+
return fn();
|
|
6337
|
+
}
|
|
6338
|
+
catch (err) {
|
|
6339
|
+
fnThrew = true;
|
|
6340
|
+
fnError = err;
|
|
6341
|
+
throw err;
|
|
6342
|
+
}
|
|
6343
|
+
finally {
|
|
6344
|
+
const restoreFailures = [];
|
|
6345
|
+
for (const o of stagedOriginals) {
|
|
6346
|
+
try {
|
|
6347
|
+
atomicWrite(o.path, o.text);
|
|
6348
|
+
}
|
|
6349
|
+
catch (err) {
|
|
6350
|
+
const msg = `${label}: ✗ could not restore ${o.path} after staged packing: ${formatPublishError(err)} — the tree is left STAGED, restore it by hand`;
|
|
6351
|
+
try {
|
|
6352
|
+
write(msg);
|
|
6353
|
+
}
|
|
6354
|
+
catch { /* a throwing writer must not mask the restore failure */ }
|
|
6355
|
+
restoreFailures.push(msg);
|
|
6356
|
+
}
|
|
6357
|
+
}
|
|
6358
|
+
if (restoreFailures.length > 0) {
|
|
6359
|
+
// Codex round 2: one aggregate error carrying BOTH fn's own failure (if any) and the restore
|
|
6360
|
+
// failures — never a bare message assignment that could itself throw out of finally.
|
|
6361
|
+
const fnPart = fnThrew ? `\n(during: ${fnError instanceof Error ? fnError.message : String(fnError)})` : '';
|
|
6362
|
+
// eslint-disable-next-line no-unsafe-finally -- a damaged tree must not read as success
|
|
6363
|
+
throw new Error(`${restoreFailures.join('\n')}${fnPart}`);
|
|
6364
|
+
}
|
|
6365
|
+
}
|
|
6366
|
+
}
|
|
6367
|
+
function cmdPublish(options, flags, cwd, writeOutput, mirrorRunner, siblingDriftFetcher, packedInstallRunner, publishExecRunner, gateAuditFsLayer,
|
|
6368
|
+
/**
|
|
6369
|
+
* AM-5 (feature publish-gate-audit-durable): test seam for the sibling-drift gate's `npm pack
|
|
6370
|
+
* --dry-run --json` call — production leaves it unset (real `execFileSync`). Takes the package
|
|
6371
|
+
* dir, returns raw stdout, or THROWS to simulate a real `npm` failure — a test can then prove the
|
|
6372
|
+
* failure reaches `parseNpmPackInventory`'s caller as `unavailable`, never a real subprocess.
|
|
6373
|
+
*/
|
|
6374
|
+
npmPackRunner) {
|
|
6240
6375
|
const json = flags.has('json');
|
|
6241
6376
|
// Under --json stdout carries exactly one JSON document, so every human line — guard notes, refusals,
|
|
6242
6377
|
// progress — goes to stderr instead of being dropped: a refusal that prints nothing is the silent
|
|
@@ -6379,19 +6514,57 @@ function cmdPublish(options, flags, cwd, writeOutput, mirrorRunner, siblingDrift
|
|
|
6379
6514
|
return null;
|
|
6380
6515
|
}
|
|
6381
6516
|
});
|
|
6382
|
-
// AM-
|
|
6383
|
-
//
|
|
6384
|
-
//
|
|
6385
|
-
//
|
|
6386
|
-
|
|
6387
|
-
|
|
6388
|
-
|
|
6389
|
-
|
|
6390
|
-
|
|
6517
|
+
// AM-4: `npm pack --dry-run --json` is a real subprocess — cache it for the lifetime of this
|
|
6518
|
+
// ENTIRE run (keyed by resolved dir), NOT per package being checked (round-1 review, finding 5):
|
|
6519
|
+
// the cache used to be re-created inside the per-package loop body, so two different dependents
|
|
6520
|
+
// of the SAME sibling packed it twice. `npmPackRunner` (AM-5) is a test seam — production leaves
|
|
6521
|
+
// it unset and runs the real subprocess; a test injects a stub that throws to prove a real `npm`
|
|
6522
|
+
// failure reaches the caller as `unavailable`, without spawning anything.
|
|
6523
|
+
// Lead fix after the fix-round's live dry-run (2026-09-14 01:02, MEASURED on the hub): the
|
|
6524
|
+
// workspace side is now PACKED BY THE LIVE TRANSPORT — `pnpm pack` into a per-run temp dir,
|
|
6525
|
+
// unpacked, and handed to core as a `packedDir` that core hashes with the SAME full walk it uses
|
|
6526
|
+
// for the published tarball. `npm pack --dry-run --json` (kept behind the `npmPackRunner` test
|
|
6527
|
+
// seam) never lists the LICENSE pnpm synthesises from the workspace root into a package whose own
|
|
6528
|
+
// tree has none, so two siblings unchanged since publication (harness-presets, scout) read as
|
|
6529
|
+
// "LICENSE only in the published copy" — a false drift the fix-round's inventory could not see.
|
|
6530
|
+
// Honest limit: the seam path (tests) still parses npm's JSON; only production takes the pnpm path.
|
|
6531
|
+
const npmPackInventoryCache = new Map();
|
|
6532
|
+
let packTmpDir;
|
|
6533
|
+
const npmPackInventory = (dir) => {
|
|
6534
|
+
const key = resolve(dir);
|
|
6535
|
+
const hit = npmPackInventoryCache.get(key);
|
|
6536
|
+
if (hit !== undefined)
|
|
6537
|
+
return hit;
|
|
6538
|
+
let out;
|
|
6539
|
+
try {
|
|
6540
|
+
if (npmPackRunner !== undefined) {
|
|
6541
|
+
out = parseNpmPackInventory(npmPackRunner(dir));
|
|
6542
|
+
}
|
|
6543
|
+
else {
|
|
6544
|
+
packTmpDir ??= mkdtempSync(join(tmpdir(), 'dz-drift-pack-'));
|
|
6545
|
+
out = { packedDir: extractIntoTempDir(dir, mkdtempSync(join(packTmpDir, 'p-'))).dir };
|
|
6546
|
+
}
|
|
6391
6547
|
}
|
|
6392
|
-
|
|
6393
|
-
|
|
6548
|
+
catch (err) {
|
|
6549
|
+
const how = npmPackRunner !== undefined ? 'npm pack --dry-run --json' : 'pnpm pack';
|
|
6550
|
+
out = { unavailable: `${how} failed: ${err.message.split('\n')[0]}` };
|
|
6551
|
+
}
|
|
6552
|
+
npmPackInventoryCache.set(key, out);
|
|
6553
|
+
return out;
|
|
6394
6554
|
};
|
|
6555
|
+
const localInventorySource = npmPackRunner !== undefined ? 'npm-pack' : 'pnpm-pack';
|
|
6556
|
+
// AM-3 (Codex round-1 review, finding 4, high): sibling-drift audit records are EXACTLY one per
|
|
6557
|
+
// package per rule per RUN. The old code appended one JSONL record per SIBLING a package depends
|
|
6558
|
+
// on (a package with two drifted deps wrote two rows under the same rule), and the `unavailable`
|
|
6559
|
+
// branch without `--allow-sibling-drift` wrote NO record at all. Every sibling outcome for a
|
|
6560
|
+
// package is now aggregated first (`pkParts`/`pkVerdict`/`pkOverrideUsed`) and written ONCE —
|
|
6561
|
+
// `block` if any sibling blocks, else `warn` if the only issues were resolved via
|
|
6562
|
+
// `--allow-sibling-drift`, else `pass` — with a detail naming every sibling and its status.
|
|
6563
|
+
// `siblingDriftAudited` guarantees the single write even though `--include-drifted`'s fixed-point
|
|
6564
|
+
// loop can revisit the SAME package across rounds: a package's own `dependencies` never change
|
|
6565
|
+
// between rounds, so a later round can only ever re-derive a SUBSET of what the first pass
|
|
6566
|
+
// already covered (its siblings that drifted got folded into the batch and are now skipped).
|
|
6567
|
+
const siblingDriftAudited = new Set();
|
|
6395
6568
|
let driftBlocked = 0;
|
|
6396
6569
|
const extraBatch = new Set();
|
|
6397
6570
|
// AM-2: --include-drifted must reach a FIXED POINT over transitive drifted siblings — a sibling
|
|
@@ -6399,92 +6572,156 @@ function cmdPublish(options, flags, cwd, writeOutput, mirrorRunner, siblingDrift
|
|
|
6399
6572
|
// (finding 2) showed the single pass never re-checked an EXPANDED batch's own new edges. Capped at
|
|
6400
6573
|
// `allPackages.length + 1` rounds (the plan's own "цикл с потолком = число пакетов").
|
|
6401
6574
|
const maxRounds = allPackages.length + 1;
|
|
6402
|
-
|
|
6403
|
-
|
|
6404
|
-
|
|
6405
|
-
|
|
6406
|
-
|
|
6407
|
-
|
|
6408
|
-
|
|
6409
|
-
|
|
6410
|
-
|
|
6411
|
-
// cannot build — it must BLOCK, never silently degrade to "no dependencies" (which used to
|
|
6412
|
-
// read as a clean n/a).
|
|
6413
|
-
const reason = `package.json unreadable/invalid (${err.message.split('\n')[0]})`;
|
|
6414
|
-
if (allowSiblingDrift) {
|
|
6415
|
-
if (auditedOverride(`${pk.name}: ${reason}`, `sibling drift check unavailable for ${pk.name} (${reason})`, pk.name))
|
|
6416
|
-
driftBlocked++;
|
|
6575
|
+
// Codex round-3 (2026-09-14): the per-run pack scratch is released in a `finally`, so an
|
|
6576
|
+
// exception thrown while hashing or auditing cannot leak a `dz-drift-pack-*` dir under tmpdir.
|
|
6577
|
+
try {
|
|
6578
|
+
for (let round = 0; round < maxRounds; round++) {
|
|
6579
|
+
let addedThisRound = false;
|
|
6580
|
+
for (const pk of targets) {
|
|
6581
|
+
let manifestObj;
|
|
6582
|
+
try {
|
|
6583
|
+
manifestObj = JSON.parse(readFileSync(join(pk.dir, 'package.json'), 'utf-8'));
|
|
6417
6584
|
}
|
|
6418
|
-
|
|
6419
|
-
|
|
6420
|
-
|
|
6585
|
+
catch (err) {
|
|
6586
|
+
// AM-3: an unreadable/invalid package.json for a BATCH package is an input this HARD gate
|
|
6587
|
+
// cannot build — it must BLOCK, never silently degrade to "no dependencies" (which used to
|
|
6588
|
+
// read as a clean n/a). AND (finding 4) the non-override branch below used to print a
|
|
6589
|
+
// BLOCKED line with NO audit record behind it — an `unavailable` outcome is logged exactly
|
|
6590
|
+
// like every other outcome, override or not.
|
|
6591
|
+
const reason = `package.json unreadable/invalid (${err.message.split('\n')[0]})`;
|
|
6592
|
+
if (!siblingDriftAudited.has(pk.name)) {
|
|
6593
|
+
siblingDriftAudited.add(pk.name);
|
|
6594
|
+
if (allowSiblingDrift) {
|
|
6595
|
+
const wrote = appendPublishGateAudit(cwd, 'sibling-drift', 'warn', `${pk.name}: ${reason} — allowed via --allow-sibling-drift`, [{ name: pk.name, version: pk.version }], '--allow-sibling-drift', gateAuditFsLayer);
|
|
6596
|
+
if (wrote.logged) {
|
|
6597
|
+
write(`dz publish: ⚠ sibling drift check unavailable for ${pk.name} (${reason}) — allowed via --allow-sibling-drift (logged)`);
|
|
6598
|
+
}
|
|
6599
|
+
else {
|
|
6600
|
+
write(`dz publish: BLOCKED ${pk.name} — sibling drift check unavailable for ${pk.name} (${reason}), and the override could not be recorded (audit write failed: ${wrote.reason ?? 'unknown reason'}); refusing rather than proceeding unlogged`);
|
|
6601
|
+
driftBlocked++;
|
|
6602
|
+
}
|
|
6603
|
+
}
|
|
6604
|
+
else {
|
|
6605
|
+
const wrote = appendPublishGateAudit(cwd, 'sibling-drift', 'block', `${pk.name}: ${reason}`, [{ name: pk.name, version: pk.version }], undefined, gateAuditFsLayer);
|
|
6606
|
+
write(`dz publish: BLOCKED ${pk.name} — sibling drift check unavailable (${reason}); add --allow-sibling-drift to override (logged) or fix the manifest${auditSuffix(wrote)}`);
|
|
6607
|
+
driftBlocked++;
|
|
6608
|
+
}
|
|
6609
|
+
}
|
|
6610
|
+
continue;
|
|
6421
6611
|
}
|
|
6422
|
-
|
|
6423
|
-
|
|
6424
|
-
|
|
6425
|
-
|
|
6426
|
-
|
|
6427
|
-
|
|
6428
|
-
|
|
6429
|
-
|
|
6430
|
-
|
|
6431
|
-
|
|
6432
|
-
|
|
6433
|
-
|
|
6434
|
-
|
|
6435
|
-
|
|
6436
|
-
|
|
6437
|
-
|
|
6438
|
-
|
|
6439
|
-
|
|
6440
|
-
|
|
6441
|
-
|
|
6442
|
-
|
|
6443
|
-
|
|
6444
|
-
|
|
6445
|
-
|
|
6446
|
-
|
|
6447
|
-
|
|
6612
|
+
const deps = manifestObj?.dependencies ?? {};
|
|
6613
|
+
const peerDeps = manifestObj?.peerDependencies ?? {};
|
|
6614
|
+
const optionalDeps = manifestObj?.optionalDependencies ?? {};
|
|
6615
|
+
// AM-6: a package with no EXTERNAL sibling to check at all is n/a for THIS gate — recorded as
|
|
6616
|
+
// a pass note ("no external siblings"), not silence (FR-6 compatibility: this branch prints
|
|
6617
|
+
// nothing to stdout, matching the pre-existing behavior). "External" covers BOTH "no
|
|
6618
|
+
// workspace: dependency declared" and "every workspace: dependency is inside THIS batch"
|
|
6619
|
+
// (publishing fresh, nothing stale to drift from) — both used to leave this package with no
|
|
6620
|
+
// audit record at all when every dep resolved to the second case.
|
|
6621
|
+
const anyWorkspaceDep = [...Object.values(deps), ...Object.values(peerDeps), ...Object.values(optionalDeps)]
|
|
6622
|
+
.some((spec) => String(spec).startsWith('workspace:'));
|
|
6623
|
+
const pkParts = [];
|
|
6624
|
+
let pkVerdict = 'pass';
|
|
6625
|
+
let pkOverrideUsed = false;
|
|
6626
|
+
if (anyWorkspaceDep) {
|
|
6627
|
+
const drifts = detectSiblingDrift({
|
|
6628
|
+
localInventory: npmPackInventory,
|
|
6629
|
+
localInventorySource,
|
|
6630
|
+
dependencies: deps,
|
|
6631
|
+
peerDependencies: peerDeps,
|
|
6632
|
+
optionalDependencies: optionalDeps,
|
|
6633
|
+
workspaceVersions,
|
|
6634
|
+
workspaceDirs,
|
|
6635
|
+
batch: batchNames,
|
|
6636
|
+
fetchPublished,
|
|
6637
|
+
});
|
|
6638
|
+
for (const r of drifts) {
|
|
6639
|
+
if (r.status === 'same') {
|
|
6640
|
+
pkParts.push(`${r.name}@${r.version}: same`);
|
|
6641
|
+
write(`dz publish: ✓ sibling drift: none (${r.name}@${r.version} = workspace)`);
|
|
6642
|
+
}
|
|
6643
|
+
else if (r.status === 'unavailable') {
|
|
6644
|
+
if (allowSiblingDrift) {
|
|
6645
|
+
pkParts.push(`${r.name}@${r.version}: unavailable (${r.reason}) — allowed via --allow-sibling-drift`);
|
|
6646
|
+
if (pkVerdict !== 'block')
|
|
6647
|
+
pkVerdict = 'warn';
|
|
6648
|
+
pkOverrideUsed = true;
|
|
6649
|
+
}
|
|
6650
|
+
else {
|
|
6651
|
+
pkParts.push(`${r.name}@${r.version}: unavailable (${r.reason})`);
|
|
6652
|
+
pkVerdict = 'block';
|
|
6653
|
+
write(`dz publish: BLOCKED ${pk.name} — sibling drift check unavailable (${r.reason}); add --allow-sibling-drift to override (logged) or check network/registry access`);
|
|
6654
|
+
driftBlocked++;
|
|
6655
|
+
}
|
|
6656
|
+
}
|
|
6657
|
+
else if (includeDrifted) {
|
|
6658
|
+
pkParts.push(`${r.name}@${r.version}: drift (${r.changedFiles.length} file(s)) — auto-included via --include-drifted`);
|
|
6659
|
+
if (!batchNames.has(r.name) && !extraBatch.has(r.name)) {
|
|
6660
|
+
extraBatch.add(r.name);
|
|
6661
|
+
addedThisRound = true;
|
|
6662
|
+
write(`dz publish: → sibling drift: ${r.name}@${r.version} differs from the workspace (${r.changedFiles.length} file(s)) — adding to the batch via --include-drifted${r.missingExports.length > 0 ? ` (missing exports: ${r.missingExports.join(', ')})` : ''}`);
|
|
6663
|
+
}
|
|
6664
|
+
}
|
|
6665
|
+
else if (allowSiblingDrift) {
|
|
6666
|
+
pkParts.push(`${r.name}@${r.version}: drift (${r.changedFiles.length} file(s)) — allowed via --allow-sibling-drift`);
|
|
6667
|
+
if (pkVerdict !== 'block')
|
|
6668
|
+
pkVerdict = 'warn';
|
|
6669
|
+
pkOverrideUsed = true;
|
|
6670
|
+
}
|
|
6671
|
+
else {
|
|
6672
|
+
pkParts.push(`${r.name}@${r.version}: drift (${r.changedFiles.length} file(s))`);
|
|
6673
|
+
pkVerdict = 'block';
|
|
6674
|
+
const suggestFilter = filterStr !== undefined ? `${filterStr},${r.name}` : `${pk.name},${r.name}`;
|
|
6675
|
+
write(`dz publish: BLOCKED ${pk.name} — sibling drift: @dzhechkov/${r.name.replace(/^@dzhechkov\//, '')}@${r.version} on the registry differs from the workspace (${r.changedFiles.length} file(s)); add ${r.name} to the batch (--filter ${suggestFilter}) or publish it first`);
|
|
6676
|
+
driftBlocked++;
|
|
6677
|
+
}
|
|
6678
|
+
}
|
|
6448
6679
|
}
|
|
6449
|
-
|
|
6450
|
-
|
|
6451
|
-
|
|
6680
|
+
// AM-3/AM-6: the single, aggregated audit write for this package — "no external siblings"
|
|
6681
|
+
// when nothing was ever checked, otherwise every sibling's status joined into one detail.
|
|
6682
|
+
if (!siblingDriftAudited.has(pk.name)) {
|
|
6683
|
+
siblingDriftAudited.add(pk.name);
|
|
6684
|
+
const detail = pkParts.length > 0 ? pkParts.join('; ') : 'no external siblings';
|
|
6685
|
+
// AM-6: an override reason is attached only when the FINAL verdict is 'warn' — if some
|
|
6686
|
+
// OTHER sibling still stands as a live block, the override never actually excused the run.
|
|
6687
|
+
const overrideReason = pkOverrideUsed && pkVerdict !== 'block' ? '--allow-sibling-drift' : undefined;
|
|
6688
|
+
const wrote = appendPublishGateAudit(cwd, 'sibling-drift', pkVerdict, detail, [{ name: pk.name, version: pk.version }], overrideReason, gateAuditFsLayer);
|
|
6689
|
+
if (pkOverrideUsed) {
|
|
6690
|
+
// AM-6: the override is only real once ITS audit row is durable — a write failure must
|
|
6691
|
+
// refuse the publish rather than print "(logged)" about a record that never landed.
|
|
6692
|
+
if (wrote.logged) {
|
|
6693
|
+
write(`dz publish: ⚠ sibling drift override recorded for ${pk.name} — allowed via --allow-sibling-drift (logged)`);
|
|
6694
|
+
}
|
|
6695
|
+
else {
|
|
6696
|
+
write(`dz publish: BLOCKED ${pk.name} — the --allow-sibling-drift override could not be recorded (audit write failed: ${wrote.reason ?? 'unknown reason'}); refusing rather than proceeding unlogged`);
|
|
6452
6697
|
driftBlocked++;
|
|
6698
|
+
}
|
|
6453
6699
|
}
|
|
6454
|
-
else {
|
|
6455
|
-
write(`dz publish:
|
|
6456
|
-
driftBlocked++;
|
|
6700
|
+
else if (pkParts.length > 0) {
|
|
6701
|
+
write(`dz publish: ℹ sibling drift audit for ${pk.name}${auditSuffix(wrote)}`);
|
|
6457
6702
|
}
|
|
6458
|
-
|
|
6459
|
-
|
|
6460
|
-
|
|
6461
|
-
|
|
6462
|
-
addedThisRound = true;
|
|
6463
|
-
write(`dz publish: → sibling drift: ${r.name}@${r.version} differs from the workspace (${r.changedFiles.length} file(s)) — adding to the batch via --include-drifted${r.missingExports.length > 0 ? ` (missing exports: ${r.missingExports.join(', ')})` : ''}`);
|
|
6703
|
+
else if (!wrote.logged) {
|
|
6704
|
+
// Codex round-2 (2026-09-14), AM-6 residual: the "no external siblings" pass note stays
|
|
6705
|
+
// silent on stdout ONLY while its record actually landed — a failed audit write is said.
|
|
6706
|
+
write(`dz publish: ℹ sibling drift audit for ${pk.name} (no external siblings)${auditSuffix(wrote)}`);
|
|
6464
6707
|
}
|
|
6465
6708
|
}
|
|
6466
|
-
else if (allowSiblingDrift) {
|
|
6467
|
-
if (auditedOverride(`${r.name}@${r.version}: ${r.changedFiles.length} file(s) differ from the workspace`, `sibling drift: ${r.name}@${r.version} differs from the workspace (${r.changedFiles.length} file(s))`, pk.name))
|
|
6468
|
-
driftBlocked++;
|
|
6469
|
-
}
|
|
6470
|
-
else {
|
|
6471
|
-
appendPublishGateAudit(cwd, 'sibling-drift', 'block', `${pk.name} depends on ${r.name}@${r.version}; ${r.changedFiles.length} file(s) differ from the workspace`);
|
|
6472
|
-
const suggestFilter = filterStr !== undefined ? `${filterStr},${r.name}` : `${pk.name},${r.name}`;
|
|
6473
|
-
write(`dz publish: BLOCKED ${pk.name} — sibling drift: @dzhechkov/${r.name.replace(/^@dzhechkov\//, '')}@${r.version} on the registry differs from the workspace (${r.changedFiles.length} file(s)); add ${r.name} to the batch (--filter ${suggestFilter}) or publish it first`);
|
|
6474
|
-
driftBlocked++;
|
|
6475
|
-
}
|
|
6476
6709
|
}
|
|
6710
|
+
if (driftBlocked > 0)
|
|
6711
|
+
break; // nothing to expand into a run that already refuses
|
|
6712
|
+
if (!includeDrifted || !addedThisRound)
|
|
6713
|
+
break; // no auto-expand requested, or fixed point reached
|
|
6714
|
+
// FR-4: --include-drifted folds the drifted sibling(s) into the batch — they bump patch like
|
|
6715
|
+
// any other package in `publishPackages`' own (unchanged) bump logic. Re-loop: the newly
|
|
6716
|
+
// folded-in sibling(s) may themselves depend on a drifted sibling outside the (now bigger) batch.
|
|
6717
|
+
filter = filter === undefined ? [...batchNames, ...extraBatch] : [...filter, ...extraBatch];
|
|
6718
|
+
targets = allPackages.filter(matchesFilter);
|
|
6719
|
+
batchNames = new Set(targets.map((p) => p.name));
|
|
6477
6720
|
}
|
|
6478
|
-
|
|
6479
|
-
|
|
6480
|
-
if (
|
|
6481
|
-
|
|
6482
|
-
// FR-4: --include-drifted folds the drifted sibling(s) into the batch — they bump patch like
|
|
6483
|
-
// any other package in `publishPackages`' own (unchanged) bump logic. Re-loop: the newly
|
|
6484
|
-
// folded-in sibling(s) may themselves depend on a drifted sibling outside the (now bigger) batch.
|
|
6485
|
-
filter = filter === undefined ? [...batchNames, ...extraBatch] : [...filter, ...extraBatch];
|
|
6486
|
-
targets = allPackages.filter(matchesFilter);
|
|
6487
|
-
batchNames = new Set(targets.map((p) => p.name));
|
|
6721
|
+
}
|
|
6722
|
+
finally {
|
|
6723
|
+
if (packTmpDir !== undefined)
|
|
6724
|
+
rmSync(packTmpDir, { recursive: true, force: true });
|
|
6488
6725
|
}
|
|
6489
6726
|
const siblingDriftFailed = driftBlocked > 0;
|
|
6490
6727
|
if (siblingDriftFailed && !dryRun) {
|
|
@@ -6523,8 +6760,8 @@ function cmdPublish(options, flags, cwd, writeOutput, mirrorRunner, siblingDrift
|
|
|
6523
6760
|
let packedInstallSmokePreviewFailed = false;
|
|
6524
6761
|
if (dryRun) {
|
|
6525
6762
|
if (bins.length === 0) {
|
|
6526
|
-
appendPublishGateAudit(cwd, 'packed-install-smoke', 'pass', 'n/a — nothing in the batch declares a bin');
|
|
6527
|
-
write(
|
|
6763
|
+
const wrote = appendPublishGateAudit(cwd, 'packed-install-smoke', 'pass', 'n/a — nothing in the batch declares a bin', targets.map((p) => ({ name: p.name, version: p.version })), undefined, gateAuditFsLayer);
|
|
6764
|
+
write(`dz publish: ○ packed install smoke: n/a (nothing in the batch declares a bin)${auditSuffix(wrote)}`);
|
|
6528
6765
|
}
|
|
6529
6766
|
else {
|
|
6530
6767
|
const scratchRoot = packedInstallScratchRoot();
|
|
@@ -6557,33 +6794,19 @@ function cmdPublish(options, flags, cwd, writeOutput, mirrorRunner, siblingDrift
|
|
|
6557
6794
|
// Lead edit after the live dry-run (13.09 12:05): the preview packed the WORKING directory with
|
|
6558
6795
|
// `workspace:^` specs still inside, so `npm install <tgz>` died with EUNSUPPORTEDPROTOCOL — the
|
|
6559
6796
|
// preview must stage package.json exactly as the live packedTransport does (sibling pins via
|
|
6560
|
-
// rewriteWorkspaceSpecs, prepublishOnly dropped) and restore the originals afterwards.
|
|
6561
|
-
|
|
6797
|
+
// rewriteWorkspaceSpecs, prepublishOnly dropped) and restore the originals afterwards. Shared
|
|
6798
|
+
// with `dz release` via `withStagedPackageJson` (feature release-smoke-staged-pack).
|
|
6562
6799
|
try {
|
|
6563
|
-
|
|
6564
|
-
const
|
|
6565
|
-
|
|
6566
|
-
|
|
6567
|
-
const scripts = rewritten['scripts'];
|
|
6568
|
-
if (scripts !== null && typeof scripts === 'object' && !Array.isArray(scripts))
|
|
6569
|
-
delete scripts['prepublishOnly'];
|
|
6570
|
-
stagedOriginals.push({ path: pkgJsonPath, text: original });
|
|
6571
|
-
writeFileSync(pkgJsonPath, JSON.stringify(rewritten, null, 2) + '\n');
|
|
6572
|
-
}
|
|
6573
|
-
for (const step of smokePlan.steps) {
|
|
6574
|
-
const r = runSmoke(step.cmd, { cwd: step.cwd, timeoutMs: step.timeoutMs });
|
|
6575
|
-
smokeExecutions.push({ stepId: step.id, exitCode: r.exitCode, stdout: r.stdout, stderr: r.stderr, ...(r.timedOut !== undefined ? { timedOut: r.timedOut } : {}) });
|
|
6576
|
-
}
|
|
6577
|
-
}
|
|
6578
|
-
finally {
|
|
6579
|
-
for (const o of stagedOriginals) {
|
|
6580
|
-
try {
|
|
6581
|
-
writeFileSync(o.path, o.text);
|
|
6582
|
-
}
|
|
6583
|
-
catch (err) {
|
|
6584
|
-
write(`dz publish: ⚠ could not restore ${o.path} after the preview smoke: ${formatPublishError(err)}`);
|
|
6800
|
+
withStagedPackageJson(targets, workspaceVersions, write, () => {
|
|
6801
|
+
for (const step of smokePlan.steps) {
|
|
6802
|
+
const r = runSmoke(step.cmd, { cwd: step.cwd, timeoutMs: step.timeoutMs });
|
|
6803
|
+
smokeExecutions.push({ stepId: step.id, exitCode: r.exitCode, stdout: r.stdout, stderr: r.stderr, ...(r.timedOut !== undefined ? { timedOut: r.timedOut } : {}) });
|
|
6585
6804
|
}
|
|
6586
|
-
}
|
|
6805
|
+
}, 'dz publish');
|
|
6806
|
+
}
|
|
6807
|
+
catch (err) {
|
|
6808
|
+
// a restore failure is a preview failure (Codex finding 1): never a green preview on a staged tree
|
|
6809
|
+
smokeExecutions.push({ stepId: 'staged-restore', exitCode: 1, stdout: '', stderr: formatPublishError(err) });
|
|
6587
6810
|
}
|
|
6588
6811
|
const smokeVerdict = judgePackedInstallSmoke(smokePlan, smokeExecutions);
|
|
6589
6812
|
try {
|
|
@@ -6595,13 +6818,13 @@ function cmdPublish(options, flags, cwd, writeOutput, mirrorRunner, siblingDrift
|
|
|
6595
6818
|
}
|
|
6596
6819
|
catch { /* best-effort cleanup */ }
|
|
6597
6820
|
if (smokeVerdict.ok) {
|
|
6598
|
-
appendPublishGateAudit(cwd, 'packed-install-smoke', 'pass', 'preview: pack/install/--version all clean');
|
|
6599
|
-
write(
|
|
6821
|
+
const wrote = appendPublishGateAudit(cwd, 'packed-install-smoke', 'pass', 'preview: pack/install/--version all clean', targets.map((p) => ({ name: p.name, version: p.version })), undefined, gateAuditFsLayer);
|
|
6822
|
+
write(`dz publish: ✓ packed install smoke (preview)${auditSuffix(wrote)}`);
|
|
6600
6823
|
}
|
|
6601
6824
|
else {
|
|
6602
6825
|
const detail = smokeVerdict.failureDetail ?? smokeVerdict.bins.find((b) => !b.ok)?.detail ?? '(no detail)';
|
|
6603
|
-
appendPublishGateAudit(cwd, 'packed-install-smoke', 'block', detail);
|
|
6604
|
-
write(`dz publish: BLOCKED — packed install smoke failed (preview): ${detail}`);
|
|
6826
|
+
const wrote = appendPublishGateAudit(cwd, 'packed-install-smoke', 'block', detail, targets.map((p) => ({ name: p.name, version: p.version })), undefined, gateAuditFsLayer);
|
|
6827
|
+
write(`dz publish: BLOCKED — packed install smoke failed (preview): ${detail}${auditSuffix(wrote)}`);
|
|
6605
6828
|
for (const b of smokeVerdict.bins.filter((b) => !b.ok))
|
|
6606
6829
|
write(` ✗ ${b.pkg} (${b.binName}): ${b.detail ?? '(no detail)'}`);
|
|
6607
6830
|
packedInstallSmokePreviewFailed = true;
|
|
@@ -6658,23 +6881,37 @@ function cmdPublish(options, flags, cwd, writeOutput, mirrorRunner, siblingDrift
|
|
|
6658
6881
|
// `pnpm publish` and skipped re-signing (ADR-001, features/publish-gate-verifies-the-tarball).
|
|
6659
6882
|
let cleanupGate = null;
|
|
6660
6883
|
try {
|
|
6661
|
-
const
|
|
6662
|
-
|
|
6663
|
-
|
|
6664
|
-
|
|
6665
|
-
|
|
6666
|
-
|
|
6667
|
-
|
|
6668
|
-
|
|
6669
|
-
|
|
6670
|
-
|
|
6671
|
-
// claim about an object that was never built. Say why, and block.
|
|
6672
|
-
write(`dz publish: could not pack ${pk.name} (${err.message.split('\n')[0]}) — the artifact was never built, so its signature was not checked`);
|
|
6884
|
+
const parsedManifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
6885
|
+
// FR-4 (feature publish-gate-audit-durable): a manifest that PARSES but is not a plain
|
|
6886
|
+
// object — `null`, an array, a bare string — is UNAVAILABLE, never "no signature". Those
|
|
6887
|
+
// are different failures with different fixes: "no signature" means run `dz sign`; a
|
|
6888
|
+
// malformed manifest means the file itself is corrupt/wrong-shaped and re-signing alone
|
|
6889
|
+
// would silently paper over that. Mirrors `readManifest`'s shape guard in
|
|
6890
|
+
// publish-sibling-drift.ts (null/array/non-object ⇒ cannot be used, say so).
|
|
6891
|
+
if (parsedManifest === null || typeof parsedManifest !== 'object' || Array.isArray(parsedManifest)) {
|
|
6892
|
+
const gotShape = parsedManifest === null ? 'null' : Array.isArray(parsedManifest) ? 'an array' : typeof parsedManifest;
|
|
6893
|
+
write(`dz publish: ${pk.name}'s ${MANIFEST_NAME} is not a JSON object (got ${gotShape}) — its signature is unavailable, not merely absent`);
|
|
6673
6894
|
artifactUnavailable = true;
|
|
6674
6895
|
}
|
|
6675
|
-
|
|
6676
|
-
|
|
6677
|
-
|
|
6896
|
+
else {
|
|
6897
|
+
const signed = parsedManifest;
|
|
6898
|
+
let extracted;
|
|
6899
|
+
try {
|
|
6900
|
+
extracted = extractPublishTarball(pk.dir);
|
|
6901
|
+
cleanupGate = extracted.cleanup;
|
|
6902
|
+
}
|
|
6903
|
+
catch (err) {
|
|
6904
|
+
// Cross-family review (codex `gpt-5.6-sol`, 2026-08-22): falling back to the working
|
|
6905
|
+
// TREE here fails the gate OPEN. The gate's whole claim is "what ships matches the
|
|
6906
|
+
// signature"; with no artifact, nothing was compared, and reporting a pass would be a
|
|
6907
|
+
// claim about an object that was never built. Say why, and block.
|
|
6908
|
+
write(`dz publish: could not pack ${pk.name} (${err.message.split('\n')[0]}) — the artifact was never built, so its signature was not checked`);
|
|
6909
|
+
artifactUnavailable = true;
|
|
6910
|
+
}
|
|
6911
|
+
verifyOk =
|
|
6912
|
+
extracted !== undefined &&
|
|
6913
|
+
verifyManifest(extracted.dir, signed, readFileSync(trustRoot, 'utf8')).ok;
|
|
6914
|
+
}
|
|
6678
6915
|
}
|
|
6679
6916
|
catch {
|
|
6680
6917
|
verifyOk = false;
|
|
@@ -6722,9 +6959,10 @@ function cmdPublish(options, flags, cwd, writeOutput, mirrorRunner, siblingDrift
|
|
|
6722
6959
|
smoke: (artifacts) => {
|
|
6723
6960
|
for (const a of artifacts)
|
|
6724
6961
|
write(`dz publish: tarball ${a.name}@${a.newVersion} sha256:${a.sha256}`);
|
|
6962
|
+
const auditPackages = artifacts.map((a) => ({ name: a.name, version: a.newVersion, tarballSha256: a.sha256 }));
|
|
6725
6963
|
if (bins.length === 0) {
|
|
6726
|
-
appendPublishGateAudit(cwd, 'packed-install-smoke', 'pass', 'n/a — nothing in the batch declares a bin');
|
|
6727
|
-
write(
|
|
6964
|
+
const wrote = appendPublishGateAudit(cwd, 'packed-install-smoke', 'pass', 'n/a — nothing in the batch declares a bin', auditPackages, undefined, gateAuditFsLayer);
|
|
6965
|
+
write(`dz publish: ○ packed install smoke: n/a (nothing in the batch declares a bin)${auditSuffix(wrote)}`);
|
|
6728
6966
|
return { ok: true };
|
|
6729
6967
|
}
|
|
6730
6968
|
const scratchRoot = packedInstallScratchRoot();
|
|
@@ -6766,13 +7004,13 @@ function cmdPublish(options, flags, cwd, writeOutput, mirrorRunner, siblingDrift
|
|
|
6766
7004
|
}
|
|
6767
7005
|
catch { /* best-effort cleanup */ }
|
|
6768
7006
|
if (verdict.ok) {
|
|
6769
|
-
appendPublishGateAudit(cwd, 'packed-install-smoke', 'pass', 'pack/install/--version all clean (live, packedTransport)');
|
|
6770
|
-
write(
|
|
7007
|
+
const wrote = appendPublishGateAudit(cwd, 'packed-install-smoke', 'pass', 'pack/install/--version all clean (live, packedTransport)', auditPackages, undefined, gateAuditFsLayer);
|
|
7008
|
+
write(`dz publish: ✓ packed install smoke${auditSuffix(wrote)}`);
|
|
6771
7009
|
return { ok: true };
|
|
6772
7010
|
}
|
|
6773
7011
|
const detail = verdict.failureDetail ?? verdict.bins.find((b) => !b.ok)?.detail ?? '(no detail)';
|
|
6774
|
-
appendPublishGateAudit(cwd, 'packed-install-smoke', 'block', detail);
|
|
6775
|
-
write(`dz publish: BLOCKED — packed install smoke failed: ${detail}`);
|
|
7012
|
+
const wrote = appendPublishGateAudit(cwd, 'packed-install-smoke', 'block', detail, auditPackages, undefined, gateAuditFsLayer);
|
|
7013
|
+
write(`dz publish: BLOCKED — packed install smoke failed: ${detail}${auditSuffix(wrote)}`);
|
|
6776
7014
|
for (const b of verdict.bins.filter((b) => !b.ok))
|
|
6777
7015
|
write(` ✗ ${b.pkg} (${b.binName}): ${b.detail ?? '(no detail)'}`);
|
|
6778
7016
|
return { ok: false, reason: detail };
|
|
@@ -7007,7 +7245,7 @@ function cmdPublish(options, flags, cwd, writeOutput, mirrorRunner, siblingDrift
|
|
|
7007
7245
|
/* dz parity — the honest feature×target map (target-parity-matrix, */
|
|
7008
7246
|
/* ADR-001): computed from the declarative model, never hand-written */
|
|
7009
7247
|
/* ------------------------------------------------------------------ */
|
|
7010
|
-
function cmdParity(options, flags, write, writeErr, cwd) {
|
|
7248
|
+
async function cmdParity(options, flags, write, writeErr, cwd) {
|
|
7011
7249
|
const json = flags.has('json');
|
|
7012
7250
|
if (flags.has('help')) {
|
|
7013
7251
|
write('dz parity [--target <name>] [--json] — the computed feature×target map (never hand-written)');
|
|
@@ -7035,32 +7273,56 @@ function cmdParity(options, flags, write, writeErr, cwd) {
|
|
|
7035
7273
|
return 1;
|
|
7036
7274
|
}
|
|
7037
7275
|
}
|
|
7038
|
-
// ADR-001 Decision 3 (feature setup-installs-apply-leg)
|
|
7039
|
-
// MEASURED, not declared — `hooks-prompt`
|
|
7040
|
-
//
|
|
7041
|
-
//
|
|
7042
|
-
// `applyLegStatus`
|
|
7043
|
-
//
|
|
7276
|
+
// ADR-001 Decision 3 (feature setup-installs-apply-leg), extended by `apply-leg-never-silent`
|
|
7277
|
+
// Decision 3 (FR-4): `learning-apply` on `claude-code` is MEASURED, not declared — `hooks-prompt`
|
|
7278
|
+
// is present for that ONE target only when the leg is OBSERVED to inject, not merely installed.
|
|
7279
|
+
// Issue #2 was exactly this cell reading `full` while the leg injected nothing in every session
|
|
7280
|
+
// but one: `applyLegStatus(root).installed` alone (file presence + structural wiring) is
|
|
7281
|
+
// necessary but not sufficient — `probeApplyLeg` is the live end-to-end proof. `computeParity`
|
|
7282
|
+
// itself is untouched (FR-5 of the earlier feature); only the capability SET fed into it for this
|
|
7283
|
+
// one cell differs from the static `TARGET_CAPABILITIES`, exactly as before this feature.
|
|
7284
|
+
// `applyLegStatus` never throws (fix round 1, Q3 finding); `probeApplyLeg` is only invoked when
|
|
7285
|
+
// `installed` is true — a leg with missing/stale/unreadable helpers has nothing live to probe,
|
|
7286
|
+
// and the pre-existing remedy for that state is unchanged.
|
|
7044
7287
|
const applyLegStatusVal = applyLegStatus(cwd);
|
|
7045
7288
|
const applyLegInstalled = applyLegStatusVal.installed;
|
|
7289
|
+
// Codex round-2: `probeApplyLeg` may REJECT (temp dir, dynamic import, a throwing remover) — doctor
|
|
7290
|
+
// catches that; parity must too, or a probe crash would crash `dz parity` instead of reading as
|
|
7291
|
+
// "installed but silent: probe-error".
|
|
7292
|
+
let applyLegProbe;
|
|
7293
|
+
if (applyLegInstalled) {
|
|
7294
|
+
try {
|
|
7295
|
+
applyLegProbe = await probeApplyLeg(cwd);
|
|
7296
|
+
}
|
|
7297
|
+
catch (err) {
|
|
7298
|
+
applyLegProbe = { ok: false, reason: `probe-error: ${err instanceof Error ? err.message : String(err)}`, elapsedMs: 0 };
|
|
7299
|
+
}
|
|
7300
|
+
}
|
|
7301
|
+
const applyLegWorking = applyLegProbe?.ok === true;
|
|
7046
7302
|
const matrix = buildParityMatrix().map((row) => {
|
|
7047
|
-
if (row.feature.id !== 'learning-apply' ||
|
|
7303
|
+
if (row.feature.id !== 'learning-apply' || applyLegWorking)
|
|
7048
7304
|
return row;
|
|
7049
7305
|
const claudeCodeCaps = TARGET_CAPABILITIES['claude-code'].filter((c) => c !== 'hooks-prompt');
|
|
7050
7306
|
return { feature: row.feature, cells: { ...row.cells, 'claude-code': computeParity(row.feature, claudeCodeCaps) } };
|
|
7051
7307
|
});
|
|
7052
|
-
// The
|
|
7053
|
-
//
|
|
7054
|
-
//
|
|
7055
|
-
// function `dz doctor` uses for those two
|
|
7056
|
-
// the two instruments cannot disagree about WHY
|
|
7308
|
+
// The remedy — named ONLY for the one cell whose grant is a live measurement, never a blanket
|
|
7309
|
+
// note for every `manual` cell (most targets are manual by DESIGN, not absence). Three distinct
|
|
7310
|
+
// states, never conflated: NOT installed (stale-version/unreadable route through
|
|
7311
|
+
// `applyLegReasonMessage`, the SAME text-producing function `dz doctor` uses for those two
|
|
7312
|
+
// reasons, so the two instruments cannot disagree about WHY); installed but the live probe did
|
|
7313
|
+
// NOT observe injection (FR-4's new "installed but silent" case, reason from `probeApplyLeg`
|
|
7314
|
+
// itself — the SAME reason `dz doctor`'s live-probe row prints, so doctor and parity cannot
|
|
7315
|
+
// disagree about a dead leg either); and the working case, where this function returns ''.
|
|
7057
7316
|
const applyLegRemedy = (featureId, t) => {
|
|
7058
|
-
if (featureId !== 'learning-apply' || t !== 'claude-code' ||
|
|
7317
|
+
if (featureId !== 'learning-apply' || t !== 'claude-code' || applyLegWorking)
|
|
7059
7318
|
return '';
|
|
7060
|
-
if (
|
|
7061
|
-
|
|
7319
|
+
if (!applyLegInstalled) {
|
|
7320
|
+
if (applyLegStatusVal.reason === 'stale-version' || applyLegStatusVal.reason === 'unreadable') {
|
|
7321
|
+
return ` — ${applyLegReasonMessage(applyLegStatusVal)}`;
|
|
7322
|
+
}
|
|
7323
|
+
return ' — not installed — run dz setup --target claude-code --memory agentdb';
|
|
7062
7324
|
}
|
|
7063
|
-
return
|
|
7325
|
+
return ` — installed but silent: ${applyLegProbe?.reason ?? 'unknown'}`;
|
|
7064
7326
|
};
|
|
7065
7327
|
// EVIDENCE staleness, folded into the report (fix round 2, R2-3). Derived from the records
|
|
7066
7328
|
// themselves — no `codex --version`, no subprocess, so `dz parity` stays a deterministic function
|
|
@@ -7352,7 +7614,20 @@ function cmdRelease(options, flags, cwd, write, runner) {
|
|
|
7352
7614
|
let smokeTmp;
|
|
7353
7615
|
const execSteps = plan.steps.filter((s) => s.kind !== 'synthetic-fail');
|
|
7354
7616
|
say(`\ndz release — executing ${execSteps.length} gate step(s) across ${plan.packages.length} package(s)…`);
|
|
7355
|
-
|
|
7617
|
+
// FR-1/FR-2 (feature release-smoke-staged-pack): the packed-install smoke's `pack` steps must
|
|
7618
|
+
// pack the SAME staged bytes `dz publish`'s preview does — sibling `workspace:*` deps rewritten
|
|
7619
|
+
// to the exact sibling version, `prepublishOnly` dropped — or `npm install` on the resulting
|
|
7620
|
+
// tarballs dies with EUNSUPPORTEDPROTOCOL (MEASURED 2026-09-13 16:05). `workspaceVersions` is
|
|
7621
|
+
// built like publish's (from the FULL workspace, not just this release's filtered batch — an
|
|
7622
|
+
// out-of-batch sibling still needs its real version). Only the `pack` sub-steps are staged; the
|
|
7623
|
+
// `install`/`bin-exists`/`bin-version` steps already run against the packed tarballs and need no
|
|
7624
|
+
// staging.
|
|
7625
|
+
const allPackages = discoverPackages(cwd);
|
|
7626
|
+
const workspaceVersions = new Map(allPackages.map((p) => [p.name, p.version]));
|
|
7627
|
+
const packStepIds = new Set((plan.packedInstallPlan?.steps ?? [])
|
|
7628
|
+
.filter((s) => s.kind === 'pack')
|
|
7629
|
+
.map((s) => `smoke:packed-install:${s.id}`));
|
|
7630
|
+
const runGateStep = (step) => {
|
|
7356
7631
|
let stepCwd = step.cwd;
|
|
7357
7632
|
if (step.tempCwd === true) {
|
|
7358
7633
|
// AM-4: boot bins in a throwaway cwd so an installer-style bin cannot mutate the workspace.
|
|
@@ -7370,6 +7645,37 @@ function cmdRelease(options, flags, cwd, write, runner) {
|
|
|
7370
7645
|
durationMs: Date.now() - started,
|
|
7371
7646
|
timedOut: r.timedOut,
|
|
7372
7647
|
});
|
|
7648
|
+
};
|
|
7649
|
+
// Plan/execution order is load-bearing (see the plan/execution skew guard test) — steps run in
|
|
7650
|
+
// exactly the order `plan.steps` lists them, one loop, no reordering. Only the `pack` steps are
|
|
7651
|
+
// wrapped in the staged window, individually, so where they fall in that order never changes.
|
|
7652
|
+
let announcedStagedPack = false;
|
|
7653
|
+
for (const step of execSteps) {
|
|
7654
|
+
if (packStepIds.has(step.id)) {
|
|
7655
|
+
if (!announcedStagedPack) {
|
|
7656
|
+
say('dz release: smoke: packed tarballs staged like the live publish (workspace:* → exact sibling versions)');
|
|
7657
|
+
announcedStagedPack = true;
|
|
7658
|
+
}
|
|
7659
|
+
// Codex finding 2: stage ONLY the package this pack step packs — sibling pins come from
|
|
7660
|
+
// workspaceVersions, and `npm pack` reads the packed package's manifest alone.
|
|
7661
|
+
const packed = factsList.filter((f) => f.name === step.pkg);
|
|
7662
|
+
try {
|
|
7663
|
+
withStagedPackageJson(packed, workspaceVersions, write, () => runGateStep(step), 'dz release');
|
|
7664
|
+
}
|
|
7665
|
+
catch (err) {
|
|
7666
|
+
// a staging/restore failure is a FAILED step with the reason, never a silent skip — and ONE
|
|
7667
|
+
// record per step: a run already recorded by runGateStep is replaced, not duplicated (Codex r2)
|
|
7668
|
+
const failed = { stepId: step.id, exitCode: 1, stdout: '', stderr: formatPublishError(err), durationMs: 0, timedOut: false };
|
|
7669
|
+
const at = executions.findIndex((e) => e.stepId === step.id);
|
|
7670
|
+
if (at >= 0)
|
|
7671
|
+
executions[at] = failed;
|
|
7672
|
+
else
|
|
7673
|
+
executions.push(failed);
|
|
7674
|
+
}
|
|
7675
|
+
}
|
|
7676
|
+
else {
|
|
7677
|
+
runGateStep(step);
|
|
7678
|
+
}
|
|
7373
7679
|
}
|
|
7374
7680
|
if (smokeTmp !== undefined) {
|
|
7375
7681
|
try {
|
|
@@ -7384,8 +7690,24 @@ function cmdRelease(options, flags, cwd, write, runner) {
|
|
|
7384
7690
|
for (const g of verdict.gates) {
|
|
7385
7691
|
const icon = g.status === 'pass' ? '✓' : g.status === 'fail' ? '✗' : '○';
|
|
7386
7692
|
say(` ${icon} ${g.gate.padEnd(7)} ${g.status.toUpperCase().padEnd(4)} ${g.passed} passed, ${g.failures.length} failed, ${g.skips.length} skipped`);
|
|
7387
|
-
for (const f of g.failures)
|
|
7693
|
+
for (const f of g.failures) {
|
|
7388
7694
|
say(` [${f.class}] ${f.pkg !== undefined ? `${f.pkg}: ` : ''}${f.reason}`);
|
|
7695
|
+
// FR-2 / AM-4 / AM-6 (feature release-gate-output-tail): print each non-empty stream's
|
|
7696
|
+
// tail under the failure line, labelled `stdout:`/`stderr:` at the failure's own 6-space
|
|
7697
|
+
// indent, with its content lines at an 8-space CONTINUATION indent so a reader can tell a
|
|
7698
|
+
// tail line from a new failure/skip bullet at a glance; --json carries the same `tails`
|
|
7699
|
+
// object as-is (present, possibly with empty strings, on every executed failure).
|
|
7700
|
+
if (f.tails !== undefined) {
|
|
7701
|
+
for (const stream of ['stdout', 'stderr']) {
|
|
7702
|
+
const t = f.tails[stream];
|
|
7703
|
+
if (t.length === 0)
|
|
7704
|
+
continue;
|
|
7705
|
+
say(` ${stream}:`);
|
|
7706
|
+
for (const tailLine of t.split('\n'))
|
|
7707
|
+
say(` ${tailLine}`);
|
|
7708
|
+
}
|
|
7709
|
+
}
|
|
7710
|
+
}
|
|
7389
7711
|
for (const sk of g.skips)
|
|
7390
7712
|
say(` [${sk.class}] ${sk.pkg}: ${sk.reason}`);
|
|
7391
7713
|
}
|
|
@@ -10211,31 +10533,154 @@ function runGuardEvaluation(root, op, text, overrideReason, publishFilter) {
|
|
|
10211
10533
|
return result;
|
|
10212
10534
|
}
|
|
10213
10535
|
/**
|
|
10214
|
-
*
|
|
10536
|
+
* AM-2: loop `writeSync` until every byte of `data` has been accepted, checking the RETURNED
|
|
10537
|
+
* length on every call (Codex round-1 review, finding 1: the old code called `writeSync` once and
|
|
10538
|
+
* ignored the return value — a short write followed by `fsyncSync` durably persists a TRUNCATED,
|
|
10539
|
+
* unparseable JSON line into an append-only log every future read walks). A call that reports zero
|
|
10540
|
+
* or negative progress can never complete the buffer and would spin forever — that is treated as a
|
|
10541
|
+
* failure, not a retry target.
|
|
10542
|
+
*/
|
|
10543
|
+
function writeAllSync(fsLayer, fd, data) {
|
|
10544
|
+
let remaining = data;
|
|
10545
|
+
while (remaining.length > 0) {
|
|
10546
|
+
const n = fsLayer.writeSync(fd, remaining);
|
|
10547
|
+
if (typeof n !== 'number' || !Number.isFinite(n) || n <= 0) {
|
|
10548
|
+
throw new Error(`writeSync made no progress (returned ${n}) with ${remaining.length} byte(s) still pending`);
|
|
10549
|
+
}
|
|
10550
|
+
// Codex round-2 (2026-09-14) finding 3: a layer claiming MORE bytes than it was given is lying
|
|
10551
|
+
// about the record, and must not reach "logged: true" through a subarray that just goes empty.
|
|
10552
|
+
if (n > remaining.length) {
|
|
10553
|
+
throw new Error(`writeSync claimed ${n} byte(s) written but only ${remaining.length} were supplied`);
|
|
10554
|
+
}
|
|
10555
|
+
remaining = remaining.subarray(n);
|
|
10556
|
+
}
|
|
10557
|
+
}
|
|
10558
|
+
const REAL_PUBLISH_GATE_AUDIT_FS = { existsSync, mkdirSync, openSync, writeSync, fsyncSync, closeSync };
|
|
10559
|
+
/**
|
|
10560
|
+
* Feature `publish-sibling-drift-gate` (FR-5/AM-6), durability + per-package identity added by
|
|
10561
|
+
* feature `publish-gate-audit-durable` (FR-1/FR-2): both the sibling-drift and packed-install-smoke
|
|
10215
10562
|
* gates write to the SAME append-only, hash-chained `.dz/guard-audit.jsonl` the declarative
|
|
10216
10563
|
* `dz guard` rules use — visibility for `dz guard promote`/`dz compounding` never depends on
|
|
10217
10564
|
* which mechanism produced the finding. `pass` records go through as an informational `note`
|
|
10218
10565
|
* (never a violation, so they can never flip the row's own verdict) so a clean check is ALSO on
|
|
10219
10566
|
* the record, not just a block or an override (AM-6: "аудит без записи = не аудит").
|
|
10220
10567
|
*
|
|
10221
|
-
*
|
|
10222
|
-
*
|
|
10223
|
-
*
|
|
10224
|
-
* it (
|
|
10568
|
+
* FR-1: one JSONL record PER PACKAGE in `packages`, each naming the package, its version, and the
|
|
10569
|
+
* tarball's sha256 (or the explicit `sha256:n/a` before a tarball exists — a dry-run preview). FR-2:
|
|
10570
|
+
* the write is `openSync('a') → writeSync → fsyncSync(fd) → closeSync`, plus an `fsyncSync` of the
|
|
10571
|
+
* `.dz` directory itself the one time this call CREATES it (a file's own fsync durably persists its
|
|
10572
|
+
* bytes; the directory entry that makes the file findable after a crash needs its own fsync — the
|
|
10573
|
+
* same lesson `integration-apply.ts`'s `fsyncDirectory` already encodes).
|
|
10574
|
+
*
|
|
10575
|
+
* Returns `{ logged, reason? }` — never a bare boolean, so a caller can print WHY a write failed,
|
|
10576
|
+
* not just that it did. Most callers are best-effort (a write failure never blocks a verdict already
|
|
10577
|
+
* decided) — the one exception is an `--allow-sibling-drift` OVERRIDE, whose caller MUST check
|
|
10578
|
+
* `logged`: an override is not real without a durable row behind it (AM-6's load-bearing property —
|
|
10579
|
+
* see `auditedOverride` in `cmdPublish`).
|
|
10225
10580
|
*/
|
|
10226
|
-
function appendPublishGateAudit(root, rule, verdict, detail, overrideReason) {
|
|
10581
|
+
function appendPublishGateAudit(root, rule, verdict, detail, packages, overrideReason, fsLayer = REAL_PUBLISH_GATE_AUDIT_FS) {
|
|
10582
|
+
if (packages.length === 0)
|
|
10583
|
+
return { logged: true }; // nothing to record about — not a failure
|
|
10584
|
+
const dzDir = join(root, '.dz');
|
|
10585
|
+
const auditPath = join(dzDir, 'guard-audit.jsonl');
|
|
10586
|
+
const dzDirExisted = fsLayer.existsSync(dzDir);
|
|
10587
|
+
// AM-2: tracked BEFORE the write — this is what decides whether the append call below is about
|
|
10588
|
+
// to CREATE the file (needing a `.dz` directory-entry fsync afterwards) or extend an existing one
|
|
10589
|
+
// (whose directory entry is already durable from a previous append).
|
|
10590
|
+
const auditFileExisted = fsLayer.existsSync(auditPath);
|
|
10227
10591
|
try {
|
|
10228
|
-
|
|
10229
|
-
? { op: 'publish', verdict, violations: [], checked: [rule], notEstablished: [], notes: [`${rule}: ${detail}`] }
|
|
10230
|
-
: { op: 'publish', verdict, violations: [{ rule, severity: 'hard', detail }], checked: [rule], notEstablished: [] }, new Date().toISOString(), overrideReason !== undefined ? { reason: overrideReason } : undefined);
|
|
10231
|
-
mkdirSync(join(root, '.dz'), { recursive: true });
|
|
10232
|
-
const auditPath = join(root, '.dz', 'guard-audit.jsonl');
|
|
10233
|
-
writeFileSync(auditPath, appendChainedLines([rec], readLogTail(auditPath)), { flag: 'a' });
|
|
10234
|
-
return true;
|
|
10592
|
+
fsLayer.mkdirSync(dzDir, { recursive: true });
|
|
10235
10593
|
}
|
|
10236
|
-
catch {
|
|
10237
|
-
return
|
|
10594
|
+
catch (err) {
|
|
10595
|
+
return { logged: false, reason: `could not create ${dzDir}: ${err.message.split('\n')[0]}` };
|
|
10596
|
+
}
|
|
10597
|
+
const records = packages.map((pkg) => {
|
|
10598
|
+
const sha = pkg.tarballSha256 !== undefined && pkg.tarballSha256 !== '' ? pkg.tarballSha256 : 'n/a';
|
|
10599
|
+
const label = `${rule}: ${pkg.name}@${pkg.version} sha256:${sha} — ${detail}`;
|
|
10600
|
+
return auditRecord(verdict === 'pass'
|
|
10601
|
+
? { op: 'publish', verdict, violations: [], checked: [rule], notEstablished: [], notes: [label] }
|
|
10602
|
+
: { op: 'publish', verdict, violations: [{ rule, severity: 'hard', detail: label }], checked: [rule], notEstablished: [] }, new Date().toISOString(), overrideReason !== undefined ? { reason: overrideReason } : undefined);
|
|
10603
|
+
});
|
|
10604
|
+
let bytes;
|
|
10605
|
+
try {
|
|
10606
|
+
bytes = appendChainedLines(records, readLogTail(auditPath));
|
|
10607
|
+
}
|
|
10608
|
+
catch (err) {
|
|
10609
|
+
return { logged: false, reason: `could not build the chained record: ${err.message.split('\n')[0]}` };
|
|
10238
10610
|
}
|
|
10611
|
+
if (bytes === '')
|
|
10612
|
+
return { logged: true };
|
|
10613
|
+
const buf = Buffer.from(bytes, 'utf-8');
|
|
10614
|
+
let fd;
|
|
10615
|
+
try {
|
|
10616
|
+
fd = fsLayer.openSync(auditPath, fsConstants.O_APPEND | fsConstants.O_CREAT | fsConstants.O_WRONLY);
|
|
10617
|
+
writeAllSync(fsLayer, fd, buf); // AM-2: loops on a short write, throws on no progress
|
|
10618
|
+
fsLayer.fsyncSync(fd);
|
|
10619
|
+
fsLayer.closeSync(fd);
|
|
10620
|
+
fd = undefined;
|
|
10621
|
+
}
|
|
10622
|
+
catch (err) {
|
|
10623
|
+
return { logged: false, reason: err.message.split('\n')[0] ?? String(err) };
|
|
10624
|
+
}
|
|
10625
|
+
finally {
|
|
10626
|
+
if (fd !== undefined) {
|
|
10627
|
+
try {
|
|
10628
|
+
fsLayer.closeSync(fd);
|
|
10629
|
+
}
|
|
10630
|
+
catch { /* best-effort */ }
|
|
10631
|
+
}
|
|
10632
|
+
}
|
|
10633
|
+
// AM-2 (Codex round-1 review, finding 1, high): a directory-entry fsync is REQUIRED, not
|
|
10634
|
+
// best-effort, whenever THIS call is the reason the entry needed persisting — swallowing its
|
|
10635
|
+
// failure used to let `logged: true` go out about a record whose directory entry can vanish on a
|
|
10636
|
+
// crash before the next fsck. Two DIFFERENT entries can need persisting, tracked independently:
|
|
10637
|
+
if (!auditFileExisted) {
|
|
10638
|
+
// The audit FILE was just created by the open() above — `.dz` (its containing directory) needs
|
|
10639
|
+
// its own fsync so the new directory entry survives a crash; the file's own fsync (above) only
|
|
10640
|
+
// guarantees the file's DATA, not that anything can find it afterwards.
|
|
10641
|
+
let dfd;
|
|
10642
|
+
try {
|
|
10643
|
+
dfd = fsLayer.openSync(dzDir, fsConstants.O_RDONLY);
|
|
10644
|
+
fsLayer.fsyncSync(dfd);
|
|
10645
|
+
}
|
|
10646
|
+
catch (err) {
|
|
10647
|
+
return { logged: false, reason: `could not fsync ${dzDir} after creating ${auditPath}: ${err.message.split('\n')[0]}` };
|
|
10648
|
+
}
|
|
10649
|
+
finally {
|
|
10650
|
+
if (dfd !== undefined) {
|
|
10651
|
+
try {
|
|
10652
|
+
fsLayer.closeSync(dfd);
|
|
10653
|
+
}
|
|
10654
|
+
catch { /* best-effort, does not affect the verdict already returned */ }
|
|
10655
|
+
}
|
|
10656
|
+
}
|
|
10657
|
+
}
|
|
10658
|
+
if (!dzDirExisted) {
|
|
10659
|
+
// `.dz` ITSELF was just created by `mkdirSync` above — its PARENT (`root`) needs its own fsync
|
|
10660
|
+
// so `.dz`'s OWN directory entry survives a crash (the fsync of `.dz` just above only durably
|
|
10661
|
+
// persists entries INSIDE `.dz`, not the fact that `.dz` exists at all).
|
|
10662
|
+
let pfd;
|
|
10663
|
+
try {
|
|
10664
|
+
pfd = fsLayer.openSync(root, fsConstants.O_RDONLY);
|
|
10665
|
+
fsLayer.fsyncSync(pfd);
|
|
10666
|
+
}
|
|
10667
|
+
catch (err) {
|
|
10668
|
+
return { logged: false, reason: `could not fsync ${root} after creating ${dzDir}: ${err.message.split('\n')[0]}` };
|
|
10669
|
+
}
|
|
10670
|
+
finally {
|
|
10671
|
+
if (pfd !== undefined) {
|
|
10672
|
+
try {
|
|
10673
|
+
fsLayer.closeSync(pfd);
|
|
10674
|
+
}
|
|
10675
|
+
catch { /* best-effort, does not affect the verdict already returned */ }
|
|
10676
|
+
}
|
|
10677
|
+
}
|
|
10678
|
+
}
|
|
10679
|
+
return { logged: true };
|
|
10680
|
+
}
|
|
10681
|
+
/** FR-2: the printed suffix a caller appends to its own verdict line — never silent about logging. */
|
|
10682
|
+
function auditSuffix(wrote) {
|
|
10683
|
+
return wrote.logged ? ' (logged)' : ` (audit NOT logged: ${wrote.reason ?? 'unknown reason'})`;
|
|
10239
10684
|
}
|
|
10240
10685
|
function renderGuardObservation(observation) {
|
|
10241
10686
|
const tag = observation.status === 'unknown' ? 'note' : 'observe';
|
|
@@ -11955,6 +12400,43 @@ function splitMutationGateOutputWrite(result) {
|
|
|
11955
12400
|
return {};
|
|
11956
12401
|
return 'path' in result ? { outputPath: result.path } : { outputError: result.error };
|
|
11957
12402
|
}
|
|
12403
|
+
/** Fix-round 1 (AM-1, feature qe-step-gate-scoped-to-feature): a `--touched` path arrives in one of
|
|
12404
|
+
* several shapes — package-relative POSIX (the common case, already correct as-is), with a leading
|
|
12405
|
+
* `./`, as an ABSOLUTE path inside the package, or REPO-relative (e.g.
|
|
12406
|
+
* `packages/@dzhechkov/harness-cli/src/x.ts`) — and on a POSIX host a caller may hand a backslash
|
|
12407
|
+
* path too (a Windows-authored change list). MEASURED (Codex round-1 review): the pre-fix
|
|
12408
|
+
* normalization only converted the native path separator, so any of the other shapes matched ZERO
|
|
12409
|
+
* registry entries and the gate silently reported `selected 0` / exit 0 — the run looked clean while
|
|
12410
|
+
* defending nothing. This resolves EVERY shape to the package-relative POSIX candidate(s) a registry
|
|
12411
|
+
* entry's `file` is written in, and — when a path genuinely resolves outside `pkgDir` (an absolute
|
|
12412
|
+
* path elsewhere, or a `../` that still escapes the package after a repo-relative reinterpretation)
|
|
12413
|
+
* — reports it as `outside`, never a silent non-match indistinguishable from "the path doesn't
|
|
12414
|
+
* exist".
|
|
12415
|
+
*/
|
|
12416
|
+
function normalizeTouchedPath(raw, pkgDir, repoTop) {
|
|
12417
|
+
// Codex round-2: normalize lexically FIRST so `lib/../../x` is seen as the traversal it is, and treat a
|
|
12418
|
+
// Windows-drive path (`C:/…`) on a POSIX host as outside the package (counted, said) rather than as a
|
|
12419
|
+
// package-relative name that silently matches nothing. Symlinked package dirs stay a named limit: the
|
|
12420
|
+
// containment check is lexical, not realpath-based.
|
|
12421
|
+
const p = posixNormalize(raw.replace(/\\/g, '/')).replace(/^(?:\.\/)+/, '');
|
|
12422
|
+
if (p === '' || p === '.')
|
|
12423
|
+
return { candidates: [], outside: false };
|
|
12424
|
+
if (/^[A-Za-z]:\//.test(p) && !isAbsolute(p))
|
|
12425
|
+
return { candidates: [], outside: true };
|
|
12426
|
+
if (isAbsolute(p)) {
|
|
12427
|
+
const rel = relative(pkgDir, resolve(p)).split(sep).join('/');
|
|
12428
|
+
return rel === '' || rel.startsWith('..') ? { candidates: [], outside: true } : { candidates: [rel], outside: false };
|
|
12429
|
+
}
|
|
12430
|
+
const candidates = new Set();
|
|
12431
|
+
if (p !== '..' && !p.startsWith('../'))
|
|
12432
|
+
candidates.add(p); // already package-relative, as given
|
|
12433
|
+
if (repoTop !== null) {
|
|
12434
|
+
const rel = relative(pkgDir, resolve(repoTop, p)).split(sep).join('/');
|
|
12435
|
+
if (rel !== '' && !rel.startsWith('..'))
|
|
12436
|
+
candidates.add(rel);
|
|
12437
|
+
}
|
|
12438
|
+
return candidates.size > 0 ? { candidates: [...candidates], outside: false } : { candidates: [], outside: true };
|
|
12439
|
+
}
|
|
11958
12440
|
function cmdMutationGate(options, flags, cwd, write, injectedRunner) {
|
|
11959
12441
|
const json = flags.has('json');
|
|
11960
12442
|
const fail = (what) => {
|
|
@@ -11978,7 +12460,9 @@ function cmdMutationGate(options, flags, cwd, write, injectedRunner) {
|
|
|
11978
12460
|
}
|
|
11979
12461
|
let entries = parsed.registry.entries;
|
|
11980
12462
|
let entryResults = parsed.entryResults;
|
|
12463
|
+
const totalRegistryEntries = entries.length;
|
|
11981
12464
|
const only = options.get('only');
|
|
12465
|
+
let onlyIds = null;
|
|
11982
12466
|
if (only !== undefined) {
|
|
11983
12467
|
const ids = only.split(',').map((s) => s.trim()).filter(Boolean);
|
|
11984
12468
|
const known = new Set([
|
|
@@ -11988,15 +12472,139 @@ function cmdMutationGate(options, flags, cwd, write, injectedRunner) {
|
|
|
11988
12472
|
const unknown = ids.filter((id) => !known.has(id));
|
|
11989
12473
|
if (unknown.length > 0)
|
|
11990
12474
|
return fail(`--only names unknown entry id(s): ${unknown.join(', ')}`);
|
|
12475
|
+
onlyIds = new Set(ids);
|
|
11991
12476
|
entries = entries.filter((e) => ids.includes(e.id));
|
|
11992
12477
|
entryResults = entryResults.filter((result) => ids.includes(result.id));
|
|
11993
12478
|
}
|
|
12479
|
+
// Feature qe-step-gate-scoped-to-feature (FR-1/FR-2/FR-3): scope the gate to the files a FEATURE
|
|
12480
|
+
// actually touched and/or entries added since a base ref, instead of the whole registry — MEASURED
|
|
12481
|
+
// 2026-09-12, 358 entries on this repo's core package ran 30-40 minutes and hit the timeout wall,
|
|
12482
|
+
// INCONCLUSIVE every time, though a feature owns only its own touched files. `--touched` and
|
|
12483
|
+
// `--added-since` UNION (a file changed by the feature OR an entry it newly added is in scope);
|
|
12484
|
+
// that union then INTERSECTS with `--only` when both are given, same algebra as an ordinary filter
|
|
12485
|
+
// chain. Both selectors are computed against the FULL (pre-`--only`) registry so their reported
|
|
12486
|
+
// counts describe what THEY matched, independent of any `--only` narrowing applied on top.
|
|
12487
|
+
const touchedRaw = options.get('touched');
|
|
12488
|
+
const addedSinceRaw = options.get('added-since');
|
|
12489
|
+
let selectionMeta = null;
|
|
12490
|
+
if (touchedRaw !== undefined || addedSinceRaw !== undefined) {
|
|
12491
|
+
let repoTop = null;
|
|
12492
|
+
try {
|
|
12493
|
+
repoTop = execSync('git rev-parse --show-toplevel', { cwd: pkgDir, stdio: 'pipe', encoding: 'utf-8' }).trim() || null;
|
|
12494
|
+
}
|
|
12495
|
+
catch { /* not in a git repo */ }
|
|
12496
|
+
const touchedIds = new Set();
|
|
12497
|
+
let outsidePackageCount = 0;
|
|
12498
|
+
if (touchedRaw !== undefined) {
|
|
12499
|
+
const touchedPaths = touchedRaw.split(',').map((s) => s.trim()).filter(Boolean);
|
|
12500
|
+
const normalizedCandidates = new Set();
|
|
12501
|
+
for (const raw of touchedPaths) {
|
|
12502
|
+
const { candidates, outside } = normalizeTouchedPath(raw, pkgDir, repoTop);
|
|
12503
|
+
if (outside)
|
|
12504
|
+
outsidePackageCount++;
|
|
12505
|
+
for (const c of candidates)
|
|
12506
|
+
normalizedCandidates.add(c);
|
|
12507
|
+
}
|
|
12508
|
+
for (const entry of parsed.registry.entries) {
|
|
12509
|
+
if (normalizedCandidates.has(entry.file))
|
|
12510
|
+
touchedIds.add(entry.id);
|
|
12511
|
+
}
|
|
12512
|
+
}
|
|
12513
|
+
const addedSinceIds = new Set();
|
|
12514
|
+
let baseAbsentMessage = null;
|
|
12515
|
+
if (addedSinceRaw !== undefined) {
|
|
12516
|
+
if (repoTop === null)
|
|
12517
|
+
return fail(`--added-since requires ${pkgDir} to be inside a git repository`);
|
|
12518
|
+
try {
|
|
12519
|
+
execFileSync('git', ['rev-parse', '--verify', '--quiet', `${addedSinceRaw}^{commit}`], { cwd: repoTop, stdio: 'pipe' });
|
|
12520
|
+
}
|
|
12521
|
+
catch {
|
|
12522
|
+
return fail(`--added-since names an unknown git ref: ${addedSinceRaw}`);
|
|
12523
|
+
}
|
|
12524
|
+
const registryRelPath = relative(repoTop, registryPath).split(sep).join('/');
|
|
12525
|
+
let baseRegistry = null;
|
|
12526
|
+
// Fix-round 1 (AM-3): only a genuinely ABSENT registry at that ref means "all entries are
|
|
12527
|
+
// new". Any OTHER `git show` failure (a bad path that isn't a missing-registry case, git
|
|
12528
|
+
// itself missing, a timeout) is a usage error, and a registry that parses to invalid JSON /
|
|
12529
|
+
// an invalid registry shape at that ref is a distinct usage error too — collapsing all three
|
|
12530
|
+
// into "absent" used to hide real failures behind a silently-too-generous selection.
|
|
12531
|
+
try {
|
|
12532
|
+
// stdio: registry-absent-at-ref is an EXPECTED outcome (git's own "fatal: path … does not
|
|
12533
|
+
// exist" on stderr would otherwise leak to the terminal by node's default inherit-stderr
|
|
12534
|
+
// behaviour) — pipe it into the caught error instead of printing it at the user.
|
|
12535
|
+
const baseText = execFileSync('git', ['show', `${addedSinceRaw}:${registryRelPath}`], { cwd: repoTop, encoding: 'utf-8', env: { ...process.env, LC_ALL: 'C', LANG: 'C' }, maxBuffer: 16 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
12536
|
+
const baseParsed = parseMutationRegistry(baseText);
|
|
12537
|
+
if (baseParsed.registry === null) {
|
|
12538
|
+
return fail(`base registry at ${addedSinceRaw} is not a valid registry: ${baseParsed.errors.join('; ')}`);
|
|
12539
|
+
}
|
|
12540
|
+
baseRegistry = baseParsed.registry;
|
|
12541
|
+
}
|
|
12542
|
+
catch (e) {
|
|
12543
|
+
const stderr = typeof e?.stderr === 'string'
|
|
12544
|
+
? e.stderr
|
|
12545
|
+
: String(e?.message ?? e);
|
|
12546
|
+
if (/does not exist in|exists on disk, but not in|Path .* does not exist/i.test(stderr)) {
|
|
12547
|
+
baseRegistry = null; // registry absent at that ref → every current entry counts as added
|
|
12548
|
+
baseAbsentMessage = `base registry absent at ${addedSinceRaw} — all ${totalRegistryEntries} entries count as added`;
|
|
12549
|
+
}
|
|
12550
|
+
else {
|
|
12551
|
+
return fail(`git show ${addedSinceRaw}:${registryRelPath} failed: ${stderr.trim()}`);
|
|
12552
|
+
}
|
|
12553
|
+
}
|
|
12554
|
+
if (baseAbsentMessage !== null && !json)
|
|
12555
|
+
write(`mutation-gate: ${baseAbsentMessage}`);
|
|
12556
|
+
for (const id of registryEntriesAddedSince(baseRegistry, parsed.registry))
|
|
12557
|
+
addedSinceIds.add(id);
|
|
12558
|
+
}
|
|
12559
|
+
let unionIds = new Set([...touchedIds, ...addedSinceIds]);
|
|
12560
|
+
if (onlyIds !== null) {
|
|
12561
|
+
const only2 = onlyIds;
|
|
12562
|
+
unionIds = new Set([...unionIds].filter((id) => only2.has(id)));
|
|
12563
|
+
}
|
|
12564
|
+
const selectorParts = [];
|
|
12565
|
+
if (touchedRaw !== undefined)
|
|
12566
|
+
selectorParts.push(`touched: ${touchedIds.size}${outsidePackageCount > 0 ? ` (${outsidePackageCount} outside package)` : ''}`);
|
|
12567
|
+
if (addedSinceRaw !== undefined)
|
|
12568
|
+
selectorParts.push(`added-since ${addedSinceRaw}: ${addedSinceIds.size}`);
|
|
12569
|
+
if (!json)
|
|
12570
|
+
write(`mutation-gate: selected ${unionIds.size} of ${totalRegistryEntries} entries (${selectorParts.join('; ')})`);
|
|
12571
|
+
entries = entries.filter((e) => unionIds.has(e.id));
|
|
12572
|
+
entryResults = entryResults.filter((result) => unionIds.has(result.id));
|
|
12573
|
+
// AM-4: every scoped run — empty or not — carries a machine-readable `selection` object in the
|
|
12574
|
+
// JSON contract, not just the empty-selection early return.
|
|
12575
|
+
selectionMeta = {
|
|
12576
|
+
selected: unionIds.size,
|
|
12577
|
+
total: totalRegistryEntries,
|
|
12578
|
+
touched: touchedRaw !== undefined ? [...touchedIds].sort() : null,
|
|
12579
|
+
addedSince: addedSinceRaw !== undefined ? [...addedSinceIds].sort() : null,
|
|
12580
|
+
base: addedSinceRaw ?? null,
|
|
12581
|
+
baseAbsent: baseAbsentMessage !== null,
|
|
12582
|
+
outsidePackage: outsidePackageCount,
|
|
12583
|
+
};
|
|
12584
|
+
if (unionIds.size === 0) {
|
|
12585
|
+
const msg = `0 entries match ${selectorParts.join(', ')} — nothing to run`;
|
|
12586
|
+
if (json) {
|
|
12587
|
+
write(JSON.stringify({
|
|
12588
|
+
packageDir: pkgDir,
|
|
12589
|
+
registryPath,
|
|
12590
|
+
selection: selectionMeta,
|
|
12591
|
+
results: [],
|
|
12592
|
+
summary: summarizeMutationResults([]),
|
|
12593
|
+
exitCode: 0,
|
|
12594
|
+
}, null, 2));
|
|
12595
|
+
}
|
|
12596
|
+
else {
|
|
12597
|
+
write(`mutation-gate: ${msg}`);
|
|
12598
|
+
}
|
|
12599
|
+
return 0;
|
|
12600
|
+
}
|
|
12601
|
+
}
|
|
11994
12602
|
if (entries.length === 0) {
|
|
11995
12603
|
const scope = only === undefined ? 'registry' : 'selected registry entries';
|
|
11996
12604
|
const error = `${scope} has no runnable entries after validation — nothing can be run; the registry is unusable`;
|
|
11997
12605
|
const summary = summarizeMutationResults(entryResults);
|
|
11998
12606
|
if (json) {
|
|
11999
|
-
write(JSON.stringify({ error, registryPath, results: entryResults, summary, exitCode: 2 }, null, 2));
|
|
12607
|
+
write(JSON.stringify({ error, registryPath, ...(selectionMeta !== null ? { selection: selectionMeta } : {}), results: entryResults, summary, exitCode: 2 }, null, 2));
|
|
12000
12608
|
}
|
|
12001
12609
|
else {
|
|
12002
12610
|
write(`dz mutation-gate: ${error}`);
|
|
@@ -12009,13 +12617,98 @@ function cmdMutationGate(options, flags, cwd, write, injectedRunner) {
|
|
|
12009
12617
|
const testCmdRaw = options.get('test-cmd') ?? parsed.registry.testCommand ?? 'npm test';
|
|
12010
12618
|
if (/[\0\n\r]/.test(testCmdRaw))
|
|
12011
12619
|
return fail('--test-cmd may not contain NUL or newline characters');
|
|
12012
|
-
|
|
12620
|
+
let testCmd = testCmdRaw;
|
|
12013
12621
|
const excludedSelfChecks = REGISTRY_SELFCHECK_TESTS.filter((testFile) => entries.some((entry) => buildMutationTestCommand(testCmd, entry).excluded.includes(testFile)));
|
|
12014
12622
|
if (!json) {
|
|
12015
12623
|
write(`mutation-gate: self-check excluded from mutant runs: ${excludedSelfChecks.join(', ') || '(none)'}`);
|
|
12016
12624
|
}
|
|
12017
|
-
|
|
12018
|
-
|
|
12625
|
+
// mutation-gate-timeout-verdict FR-3/FR-4: precedence is the `--timeout` flag > the registry's
|
|
12626
|
+
// own `timeoutMs` field > the 300000ms default. A package whose real baseline run is longer than
|
|
12627
|
+
// the default (this repo's core package, MEASURED ≈5-8 min) declares its floor in the registry so
|
|
12628
|
+
// a bare `dz mutation-gate` — no flag — still succeeds (AC-5).
|
|
12629
|
+
const timeoutFlagRaw = options.get('timeout');
|
|
12630
|
+
let timeout;
|
|
12631
|
+
let timeoutSource;
|
|
12632
|
+
if (timeoutFlagRaw !== undefined) {
|
|
12633
|
+
const timeoutOpt = Number(timeoutFlagRaw);
|
|
12634
|
+
if (Number.isFinite(timeoutOpt) && timeoutOpt > 0) {
|
|
12635
|
+
timeout = timeoutOpt;
|
|
12636
|
+
timeoutSource = 'flag';
|
|
12637
|
+
}
|
|
12638
|
+
else {
|
|
12639
|
+
timeout = 300000;
|
|
12640
|
+
timeoutSource = 'default';
|
|
12641
|
+
}
|
|
12642
|
+
}
|
|
12643
|
+
else if (parsed.registry.timeoutMs !== undefined) {
|
|
12644
|
+
timeout = parsed.registry.timeoutMs;
|
|
12645
|
+
timeoutSource = 'registry';
|
|
12646
|
+
}
|
|
12647
|
+
else {
|
|
12648
|
+
timeout = 300000;
|
|
12649
|
+
timeoutSource = 'default';
|
|
12650
|
+
}
|
|
12651
|
+
if (!json)
|
|
12652
|
+
write(`mutation-gate: timeout: ${timeout} ms (${timeoutSource})`);
|
|
12653
|
+
// mutation-gate-baseline-honesty FR-2/FR-3: precedence is the `--max-workers` flag > the
|
|
12654
|
+
// registry's own `maxWorkers` field > `min(4, max(1, floor(cpus/2)))`. Baseline and mutant runs
|
|
12655
|
+
// spawn the package's FULL testCommand at vitest's DEFAULT worker count (= cpu cores) unless
|
|
12656
|
+
// capped — MEASURED: under embedding-daemon tests (0.7-3.5 GB/process) this repo's core package
|
|
12657
|
+
// hit load 62-358 and 0.4-1.8 GB free on an 8-core/16GB box, killing three full overnight runs
|
|
12658
|
+
// (0bb74d66); the same suite at `--maxWorkers=2` passed 6909/6909.
|
|
12659
|
+
const maxWorkersFlagRaw = options.get('max-workers');
|
|
12660
|
+
const defaultMaxWorkers = Math.min(4, Math.max(1, Math.floor(cpus().length / 2)));
|
|
12661
|
+
let maxWorkers;
|
|
12662
|
+
let maxWorkersSource;
|
|
12663
|
+
if (maxWorkersFlagRaw !== undefined) {
|
|
12664
|
+
// fix-round 1, AM-1: an invalid `--max-workers` value (0, negative, fractional, `NaN`,
|
|
12665
|
+
// non-numeric) is a USAGE ERROR (exit 2) — mirroring the `--only` unknown-id fail() style —
|
|
12666
|
+
// never a silent fallback to the default. A silent fallback would make a typo'd flag run
|
|
12667
|
+
// uncapped-by-mistake while LOOKING capped (the source line still says "(default)").
|
|
12668
|
+
const maxWorkersOpt = Number(maxWorkersFlagRaw);
|
|
12669
|
+
if (!Number.isInteger(maxWorkersOpt) || maxWorkersOpt < 1) {
|
|
12670
|
+
return fail(`--max-workers must be a positive integer, got '${maxWorkersFlagRaw}'`);
|
|
12671
|
+
}
|
|
12672
|
+
maxWorkers = maxWorkersOpt;
|
|
12673
|
+
maxWorkersSource = 'flag';
|
|
12674
|
+
}
|
|
12675
|
+
else if (parsed.registry.maxWorkers !== undefined) {
|
|
12676
|
+
maxWorkers = parsed.registry.maxWorkers;
|
|
12677
|
+
maxWorkersSource = 'registry';
|
|
12678
|
+
}
|
|
12679
|
+
else {
|
|
12680
|
+
maxWorkers = defaultMaxWorkers;
|
|
12681
|
+
maxWorkersSource = 'default';
|
|
12682
|
+
}
|
|
12683
|
+
// FR-3: inject the ceiling into the command ONLY when it is (detectably) a vitest run and does
|
|
12684
|
+
// not already name the flag itself — an arbitrary testCommand cannot be assumed to accept
|
|
12685
|
+
// `--maxWorkers`. VITEST_MAX_WORKERS is set in the env unconditionally (below, at spawn time)
|
|
12686
|
+
// regardless of this detection, so a vitest command reached indirectly (e.g. through a package
|
|
12687
|
+
// script) is still capped.
|
|
12688
|
+
// fix-round 1, AM-3: detection AND injection are scoped to the VITEST SEGMENT — from the first
|
|
12689
|
+
// `vitest run` token to the next `&&`/`||`/`;`/`|` (or end of string) — and the flag lands right
|
|
12690
|
+
// after `vitest run`, never appended to the tail of a whole (possibly compound) command. A
|
|
12691
|
+
// raw-substring append over the FULL command turned `vitest run … && cleanup` into
|
|
12692
|
+
// `vitest run … && cleanup --maxWorkers=2` (silently handed to `cleanup`, not vitest), and the
|
|
12693
|
+
// existing-flag check could be suppressed by an unrelated `--maxWorkers` substring living outside
|
|
12694
|
+
// the vitest segment entirely (e.g. inside `cleanup`'s own args, or before `vitest run` in the
|
|
12695
|
+
// same command).
|
|
12696
|
+
const vitestRunIdx = testCmd.indexOf('vitest run');
|
|
12697
|
+
const isVitestCommand = vitestRunIdx !== -1;
|
|
12698
|
+
if (isVitestCommand) {
|
|
12699
|
+
const tailFromRun = testCmd.slice(vitestRunIdx);
|
|
12700
|
+
const terminator = /&&|\|\||;|\|/.exec(tailFromRun);
|
|
12701
|
+
const vitestSegment = terminator !== null ? tailFromRun.slice(0, terminator.index) : tailFromRun;
|
|
12702
|
+
if (!vitestSegment.includes('--maxWorkers')) {
|
|
12703
|
+
const insertAt = vitestRunIdx + 'vitest run'.length;
|
|
12704
|
+
testCmd = `${testCmd.slice(0, insertAt)} --maxWorkers=${maxWorkers}${testCmd.slice(insertAt)}`;
|
|
12705
|
+
}
|
|
12706
|
+
}
|
|
12707
|
+
if (!json) {
|
|
12708
|
+
write(isVitestCommand
|
|
12709
|
+
? `mutation-gate: workers: ${maxWorkers} (${maxWorkersSource})`
|
|
12710
|
+
: 'mutation-gate: workers: n/a — test command is not vitest');
|
|
12711
|
+
}
|
|
12019
12712
|
// Route-b guard mode: `per-entry` (default, strongest — each red entry re-baselines the restored
|
|
12020
12713
|
// tree, so a flaky neighbour flips THAT entry to INCONCLUSIVE) or `final` (cheap — one re-run at
|
|
12021
12714
|
// the end; if it is not green, every red-based verdict of the run is downgraded, because any of
|
|
@@ -12092,22 +12785,29 @@ function cmdMutationGate(options, flags, cwd, write, injectedRunner) {
|
|
|
12092
12785
|
const realScratchRoot = realpathSync(copyDir);
|
|
12093
12786
|
const requireCompletionReceipt = parsed.registry.requireCompletionReceipt === true;
|
|
12094
12787
|
const invokeSuite = (suiteCommand, phase, entryId) => {
|
|
12788
|
+
// FR-3: VITEST_MAX_WORKERS is set in any case — regardless of whether the command was
|
|
12789
|
+
// recognised as vitest and got the `--maxWorkers=<n>` flag injected — so a vitest command
|
|
12790
|
+
// reached indirectly (a wrapper script) is still capped.
|
|
12791
|
+
const extraEnv = { VITEST_MAX_WORKERS: String(maxWorkers) };
|
|
12095
12792
|
if (injectedRunner !== undefined) {
|
|
12096
12793
|
return injectedRunner(suiteCommand, {
|
|
12097
12794
|
cwd: copyDir,
|
|
12098
12795
|
timeoutMs: timeout,
|
|
12099
12796
|
phase,
|
|
12797
|
+
env: extraEnv,
|
|
12100
12798
|
...(entryId !== undefined ? { entryId } : {}),
|
|
12101
12799
|
});
|
|
12102
12800
|
}
|
|
12801
|
+
const startedAt = performance.now();
|
|
12103
12802
|
const run = spawnSync(suiteCommand, {
|
|
12104
12803
|
cwd: copyDir,
|
|
12105
12804
|
shell: true,
|
|
12106
12805
|
encoding: 'utf-8',
|
|
12107
12806
|
timeout,
|
|
12108
12807
|
maxBuffer: 64 * 1024 * 1024,
|
|
12109
|
-
env: { ...process.env, FORCE_COLOR: '0' },
|
|
12808
|
+
env: { ...process.env, FORCE_COLOR: '0', ...extraEnv },
|
|
12110
12809
|
});
|
|
12810
|
+
const elapsedMs = Math.round(performance.now() - startedAt);
|
|
12111
12811
|
const errorCode = run.error && 'code' in run.error && typeof run.error.code === 'string'
|
|
12112
12812
|
? run.error.code
|
|
12113
12813
|
: undefined;
|
|
@@ -12118,11 +12818,28 @@ function cmdMutationGate(options, flags, cwd, write, injectedRunner) {
|
|
|
12118
12818
|
throw run.error;
|
|
12119
12819
|
}
|
|
12120
12820
|
const signal = typeof run.signal === 'string' ? run.signal : undefined;
|
|
12821
|
+
// mutation-gate-timeout-verdict FR-1/FR-2: a genuine ETIMEDOUT must NEVER be read as a real
|
|
12822
|
+
// suite verdict, even when the killed child intercepted the kill signal and exited with its
|
|
12823
|
+
// OWN status (e.g. a SIGTERM handler calling `process.exit(1)`) — MEASURED: node still sets
|
|
12824
|
+
// `run.error.code === 'ETIMEDOUT'` in that case, but a numeric `run.status` used to win the
|
|
12825
|
+
// `typeof run.status !== 'number'` check below, so the timeout was silently reported as
|
|
12826
|
+
// "baseline suite RED (exit 1)", indistinguishable from a real red suite. ETIMEDOUT now short
|
|
12827
|
+
// -circuits to `exitCode: null` unconditionally, before that check runs.
|
|
12828
|
+
if (errorCode === 'ETIMEDOUT') {
|
|
12829
|
+
// Codex round-1 (2026-09-14): report BOTH facts when both exist — a trapped SIGTERM that
|
|
12830
|
+
// exits 1 shows `child exit 1; signal SIGTERM`, a plain kill shows `no exit code; signal …`.
|
|
12831
|
+
const exitPart = typeof run.status === 'number' ? `child exit ${run.status}` : 'child produced no exit code';
|
|
12832
|
+
const childExit = signal === undefined ? exitPart : `${exitPart}; signal ${signal}`;
|
|
12833
|
+
const suggestedMs = Math.max(timeout + 1, Math.ceil(elapsedMs * 2));
|
|
12834
|
+
return {
|
|
12835
|
+
exitCode: null,
|
|
12836
|
+
output: `${String(run.stdout ?? '')}\n${String(run.stderr ?? '')}`,
|
|
12837
|
+
failureReason: `timeout after ${timeout}ms (elapsed ${elapsedMs}ms); ${childExit}; try --timeout ${suggestedMs} or the registry's timeoutMs field`,
|
|
12838
|
+
};
|
|
12839
|
+
}
|
|
12121
12840
|
let failureReason;
|
|
12122
12841
|
if (typeof run.status !== 'number') {
|
|
12123
|
-
if (errorCode === '
|
|
12124
|
-
failureReason = `timeout after ${timeout}ms${signal === undefined ? '' : `; signal=${signal}`}`;
|
|
12125
|
-
else if (errorCode === 'ENOBUFS')
|
|
12842
|
+
if (errorCode === 'ENOBUFS')
|
|
12126
12843
|
failureReason = 'maxBuffer exceeded (ENOBUFS; 67108864-byte output ceiling)';
|
|
12127
12844
|
else if (signal !== undefined)
|
|
12128
12845
|
failureReason = `child killed by signal ${signal}`;
|
|
@@ -12384,7 +13101,7 @@ function cmdMutationGate(options, flags, cwd, write, injectedRunner) {
|
|
|
12384
13101
|
}
|
|
12385
13102
|
const exitCode = mutationGateExitCode(results, baseline.ok);
|
|
12386
13103
|
if (json) {
|
|
12387
|
-
write(JSON.stringify({ packageDir: pkgDir, registryPath, testCommand: testCmd, rebaselineMode, baseline, results, summary: summarizeMutationResults(results), warnings, internalRetries, exitCode }, null, 2));
|
|
13104
|
+
write(JSON.stringify({ packageDir: pkgDir, registryPath, testCommand: testCmd, rebaselineMode, baseline, results, summary: summarizeMutationResults(results), warnings, internalRetries, ...(selectionMeta !== null ? { selection: selectionMeta } : {}), exitCode }, null, 2));
|
|
12388
13105
|
return exitCode;
|
|
12389
13106
|
}
|
|
12390
13107
|
write(renderMutationReport(results, baseline, pkgDir));
|
|
@@ -19757,11 +20474,11 @@ export async function runCli(argv, io = {}) {
|
|
|
19757
20474
|
case 'auto-canonicalize':
|
|
19758
20475
|
return await cmdAutoCanonicalize(options, cwd, write);
|
|
19759
20476
|
case 'publish':
|
|
19760
|
-
return cmdPublish(options, flags, cwd, write, io.publishMirrorRunner, io.publishSiblingDriftFetcher, io.publishPackedInstallRunner, io.publishExecRunner);
|
|
20477
|
+
return cmdPublish(options, flags, cwd, write, io.publishMirrorRunner, io.publishSiblingDriftFetcher, io.publishPackedInstallRunner, io.publishExecRunner, io.publishGateAuditFsLayer, io.publishNpmPackRunner);
|
|
19761
20478
|
case 'release':
|
|
19762
20479
|
return cmdRelease(options, flags, cwd, write, io.releaseRunner);
|
|
19763
20480
|
case 'parity':
|
|
19764
|
-
return cmdParity(options, flags, write, writeErr, cwd);
|
|
20481
|
+
return await cmdParity(options, flags, write, writeErr, cwd);
|
|
19765
20482
|
case 'registry':
|
|
19766
20483
|
return cmdRegistry(options, cwd, write);
|
|
19767
20484
|
case 'benchmark':
|