codemetrics-ai 0.5.0 → 0.6.0-preview.1

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.
Files changed (98) hide show
  1. package/README.md +31 -1
  2. package/dist/analyzer.d.ts +1 -0
  3. package/dist/analyzer.js +8 -4
  4. package/dist/cli.js +7 -3
  5. package/dist/dependency-evidence.d.ts +5 -0
  6. package/dist/dependency-evidence.js +30 -0
  7. package/dist/dependency-inventory.d.ts +67 -0
  8. package/dist/dependency-inventory.js +203 -0
  9. package/dist/dependency-metadata.d.ts +65 -0
  10. package/dist/dependency-metadata.js +125 -0
  11. package/dist/dependency-pnpm.d.ts +4 -0
  12. package/dist/dependency-pnpm.js +237 -0
  13. package/dist/dependency-spec.d.ts +13 -0
  14. package/dist/dependency-spec.js +30 -0
  15. package/dist/evidence-reader.js +3 -1
  16. package/dist/evidence-tools.js +4 -0
  17. package/dist/function-scoring.d.ts +2 -1
  18. package/dist/function-scoring.js +13 -24
  19. package/dist/index.d.ts +2 -0
  20. package/dist/index.js +1 -0
  21. package/dist/python/ASYNC_ADAPTERS.md +38 -0
  22. package/dist/python/ASYNC_CONTEXT.md +58 -0
  23. package/dist/python/ASYNC_SCORING.md +174 -0
  24. package/dist/python/ASYNC_USAGE.md +62 -0
  25. package/dist/python/BEHAVIOR_REVIEW.md +84 -0
  26. package/dist/python/COVERAGE.md +96 -0
  27. package/dist/python/DECOMPOSITION.md +46 -0
  28. package/dist/python/DUPLICATION.md +36 -0
  29. package/dist/python/ERROR_HANDLING.md +45 -0
  30. package/dist/python/ERROR_HANDLING_CONTEXT.md +61 -0
  31. package/dist/python/ERROR_HANDLING_OPERATIONS.md +54 -0
  32. package/dist/python/ERROR_HANDLING_TRANSFERS.md +64 -0
  33. package/dist/python/METHOD_COMPLEXITY.md +46 -0
  34. package/dist/python/README.md +162 -0
  35. package/dist/python/analysis.py +54 -0
  36. package/dist/python/async_adapters.py +243 -0
  37. package/dist/python/async_bindings.py +180 -0
  38. package/dist/python/async_context.py +143 -0
  39. package/dist/python/async_helper_paths.py +186 -0
  40. package/dist/python/async_provenance.py +129 -0
  41. package/dist/python/async_scoring.py +203 -0
  42. package/dist/python/async_usage.py +244 -0
  43. package/dist/python/coverage_collect.py +121 -0
  44. package/dist/python/coverage_provenance.py +77 -0
  45. package/dist/python/coverage_review.py +273 -0
  46. package/dist/python/coverage_worker.py +46 -0
  47. package/dist/python/decomposition.py +199 -0
  48. package/dist/python/duplication.py +177 -0
  49. package/dist/python/error_handling.py +249 -0
  50. package/dist/python/error_handling_context.py +294 -0
  51. package/dist/python/error_handling_operations.py +228 -0
  52. package/dist/python/error_handling_transfers.py +211 -0
  53. package/dist/python/review_cli.py +36 -0
  54. package/dist/python/source_facts.py +293 -0
  55. package/dist/python/source_selection.py +157 -0
  56. package/dist/python-analyzer.d.ts +21 -0
  57. package/dist/python-analyzer.js +123 -0
  58. package/dist/python-async-adapters.d.ts +67 -0
  59. package/dist/python-async-adapters.js +197 -0
  60. package/dist/python-async-context.d.ts +73 -0
  61. package/dist/python-async-context.js +181 -0
  62. package/dist/python-async-scoring.d.ts +89 -0
  63. package/dist/python-async-scoring.js +249 -0
  64. package/dist/python-async-usage.d.ts +56 -0
  65. package/dist/python-async-usage.js +143 -0
  66. package/dist/python-cli.d.ts +2 -0
  67. package/dist/python-cli.js +53 -0
  68. package/dist/python-collect-cli.d.ts +2 -0
  69. package/dist/python-collect-cli.js +28 -0
  70. package/dist/python-complexity.d.ts +18 -0
  71. package/dist/python-complexity.js +36 -0
  72. package/dist/python-coverage.d.ts +63 -0
  73. package/dist/python-coverage.js +173 -0
  74. package/dist/python-decomposition.d.ts +56 -0
  75. package/dist/python-decomposition.js +102 -0
  76. package/dist/python-duplication.d.ts +65 -0
  77. package/dist/python-duplication.js +89 -0
  78. package/dist/python-error-context.d.ts +84 -0
  79. package/dist/python-error-context.js +165 -0
  80. package/dist/python-error-handling.d.ts +56 -0
  81. package/dist/python-error-handling.js +120 -0
  82. package/dist/python-error-operations.d.ts +44 -0
  83. package/dist/python-error-operations.js +155 -0
  84. package/dist/python-error-transfers.d.ts +38 -0
  85. package/dist/python-error-transfers.js +125 -0
  86. package/dist/python-evidence.d.ts +23 -0
  87. package/dist/python-evidence.js +255 -0
  88. package/dist/score-arithmetic.d.ts +1 -12
  89. package/dist/score-arithmetic.js +1 -40
  90. package/dist/shared-source-facts/arithmetic.d.mts +9 -0
  91. package/dist/shared-source-facts/arithmetic.mjs +32 -0
  92. package/dist/shared-source-facts/contract.d.mts +35 -0
  93. package/dist/shared-source-facts/contract.mjs +95 -0
  94. package/dist/shared-source-facts/population.d.mts +12 -0
  95. package/dist/shared-source-facts/population.mjs +41 -0
  96. package/dist/source-facts.d.ts +4 -0
  97. package/dist/source-facts.js +16 -0
  98. package/package.json +15 -6
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # codemetrics-ai
2
2
 
