@dailephd/my-dev-kit-lab 0.9.3 → 0.9.4

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/CHANGELOG.md CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
  All notable changes to my-dev-kit-lab are documented here.
4
4
 
5
+ ## [0.9.4] - 2026-10-23
6
+
7
+ ### Kit schema-3 and npm 12 compatibility
8
+
9
+ - Accepted my-dev-kit symbol-index schema `3` in addition to schema `2` for the `incremental-change-staleness` affected-neighborhood evidence. Schema-3 symbols use their persisted qualified `nodeId`; identities are not reconstructed from paths and simple names. Same-name symbols in one file stay distinct, ambiguous names are reported as ambiguous, and missing or mismatched identities stay unresolved. Other schema values are rejected, and schema-2 results are unchanged.
10
+ - Package content checks and `npm run verify:packed-package` accept the npm 11 and npm 12 `npm pack --json` formats through one validator that requires exactly one package, a nonempty file list, and safe relative paths.
11
+ - Corrected the packed-package verifier so a symlink or junction invocation runs, early failures print `PACKED_PACKAGE_VERDICT: FAIL`, and verified failures exit nonzero.
12
+ - Corrected the npm 12 output-prefix assumption in the target `test:security` test without weakening its assertions.
13
+ - Patched vulnerable development-only dependencies by pinning Vite to 7.3.7 and Vitest to 4.1.12 (with `@vitest/mocker` 4.1.12), which clears the dependency-audit advisories GHSA-587h-7wmj-h5pm, GHSA-qxg6-89mg-rqv8, and GHSA-xp26-3454-mjgp. The published runtime dependencies and package contents are unchanged.
14
+ - Security-verdict policy, report and execution schemas, commands, and package contents are unchanged.
15
+
5
16
  ## [0.9.3] - 2026-10-16
6
17
 
7
18
  - Improved security-validator runtime evidence and secret-scan accuracy while retaining bounded proof and candidate-versus-confirmed finding distinctions.
package/README.md CHANGED
@@ -6,21 +6,23 @@ my-dev-kit provides local repository indexing and graph-guided retrieval. my-dev
6
6
 
