@dzhechkov/harness-core 0.8.33 → 0.8.35
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 +52 -52
- package/README.md +228 -5
- package/dist/agentdb-index.d.ts +39 -7
- package/dist/agentdb-index.d.ts.map +1 -1
- package/dist/agentdb-index.js +217 -23
- package/dist/agentdb-index.js.map +1 -1
- package/dist/apply-leg.d.ts +197 -6
- package/dist/apply-leg.d.ts.map +1 -1
- package/dist/apply-leg.js +858 -46
- package/dist/apply-leg.js.map +1 -1
- package/dist/index.d.ts +7 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -4
- package/dist/index.js.map +1 -1
- package/dist/mutation-gate.d.ts +19 -0
- package/dist/mutation-gate.d.ts.map +1 -1
- package/dist/mutation-gate.js +37 -1
- package/dist/mutation-gate.js.map +1 -1
- package/dist/operations.d.ts +16 -1
- package/dist/operations.d.ts.map +1 -1
- package/dist/operations.js +115 -13
- package/dist/operations.js.map +1 -1
- package/dist/publish-sibling-drift.d.ts +72 -0
- package/dist/publish-sibling-drift.d.ts.map +1 -1
- package/dist/publish-sibling-drift.js +150 -4
- package/dist/publish-sibling-drift.js.map +1 -1
- package/dist/release.d.ts +72 -0
- package/dist/release.d.ts.map +1 -1
- package/dist/release.js +236 -19
- package/dist/release.js.map +1 -1
- package/dist/setup.d.ts.map +1 -1
- package/dist/setup.js +90 -14
- package/dist/setup.js.map +1 -1
- package/dist/skills.d.ts +87 -3
- package/dist/skills.d.ts.map +1 -1
- package/dist/skills.js +266 -15
- package/dist/skills.js.map +1 -1
- package/dist/vector-tier.d.ts +27 -2
- package/dist/vector-tier.d.ts.map +1 -1
- package/dist/vector-tier.js +117 -4
- package/dist/vector-tier.js.map +1 -1
- package/package.json +2 -2
- package/sbom.json +51 -51
- package/src/agentdb-index.ts +223 -24
- package/src/apply-leg.ts +875 -46
- package/src/index.ts +18 -2
- package/src/mutation-gate.ts +58 -2
- package/src/operations.ts +117 -14
- package/src/publish-sibling-drift.ts +209 -4
- package/src/release.ts +263 -17
- package/src/setup.ts +81 -16
- package/src/skills.ts +303 -14
- package/src/vector-tier.ts +157 -5
package/src/index.ts
CHANGED
|
@@ -12,6 +12,12 @@ export const HARNESS_CORE_VERSION: string =
|
|
|
12
12
|
|
|
13
13
|
export { REPOSITORY_ORIGIN } from './repository-origin.js';
|
|
14
14
|
|
|
15
|
+
// Fix-round 1 (feature recall-short-terms, Codex HIGH-1c): `dz recall`'s CLI printer needs the
|
|
16
|
+
// single source of truth for "why did this query tokenize to nothing" without harness-cli taking
|
|
17
|
+
// a new direct dependency on `@dzhechkov/memory` (a publishing-surface change outside this
|
|
18
|
+
// feature's scope) — harness-core already depends on memory, so it re-exports the one helper.
|
|
19
|
+
export { noSearchableTermsReason } from '@dzhechkov/memory';
|
|
20
|
+
|
|
15
21
|
export * from './skills.js';
|
|
16
22
|
export * from './apply.js';
|
|
17
23
|
export {
|
|
@@ -246,6 +252,7 @@ export {
|
|
|
246
252
|
applyLegHookEntries,
|
|
247
253
|
applyLegStatus,
|
|
248
254
|
applyLegReasonMessage,
|
|
255
|
+
probeApplyLeg,
|
|
249
256
|
resolveIdleMs,
|
|
250
257
|
IDLE_MS_INT32_MAX,
|
|
251
258
|
} from './apply-leg.js';
|
|
@@ -255,6 +262,7 @@ export type {
|
|
|
255
262
|
ApplyLegHookPresence,
|
|
256
263
|
ApplyLegStatus,
|
|
257
264
|
ApplyLegNotInstalledReason,
|
|
265
|
+
ApplyLegProbeResult,
|
|
258
266
|
ResolvedIdleMs,
|
|
259
267
|
} from './apply-leg.js';
|
|
260
268
|
// embed-socket-short-path: the ONE resolver the daemon (inlined text), the recall hook (inlined
|
|
@@ -303,7 +311,7 @@ export {
|
|
|
303
311
|
segmentRun,
|
|
304
312
|
} from './eta.js';
|
|
305
313
|
export type { CheckpointObservation, EtaEstimate, EtaInput, IncompleteCoverageSample, RunSegment, StageDurationSample, StageSample } from './eta.js';
|
|
306
|
-
export { indexPatternsToAgentdb, resolveAgentdbPath, searchAgentdbPatterns, listAgentdbDzIds, resolveAgentdbEmbedder, cosineSimilarity, importVectorsToAgentdb, reindexAgentdbRows, bumpAgentdbUses, clearAgentdbQuarantine, deleteAgentdbByDzIds, readAgentdbRowsByTaskType, DZ_OWNED_TASK_TYPES, ensureAgentdbSchema } from './agentdb-index.js';
|
|
314
|
+
export { indexPatternsToAgentdb, resolveAgentdbPath, searchAgentdbPatterns, listAgentdbDzIds, resolveAgentdbEmbedder, resetAgentdbEmbedderCache, getAgentdbEmbedderCacheStats, cosineSimilarity, importVectorsToAgentdb, reindexAgentdbRows, bumpAgentdbUses, clearAgentdbQuarantine, deleteAgentdbByDzIds, readAgentdbRowsByTaskType, DZ_OWNED_TASK_TYPES, ensureAgentdbSchema, readStoreGeneration, bumpStoreGeneration } from './agentdb-index.js';
|
|
307
315
|
export type { AgentdbSearchHit, AgentdbSearchResult, AgentdbImportRow } from './agentdb-index.js';
|
|
308
316
|
export { DEFAULT_EMBED_MODEL, LEGACY_EMBED_MODEL, DEFAULT_EMBED_DIM, KNOWN_EMBED_DIMS, resolveEmbedModel, readEmbedManifest, writeEmbedManifest, embedManifestPath, legacyEmbedManifest } from './embedding-config.js';
|
|
309
317
|
export type { EmbedModelConfig, EmbedModelSource, EmbedManifest } from './embedding-config.js';
|
|
@@ -742,6 +750,8 @@ export {
|
|
|
742
750
|
buildReleaseNotes,
|
|
743
751
|
releaseTagName,
|
|
744
752
|
firstOutputLine,
|
|
753
|
+
testsFailureDetail,
|
|
754
|
+
outputTail,
|
|
745
755
|
RELEASE_GATE_ORDER,
|
|
746
756
|
RELEASE_TIMEOUTS,
|
|
747
757
|
} from './release.js';
|
|
@@ -763,13 +773,19 @@ export type {
|
|
|
763
773
|
} from './release.js';
|
|
764
774
|
export { formatPublishError } from './publish.js';
|
|
765
775
|
// Sibling-drift + packed-install-smoke gates (feature publish-sibling-drift-gate, ADR-001).
|
|
766
|
-
export { detectSiblingDrift } from './publish-sibling-drift.js';
|
|
776
|
+
export { detectSiblingDrift, parseNpmPackInventory } from './publish-sibling-drift.js';
|
|
767
777
|
export type {
|
|
768
778
|
SiblingDriftStatus,
|
|
779
|
+
InventorySource,
|
|
769
780
|
SiblingDriftResult,
|
|
770
781
|
FetchedPublished,
|
|
771
782
|
FetchPublished,
|
|
772
783
|
DetectSiblingDriftOptions,
|
|
784
|
+
PackInventory,
|
|
785
|
+
PackInventoryUnavailable,
|
|
786
|
+
LocalInventoryResult,
|
|
787
|
+
PackedTree,
|
|
788
|
+
LocalInventory,
|
|
773
789
|
} from './publish-sibling-drift.js';
|
|
774
790
|
export { planPackedInstallSmoke, judgePackedInstallSmoke, packedTarballName } from './packed-install-smoke.js';
|
|
775
791
|
export type {
|
package/src/mutation-gate.ts
CHANGED
|
@@ -77,6 +77,22 @@ export interface MutationRegistry {
|
|
|
77
77
|
readonly testCommand?: string;
|
|
78
78
|
/** opt-in proof that the suite harness reached its clean completion path. */
|
|
79
79
|
readonly requireCompletionReceipt?: boolean;
|
|
80
|
+
/**
|
|
81
|
+
* optional per-registry suite-run ceiling in milliseconds (mutation-gate-timeout-verdict FR-3):
|
|
82
|
+
* a package whose real baseline runs longer than the executor's 300000ms default (e.g. this
|
|
83
|
+
* repo's core package, MEASURED ≈5-8 min) declares its own floor here so `dz mutation-gate` with
|
|
84
|
+
* no `--timeout` flag still succeeds — precedence is flag > this field > the 300000ms default.
|
|
85
|
+
*/
|
|
86
|
+
readonly timeoutMs?: number;
|
|
87
|
+
/**
|
|
88
|
+
* optional per-registry vitest worker ceiling (mutation-gate-baseline-honesty FR-2): baseline and
|
|
89
|
+
* mutant runs spawn the package's FULL `testCommand` at vitest's default worker count (= cpu
|
|
90
|
+
* cores), and under embedding-daemon tests (0.7-3.5 GB/process) this repo's core package measured
|
|
91
|
+
* load 62-358 and 0.4-1.8 GB free on an 8-core/16GB box — three full runs died overnight
|
|
92
|
+
* (0bb74d66). The same suite with `--maxWorkers=2` passed (6909/6909). Precedence is the
|
|
93
|
+
* `--max-workers` flag > this field > `min(4, max(1, floor(cpus/2)))`.
|
|
94
|
+
*/
|
|
95
|
+
readonly maxWorkers?: number;
|
|
80
96
|
readonly entries: readonly MutationRegistryEntry[];
|
|
81
97
|
}
|
|
82
98
|
|
|
@@ -306,10 +322,12 @@ export function parseMutationRegistry(text: string): ParsedRegistry {
|
|
|
306
322
|
let entriesRaw: unknown;
|
|
307
323
|
let testCommand: string | undefined;
|
|
308
324
|
let requireCompletionReceipt: boolean | undefined;
|
|
325
|
+
let timeoutMs: number | undefined;
|
|
326
|
+
let maxWorkers: number | undefined;
|
|
309
327
|
if (Array.isArray(raw)) {
|
|
310
328
|
entriesRaw = raw;
|
|
311
329
|
} else if (raw && typeof raw === 'object') {
|
|
312
|
-
const obj = raw as { testCommand?: unknown; requireCompletionReceipt?: unknown; entries?: unknown };
|
|
330
|
+
const obj = raw as { testCommand?: unknown; requireCompletionReceipt?: unknown; timeoutMs?: unknown; maxWorkers?: unknown; entries?: unknown };
|
|
313
331
|
entriesRaw = obj.entries;
|
|
314
332
|
if (obj.testCommand !== undefined) {
|
|
315
333
|
if (typeof obj.testCommand !== 'string' || obj.testCommand.trim() === '') {
|
|
@@ -323,6 +341,18 @@ export function parseMutationRegistry(text: string): ParsedRegistry {
|
|
|
323
341
|
}
|
|
324
342
|
requireCompletionReceipt = obj.requireCompletionReceipt;
|
|
325
343
|
}
|
|
344
|
+
if (obj.timeoutMs !== undefined) {
|
|
345
|
+
if (typeof obj.timeoutMs !== 'number' || !Number.isFinite(obj.timeoutMs) || obj.timeoutMs <= 0) {
|
|
346
|
+
return { registry: null, entryResults: [], errors: ['timeoutMs must be a finite number > 0 when present'] };
|
|
347
|
+
}
|
|
348
|
+
timeoutMs = obj.timeoutMs;
|
|
349
|
+
}
|
|
350
|
+
if (obj.maxWorkers !== undefined) {
|
|
351
|
+
if (typeof obj.maxWorkers !== 'number' || !Number.isInteger(obj.maxWorkers) || obj.maxWorkers < 1) {
|
|
352
|
+
return { registry: null, entryResults: [], errors: ['maxWorkers must be a positive integer when present'] };
|
|
353
|
+
}
|
|
354
|
+
maxWorkers = obj.maxWorkers;
|
|
355
|
+
}
|
|
326
356
|
}
|
|
327
357
|
if (!Array.isArray(entriesRaw)) {
|
|
328
358
|
return { registry: null, entryResults: [], errors: ['registry must be an array of entries or {testCommand?, requireCompletionReceipt?, entries: [...]}'] };
|
|
@@ -439,6 +469,8 @@ export function parseMutationRegistry(text: string): ParsedRegistry {
|
|
|
439
469
|
registry: {
|
|
440
470
|
...(testCommand !== undefined ? { testCommand } : {}),
|
|
441
471
|
...(requireCompletionReceipt !== undefined ? { requireCompletionReceipt } : {}),
|
|
472
|
+
...(timeoutMs !== undefined ? { timeoutMs } : {}),
|
|
473
|
+
...(maxWorkers !== undefined ? { maxWorkers } : {}),
|
|
442
474
|
entries,
|
|
443
475
|
},
|
|
444
476
|
entryResults,
|
|
@@ -446,6 +478,26 @@ export function parseMutationRegistry(text: string): ParsedRegistry {
|
|
|
446
478
|
};
|
|
447
479
|
}
|
|
448
480
|
|
|
481
|
+
// ── Feature scoping (qe-step-gate-scoped-to-feature) — pure registry diff, no IO ───────────────
|
|
482
|
+
//
|
|
483
|
+
// Step 8's QE mutation gate ran the FULL registry unconditionally (MEASURED 2026-09-12: 358
|
|
484
|
+
// entries on this repo's core package, 30-40 minutes, timed out INCONCLUSIVE every time), though a
|
|
485
|
+
// feature only owns its own touched files and any entries it newly declares. The `--touched` and
|
|
486
|
+
// `--added-since` CLI selectors (harness-cli's executor) scope the run; the executor reads the base
|
|
487
|
+
// registry via `git show <ref>:<path>` (I/O) and hands both parsed registries to this PURE diff so
|
|
488
|
+
// the comparison itself stays testable without a filesystem or git process (NFR-1).
|
|
489
|
+
|
|
490
|
+
/** Entry ids present in `current` but absent from `base` (by id, not by content). A `null` base
|
|
491
|
+
* means the registry did not exist at the reference point — every current entry counts as added. */
|
|
492
|
+
export function registryEntriesAddedSince(
|
|
493
|
+
base: MutationRegistry | null,
|
|
494
|
+
current: MutationRegistry,
|
|
495
|
+
): string[] {
|
|
496
|
+
if (base === null) return current.entries.map((entry) => entry.id);
|
|
497
|
+
const baseIds = new Set(base.entries.map((entry) => entry.id));
|
|
498
|
+
return current.entries.filter((entry) => !baseIds.has(entry.id)).map((entry) => entry.id);
|
|
499
|
+
}
|
|
500
|
+
|
|
449
501
|
// ── Mutation application — exact text surgery, exactly once (rule 1) ──────────────────────────
|
|
450
502
|
|
|
451
503
|
export interface AppliedMutation {
|
|
@@ -732,7 +784,11 @@ export function attributeBaselineRedness(
|
|
|
732
784
|
if (file !== null && !files.includes(file)) files.push(file);
|
|
733
785
|
};
|
|
734
786
|
|
|
735
|
-
|
|
787
|
+
// mutation-gate-baseline-honesty FR-1: vitest 3 prints an optional POOL LABEL between `FAIL` and
|
|
788
|
+
// the file path (`FAIL |serial| test/x.test.ts > case`, `FAIL |parallel| …`) — the plain
|
|
789
|
+
// `(\S+)` used to capture the label itself as "the file", which normaliseReportedFile then
|
|
790
|
+
// rejects, turning a perfectly parseable red run into `unparseable from runner output`.
|
|
791
|
+
const vitestMatches = [...output.matchAll(/^\s*FAIL\s+(?:\|[^|\n]*\|\s+)?(\S+)/gm)];
|
|
736
792
|
for (const match of vitestMatches) add(match[1] ?? '');
|
|
737
793
|
|
|
738
794
|
const tapMatches = [...output.matchAll(/^not ok \d+\s+-\s+(.+)$/gm)];
|
package/src/operations.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* @packageDocumentation
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { execFileSync, spawnSync } from 'node:child_process';
|
|
9
|
+
import { execFileSync, spawnSync, type SpawnSyncOptionsWithStringEncoding } from 'node:child_process';
|
|
10
10
|
import { randomBytes } from 'node:crypto';
|
|
11
11
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
12
12
|
import { homedir, tmpdir } from 'node:os';
|
|
@@ -1437,18 +1437,34 @@ export async function runDoctor(options: { projectRoot: string }): Promise<Docto
|
|
|
1437
1437
|
const WRITER_EVENTS = ['SessionStart', 'SessionEnd', 'PreCompact'];
|
|
1438
1438
|
let eventsWithWriter: string[] = [];
|
|
1439
1439
|
let settingsReadable = false;
|
|
1440
|
+
// Lead edit after Codex review (finding 1, 2026-09-13): three states, not a boolean — an
|
|
1441
|
+
// EXISTING settings.json that cannot be parsed is "unknowable", never "agrees".
|
|
1442
|
+
const settingsPath = join(root, '.claude', 'settings.json');
|
|
1443
|
+
const settingsPresent = existsSync(settingsPath);
|
|
1440
1444
|
try {
|
|
1441
|
-
const settings = JSON.parse(readFileSync(
|
|
1445
|
+
const settings = JSON.parse(readFileSync(settingsPath, 'utf-8')) as {
|
|
1442
1446
|
hooks?: Record<string, unknown[]>;
|
|
1443
1447
|
};
|
|
1444
|
-
settingsReadable = true;
|
|
1445
1448
|
eventsWithWriter = WRITER_EVENTS.filter((ev) => (settings.hooks?.[ev] ?? []).some((h) => commandsOf(h).some(invokesWriter)));
|
|
1449
|
+
// Lead edit after Codex round 2 (new finding 2): "readable" means the hooks were actually
|
|
1450
|
+
// INSPECTED — a parseable but malformed shape (`null`, a non-array event) throws inside the
|
|
1451
|
+
// traversal and must land in the "cannot be compared" row, never in the OK row.
|
|
1452
|
+
settingsReadable = true;
|
|
1446
1453
|
} catch {
|
|
1447
1454
|
}
|
|
1455
|
+
const settingsUnreadable = settingsPresent && !settingsReadable;
|
|
1448
1456
|
const allWired = eventsWithWriter.length === WRITER_EVENTS.length;
|
|
1449
1457
|
const hooksInvokeAgentdbWriter = eventsWithWriter.length > 0;
|
|
1450
1458
|
|
|
1451
|
-
if (
|
|
1459
|
+
if (settingsUnreadable) {
|
|
1460
|
+
// Lead edit after Codex round 2: precedence — an unparseable settings.json is diagnosed FIRST for
|
|
1461
|
+
// both backends; the hooks are UNKNOWABLE, so neither "not wired" nor "in agreement" may be claimed.
|
|
1462
|
+
checks.push({
|
|
1463
|
+
name: 'memory hooks match config',
|
|
1464
|
+
ok: false,
|
|
1465
|
+
detail: `.dz/config.json says memory.backend=${configuredMemoryBackend} but .claude/settings.json exists and could not be parsed or inspected (invalid JSON or a malformed hooks shape) — the hooks cannot be compared; fix the file (or re-run dz setup)`,
|
|
1466
|
+
});
|
|
1467
|
+
} else if (configuredMemoryBackend === 'agentdb' && !allWired) {
|
|
1452
1468
|
checks.push({
|
|
1453
1469
|
name: 'memory hooks match config',
|
|
1454
1470
|
ok: false,
|
|
@@ -1460,6 +1476,15 @@ export async function runDoctor(options: { projectRoot: string }): Promise<Docto
|
|
|
1460
1476
|
ok: false,
|
|
1461
1477
|
detail: '.dz/config.json says memory.backend=jsonl but SessionStart/SessionEnd/PreCompact hooks still invoke agentdb-writer.mjs — run: dz setup --target claude-code --memory jsonl (or --memory agentdb to keep agentdb and bring the config back in sync)',
|
|
1462
1478
|
});
|
|
1479
|
+
} else {
|
|
1480
|
+
// the OK receipt comes from the SAME comparison that produces the red rows (all three events observed)
|
|
1481
|
+
checks.push({
|
|
1482
|
+
name: 'memory hooks match config',
|
|
1483
|
+
ok: true,
|
|
1484
|
+
detail: configuredMemoryBackend === 'agentdb'
|
|
1485
|
+
? 'memory.backend=agentdb — SessionStart/SessionEnd/PreCompact all invoke .dz/agentdb-writer.mjs'
|
|
1486
|
+
: `memory.backend=jsonl — SessionStart/SessionEnd/PreCompact do not invoke .dz/agentdb-writer.mjs${settingsPresent ? '' : ' (no .claude/settings.json — no hooks at all)'}`,
|
|
1487
|
+
});
|
|
1463
1488
|
}
|
|
1464
1489
|
}
|
|
1465
1490
|
// configuredMemoryBackend === 'unknown': no .dz/config.json yet — nothing to compare, same
|
|
@@ -1498,13 +1523,52 @@ export async function runDoctor(options: { projectRoot: string }): Promise<Docto
|
|
|
1498
1523
|
resolved.reason === 'tmpdir-short'
|
|
1499
1524
|
? ` (tmpdir-short: project path ${projectPathBytes} bytes > ${EMBED_SOCKET_PATH_BYTES_LIMIT})`
|
|
1500
1525
|
: '';
|
|
1526
|
+
// FR-6 (hook-recall-hybrid-parity): a socket that merely EXISTS is not proof of what it
|
|
1527
|
+
// answers with — probe it. A non-live fixture (a plain file, no listener) fails the probe
|
|
1528
|
+
// near-instantly and this note stays empty, so every pre-existing detail string here is
|
|
1529
|
+
// untouched.
|
|
1530
|
+
// AM-8 (fix round 1): the socket-speaking probe itself now lives in apply-leg.ts (the
|
|
1531
|
+
// module that already owns the daemon's wire protocol + socket-path resolution) rather
|
|
1532
|
+
// than duplicating a `node:net` IO surface here — dynamic `import()`, same pattern as
|
|
1533
|
+
// `embed-socket-path.js` two lines above, so operations.ts carries no new top-level IO
|
|
1534
|
+
// import for this.
|
|
1535
|
+
const { probeRecallEngine } = await import('./apply-leg.js');
|
|
1536
|
+
const engine = sockAlive ? await probeRecallEngine(resolved.path) : undefined;
|
|
1537
|
+
const engineNote = engine !== undefined ? ` (engine: ${engine})` : '';
|
|
1501
1538
|
checks.push({
|
|
1502
1539
|
name: 'apply-leg alive (embed daemon)',
|
|
1503
1540
|
ok: sockAlive,
|
|
1504
1541
|
detail: sockAlive
|
|
1505
|
-
? `embed socket present at ${resolved.path}${tmpdirNote} — recall injection can run`
|
|
1542
|
+
? `embed socket present at ${resolved.path}${tmpdirNote}${engineNote} — recall injection can run`
|
|
1506
1543
|
: `embed socket ABSENT at ${resolved.path}: the recall hook is wired but cannot inject (the hook self-heals on the next prompt; a persistent absence means the daemon cannot start)`,
|
|
1507
1544
|
});
|
|
1545
|
+
|
|
1546
|
+
// APPLY-LEG INJECTS — live probe (feature `apply-leg-never-silent`, ADR-001 Decision 1).
|
|
1547
|
+
// Issue #2's whole defect was that `installed`/`alive` above can ALL read green while the
|
|
1548
|
+
// leg injects nothing in every session but one (a foreign install-root symptom the file-
|
|
1549
|
+
// presence and socket-presence checks above cannot see by construction — they check for
|
|
1550
|
+
// the RIGHT FILES at the RIGHT PATH, never whether the deployed COMMAND actually resolves
|
|
1551
|
+
// to this project's store from a foreign cwd). This row is the difference: it runs the
|
|
1552
|
+
// ACTUAL configured hook command end-to-end and is green ONLY on an observed injection.
|
|
1553
|
+
// Deliberately a SEPARATE try/catch from the socket-alive check above: a probe failure
|
|
1554
|
+
// must never suppress the (already useful) socket-presence row, and vice versa.
|
|
1555
|
+
try {
|
|
1556
|
+
const { probeApplyLeg } = await import('./apply-leg.js');
|
|
1557
|
+
const probe = await probeApplyLeg(root);
|
|
1558
|
+
checks.push({
|
|
1559
|
+
name: 'apply-leg injects (live probe)',
|
|
1560
|
+
ok: probe.ok,
|
|
1561
|
+
detail: probe.ok
|
|
1562
|
+
? `live probe injected its beacon lesson in ${probe.elapsedMs}ms — the recall hook actually finds this project's store`
|
|
1563
|
+
: `installed but silent: ${probe.reason ?? 'unknown'} (probed in ${probe.elapsedMs}ms) — dz setup wrote the hook, but it is not injecting anything into real sessions`,
|
|
1564
|
+
});
|
|
1565
|
+
} catch (err) {
|
|
1566
|
+
checks.push({
|
|
1567
|
+
name: 'apply-leg injects (live probe)',
|
|
1568
|
+
ok: false,
|
|
1569
|
+
detail: `live probe could not run: ${err instanceof Error ? err.message : String(err)}`,
|
|
1570
|
+
});
|
|
1571
|
+
}
|
|
1508
1572
|
}
|
|
1509
1573
|
}
|
|
1510
1574
|
} catch {
|
|
@@ -1815,18 +1879,57 @@ function probeCodexVersion(): string | null {
|
|
|
1815
1879
|
* with the helper's own self-failure note unable to fire because the process never started.
|
|
1816
1880
|
* Grading on file presence would call that "installed".
|
|
1817
1881
|
*/
|
|
1818
|
-
|
|
1882
|
+
/**
|
|
1883
|
+
* `opts` (feature `apply-leg-never-silent`, ADR-001 D1): additive, optional — every pre-existing
|
|
1884
|
+
* 2-arg caller (the Codex veto-hook liveness checks above) is unaffected. `cwd`/`env` let a caller
|
|
1885
|
+
* reproduce the EXACT conditions a real invoking session presents (a foreign cwd, an overridden
|
|
1886
|
+
* `CLAUDE_PROJECT_DIR`) rather than always running from THIS process's own cwd/env — the apply-leg
|
|
1887
|
+
* live probe needs exactly that to prove install-root resolution end-to-end, not merely structurally.
|
|
1888
|
+
* `stdout` is returned alongside `stderr`/`status` for the same reason: a UserPromptSubmit hook's
|
|
1889
|
+
* payload (`hookSpecificOutput.additionalContext`) rides stdout, not stderr (see `probeApplyLeg`'s
|
|
1890
|
+
* own doc comment for the measured stderr-visibility fact this displaces).
|
|
1891
|
+
*/
|
|
1892
|
+
export function probeHookLiveness(
|
|
1893
|
+
command: string,
|
|
1894
|
+
payload: string,
|
|
1895
|
+
opts: { readonly cwd?: string; readonly env?: Readonly<Record<string, string>>; readonly timeoutMs?: number } = {},
|
|
1896
|
+
): { readonly status: number | null; readonly stdout: string; readonly stderr: string } {
|
|
1819
1897
|
const shell = process.env['SHELL'] ?? '/bin/sh';
|
|
1820
1898
|
try {
|
|
1821
|
-
|
|
1899
|
+
// AM-5 (fix round 1, apply-leg-never-silent): `detached: true` puts the shell in its OWN
|
|
1900
|
+
// process GROUP (pgid === its own pid) instead of sharing the caller's — `spawnSync`'s own
|
|
1901
|
+
// timeout kill signals only the DIRECT child (the shell), never anything the shell forked, so a
|
|
1902
|
+
// `node "<hook>" || true` grandchild that is still running when the shell dies is orphaned but
|
|
1903
|
+
// free to keep touching whatever this probe is about to remove (the beacon, the temp cwd).
|
|
1904
|
+
// Killing the NEGATIVE pid below reaches the whole group in one signal. `@types/node`'s
|
|
1905
|
+
// `SpawnSyncOptions` does not DECLARE `detached` (only the async `SpawnOptions` does) — MEASURED
|
|
1906
|
+
// this is a typings gap, not a runtime one: a real child under `spawnSync(..., {detached:true})`
|
|
1907
|
+
// reports its own pgid === its own pid (verified with `ps -o pgid=`), exactly as it would under
|
|
1908
|
+
// async `spawn`. Widened via an inline type intersection rather than `as any` so every OTHER key
|
|
1909
|
+
// stays checked.
|
|
1910
|
+
const spawnOpts: SpawnSyncOptionsWithStringEncoding & { readonly detached?: boolean } = {
|
|
1822
1911
|
input: payload,
|
|
1823
1912
|
encoding: 'utf8',
|
|
1824
|
-
timeout: 20_000,
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1913
|
+
timeout: opts.timeoutMs ?? 20_000,
|
|
1914
|
+
detached: true,
|
|
1915
|
+
...(opts.cwd !== undefined ? { cwd: opts.cwd } : {}),
|
|
1916
|
+
env: { ...process.env, DZ_HOOK_LIVENESS_PROBE: '1', ...(opts.env ?? {}) },
|
|
1917
|
+
};
|
|
1918
|
+
const res = spawnSync(shell, ['-lc', command], spawnOpts);
|
|
1919
|
+
// Belt, run on EVERY outcome (timeout OR a clean, on-time exit): a `node` grandchild can still
|
|
1920
|
+
// be alive in the group even after the shell itself exited normally (e.g. it double-forked or
|
|
1921
|
+
// outlived a `|| true` that already returned). ESRCH — the common, successful case, everything
|
|
1922
|
+
// already exited — is swallowed; this is best-effort cleanup, never a probe failure.
|
|
1923
|
+
if (typeof res.pid === 'number' && res.pid > 0) {
|
|
1924
|
+
try {
|
|
1925
|
+
process.kill(-res.pid, 'SIGKILL');
|
|
1926
|
+
} catch {
|
|
1927
|
+
/* group already gone */
|
|
1928
|
+
}
|
|
1929
|
+
}
|
|
1930
|
+
return { status: res.status, stdout: res.stdout ?? '', stderr: res.stderr ?? '' };
|
|
1828
1931
|
} catch (err) {
|
|
1829
|
-
return { status: null, stderr: String((err as Error)?.message ?? err) };
|
|
1932
|
+
return { status: null, stdout: '', stderr: String((err as Error)?.message ?? err) };
|
|
1830
1933
|
}
|
|
1831
1934
|
}
|
|
1832
1935
|
|
|
@@ -2006,7 +2109,7 @@ export function runSyncCodexHooks(options: CodexHooksSyncOptions = {}): CodexHoo
|
|
|
2006
2109
|
// a home that never opted in (the leg-1 F12 lesson).
|
|
2007
2110
|
if (options.check === true) {
|
|
2008
2111
|
const drift = diffCodexHooks(currentText, entries, manifest);
|
|
2009
|
-
const live = drift.installed && options.liveness !== false ? probeHookLiveness(entries[0]!.command, ALLOWED_PROBE_PAYLOAD) : { status: null, stderr: '' };
|
|
2112
|
+
const live = drift.installed && options.liveness !== false ? probeHookLiveness(entries[0]!.command, ALLOWED_PROBE_PAYLOAD) : { status: null, stdout: '', stderr: '' };
|
|
2010
2113
|
const executable = drift.installed && (options.liveness === false || live.status === 0 || live.status === 2);
|
|
2011
2114
|
// `--check` must report the TRUST axis too. Without it the report said `installed && executable`
|
|
2012
2115
|
// with `trust: 'unknown'`, and the CLI printed a success word for it — the exact G-G/AM-17
|
|
@@ -2105,7 +2208,7 @@ export function runSyncCodexHooks(options: CodexHooksSyncOptions = {}): CodexHoo
|
|
|
2105
2208
|
|
|
2106
2209
|
|
|
2107
2210
|
// (6) LIVENESS: exit 127 is ALLOW to the runtime, so it must never be graded as installed (G-L).
|
|
2108
|
-
const live = options.liveness === false ? { status: 0, stderr: '' } : probeHookLiveness(entries[0]!.command, ALLOWED_PROBE_PAYLOAD);
|
|
2211
|
+
const live = options.liveness === false ? { status: 0, stdout: '', stderr: '' } : probeHookLiveness(entries[0]!.command, ALLOWED_PROBE_PAYLOAD);
|
|
2109
2212
|
const executable = live.status === 0 || live.status === 2;
|
|
2110
2213
|
if (!executable) {
|
|
2111
2214
|
warnings.push(
|
|
@@ -28,10 +28,24 @@
|
|
|
28
28
|
|
|
29
29
|
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
30
30
|
import { createHash } from 'node:crypto';
|
|
31
|
-
import { join, relative } from 'node:path';
|
|
31
|
+
import { join, relative, isAbsolute } from 'node:path';
|
|
32
32
|
|
|
33
33
|
export type SiblingDriftStatus = 'same' | 'drift' | 'unavailable';
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* AM-6 (feature publish-gate-audit-durable): which mechanism produced BOTH sides' file inventory
|
|
37
|
+
* for this comparison — named on every result, never left implicit. `'npm-pack'`: the caller
|
|
38
|
+
* injected {@link DetectSiblingDriftOptions.localInventory} (the CLI's `npm pack --dry-run --json`
|
|
39
|
+
* via {@link parseNpmPackInventory}); the workspace side is exactly what npm will ship, and the
|
|
40
|
+
* published side is hashed by a FULL recursive walk of the already-unpacked tarball (AM-1 — the
|
|
41
|
+
* two sides must be symmetric: "every file npm put there" on one side, "every file npm will put
|
|
42
|
+
* there" on the other). `'readdir-approximation'`: no provider was injected — BOTH sides fall back
|
|
43
|
+
* to the pre-existing `dist`/`files`/`bin` walk ({@link shippedInventoryDirs}), which stays
|
|
44
|
+
* symmetric by construction (same function, same rules, both sides) but can miss a file
|
|
45
|
+
* `.npmignore` excludes or include one npm would never ship.
|
|
46
|
+
*/
|
|
47
|
+
export type InventorySource = 'npm-pack' | 'pnpm-pack' | 'readdir-approximation';
|
|
48
|
+
|
|
35
49
|
export interface SiblingDriftResult {
|
|
36
50
|
readonly name: string;
|
|
37
51
|
readonly version: string;
|
|
@@ -42,6 +56,12 @@ export interface SiblingDriftResult {
|
|
|
42
56
|
readonly missingExports: readonly string[];
|
|
43
57
|
/** Present only when status === 'unavailable'. */
|
|
44
58
|
readonly reason?: string;
|
|
59
|
+
/**
|
|
60
|
+
* AM-6: named per-result (not merely per-call) because `detectSiblingDrift` short-circuits to
|
|
61
|
+
* `'unavailable'` before ever reaching the hashing step for some entries — those still carry the
|
|
62
|
+
* source that WOULD have been used, so a reader never has to guess.
|
|
63
|
+
*/
|
|
64
|
+
readonly inventorySource: InventorySource;
|
|
45
65
|
}
|
|
46
66
|
|
|
47
67
|
export interface FetchedPublished {
|
|
@@ -65,6 +85,21 @@ export interface DetectSiblingDriftOptions {
|
|
|
65
85
|
/** Names being published in THIS batch — they publish fresh, so drift cannot be measured against them. */
|
|
66
86
|
readonly batch: ReadonlySet<string>;
|
|
67
87
|
readonly fetchPublished: FetchPublished;
|
|
88
|
+
/**
|
|
89
|
+
* FR-3 (feature publish-gate-audit-durable): the LOCAL (workspace) package's shipped-file
|
|
90
|
+
* inventory, asked from npm instead of approximated by walking `dist`/`files`/`bin` by hand —
|
|
91
|
+
* `.npmignore` and nested ignore rules make the hand-rolled walk wrong in both directions (a file
|
|
92
|
+
* npm will never ship can still be read off disk, producing a false drift). No production default
|
|
93
|
+
* lives in THIS module — core stays pure (never spawns `npm`, per the core-boundary import
|
|
94
|
+
* ratchet). The CLI runs `npm pack --dry-run --json` and hands the stdout to
|
|
95
|
+
* {@link parseNpmPackInventory}, then passes the resulting closure here; a caller that injects
|
|
96
|
+
* nothing (`undefined`) makes `detectSiblingDrift` fall back to the named
|
|
97
|
+
* `'readdir-approximation'` {@link InventorySource} on BOTH sides (AM-1) — never a silent "no
|
|
98
|
+
* drift".
|
|
99
|
+
*/
|
|
100
|
+
readonly localInventory?: LocalInventory;
|
|
101
|
+
/** Label for the injected provider's source (default `'npm-pack'`); the CLI passes `'pnpm-pack'` for a packed tree. */
|
|
102
|
+
readonly localInventorySource?: InventorySource;
|
|
68
103
|
}
|
|
69
104
|
|
|
70
105
|
function listFilesRecursive(root: string, dir: string): string[] {
|
|
@@ -83,6 +118,120 @@ function sha256(data: string | Buffer): string {
|
|
|
83
118
|
return createHash('sha256').update(data).digest('hex');
|
|
84
119
|
}
|
|
85
120
|
|
|
121
|
+
// ── FR-3 (feature publish-gate-audit-durable): ask npm, don't approximate ──────────────────────
|
|
122
|
+
|
|
123
|
+
/** The exact set of relative paths `npm pack` will ship for a package — no `.npmignore` guessing. */
|
|
124
|
+
export interface PackInventory {
|
|
125
|
+
readonly paths: readonly string[];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** `npm pack --dry-run --json` could not be run or answered in a shape this code cannot use. */
|
|
129
|
+
export interface PackInventoryUnavailable {
|
|
130
|
+
readonly unavailable: string;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Lead fix after the fix-round's live dry-run (2026-09-14 01:02): the workspace side PACKED BY THE
|
|
135
|
+
* LIVE TRANSPORT (`pnpm pack`) and unpacked into `packedDir`. pnpm synthesises a LICENSE from the
|
|
136
|
+
* workspace root into the tarball of a package whose own tree has none; `npm pack --dry-run --json`
|
|
137
|
+
* never lists that file, so a `paths` inventory read every such sibling as "LICENSE only in the
|
|
138
|
+
* published copy" — 2 false drifts (harness-presets, scout) on a tree unchanged since publication.
|
|
139
|
+
* A packed tree is hashed by the SAME full walk as the published side, symmetric by construction.
|
|
140
|
+
*/
|
|
141
|
+
export interface PackedTree {
|
|
142
|
+
readonly packedDir: string;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export type LocalInventoryResult = PackInventory | PackedTree | PackInventoryUnavailable;
|
|
146
|
+
|
|
147
|
+
/** Ask what npm would ship for the package rooted at `dir`. Injected in tests (no subprocess). */
|
|
148
|
+
export type LocalInventory = (dir: string) => LocalInventoryResult;
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* C-1/AM-4: `npm pack --dry-run --json` is a real subprocess call — the CLI caches its result per
|
|
152
|
+
* absolute directory for the lifetime of ONE `dz publish` run (not per package being checked), so
|
|
153
|
+
* a run that checks the same sibling from more than one dependent package packs it only once. Core
|
|
154
|
+
* itself never runs the subprocess or owns the cache (core-boundary import ratchet) — this parser
|
|
155
|
+
* is the pure half only.
|
|
156
|
+
*
|
|
157
|
+
* Parses `npm pack --dry-run --json`'s stdout (an array with one element; `files[]` holds
|
|
158
|
+
* `{path,size,mode}` per shipped path, plus `integrity`/`shasum`/`entryCount`) into the exact set of
|
|
159
|
+
* relative paths npm intends to ship, honouring `.npmignore`/`files`/default-ignore exactly the way
|
|
160
|
+
* a real `npm publish` would. A failure to run, parse, or make sense of the shape — including a
|
|
161
|
+
* malformed individual `files[]` element (AM-5: a corrupt entry is a reason to say the WHOLE
|
|
162
|
+
* inventory is untrustworthy, never a file to silently drop) — is `{ unavailable: reason }`: an
|
|
163
|
+
* input this gate cannot read is a reason to say so, never a silent "nothing to compare".
|
|
164
|
+
*/
|
|
165
|
+
export function parseNpmPackInventory(stdout: string): LocalInventoryResult {
|
|
166
|
+
try {
|
|
167
|
+
const parsed: unknown = JSON.parse(stdout);
|
|
168
|
+
const entry = Array.isArray(parsed) ? (parsed[0] as unknown) : undefined;
|
|
169
|
+
const files = entry !== null && typeof entry === 'object' ? (entry as Record<string, unknown>)['files'] : undefined;
|
|
170
|
+
if (!Array.isArray(files)) return { unavailable: 'npm pack --dry-run --json returned no files[] array' };
|
|
171
|
+
// AM-5 (Codex round-1 finding 6, medium): a malformed element used to be `.filter()`ed out
|
|
172
|
+
// silently — a `files[]` entry npm itself always shapes as `{path,size,mode}` should never fail
|
|
173
|
+
// to parse; if one DOES (missing/non-string `path`, or a non-object element), that is a signal
|
|
174
|
+
// this output cannot be trusted, not a single file to quietly drop from the comparison. Say so.
|
|
175
|
+
const paths: string[] = [];
|
|
176
|
+
for (let i = 0; i < files.length; i++) {
|
|
177
|
+
const f = files[i];
|
|
178
|
+
if (f === null || typeof f !== 'object') {
|
|
179
|
+
return { unavailable: `npm pack --dry-run --json files[${i}] is not an object (got ${JSON.stringify(f)})` };
|
|
180
|
+
}
|
|
181
|
+
const path = (f as Record<string, unknown>)['path'];
|
|
182
|
+
if (typeof path !== 'string' || path === '') {
|
|
183
|
+
return { unavailable: `npm pack --dry-run --json files[${i}].path is missing or not a non-empty string (got ${JSON.stringify(path)})` };
|
|
184
|
+
}
|
|
185
|
+
paths.push(path);
|
|
186
|
+
}
|
|
187
|
+
return { paths };
|
|
188
|
+
} catch (err) {
|
|
189
|
+
return { unavailable: `npm pack --dry-run --json output could not be parsed: ${(err as Error).message.split('\n')[0]}` };
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Hash exactly the paths `npm pack` names (package.json normalized separately, as {@link hashTree} does). */
|
|
194
|
+
/**
|
|
195
|
+
* Codex round-2 (2026-09-14) new findings 1+2: a listed path that is absent, a directory, absolute,
|
|
196
|
+
* or that climbs out of `dir` via `..` used to be SKIPPED silently — a comparison over a listing
|
|
197
|
+
* the tree does not match is not a comparison, it is `unavailable`; and an inventory must never
|
|
198
|
+
* read outside the package directory. Thrown here, turned into an `unavailable` result by the caller.
|
|
199
|
+
*/
|
|
200
|
+
class InventoryListingError extends Error {}
|
|
201
|
+
|
|
202
|
+
function hashTreeFromPaths(dir: string, paths: readonly string[], manifest: Record<string, unknown>): Map<string, string> {
|
|
203
|
+
const map = new Map<string, string>();
|
|
204
|
+
for (const rel of paths) {
|
|
205
|
+
if (rel === 'package.json') continue; // normalized below, not hashed raw
|
|
206
|
+
if (isAbsolute(rel) || rel.split(/[\\/]/).includes('..')) {
|
|
207
|
+
throw new InventoryListingError(`inventory path "${rel}" is absolute or leaves the package directory`);
|
|
208
|
+
}
|
|
209
|
+
const abs = join(dir, rel);
|
|
210
|
+
if (!existsSync(abs)) throw new InventoryListingError(`inventory path "${rel}" does not exist in the workspace copy`);
|
|
211
|
+
if (statSync(abs).isDirectory()) throw new InventoryListingError(`inventory path "${rel}" is a directory, not a file`);
|
|
212
|
+
map.set(rel, sha256(readFileSync(abs)));
|
|
213
|
+
}
|
|
214
|
+
map.set('package.json', sha256(normalizedPackageJsonText(manifest)));
|
|
215
|
+
return map;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* AM-1: hash EVERY file under `dir` (the already-unpacked published tarball) — the literal "full
|
|
220
|
+
* recursive walk of what npm put there" the amendment names, used ONLY as the symmetric partner to
|
|
221
|
+
* {@link hashTreeFromPaths} (i.e. only when a `localInventory` provider is injected). `dir` here is
|
|
222
|
+
* always an extracted tarball, never the workspace tree, so there is no `.npmignore` to consult:
|
|
223
|
+
* everything that exists on disk is, by construction, exactly what npm shipped.
|
|
224
|
+
*/
|
|
225
|
+
function hashTreeFull(dir: string, manifest: Record<string, unknown>): Map<string, string> {
|
|
226
|
+
const map = new Map<string, string>();
|
|
227
|
+
for (const rel of listFilesRecursive(dir, dir)) {
|
|
228
|
+
if (rel === 'package.json') continue; // normalized below, not hashed raw
|
|
229
|
+
map.set(rel, sha256(readFileSync(join(dir, rel))));
|
|
230
|
+
}
|
|
231
|
+
map.set('package.json', sha256(normalizedPackageJsonText(manifest)));
|
|
232
|
+
return map;
|
|
233
|
+
}
|
|
234
|
+
|
|
86
235
|
/**
|
|
87
236
|
* package.json PARSED and validated. `null` (never `undefined`) means "this side cannot be built
|
|
88
237
|
* at all" — AM-3: a missing or unparseable manifest on EITHER side must surface as `unavailable`,
|
|
@@ -208,6 +357,9 @@ function missingExportNames(publishedDir: string, workspaceDir: string): string[
|
|
|
208
357
|
export function detectSiblingDrift(opts: DetectSiblingDriftOptions): SiblingDriftResult[] {
|
|
209
358
|
const results: SiblingDriftResult[] = [];
|
|
210
359
|
const seen = new Set<string>();
|
|
360
|
+
// AM-6: named ONCE per call — every result below (including the short-circuited `unavailable`
|
|
361
|
+
// ones) carries the source that is or would have been used for this comparison.
|
|
362
|
+
const inventorySource: InventorySource = opts.localInventory !== undefined ? (opts.localInventorySource ?? 'npm-pack') : 'readdir-approximation';
|
|
211
363
|
// AM-3: `optionalDependencies` ships and pins EXACTLY like `dependencies`/`peerDependencies` —
|
|
212
364
|
// checking only the first two let a stale optional sibling through untouched (round-1 finding 3).
|
|
213
365
|
const entries = [
|
|
@@ -233,6 +385,7 @@ export function detectSiblingDrift(opts: DetectSiblingDriftOptions): SiblingDrif
|
|
|
233
385
|
changedFiles: [],
|
|
234
386
|
missingExports: [],
|
|
235
387
|
reason: `${dep} is declared workspace:-protocol but is not a known workspace package`,
|
|
388
|
+
inventorySource,
|
|
236
389
|
});
|
|
237
390
|
continue;
|
|
238
391
|
}
|
|
@@ -246,6 +399,7 @@ export function detectSiblingDrift(opts: DetectSiblingDriftOptions): SiblingDrif
|
|
|
246
399
|
changedFiles: [],
|
|
247
400
|
missingExports: [],
|
|
248
401
|
reason: `could not fetch ${dep}@${version} from the registry (network unavailable or the version was not found)`,
|
|
402
|
+
inventorySource,
|
|
249
403
|
});
|
|
250
404
|
continue;
|
|
251
405
|
}
|
|
@@ -264,12 +418,62 @@ export function detectSiblingDrift(opts: DetectSiblingDriftOptions): SiblingDrif
|
|
|
264
418
|
changedFiles: [],
|
|
265
419
|
missingExports: [],
|
|
266
420
|
reason: `${dep}@${version}: package.json in ${side} is missing or not valid JSON — cannot compare`,
|
|
421
|
+
inventorySource,
|
|
267
422
|
});
|
|
268
423
|
continue;
|
|
269
424
|
}
|
|
270
425
|
|
|
271
|
-
|
|
272
|
-
|
|
426
|
+
// FR-3/AM-1: the LOCAL package's inventory comes from npm, not from a hand-rolled dist/files/bin
|
|
427
|
+
// walk — `.npmignore` (and nested ignore rules) can exclude a file this gate would otherwise walk
|
|
428
|
+
// straight into, producing a false drift about a file npm was never going to ship. AM-1 (Codex
|
|
429
|
+
// review, round-1 finding 3, high): the two sides must stay SYMMETRIC. With a provider injected,
|
|
430
|
+
// the workspace side is npm's OWN shipped-path list; the published side must then be hashed by a
|
|
431
|
+
// FULL recursive walk of the already-unpacked tarball (every file npm actually put there —
|
|
432
|
+
// README/LICENSE included, since npm auto-packs those regardless of `files`), not the narrower
|
|
433
|
+
// `dist`/`files`/`bin` approximation `hashTree` uses — that approximation would silently OMIT an
|
|
434
|
+
// auto-packed README/LICENSE from the published side while the workspace side (via real `npm
|
|
435
|
+
// pack`) correctly includes them, reading as a false "only in workspace" drift. WITHOUT a
|
|
436
|
+
// provider, core has no way to ask npm on either side, so it degrades to the SAME approximation
|
|
437
|
+
// on BOTH sides (symmetry preserved, just cruder) — a named approximation, never a subprocess.
|
|
438
|
+
let workspaceHashes: Map<string, string>;
|
|
439
|
+
let publishedHashes: Map<string, string>;
|
|
440
|
+
if (opts.localInventory !== undefined) {
|
|
441
|
+
const localResult = opts.localInventory(workspaceDir);
|
|
442
|
+
if ('unavailable' in localResult) {
|
|
443
|
+
results.push({
|
|
444
|
+
name: dep,
|
|
445
|
+
version,
|
|
446
|
+
status: 'unavailable',
|
|
447
|
+
changedFiles: [],
|
|
448
|
+
missingExports: [],
|
|
449
|
+
reason: `${dep}@${version}: local package inventory unavailable (${localResult.unavailable})`,
|
|
450
|
+
inventorySource,
|
|
451
|
+
});
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
try {
|
|
455
|
+
workspaceHashes = 'packedDir' in localResult
|
|
456
|
+
? hashTreeFull(localResult.packedDir, workspaceManifest)
|
|
457
|
+
: hashTreeFromPaths(workspaceDir, localResult.paths, workspaceManifest);
|
|
458
|
+
} catch (err) {
|
|
459
|
+
if (!(err instanceof InventoryListingError)) throw err;
|
|
460
|
+
results.push({
|
|
461
|
+
name: dep,
|
|
462
|
+
version,
|
|
463
|
+
status: 'unavailable',
|
|
464
|
+
changedFiles: [],
|
|
465
|
+
missingExports: [],
|
|
466
|
+
reason: `${dep}@${version}: local package inventory unusable (${err.message})`,
|
|
467
|
+
inventorySource,
|
|
468
|
+
});
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
471
|
+
publishedHashes = hashTreeFull(fetched.dir, publishedManifest);
|
|
472
|
+
} else {
|
|
473
|
+
workspaceHashes = hashTree(workspaceDir, workspaceManifest);
|
|
474
|
+
publishedHashes = hashTree(fetched.dir, publishedManifest);
|
|
475
|
+
}
|
|
476
|
+
|
|
273
477
|
const allKeys = new Set<string>([...publishedHashes.keys(), ...workspaceHashes.keys()]);
|
|
274
478
|
const changed: string[] = [];
|
|
275
479
|
for (const key of allKeys) {
|
|
@@ -278,7 +482,7 @@ export function detectSiblingDrift(opts: DetectSiblingDriftOptions): SiblingDrif
|
|
|
278
482
|
changed.sort();
|
|
279
483
|
|
|
280
484
|
if (changed.length === 0) {
|
|
281
|
-
results.push({ name: dep, version, status: 'same', changedFiles: [], missingExports: [] });
|
|
485
|
+
results.push({ name: dep, version, status: 'same', changedFiles: [], missingExports: [], inventorySource });
|
|
282
486
|
} else {
|
|
283
487
|
results.push({
|
|
284
488
|
name: dep,
|
|
@@ -286,6 +490,7 @@ export function detectSiblingDrift(opts: DetectSiblingDriftOptions): SiblingDrif
|
|
|
286
490
|
status: 'drift',
|
|
287
491
|
changedFiles: changed,
|
|
288
492
|
missingExports: missingExportNames(fetched.dir, workspaceDir),
|
|
493
|
+
inventorySource,
|
|
289
494
|
});
|
|
290
495
|
}
|
|
291
496
|
}
|