3
- Deterministic source analysis for JavaScript, TypeScript, JSX and TSX. Version 0.5.0 implements source metrics and React hook/effect checks and emits schema-v3 evidence, including executed scoring decisions. Scores are **uncalibrated across ecosystems**; compare them only with compatible runs of this analyzer.
3
+ Deterministic source analysis for JavaScript, TypeScript, JSX and TSX, with Python support in preview 0.6.0-preview.1. The package implements source metrics and React hook/effect checks and emits schema-v3 evidence, including executed scoring decisions. Scores are **uncalibrated across ecosystems**; compare them only with compatible runs of this analyzer.
4
4
 
5
5
  Each scored dimension includes `scoringDecision`: policy inputs, executed steps, rounding and finding attribution. These are aggregate policy decisions, not independent finding deductions. See [the decision contract](../../shared/scorecard-schema/scoring-decisions.md).
6
6
 
@@ -20,6 +20,10 @@ Default output paths are `.scorecard/javascript-typescript/metrics.csv` and `.sc
20
20
 
21
21
  ## Implemented scope
22
22
 
23
+ Preview 0.6.0-preview.1 adds unscored npm/pnpm dependency inventory and optional
24
+ registry observations under ruleset `javascript-typescript-2026-10-09-dependency-evidence`.
25
+ It requires fresh baselines. Stable 0.5.0 does not include this extension. Install the preview explicitly; the npm `latest` tag stays on the stable release.
26
+
23
27
  | Dimension | Implementation |
24
28
  |---|---|
25
29
  | codeQuality (Method Complexity) | 40% one worst function score + 60% mean remaining; owned CC findings above 10. Decomposition has descriptive size/distribution evidence and no score. |
@@ -27,6 +31,7 @@ Default output paths are `.scorecard/javascript-typescript/metrics.csv` and `.sc
27
31
  | performanceAsync (Async/Blocking Usage) | React hook/effect checks, standard Promise async executors, and informational async Array.forEach callbacks |
28
32
  | architecture | Module dependencies, explicit type-only separation, internal cycle groups and fan-in/out rankings; score remains unmeasured |
29
33
  | errorHandling | Contextual failure dispositions for distinct catch/Promise handler bodies; scores unexplained swallowing only with a fully assessed population |
34
+ | dependencyManagement (preview) | npm lockfile v2/v3 and pnpm v9 inventory, workspace uses, optional public-registry deprecation/update/advisory observations; no score |
30
35
  | Other dimensions | Explicitly skipped |
31
36
 
32
37
  React checks run only when a source file imports React. Aliased named imports and namespace imports are recognized; local functions shadowing imports are excluded. Missing dependency arrays are advisory because running after every render can be intentional. The implementation does not claim exhaustive Rules of Hooks, dependency-array correctness, or general concurrency analysis. Source syntax errors make the run incomplete and source dimensions unscored. Semantic type errors are outside this AST-focused version's completeness check.
@@ -64,6 +69,23 @@ Cycles are strongly connected groups of resolved internal value/mixed dependenci
64
69
 
65
70
  Architecture retains `status: skipped` and no score until calibration. Other measured dimensions remain usable when this diagnostic graph has resolution gaps. Source parse failures retain partial graph evidence with `status: failed` and the existing incomplete-analysis exit. Schema v3 is unchanged; the new scope/ruleset requires fresh baselines. See the [graph contract](../../shared/scorecard-schema/javascript-typescript-module-graph.md).
66
71
 
72
+ ## Dependency observations (development)
73
+
74
+ Inventory supports npm lockfiles v2/v3 and pnpm v9, including pnpm 12 environment/project documents, catalogs, workspace links, aliases, and peer snapshots. The project graph is selected; environment tooling is explicitly excluded. Peer variants retain locations without multiplying package/version counts. Older pnpm formats and file/git/custom resolutions remain gaps. Integrity-only records do not establish public-registry provenance.
75
+
76
+ Inventory is local by default. `--dependency-metadata` explicitly enables bounded
77
+ public npm registry metadata and bulk advisory queries. It never installs target
78
+ dependencies or runs their scripts. Unsupported package managers, stale locks,
79
+ private/unknown sources, and failed queries are exposed as coverage gaps.
80
+ Optional peers and locked range differences remain contextual notices. Duplicate
81
+ installations retain their locations without creating independent penalties.
82
+
83
+ Deprecation, `wanted` updates within direct declared ranges, `latest` releases,
84
+ and advisories are separate observations. Exact pins and newer major versions do
85
+ not establish neglect; advisory presence does not establish runtime reachability.
86
+ The dimension remains unscored. See the [dependency policy](../../shared/scorecard-schema/javascript-typescript-dependency-policy.md)
87
+ for counting, limits, availability, and calibration boundaries.
88
+
67
89
  ## Discovery