7
7
  Whole-ecosystem workflows are centralized in [my-dev-kit/docs/ECOSYSTEM_DEVELOPMENT_WORKFLOWS.md](https://github.com/dailephd/my-dev-kit/blob/main/docs/ECOSYSTEM_DEVELOPMENT_WORKFLOWS.md). Its [command-surface compatibility map](https://github.com/dailephd/my-dev-kit/blob/main/docs/ECOSYSTEM_DEVELOPMENT_WORKFLOWS.md#915-command-surface-compatibility-map) documents which Lab outputs can safely seed my-dev-kit investigation, Observer reference work, or Orchestrator evidence, and which look-alike artifacts are not direct inputs.
8
8
 
9
- Version v0.9.3 adds hardened prompt contracts and output validation, extensible reports with metric evidence, broader gallery inputs, and prompt-template comparison. Security validation and verdict strictness remain unchanged. The v0.4.5 context-integrity evaluation remains intentionally frozen against the published `@dailephd/my-dev-kit@1.10.4` and `@dailephd/my-dev-kit-orchestrator@1.2.3` contracts; those versions are historical validation baselines, not a statement that they are the ecosystem's current releases. See [docs/CURRENT_STATE.md](docs/CURRENT_STATE.md) for current state.
9
+ The v0.9.3 release added hardened prompt contracts and output validation, extensible reports with metric evidence, broader gallery inputs, and prompt-template comparison. The v0.4.5 context-integrity evaluation remains intentionally frozen against the published `@dailephd/my-dev-kit@1.10.4` and `@dailephd/my-dev-kit-orchestrator@1.2.3` contracts; those versions are historical validation baselines, not a statement that they are the ecosystem's current releases. See [docs/CURRENT_STATE.md](docs/CURRENT_STATE.md) for current state.
10
10
 
11
11
  v0.9.0 adds the `agent-success-rate` plugin, the eighth registered experiment plugin, which evaluates implementation patches under the `raw-full-file` and `context-pack` treatments.
12
12
 
13
- v0.9.2 is the previous package release.
13
+ v0.9.3 is the previous package release.
14
14
 
15
15
  The v0.9.2 release corrected external npm package entrypoint validation, preserved strict Lab self-package validation, eliminated nine inherited false positives, corrected Semgrep failed/incomplete-scan evidence, and included campaign filesystem identity, path-safety, and cross-platform corrections. Security verdict strictness is unchanged. The v0.9.2 release-document state is final; actual npm publication is established separately through registry evidence.
16
16
 
17
17
  The v0.9.3 release adds configurable Codex/Claude prompt contracts, strict structured-diff output handling, generic report sections, HTML metric evidence and caveats, source-based gallery items including tutorial manifests, and prompt-template comparison. See [docs/CURRENT_STATE.md](docs/CURRENT_STATE.md) for release and validation details.
18
18
 
19
+ The v0.9.4 release supports my-dev-kit symbol-index schema 3 alongside schema 2, preserves qualified symbol identities, and handles ambiguous names conservatively. Package inventory validation supports npm 11 and npm 12 formats, and the packed-package verifier reports early failures reliably. Development-only Vite and Vitest dependencies are patched; security-verdict policy is unchanged. See [docs/CURRENT_STATE.md](docs/CURRENT_STATE.md), [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md), [docs/COMMANDS.md](docs/COMMANDS.md), and [docs/WORKFLOWS.md](docs/WORKFLOWS.md).
20
+
19
21
  v0.4.6 adds a supported `my-dev-kit-lab` installed CLI router (`--help`, `--version`, `security validate`, `audit`, the `experiment` family, `report render`, `plots generate`, `gallery build`, `demo final`, and the historical direct final-demo invocation form), a writable lab workspace model kept separate from the installed package and the inspected target, and a permanent packed-tarball installation/execution acceptance gate (`npm run verify:packed-package`). See [docs/ROADMAP.md](docs/ROADMAP.md) and [docs/CURRENT_STATE.md](docs/CURRENT_STATE.md) for status detail and [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the runtime path model. The "Installed CLI" section below documents the shipped command surface; the source-checkout `npm run` workflow in Quickstart remains available for contributors.
20
22
 
21
23
  ## Current capabilities
22
24
 
23
- Package metadata is `0.9.3`. The v0.9.3 release adds configurable Codex/Claude prompt contracts, strict structured-diff output handling, generic report sections, HTML metric evidence and caveats, source-based gallery items including tutorial manifests, and prompt-template comparison. Registered metrics have evidence-based glossary definitions and links, and prompt comparison validates persisted identity and checkpoint evidence. Real-provider improvement remains NOT_VERIFIED because no paid smoke was authorized; its measurement is a separate opt-in empirical objective, not a v0.9.3 release gate. Bounded tool-use remains prompt guidance, not provider enforcement. See [Current State](docs/CURRENT_STATE.md) and [the roadmap](docs/ROADMAP.md).
25
+ Package metadata is `0.9.4`. The v0.9.4 release supports my-dev-kit symbol-index schema 3 alongside schema 2, preserves qualified identities, handles ambiguity conservatively, supports npm 11 and npm 12 package inventories, corrects packed-package verifier behavior, and patches development-only security dependencies. Security-verdict policy is unchanged. The v0.9.3 release added configurable Codex/Claude prompt contracts, strict structured-diff output handling, generic report sections, HTML metric evidence and caveats, source-based gallery items including tutorial manifests, and prompt-template comparison. Registered metrics have evidence-based glossary definitions and links, and prompt comparison validates persisted identity and checkpoint evidence. Real-provider improvement remains NOT_VERIFIED because no paid smoke was authorized; its measurement is a separate opt-in empirical objective, not a v0.9.3 release gate. Bounded tool-use remains prompt guidance, not provider enforcement. See [Current State](docs/CURRENT_STATE.md) and [the roadmap](docs/ROADMAP.md).
24
26
 
25
27
  - **Run context-strategy experiments:** compare `raw-full-file` with `my-dev-kit-guided` using deterministic fixtures or locally configured Codex and Claude CLIs.
26
28
  - **Measure warm-index reuse (v0.5.0–v0.5.1, released):** the `warm-index-reuse` experiment plugin builds one my-dev-kit index per benchmark project, reuses it across that project's tasks, and compares every task with a matched `raw-full-file` baseline. Reports separate one-time index-build cost from per-task retrieval cost, show amortized index cost and cumulative measurements, and include deterministic fake-agent correctness and token evidence; `plots generate` renders four warm-index SVG charts. The v0.5.1 release adds a dedicated 12-task benchmark corpus (`benchmarks/contracts/warm-index-benchmark-cases.json`: six medium and six large/mixed tasks tagged `localized`, `cross-module`, or `broad-change`) for comparing warm-index behavior as task count grows. Estimated context tokens (a character-based context-size estimate) and fake-agent total tokens (simulated harness telemetry) are reported separately, and neither is provider billing telemetry.
@@ -4,7 +4,13 @@ import { compareCodeUnits, interpretIndexManifest, readJson } from "./indexSnaps
4
4
  export const AFFECTED_NEIGHBORHOOD_GRAPH_EVIDENCE_SCHEMA_VERSION = "my-dev-kit-lab-affected-neighborhood-graph-evidence-v1";
5
5
  export const AFFECTED_NEIGHBORHOOD_SEED_MAPPING_SCHEMA_VERSION = "my-dev-kit-lab-affected-neighborhood-seed-mapping-v1";
6
6
  const MANIFEST_FILE = "manifest.json";
7
- const SUPPORTED_SYMBOL_INDEX_SCHEMA_VERSION = "2";
7
+ /**
8
+ * Schema 2 (my-dev-kit 1.13.x) identifies a symbol by file path + name. Schema 3 (my-dev-kit 1.14.x)
9
+ * additionally persists each symbol's actual graph `nodeId`, which may be a distinct `symbolq:` identity
10
+ * for same-named members. Both are understood; anything else is rejected.
11
+ */
12
+ const SUPPORTED_SYMBOL_INDEX_SCHEMA_VERSIONS = ["2", "3"];
13
+ const SCHEMA_WITH_PERSISTED_SYMBOL_NODE_IDS = "3";
8
14
  const SUPPORTED_CODE_GRAPH_ARTIFACT_KIND = "code-graph";
9
15
  const SUPPORTED_CODE_GRAPH_VERSION_PATTERN = /^1\./;
10
16
  // Same bounds as the v0.6.0 freshness/snapshot evidence lists.
@@ -57,18 +63,23 @@ function sortWarnings(warnings) {
57
63
  return [...warnings].sort((left, right) => compareCodeUnits(left.code, right.code) || compareCodeUnits(left.subject ?? "", right.subject ?? "") || compareCodeUnits(left.message, right.message));
58
64
  }
59
65
  /**
60
- * Policy: reads indexed-file membership and baseline symbols from a symbol-index artifact. Only
61
- * schema version 2 is understood. A file entry whose symbol list is malformed keeps its
62
- * membership but yields no symbol record; a malformed symbol entry is dropped with a warning.
63
- * Pure.
66
+ * Policy: reads indexed-file membership and baseline symbol identities from a symbol-index artifact.
67
+ * Schema versions 2 and 3 are understood; anything else is unsupported. Under schema 2 a symbol's
68
+ * identity is its upstream file+name node ID. Under schema 3 it is the `nodeId` the index persisted,
69
+ * which is never reconstructed from the simple name: same-named members keep distinct identities,
70
+ * and a record without a usable `nodeId` stays visible as an unmappable identity with a warning
71
+ * instead of receiving a guessed one. A file entry whose symbol list is malformed keeps its
72
+ * membership but yields no symbol record; a malformed symbol entry is dropped with a warning. Pure.
64
73
  */
65
74
  export function interpretSymbolIndexForNeighborhood(symbolIndex) {
66
75
  if (!isRecord(symbolIndex)) {
67
76
  return { ok: false, message: "Symbol index is not a JSON object." };
68
77
  }
69
- if (symbolIndex.schemaVersion !== SUPPORTED_SYMBOL_INDEX_SCHEMA_VERSION) {
70
- return { ok: false, message: `Symbol index schemaVersion is not the supported ${SUPPORTED_SYMBOL_INDEX_SCHEMA_VERSION}.` };
78
+ const schemaVersion = symbolIndex.schemaVersion;
79
+ if (typeof schemaVersion !== "string" || !SUPPORTED_SYMBOL_INDEX_SCHEMA_VERSIONS.includes(schemaVersion)) {
80
+ return { ok: false, message: `Symbol index schemaVersion is not a supported version (${SUPPORTED_SYMBOL_INDEX_SCHEMA_VERSIONS.join(", ")}).` };
71
81
  }
82
+ const persistsNodeIds = schemaVersion === SCHEMA_WITH_PERSISTED_SYMBOL_NODE_IDS;
72
83
  if (!Array.isArray(symbolIndex.files)) {
73
84
  return { ok: false, message: "Symbol index does not list indexed files." };
74
85
  }
@@ -92,29 +103,57 @@ export function interpretSymbolIndexForNeighborhood(symbolIndex) {
92
103
  });
93
104
  continue;
94
105
  }
95
- const seen = new Set();
106
+ const identities = new Map();
96
107
  const duplicates = new Set();
108
+ const missingNodeIdNames = new Set();
97
109
  for (const symbol of entry.symbols) {
98
110
  if (!isRecord(symbol) || !nonEmptyString(symbol.name)) {
99
111
  warnings.push({ code: "invalid-symbol-entry", subject: boundedText(filePath), message: "Symbol index entry lacks a symbol name and was ignored." });
100
112
  continue;
101
113
  }
102
- if (seen.has(symbol.name)) {
103
- duplicates.add(symbol.name);
114
+ const name = symbol.name;
115
+ let identity;
116
+ let identityKey;
117
+ if (!persistsNodeIds) {
118
+ identity = { name, nodeId: symbolNodeId(filePath, name), persisted: false };
119
+ identityKey = identity.nodeId;
120
+ }
121
+ else if (nonEmptyString(symbol.nodeId)) {
122
+ identity = { name, nodeId: symbol.nodeId, persisted: true };
123
+ identityKey = `id:${symbol.nodeId}`;
104
124
  }
105
- seen.add(symbol.name);
125
+ else {
126
+ // No persisted identity: one unmappable identity per name, never a reconstructed one.
127
+ identity = { name, nodeId: null, persisted: true };
128
+ identityKey = `missing:${name}`;
129
+ missingNodeIdNames.add(name);
130
+ }
131
+ if (identities.has(identityKey)) {
132
+ duplicates.add(name);
133
+ continue;
134
+ }
135
+ identities.set(identityKey, identity);
106
136
  }
107
- records.set(filePath, {
108
- symbolNames: [...seen].sort(compareCodeUnits),
109
- duplicateSymbolNames: [...duplicates].sort(compareCodeUnits),
110
- });
137
+ for (const name of [...missingNodeIdNames].sort(compareCodeUnits)) {
138
+ warnings.push({
139
+ code: "symbol-node-id-missing",
140
+ subject: boundedText(`${filePath}#${name}`),
141
+ message: "Schema-3 symbol record has no usable persisted nodeId; its identity is not guessed and it cannot be mapped to a graph node.",
142
+ });
143
+ }
144
+ const sortedRecords = [...identities.values()].sort((left, right) => compareCodeUnits(left.nodeId ?? "", right.nodeId ?? "") || compareCodeUnits(left.name, right.name));
145
+ const names = [...new Set(sortedRecords.map((record) => record.name))].sort(compareCodeUnits);
146
+ const sameName = names.filter((name) => sortedRecords.filter((record) => record.name === name).length > 1);
147
+ // Schema-2 evidence keeps its original shape: each name is its only identity, so no per-record data is added.
148
+ const fileRecord = { symbolNames: names, duplicateSymbolNames: [...duplicates].sort(compareCodeUnits) };
149
+ records.set(filePath, persistsNodeIds ? { ...fileRecord, records: sortedRecords, sameNameSymbolNames: sameName } : fileRecord);
111
150
  }
112
151
  const indexedFilePaths = [...paths].sort(compareCodeUnits);
113
152
  const symbolFiles = new Map();
114
153
  for (const key of [...records.keys()].sort(compareCodeUnits)) {
115
154
  symbolFiles.set(key, records.get(key));
116
155
  }
117
- return { ok: true, value: { indexedFilePaths, symbolFiles, warnings } };
156
+ return { ok: true, value: { schemaVersion, indexedFilePaths, symbolFiles, warnings } };
118
157
  }
119
158
  /**
120
159
  * Policy: validates the structure of a code-graph artifact and preserves its nodes and edges by
@@ -234,7 +273,7 @@ export async function loadAffectedNeighborhoodGraphEvidence(options) {
234
273
  }
235
274
  const symbolIndexSummary = {
236
275
  path: normalizePath(path.relative(path.resolve(indexDir), symbolIndexFile)),
237
- schemaVersion: SUPPORTED_SYMBOL_INDEX_SCHEMA_VERSION,
276
+ schemaVersion: symbols.value.schemaVersion,
238
277
  };
239
278
  const warnings = [...symbols.value.warnings];
240
279
  let codeGraphSummary = null;
@@ -310,10 +349,35 @@ function lookupNode(evidence, expectedNodeId, kind) {
310
349
  export function lookupFileNode(evidence, filePath) {
311
350
  return lookupNode(evidence, fileNodeId(filePath), "file");
312
351
  }
313
- /** Same-file symbol lookup. Never searches for a same-named symbol in another file. */
352
+ /**
353
+ * Same-file symbol lookup by the upstream file+name identity. Never searches for a same-named symbol
354
+ * in another file. This is the schema-2 identity only; it does not find a qualified (`symbolq:`) member.
355
+ */
314
356
  export function lookupSymbolNode(evidence, filePath, symbolName) {
315
357
  return lookupNode(evidence, symbolNodeId(filePath, symbolName), "symbol");
316
358
  }
359
+ /**
360
+ * Exact lookup of one baseline symbol identity. The node must exist under the identity the index
361
+ * actually recorded, be a `symbol` node, and (when the graph names a path) belong to the same
362
+ * file; otherwise it is unresolved. A record without a persisted identity is never mapped, and no
363
+ * identity is derived from its name.
364
+ */
365
+ export function lookupSymbolRecordNode(evidence, filePath, record) {
366
+ if (record.nodeId === null)
367
+ return { ok: false, expectedNodeId: "", reason: "symbol-node-id-missing" };
368
+ const lookup = lookupNode(evidence, record.nodeId, "symbol");
369
+ if (!lookup.ok)
370
+ return lookup;
371
+ const node = evidence.nodes.get(lookup.nodeId);
372
+ if (node && node.path !== null && normalizePath(node.path) !== filePath) {
373
+ return { ok: false, expectedNodeId: record.nodeId, reason: "graph-node-path-mismatch" };
374
+ }
375
+ return lookup;
376
+ }
377
+ /** The baseline identities of one indexed file; evidence without per-record data keeps the schema-2 file+name identity. */
378
+ function symbolRecordsOf(filePath, file) {
379
+ return file.records ?? file.symbolNames.map((name) => ({ name, nodeId: symbolNodeId(filePath, name), persisted: false }));
380
+ }
317
381
  /**
318
382
  * Maps confirmed changed indexed files, and the baseline symbols belonging to them, onto baseline
319
383
  * graph nodes. Pure and deterministic: consumes only the v0.6.0 snapshot and freshness assessment
@@ -404,18 +468,21 @@ export function mapAffectedNeighborhoodSeeds(input) {
404
468
  continue;
405
469
  }
406
470
  if (record.duplicateSymbolNames.length > 0) {
407
- warn("duplicate-symbol-records", `Baseline symbol index lists ${record.duplicateSymbolNames.length} symbol name(s) more than once; each counts as one identity.`, filePath);
471
+ warn("duplicate-symbol-records", `Baseline symbol index repeats ${record.duplicateSymbolNames.length} identical symbol record(s); each counts as one identity.`, filePath);
408
472
  }
409
- for (const name of record.symbolNames) {
473
+ // Each distinct baseline identity is a seed in its own right: same-named members that the index
474
+ // persisted with different node IDs are never merged, and none is reconstructed from the name.
475
+ for (const identity of symbolRecordsOf(filePath, record)) {
476
+ const name = identity.name;
410
477
  symbolIdentityCount += 1;
411
- const symbolExpectedId = symbolNodeId(filePath, name);
478
+ const symbolExpectedId = identity.nodeId ?? "";
412
479
  let symbolResolvedId = null;
413
480
  let symbolReason = null;
414
481
  if (!codeGraphLoaded) {
415
482
  symbolReason = "graph-unavailable";
416
483
  }
417
484
  else {
418
- const lookup = lookupSymbolNode(graph, filePath, name);
485
+ const lookup = lookupSymbolRecordNode(graph, filePath, identity);
419
486
  if (lookup.ok)
420
487
  symbolResolvedId = lookup.nodeId;
421
488
  else
@@ -426,7 +493,12 @@ export function mapAffectedNeighborhoodSeeds(input) {
426
493
  seeds.add(symbolResolvedId);
427
494
  }
428
495
  else {
429
- unresolved.push({ subject: "changed-symbol", path: boundedText(filePath), name: boundedText(name), reason: symbolReason });
496
+ unresolved.push({
497
+ subject: "changed-symbol",
498
+ path: boundedText(filePath),
499
+ name: boundedText(name),
500
+ reason: symbolReason,
501
+ });
430
502
  }
431
503
  changedSymbols.push({
432
504
  path: filePath,
@@ -490,11 +562,41 @@ function uniqueSorted(values) {
490
562
  function normalizeExpectedPath(value) {
491
563
  return normalizePath(value).replace(/^(\.\/)+/, "");
492
564
  }
565
+ /**
566
+ * Candidate graph nodes for one expected symbol in one expected file. A request matches a baseline
567
+ * identity by its simple name or by that identity's exact persisted node ID; every matching identity
568
+ * is a distinct candidate (same-named members are never collapsed). A file the symbol index has no
569
+ * record for keeps the schema-2 file+name lookup. Nothing is derived from the name beyond that.
570
+ */
571
+ function resolveExpectedSymbolInFile(graph, filePath, request) {
572
+ const file = graph.symbolFiles.get(filePath);
573
+ if (!file) {
574
+ const lookup = lookupSymbolNode(graph, filePath, request);
575
+ return { nodeIds: lookup.ok ? [lookup.nodeId] : [], identityMissing: false, pathMismatch: !lookup.ok && lookup.reason === "graph-node-path-mismatch" };
576
+ }
577
+ const nodeIds = [];
578
+ let identityMissing = false;
579
+ let pathMismatch = false;
580
+ for (const identity of symbolRecordsOf(filePath, file)) {
581
+ if (identity.name !== request && identity.nodeId !== request)
582
+ continue;
583
+ const lookup = lookupSymbolRecordNode(graph, filePath, identity);
584
+ if (lookup.ok)
585
+ nodeIds.push(lookup.nodeId);
586
+ else if (lookup.reason === "symbol-node-id-missing")
587
+ identityMissing = true;
588
+ else if (lookup.reason === "graph-node-path-mismatch")
589
+ pathMismatch = true;
590
+ }
591
+ return { nodeIds, identityMissing, pathMismatch };
592
+ }
493
593
  /**
494
594
  * Policy: maps a task's expected files and symbols to baseline graph nodes. Files resolve only to
495
595
  * their exact `file:` node. A symbol resolves only within the task's own expected files and only
496
- * when exactly one same-name node exists there; zero matches is unresolved and several is
497
- * ambiguous (no winner is picked, no node is added). Never searches the rest of the graph. Pure.
596
+ * when exactly one matching identity exists there; zero matches is unresolved and several (in one
597
+ * file or across files, including same-named members with distinct persisted node IDs) is
598
+ * ambiguous (no winner is picked, no node is added). A request that is itself a persisted node ID
599
+ * resolves to exactly that identity. Never searches the rest of the graph. Pure.
498
600
  */
499
601
  export function mapAffectedNeighborhoodTask(input) {
500
602
  const { graph } = input;
@@ -561,15 +663,21 @@ export function mapAffectedNeighborhoodTask(input) {
561
663
  const resolvedSymbols = [];
562
664
  for (const name of expectedSymbols) {
563
665
  // Scope is the task's expected files, whether or not their own file node resolved.
564
- const candidates = uniqueSorted(expectedFiles.flatMap((filePath) => {
565
- const lookup = lookupSymbolNode(graph, filePath, name);
566
- return lookup.ok ? [lookup.nodeId] : [];
567
- }));
568
- if (candidates.length === 1) {
666
+ const found = expectedFiles.map((filePath) => resolveExpectedSymbolInFile(graph, filePath, name));
667
+ const candidates = uniqueSorted(found.flatMap((entry) => entry.nodeIds));
668
+ // A same-name identity that persisted no node ID cannot be excluded, so a lone mappable candidate
669
+ // is not trusted as "the" owner when such a sibling exists.
670
+ const identityMissing = found.some((entry) => entry.identityMissing);
671
+ if (candidates.length === 1 && !identityMissing) {
569
672
  resolvedSymbols.push({ name, nodeId: candidates[0] });
570
673
  }
571
- else if (candidates.length === 0) {
572
- unresolved.push({ subject: "expected-symbol", name: boundedText(name), reason: "graph-node-missing" });
674
+ else if (candidates.length <= 1) {
675
+ const reason = identityMissing
676
+ ? "symbol-node-id-missing"
677
+ : found.some((entry) => entry.pathMismatch)
678
+ ? "graph-node-path-mismatch"
679
+ : "graph-node-missing";
680
+ unresolved.push({ subject: "expected-symbol", name: boundedText(name), reason });
573
681
  }
574
682
  else {
575
683
  ambiguous.push({ name: boundedText(name), candidateNodeIds: candidates });
@@ -14,27 +14,13 @@ export function parseNpmPackDryRun(stdout) {
14
14
  return { files: [], parseError: "npm pack --dry-run produced no output" };
15
15
  }
16
16
  if (/^[\[{]/.test(stdout.trim())) {
17
- try {
18
- const inventory = JSON.parse(stdout);
19
- if (!Array.isArray(inventory) || inventory.length !== 1 ||
20
- !inventory[0] || !Array.isArray(inventory[0].files) || !inventory[0].files.length) {
21
- return { files: [], parseError: "npm pack JSON must contain exactly one nonempty package inventory" };
22
- }
23
- const files = [];
24
- for (const file of inventory[0].files) {
25
- if (!file || typeof file.path !== "string" || !safeInventoryPath(file.path)) {
26
- return { files: [], parseError: "npm pack JSON contains a malformed or unsafe file path" };
27
- }
28
- // JSON paths are package-relative, unlike text tarball paths. Add the
29
- // archive root exactly once so a real nested package/ directory cannot
30
- // masquerade as a root entrypoint in the existing detectors.
31
- files.push(`package/${file.path.replace(/\\/g, "/").replace(/^(\.\/)+/, "")}`);
32
- }
33
- return { files };
34
- }
35
- catch {
36
- return { files: [], parseError: "npm pack JSON is unparseable" };
37
- }
17
+ const parsed = parseNpmPackJsonEntry(stdout);
18
+ if (!parsed.entry)
19
+ return { files: [], parseError: parsed.parseError };
20
+ // JSON paths are package-relative, unlike text tarball paths. Add the
21
+ // archive root exactly once so a real nested package/ directory cannot
22
+ // masquerade as a root entrypoint in the existing detectors.
23
+ return { files: parsed.entry.files.map((file) => `package/${file}`) };
38
24
  }
39
25
  const files = [];
40
26
  let inContents = false;
@@ -78,6 +64,45 @@ export function parseNpmPackDryRun(stdout) {
78
64
  }
79
65
  return { files, totalSize };
80
66
  }
67
+ // Validated single-package entry from `npm pack --json` (or --dry-run --json) output, accepting both
68
+ // the npm 11 array shape and the npm 12 identity-keyed object shape. Exactly one package with a
69
+ // nonempty, safe file list is required; anything else is an error, never an empty success.
70
+ export function parseNpmPackJsonEntry(stdout) {
71
+ try {
72
+ const packages = packageInventories(JSON.parse(stdout));
73
+ const inventory = packages?.length === 1 ? packages[0] : null;
74
+ if (!isPlainObject(inventory) || !Array.isArray(inventory.files) || !inventory.files.length) {
75
+ return { parseError: "npm pack JSON must contain exactly one nonempty package inventory" };
76
+ }
77
+ const files = [];
78
+ for (const file of inventory.files) {
79
+ if (!file || typeof file.path !== "string" || !safeInventoryPath(file.path)) {
80
+ return { parseError: "npm pack JSON contains a malformed or unsafe file path" };
81
+ }
82
+ files.push(file.path.replace(/\\/g, "/").replace(/^(\.\/)+/, ""));
83
+ }
84
+ const text = (value) => (typeof value === "string" ? value : undefined);
85
+ return { entry: { name: text(inventory.name), version: text(inventory.version), filename: text(inventory.filename), files } };
86
+ }
87
+ catch {
88
+ return { parseError: "npm pack JSON is unparseable" };
89
+ }
90
+ }
91
+ function isPlainObject(value) {
92
+ return typeof value === "object" && value !== null && !Array.isArray(value);
93
+ }
94
+ // The package inventories in `npm pack --json` output. npm 11 and earlier print an array of package
95
+ // objects; npm 12 prints one object keyed by package identity whose values are the package objects.
96
+ // Anything else (including a bare package object, or a keyed object holding non-objects) is not a
97
+ // recognized shape and yields null, so it can never be read as an empty or successful package.
98
+ function packageInventories(output) {
99
+ if (Array.isArray(output))
100
+ return output;
101
+ if (!isPlainObject(output))
102
+ return null;
103
+ const packages = Object.values(output);
104
+ return packages.every(isPlainObject) ? packages : null;
105
+ }
81
106
  function safeInventoryPath(file) {
82
107
  const normalized = file.replace(/\\/g, "/").replace(/^(\.\/)+/, "");
83
108
  return Boolean(normalized) && !normalized.startsWith("/") &&
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dailephd/my-dev-kit-lab",
3
- "version": "0.9.3",
3
+ "version": "0.9.4",
4
4
  "type": "module",
5
5
  "description": "Evidence, benchmark, and evaluation companion for my-dev-kit.",
6
6
  "bin": {
@@ -93,10 +93,10 @@
93
93
  "@types/node": "^24.0.0",
94
94
  "tsx": "^4.20.3",
95
95
  "typescript": "^5.8.3",
96
- "vitest": "^4.1.11"
96
+ "vitest": "4.1.12"
97
97
  },
98
98
  "overrides": {
99
- "vite": "7.3.6"
99
+ "vite": "7.3.7"
100
100
  },
101
101
  "engines": {
102
102
  "node": ">=24"