68
90
 
69
91
  The CLI discovers files from each package's tsconfig when present, otherwise scans source extensions. Positive npm workspace globs (including object-form `workspaces.packages`) are supported. Each child package owns its files and exclusions; the root cannot re-include a child's excluded files. External tsconfig references are not recursively analyzed as separate packages: include them in the workspace manifest. Explicit missing manifests/configs and unsupported negative workspace patterns are errors.
@@ -176,3 +198,11 @@ Uptime Kuma rerun. Numerical experiments remain outside the analyzer and are not
176
198
  consumer scoring policies. The subsequent [contextual handler policy](../../shared/scorecard-schema/javascript-typescript-error-handling-policy.md)
177
199
  enables the agreed population formula only where bounded classification establishes
178
200
  an assessable population. Decomposition remains unscored.
201
+
202
+ ## Experimental Python command in development builds
203
+
204
+ Local development archives bundle `codemetrics-python` and its Python 3.12 AST adapter, version `0.15.0`. It parses source without importing the target and produces evidence v3 with a production Method Complexity score, owned statements, block nesting, duplication, optional coverage/CRAP review evidence, and unscored exception-handler observations at `dimensions.errorHandling.review`, plus bounded lexical context and conservative patterns at `dimensions.errorHandling.contextReview`, with owned raise/cause syntax at `dimensions.errorHandling.transferReview` and owned calls/returns plus unresolved-suite inventory at `dimensions.errorHandling.operationReview`, and async-owned call evidence at `dimensions.performanceAsync.review`. `--coverage-json` imports observations; `--collection-manifest` checks their source binding. Adapter 0.15.0 also scores Async/Blocking Usage from production-owner contributions at `performanceAsync.asyncScoring`, with bounded paths at `performanceAsync.helperReview`. The other seven Python dimensions remain unavailable; decomposition, duplication, coverage/CRAP, and handler observations remain unscored. Install `codemetrics-ai@0.6.0-preview.1` explicitly to use these commands; stable npm 0.5.0 does not include them. For example: `npm exec --yes --package=codemetrics-ai@0.6.0-preview.1 -- codemetrics-python --project ./pyproject.toml --scorecard-output ./python-evidence.json`. The installed package includes its Python guide at `dist/python/README.md` and scoring policy at `dist/python/ASYNC_SCORING.md`. Ordinary analysis/import needs only Node.js 20+ and Python 3.12. The separate `codemetrics-python-collect` command explicitly executes tests and requires a caller-supplied environment containing coverage.py and the project's test dependencies.
205
+
206
+ Python adapter 0.13.0 also embeds unscored async call context at `dimensions.performanceAsync.contextReview`, including unresolved reason/shape inventories and bounded local callback/stored-wrapper links. The package ships `dist/python/ASYNC_CONTEXT.md`; historical 0.12.0 reports remain valid without this extension.
207
+
208
+ Python adapter 0.14.0 additionally embeds unscored partial construction/reference context and narrow `sync_to_async` decorator links at `dimensions.performanceAsync.adapterReview`. The package ships `dist/python/ASYNC_ADAPTERS.md`; historical 0.13.0 evidence remains valid without this report. This adapter report itself remains unscored; 0.15.0 adds the separate async scoring contract. Error Handling remains unscored.
@@ -5,6 +5,7 @@ export declare function analyze(options: {
5
5
  tsconfig?: string;
6
6
  runId?: string;
7
7
  auditId?: string;
8
+ dependencyMetadata?: boolean;
8
9
  }, version: string): {
9
10
  evidence: Evidence;
10
11
  metrics: Metric[];
package/dist/analyzer.js CHANGED
@@ -12,6 +12,8 @@ import { decompositionModule, decompositionEvidence } from "./decomposition-evid
12
12
  import { HandlerResolver } from "./handler-resolution.js";
13
13
  import { inspectErrorHandling, errorHandlingEvidence } from "./error-handling.js";
14
14
  import { FailureContracts } from "./failure-contracts.js";
15
+ import { dependencyInventory } from "./dependency-inventory.js";
16
+ import { dependencyEvidence } from "./dependency-evidence.js";
15
17
  export function analyze(options, version) {
16
18
  const identity = invocationIds(options.runId, options.auditId);
17
19
  const discovery = discover(options.project, options.tsconfig);
@@ -69,6 +71,8 @@ export function analyze(options, version) {
69
71
  dimensions[key] = { status: "failed", basis: "Incomplete source analysis; partial findings are unscored.", findings: dimensions[key].findings, scope: dimensions[key].scope };
70
72
  dimensions.architecture = moduleGraph.finish(diagnostics.length > 0);
71
73
  dimensions.errorHandling = errorHandlingEvidence(errorHandling, diagnostics.length > 0);
74
+ const dependencies = dependencyInventory(discovery.packages, discovery.repositoryRoot);
75
+ dimensions.dependencyManagement = dependencyEvidence(dependencies.inventory, undefined, options.dependencyMetadata ? "public-registry" : "offline");
72
76
  dimensions.codeQuality.componentDetails = { ...(dimensions.codeQuality.componentDetails ?? {}),
73
77
  decomposition: decompositionEvidence(decompositionModules, diagnostics.length > 0) };
74
78
  const evidence = {
@@ -77,11 +81,11 @@ export function analyze(options, version) {
77
81
  filters: { totalUnits: discovery.packages.reduce((sum, pkg) => sum + pkg.files.length, 0) + discovery.skipped.length,
78
82
  analyzedUnits: analyzedFiles, skipped: discovery.skipped },
79
83
  population: { types: new Set(metrics.map(metric => `${metric.project}|${metric.file}|${metric.type}`)).size, members: metrics.length },
80
- dimensions, analysis: { ...identity, status: diagnostics.length ? "incomplete" : "complete", ruleset: "javascript-typescript-2026-10-09-handler-contracts",
81
- calibration: "uncalibrated", configurationFingerprint: hash(JSON.stringify(canonicalConfiguration(discovery.packages.map(pkg => ({ name: pkg.name,
82
- options: pkg.options, selection: pkg.selection })), discovery.repositoryRoot))), diagnostics, suppressions: [] }
84
+ dimensions, analysis: { ...identity, status: diagnostics.length ? "incomplete" : "complete", ruleset: "javascript-typescript-2026-10-09-dependency-evidence",
85
+ calibration: "uncalibrated", configurationFingerprint: hash(JSON.stringify(canonicalConfiguration({ packages: discovery.packages.map(pkg => ({ name: pkg.name,
86
+ options: pkg.options, selection: pkg.selection })), dependencyMetadata: options.dependencyMetadata ? "public-registry" : "offline" }, discovery.repositoryRoot))), diagnostics, suppressions: [] }
83
87
  };
84
- return { evidence, metrics, csv: toCsv(metrics), inputs: discovery.packages.flatMap(pkg => [pkg.entryPoint, ...pkg.files]) };
88
+ return { evidence, metrics, csv: toCsv(metrics), inputs: [...dependencies.inputs, ...discovery.packages.flatMap(pkg => [pkg.entryPoint, ...pkg.files])] };
85
89
  }
86
90
  function canonicalConfiguration(value, root) {
87
91
  if (typeof value === "string")
package/dist/cli.js CHANGED
@@ -5,18 +5,20 @@ import { analyze } from "./analyzer.js";
5
5
  import { defaultEvidencePath, defaultMetricsPath } from "./scorecard-contract.js";
6
6
  import { compare, gate, readEvidence, sarif, validateEvidence } from "./evidence-tools.js";
7
7
  import { distinctOutputs, write } from "./io.js";
8
+ import { enrichDependencies } from "./dependency-evidence.js";
8
9
  try {
9
10
  const { values } = parseArgs({ options: {
10
11
  project: { type: "string" }, tsconfig: { type: "string" }, "run-id": { type: "string" }, "audit-id": { type: "string" }, output: { type: "string", default: defaultMetricsPath },
11
12
  "scorecard-output": { type: "string", default: defaultEvidencePath }, baseline: { type: "string" },
12
13
  "comparison-output": { type: "string" }, sarif: { type: "string" }, "fail-on-new": { type: "string" },
13
- "max-score-drop": { type: "string" }, help: { type: "boolean", short: "h" }, version: { type: "boolean" }
14
+ "max-score-drop": { type: "string" }, "dependency-metadata": { type: "boolean" }, help: { type: "boolean", short: "h" }, version: { type: "boolean" }
14
15
  } });
15
16
  const version = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
16
17
  if (values.help)
17
18
  console.log([
18
19
  "codemetrics-ai — JavaScript, TypeScript and React source analysis",
19
20
  "--project <package.json> --tsconfig <tsconfig.json>",
21
+ "--dependency-metadata (opt in to bounded public npm registry observations; inventory is always local)",
20
22
  "--run-id <uuid> --audit-id <uuid> (generated when omitted)",
21
23
  "--output <metrics.csv> --scorecard-output <evidence.json>",
22
24
  "--baseline <evidence.json> --comparison-output <comparison.json>",
@@ -30,9 +32,11 @@ try {
30
32
  if ((values["fail-on-new"] || values["max-score-drop"] || values["comparison-output"]) && !values.baseline)
31
33
  throw new Error("Comparison and quality gates require --baseline.");
32
34
  distinctOutputs([values.project ?? "package.json", values.tsconfig, values.baseline], [values.output, values["scorecard-output"], values["comparison-output"], values.sarif]);
33
- const result = analyze({ ...values, runId: values["run-id"], auditId: values["audit-id"] }, version);
34
- validateEvidence(result.evidence);
35
+ const result = analyze({ ...values, runId: values["run-id"], auditId: values["audit-id"], dependencyMetadata: values["dependency-metadata"] }, version);
35
36
  distinctOutputs(result.inputs, [values.output, values["scorecard-output"], values["comparison-output"], values.sarif]);
37
+ if (values["dependency-metadata"])
38
+ await enrichDependencies(result.evidence);
39
+ validateEvidence(result.evidence);
36
40
  const comparison = values.baseline ? compare(result.evidence, readEvidence(values.baseline)) : undefined;
37
41
  const failed = comparison ? gate(comparison, values["fail-on-new"], values["max-score-drop"] === undefined ? undefined : Number(values["max-score-drop"])) : false;
38
42
  write(values.output, result.csv);
@@ -0,0 +1,5 @@
1
+ import { type Dimension, type Evidence } from "./evidence.js";
2
+ import type { DependencyInventory } from "./dependency-inventory.js";
3
+ import { type DependencyMetadata, type RegistryReader } from "./dependency-metadata.js";
4
+ export declare function dependencyEvidence(inventory: DependencyInventory, metadata?: DependencyMetadata, metadataMode?: "offline" | "public-registry"): Dimension;
5
+ export declare function enrichDependencies(evidence: Evidence, reader?: RegistryReader): Promise<void>;
@@ -0,0 +1,30 @@
1
+ import { hash } from "./evidence.js";
2
+ import { dependencyMetadata, noMetadata } from "./dependency-metadata.js";
3
+ export function dependencyEvidence(inventory, metadata = noMetadata(), metadataMode = metadata.status === "notRequested" ? "offline" : "public-registry") {
4
+ const findings = [];
5
+ const add = (id, name, category, message, observations) => findings.push({
6
+ category, ruleId: `javascript-typescript/dependencyManagement/${category}`, fingerprint: hash(`${id}|${category}`),
7
+ severity: "info", confidence: "high", package: name, message, observations: { ...observations, scoreDisposition: "excludedUnscored" }
8
+ });
9
+ for (const row of metadata.observations) {
10
+ if (row.deprecated)
11
+ add(row.packageId, row.name, "deprecatedPackage", `${row.name} ${row.version} is deprecated: ${row.deprecated}`, { packageId: row.packageId, version: row.version });
12
+ if (row.upgrades?.some(upgrade => upgrade.newerWithinRange))
13
+ add(row.packageId, row.name, "updateWithinDeclaredRange", `${row.name} has an update within a declared version range; compatibility and intent require review.`, { packageId: row.packageId, upgrades: row.upgrades });
14
+ for (const advisory of row.vulnerabilities.advisories ?? [])
15
+ add(`${row.packageId}|${advisory.id}`, row.name, "reportedVulnerability", `${row.name} ${row.version}: ${advisory.title}`, { packageId: row.packageId, advisory, reachability: "notAssessed" });
16
+ }
17
+ return { status: "skipped", basis: "Dependency inventory and registry observations only; numerical scoring is not calibrated. Missing evidence is not a code defect.",
18
+ scope: { id: "javascript-typescript/dependencyManagement/lock-observations-v1", coverage: "partial",
19
+ includes: ["npm-lockfile-v2-v3", "pnpm-lockfile-v9", "selected-workspace-reachability", "public-registry-metadata-if-requested"],
20
+ excludes: ["dependency-scoring", "runtime-reachability", "upgrade-safety", "private-registry-authentication", "yarn-bun-lockfiles", "pnpm-file-git-custom-resolutions", "pnpm-environment-tooling-document", "implicit-registry-provenance"] },
21
+ findings, dependencyInventory: inventory, dependencyMetadata: metadata, metadataMode };
22
+ }
23
+ export async function enrichDependencies(evidence, reader) {
24
+ if (evidence.dimensions.dependencyManagement.metadataMode !== "public-registry")
25
+ throw new Error("Analyze with dependencyMetadata: true before requesting registry observations");
26
+ const inventory = evidence.dimensions.dependencyManagement.dependencyInventory;
27
+ if (!inventory)
28
+ throw new Error("Dependency inventory is required before metadata acquisition");
29
+ evidence.dimensions.dependencyManagement = dependencyEvidence(inventory, await dependencyMetadata(inventory, reader));
30
+ }
@@ -0,0 +1,67 @@
1
+ import type { PackageInput } from "./discovery.js";
2
+ import { declarations, type DependencyKind } from "./dependency-spec.js";
3
+ export { declarations, registrySpec, type DependencyKind } from "./dependency-spec.js";
4
+ export interface DependencyUse {
5
+ project: string;
6
+ manifest: string;
7
+ from: string;
8
+ requested: string;
9
+ range: string;
10
+ resolvedRange?: string;
11
+ kind: DependencyKind;
12
+ direct: boolean;
13
+ optional: boolean;
14
+ }
15
+ export interface DependencyPackage {
16
+ id: string;
17
+ name: string;
18
+ version: string;
19
+ source: "publicRegistry" | "otherSource" | "workspace";
20
+ locations: string[];
21
+ uses: DependencyUse[];
22
+ }
23
+ export interface DependencyGap {
24
+ manifest: string;
25
+ reason: string;
26
+ dependency?: string;
27
+ from?: string;
28
+ range?: string;
29
+ }
30
+ export interface DependencyInventory {
31
+ version: 1;
32
+ status: "complete" | "gaps";
33
+ countingUnit: "packageNameVersionSource";
34
+ packages: DependencyPackage[];
35
+ gaps: DependencyGap[];
36
+ notices: {
37
+ manifest: string;
38
+ from: string;
39
+ dependency: string;
40
+ reason: string;
41
+ range: string;
42
+ version?: string;
43
+ }[];
44
+ manifests: {
45
+ file: string;
46
+ lockfile?: string;
47
+ manager?: string;
48
+ declarations: ReturnType<typeof declarations>;
49
+ overrides?: unknown;
50
+ }[];
51
+ lockfiles: {
52
+ file: string;
53
+ version: number;
54
+ sha256: string;
55
+ projectDocument?: number;
56
+ environmentDocument?: "excluded";
57
+ }[];
58
+ configurationInputs?: {
59
+ file: string;
60
+ sha256: string;
61
+ }[];
62
+ }
63
+ /** Reads only selected manifests and reachable lockfile entries; never installs or executes packages. */
64
+ export declare function dependencyInventory(selected: Pick<PackageInput, "entryPoint" | "root" | "name">[], root: string): {
65
+ inventory: DependencyInventory;
66
+ inputs: string[];
67
+ };
@@ -0,0 +1,203 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import semver from "semver";
4
+ import { hash } from "./evidence.js";
5
+ import { pnpmInventory } from "./dependency-pnpm.js";
6
+ import { declarations, registrySpec } from "./dependency-spec.js";
7
+ export { declarations, registrySpec } from "./dependency-spec.js";
8
+ const object = (value) => !!value && typeof value === "object" && !Array.isArray(value);
9
+ const slash = (value) => value.replaceAll("\\", "/");
10
+ const within = (root, file) => { const r = path.relative(root, file); return !path.isAbsolute(r) && r !== ".." && !r.startsWith(".." + path.sep); };
11
+ function read(file) {
12
+ if (fs.statSync(file).size > 20 * 1024 * 1024)
13
+ throw new Error("Dependency input exceeds 20 MiB");
14
+ const text = fs.readFileSync(file, "utf8"), data = JSON.parse(text);
15
+ if (!object(data))
16
+ throw new Error("Dependency input must be an object");
17
+ return { data, sha256: hash(text) };
18
+ }
19
+ function locate(start, root, inputs) {
20
+ const directories = [];
21
+ for (let dir = start; within(root, dir); dir = path.dirname(dir)) {
22
+ directories.push(dir);
23
+ if (dir === root)
24
+ break;
25
+ }
26
+ let manager;
27
+ for (const dir of directories) {
28
+ const manifest = path.join(dir, "package.json");
29
+ if (fs.existsSync(manifest))
30
+ inputs.add(manifest);
31
+ const declared = fs.existsSync(manifest) ? read(manifest).data.packageManager : undefined;
32
+ if (!manager && typeof declared === "string")
33
+ manager = declared;
34
+ }
35
+ if (manager && !/^(npm|pnpm)@/.test(manager))
36
+ return { manager };
37
+ for (const dir of directories) {
38
+ const candidates = manager?.startsWith("pnpm@") ? ["pnpm-lock.yaml"] : manager?.startsWith("npm@") ? ["npm-shrinkwrap.json", "package-lock.json"] : ["pnpm-lock.yaml", "npm-shrinkwrap.json", "package-lock.json"];
39
+ if (!manager && fs.existsSync(path.join(dir, "pnpm-lock.yaml")) && ["npm-shrinkwrap.json", "package-lock.json", "yarn.lock", "bun.lock"].some(name => fs.existsSync(path.join(dir, name))))
40
+ return { manager: "ambiguous lockfiles without packageManager" };
41
+ for (const name of candidates) {
42
+ const file = path.join(dir, name);
43
+ if (fs.existsSync(file))
44
+ return { file, manager: name === "pnpm-lock.yaml" ? "pnpm" : "npm" };
45
+ }
46
+ const unsupported = ["yarn.lock", "bun.lock", "bun.lockb"].find(name => fs.existsSync(path.join(dir, name)));
47
+ if (unsupported) {
48
+ inputs.add(path.join(dir, unsupported));
49
+ return { manager: unsupported };
50
+ }
51
+ }
52
+ return { manager };
53
+ }
54
+ function validLocation(location) {
55
+ return !location.includes("\\") && !path.posix.isAbsolute(location) && !location.split("/").includes("..") && !location.includes(":");
56
+ }
57
+ function resolve(packages, from, name) {
58
+ if (!/^(?:@[^/]+\/)?[^/]+$/.test(name) || name.includes("\\") || name.includes(".."))
59
+ return;
60
+ for (let dir = from;; dir = path.posix.dirname(dir)) {
61
+ const at = path.posix.join(dir, "node_modules", name);
62
+ if (object(packages[at]))
63
+ return at;
64
+ if (!dir || dir === ".")
65
+ return;
66
+ }
67
+ }
68
+ function packageSource(entry) {
69
+ if (typeof entry.resolved !== "string")
70
+ return "otherSource";
71
+ try {
72
+ const url = new URL(entry.resolved);
73
+ return url.protocol === "https:" && url.hostname === "registry.npmjs.org" && !url.username && !url.password ? "publicRegistry" : "otherSource";
74
+ }
75
+ catch {
76
+ return "otherSource";
77
+ }
78
+ }
79
+ /** Reads only selected manifests and reachable lockfile entries; never installs or executes packages. */
80
+ export function dependencyInventory(selected, root) {
81
+ const inventory = { version: 1, status: "complete", countingUnit: "packageNameVersionSource", packages: [], gaps: [], notices: [], manifests: [], lockfiles: [] };
82
+ const inputs = new Set(selected.map(pkg => pkg.entryPoint)), rows = new Map();
83
+ const locks = new Map();
84
+ const relative = (file) => slash(path.relative(root, file));
85
+ for (const pkg of selected) {
86
+ const manifest = relative(pkg.entryPoint);
87
+ const gap = (reason, dependency, from, range) => inventory.gaps.push({ manifest, reason, ...(dependency ? { dependency } : {}), ...(from !== undefined ? { from } : {}), ...(range ? { range } : {}) });
88
+ try {
89
+ const data = read(pkg.entryPoint).data, declared = declarations(data), located = locate(pkg.root, root, inputs);
90
+ inventory.manifests.push({ file: manifest, declarations: declared, ...(data.overrides ? { overrides: data.overrides } : {}), ...(located.file ? { lockfile: relative(located.file) } : {}), ...(located.manager ? { manager: located.manager } : {}) });
91
+ if (!located.file) {
92
+ gap(located.manager && !/^(npm|pnpm)@/.test(located.manager) ? `Unsupported package manager/lockfile: ${located.manager}` : `No ${located.manager?.split("@")[0] ?? "npm or pnpm"} lockfile found`);
93
+ continue;
94
+ }
95
+ inputs.add(located.file);
96
+ if (!within(root, fs.realpathSync(located.file))) {
97
+ gap("Lockfile is outside repository");
98
+ continue;
99
+ }
100
+ if (located.manager === "pnpm") {
101
+ pnpmInventory(pkg, root, located.file, inventory, inputs, rows);
102
+ continue;
103
+ }
104
+ if (!locks.has(located.file))
105
+ locks.set(located.file, read(located.file));
106
+ const lock = locks.get(located.file), packages = lock.data.packages;
107
+ if (![2, 3].includes(Number(lock.data.lockfileVersion)) || !object(packages)) {
108
+ gap("Only npm lockfile versions 2 and 3 with a packages map are supported");
109
+ continue;
110
+ }
111
+ if (!inventory.lockfiles.some(row => row.file === relative(located.file)))
112
+ inventory.lockfiles.push({ file: relative(located.file), version: Number(lock.data.lockfileVersion), sha256: lock.sha256 });
113
+ if (Object.keys(packages).some(location => !validLocation(location))) {
114
+ gap("Unsafe or malformed lockfile location");
115
+ continue;
116
+ }
117
+ const start = slash(path.relative(path.dirname(located.file), pkg.root)), locked = packages[start];
118
+ if (!object(locked)) {
119
+ gap("Selected manifest has no lockfile package entry");
120
+ continue;
121
+ }
122
+ if (JSON.stringify(declared) !== JSON.stringify(declarations(locked))) {
123
+ gap("Manifest and lockfile declarations differ; resolved inventory is withheld");
124
+ continue;
125
+ }
126
+ const visited = new Set();
127
+ const queue = declared.map(edge => ({ from: start, ...edge, mayBeAbsent: edge.optional, role: edge.kind, direct: true }));
128
+ for (let index = 0; index < queue.length; index++) {
129
+ if (index >= 100000) {
130
+ gap("Dependency traversal limit exceeded");
131
+ break;
132
+ }
133
+ const edge = queue[index], position = resolve(packages, edge.from, edge.requested);
134
+ if (!position) {
135
+ if (edge.mayBeAbsent)
136
+ inventory.notices.push({ manifest, from: edge.from, dependency: edge.requested, range: edge.range, reason: "Optional dependency/peer is absent from the lockfile; no missing-package deduction" });
137
+ else
138
+ gap(`Unresolved ${edge.kind} dependency in lockfile`, edge.requested, edge.from, edge.range);
139
+ continue;
140
+ }
141
+ let location = position, entry = packages[location];
142
+ const linked = entry.link === true;
143
+ if (linked) {
144
+ if (typeof entry.resolved !== "string" || !validLocation(entry.resolved) || !object(packages[entry.resolved])) {
145
+ gap("Unresolved workspace/link target", edge.requested);
146
+ continue;
147
+ }
148
+ location = entry.resolved;
149
+ entry = packages[location];
150
+ const target = path.resolve(path.dirname(located.file), location, "package.json");
151
+ if (!within(root, target) || !fs.existsSync(target) || !within(root, fs.realpathSync(target))) {
152
+ gap("Workspace manifest unavailable or outside repository", edge.requested);
153
+ continue;
154
+ }
155
+ inputs.add(target);
156
+ if (JSON.stringify(declarations(read(target).data)) !== JSON.stringify(declarations(entry))) {
157
+ gap("Workspace manifest and lockfile differ", edge.requested);
158
+ continue;
159
+ }
160
+ }
161
+ const spec = registrySpec(edge.requested, edge.range);
162
+ const name = typeof entry.name === "string" ? entry.name : spec?.name ?? edge.requested;
163
+ if (typeof entry.version !== "string" || !semver.valid(entry.version)) {
164
+ gap("Missing/non-semver resolved version", edge.requested);
165
+ continue;
166
+ }
167
+ if (spec && semver.validRange(spec.range) && !semver.satisfies(entry.version, spec.range))
168
+ inventory.notices.push({ manifest, from: edge.from, dependency: edge.requested, range: edge.range, version: entry.version,
169
+ reason: "Locked version differs from requested range; overrides or peer-resolution policy may explain this. Installation validity is not assessed" });
170
+ if (spec && spec.name !== name)
171
+ inventory.notices.push({ manifest, from: edge.from, dependency: edge.requested, range: edge.range, version: entry.version,
172
+ reason: `Locked identity is ${name}; an alias/override may replace the requested package. Installation policy is not assessed` });
173
+ const source = linked ? "workspace" : packageSource(entry), id = hash(`${name}|${entry.version}|${source}`);
174
+ const row = rows.get(id) ?? { id, name, version: entry.version, source, locations: [], uses: [] };
175
+ rows.set(id, row);
176
+ const install = `${relative(located.file)}#${position}`;
177
+ if (!row.locations.includes(install))
178
+ row.locations.push(install);
179
+ const use = { project: pkg.name, manifest, from: edge.from, requested: edge.requested, range: edge.range, kind: edge.role, direct: edge.direct, optional: edge.optional };
180
+ if (!row.uses.some(existing => JSON.stringify(existing) === JSON.stringify(use)))
181
+ row.uses.push(use);
182
+ const key = `${location}|${edge.role}|${edge.optional}`;
183
+ if (visited.has(key))
184
+ continue;
185
+ visited.add(key);
186
+ for (const child of declarations(entry, false))
187
+ queue.push({ from: location, ...child, mayBeAbsent: child.optional, optional: edge.optional || child.optional, role: edge.role, direct: false });
188
+ }
189
+ }
190
+ catch (error) {
191
+ gap(error instanceof Error ? error.message : "Dependency inventory unavailable");
192
+ }
193
+ }
194
+ inventory.packages = [...rows.values()].sort((a, b) => a.name.localeCompare(b.name, "en") || a.version.localeCompare(b.version, "en") || a.source.localeCompare(b.source, "en"));
195
+ for (const row of inventory.packages) {
196
+ row.locations.sort();
197
+ row.uses.sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b), "en"));
198
+ }
199
+ inventory.gaps = [...new Map(inventory.gaps.map(gap => [JSON.stringify(gap), gap])).values()];
200
+ inventory.notices = [...new Map(inventory.notices.map(notice => [JSON.stringify(notice), notice])).values()];
201
+ inventory.status = inventory.gaps.length ? "gaps" : "complete";
202
+ return { inventory, inputs: [...inputs] };
203
+ }
@@ -0,0 +1,65 @@
1
+ import { type DependencyInventory } from "./dependency-inventory.js";
2
+ export interface MetadataResult {
3
+ status: "available" | "unavailable";
4
+ data?: unknown;
5
+ sha256?: string;
6
+ reason?: string;
7
+ }
8
+ export interface RegistryReader {
9
+ package(name: string): Promise<MetadataResult>;
10
+ advisories(packages: Record<string, string[]>): Promise<MetadataResult>;
11
+ }
12
+ export interface PackageObservation {
13
+ packageId: string;
14
+ name: string;
15
+ version: string;
16
+ metadata: {
17
+ status: "available" | "unavailable";
18
+ reason?: string;
19
+ sha256?: string;
20
+ };
21
+ deprecated?: string | null;
22
+ latest?: string | null;
23
+ upgrades?: {
24
+ manifest: string;
25
+ range: string;
26
+ wanted: string | null;
27
+ newerWithinRange: boolean;
28
+ latestOutsideRange: boolean | null;
29
+ reason?: string;
30
+ }[];
31
+ nodeEngine?: string | null;
32
+ vulnerabilities: {
33
+ status: "available" | "unavailable";
34
+ reason?: string;
35
+ advisories?: {
36
+ id: string;
37
+ title: string;
38
+ url: string;
39
+ severity: string;
40
+ range: string;
41
+ }[];
42
+ };
43
+ }
44
+ export interface DependencyMetadata {
45
+ version: 1;
46
+ status: "notRequested" | "available" | "partial" | "unavailable";
47
+ source: "https://registry.npmjs.org";
48
+ checkedAtUtc?: string;
49
+ scope: "public-registry-lockfile-packages";
50
+ observations: PackageObservation[];
51
+ availablePackages: number;
52
+ unavailablePackages: number;
53
+ advisoryResponseSha256?: string;
54
+ }
55
+ export declare const noMetadata: () => DependencyMetadata;
56
+ /** Explicit opt-in, bounded public-registry reads. No credentials, redirects, installs or target scripts. */
57
+ export declare function publicRegistryReader(options?: {
58
+ timeoutMs?: number;
59
+ maxBytes?: number;
60
+ fetch?: typeof fetch;
61
+ }): RegistryReader;
62
+ export declare function dependencyMetadata(inventory: DependencyInventory, reader?: RegistryReader, options?: {
63
+ maxPackages?: number;
64
+ deadlineMs?: number;
65
+ }): Promise<DependencyMetadata>;