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
@@ -0,0 +1,125 @@
1
+ import semver from "semver";
2
+ import { hash } from "./evidence.js";
3
+ import { registrySpec } from "./dependency-inventory.js";
4
+ const object = (value) => !!value && typeof value === "object" && !Array.isArray(value);
5
+ export const noMetadata = () => ({ version: 1, status: "notRequested", source: "https://registry.npmjs.org", scope: "public-registry-lockfile-packages", observations: [], availablePackages: 0, unavailablePackages: 0 });
6
+ const unavailable = (reason) => ({ status: "unavailable", reason });
7
+ /** Explicit opt-in, bounded public-registry reads. No credentials, redirects, installs or target scripts. */
8
+ export function publicRegistryReader(options = {}) {
9
+ const request = async (route, body) => {
10
+ try {
11
+ const response = await (options.fetch ?? fetch)(`https://registry.npmjs.org/${route}`, {
12
+ method: body ? "POST" : "GET", redirect: "error", signal: AbortSignal.timeout(options.timeoutMs ?? 10000),
13
+ headers: { accept: body ? "application/json" : "application/vnd.npm.install-v1+json", ...(body ? { "content-type": "application/json" } : {}) },
14
+ ...(body ? { body: JSON.stringify(body) } : {})
15
+ });
16
+ if (!response.ok) {
17
+ await response.body?.cancel();
18
+ return unavailable(`Registry HTTP ${response.status}`);
19
+ }
20
+ const limit = options.maxBytes ?? 5 * 1024 * 1024;
21
+ if (Number(response.headers.get("content-length")) > limit) {
22
+ await response.body?.cancel();
23
+ return unavailable("Registry response exceeds size limit");
24
+ }
25
+ if (!response.body)
26
+ return unavailable("Empty registry response");
27
+ const reader = response.body.getReader(), chunks = [];
28
+ let size = 0;
29
+ for (;;) {
30
+ const { done, value } = await reader.read();
31
+ if (done)
32
+ break;
33
+ size += value.length;
34
+ if (size > limit) {
35
+ await reader.cancel();
36
+ return unavailable("Registry response exceeds size limit");
37
+ }
38
+ chunks.push(value);
39
+ }
40
+ const text = Buffer.concat(chunks).toString("utf8"), data = JSON.parse(text);
41
+ return { status: "available", data, sha256: hash(text) };
42
+ }
43
+ catch {
44
+ return unavailable("Registry request failed, timed out, or returned invalid JSON");
45
+ }
46
+ };
47
+ return { package: name => request(encodeURIComponent(name)), advisories: packages => request("-/npm/v1/security/advisories/bulk", packages) };
48
+ }
49
+ function packageDetails(pkg, result) {
50
+ const data = result.data;
51
+ if (result.status !== "available" || !object(data) || data.name !== pkg.name || !object(data.versions) || !object(data.versions[pkg.version]))
52
+ return { metadata: { status: "unavailable", reason: result.reason ?? "Installed package/version metadata is missing or malformed" } };
53
+ const installed = data.versions[pkg.version];
54
+ if (installed.version !== pkg.version || (installed.deprecated !== undefined && typeof installed.deprecated !== "string") ||
55
+ installed.engines !== undefined && (!object(installed.engines) || installed.engines.node !== undefined && typeof installed.engines.node !== "string"))
56
+ return { metadata: { status: "unavailable", reason: "Installed-version metadata is malformed" } };
57
+ const latest = object(data["dist-tags"]) && typeof data["dist-tags"].latest === "string" && semver.valid(data["dist-tags"].latest) && object(data.versions[data["dist-tags"].latest]) ? data["dist-tags"].latest : null;
58
+ const versions = Object.keys(data.versions).filter(version => semver.valid(version));
59
+ const upgrades = pkg.uses.filter(use => use.direct).map(use => {
60
+ const spec = registrySpec(use.requested, use.resolvedRange ?? use.range), range = spec?.name === pkg.name ? spec.range : undefined;
61
+ const wanted = range && semver.validRange(range) ? semver.maxSatisfying(versions, range) : null;
62
+ return { manifest: use.manifest, range: use.range, wanted, newerWithinRange: !!wanted && semver.gt(wanted, pkg.version),
63
+ latestOutsideRange: latest && range && semver.validRange(range) ? !semver.satisfies(latest, range) : null,
64
+ ...(!range ? { reason: "Original range is unsupported or names a different package replaced by an alias/override" } : {}) };
65
+ });
66
+ return { metadata: { status: "available", sha256: result.sha256 }, deprecated: typeof installed.deprecated === "string" ? installed.deprecated : null, latest, upgrades,
67
+ nodeEngine: object(installed.engines) && typeof installed.engines.node === "string" ? installed.engines.node : null };
68
+ }
69
+ function advisoryDetails(pkg, result) {
70
+ if (result.status !== "available" || !object(result.data) || "error" in result.data)
71
+ return { status: "unavailable", reason: result.reason ?? "Malformed advisory response" };
72
+ const entries = Object.hasOwn(result.data, pkg.name) ? result.data[pkg.name] : [];
73
+ if (!Array.isArray(entries))
74
+ return { status: "unavailable", reason: "Malformed package advisory list" };
75
+ const advisories = new Map();
76
+ for (const row of entries) {
77
+ if (!object(row) || row.name !== pkg.name || !["string", "number"].includes(typeof row.id) || typeof row.title !== "string" ||
78
+ typeof row.url !== "string" || typeof row.vulnerable_versions !== "string" || !semver.validRange(row.vulnerable_versions) ||
79
+ typeof row.severity !== "string" || !["low", "moderate", "high", "critical", "info"].includes(row.severity))
80
+ return { status: "unavailable", reason: "Malformed advisory fields" };
81
+ if (!/^https:\/\//.test(row.url))
82
+ return { status: "unavailable", reason: "Invalid advisory URL" };
83
+ if (semver.satisfies(pkg.version, row.vulnerable_versions, { includePrerelease: true })) {
84
+ const id = String(row.id);
85
+ advisories.set(id, { id, title: row.title, url: row.url, severity: row.severity, range: row.vulnerable_versions });
86
+ }
87
+ }
88
+ return { status: "available", advisories: [...advisories.values()].sort((a, b) => a.id.localeCompare(b.id, "en")) };
89
+ }
90
+ export async function dependencyMetadata(inventory, reader = publicRegistryReader(), options = {}) {
91
+ const maximum = options.maxPackages ?? 100, deadline = Date.now() + (options.deadlineMs ?? 60000);
92
+ if (!Number.isSafeInteger(maximum) || maximum < 1 || maximum > 1000)
93
+ throw new Error("Metadata package limit must be an integer from 1 to 1000");
94
+ const direct = new Set(inventory.packages.filter(pkg => pkg.uses.some(use => use.direct)).map(pkg => pkg.name));
95
+ const names = [...new Set(inventory.packages.filter(pkg => pkg.source === "publicRegistry").map(pkg => pkg.name))]
96
+ .sort((a, b) => Number(direct.has(b)) - Number(direct.has(a)) || a.localeCompare(b, "en")).slice(0, maximum);
97
+ const payload = Object.create(null);
98
+ for (const name of names)
99
+ payload[name] = [...new Set(inventory.packages.filter(pkg => pkg.source === "publicRegistry" && pkg.name === name).map(pkg => pkg.version))];
100
+ const results = new Map();
101
+ let cursor = 0;
102
+ const safe = async (work) => { try {
103
+ return await work();
104
+ }
105
+ catch {
106
+ return unavailable("Registry reader failed");
107
+ } };
108
+ await Promise.all(Array.from({ length: Math.min(4, names.length) }, async () => {
109
+ while (cursor < names.length) {
110
+ const name = names[cursor++];
111
+ results.set(name, Date.now() >= deadline ? unavailable("Metadata time budget exhausted") : await safe(() => reader.package(name)));
112
+ }
113
+ }));
114
+ const audit = names.length && Date.now() < deadline ? await safe(() => reader.advisories(payload)) : unavailable("No eligible packages or metadata time budget exhausted");
115
+ const observations = inventory.packages.map(pkg => {
116
+ const eligible = pkg.source === "publicRegistry";
117
+ const metadata = eligible ? (results.get(pkg.name) ?? unavailable("Metadata package limit exceeded")) : unavailable("Workspace, private/other source, or registry provenance unavailable");
118
+ return { packageId: pkg.id, name: pkg.name, version: pkg.version, ...packageDetails(pkg, metadata),
119
+ vulnerabilities: eligible && Object.hasOwn(payload, pkg.name) ? advisoryDetails(pkg, audit) : { status: "unavailable", reason: metadata.reason } };
120
+ });
121
+ const available = observations.filter(row => row.metadata.status === "available" && row.vulnerabilities.status === "available").length;
122
+ const anyAvailable = observations.some(row => row.metadata.status === "available" || row.vulnerabilities.status === "available");
123
+ return { version: 1, status: observations.length && available === observations.length ? "available" : anyAvailable ? "partial" : "unavailable", source: "https://registry.npmjs.org", scope: "public-registry-lockfile-packages",
124
+ checkedAtUtc: new Date().toISOString(), observations, availablePackages: available, unavailablePackages: observations.length - available, ...(audit.sha256 ? { advisoryResponseSha256: audit.sha256 } : {}) };
125
+ }
@@ -0,0 +1,4 @@
1
+ import type { PackageInput } from "./discovery.js";
2
+ import type { DependencyInventory, DependencyPackage } from "./dependency-inventory.js";
3
+ /** pnpm v9 adapter, including pnpm 12 environment + project YAML documents. No target code executes. */
4
+ export declare function pnpmInventory(pkg: Pick<PackageInput, "entryPoint" | "root" | "name">, root: string, file: string, inventory: DependencyInventory, inputs: Set<string>, rows: Map<string, DependencyPackage>): void;
@@ -0,0 +1,237 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import semver from "semver";
4
+ import { parseAllDocuments } from "yaml";
5
+ import { hash } from "./evidence.js";
6
+ import { declarations, registrySpec } from "./dependency-spec.js";
7
+ const object = (value) => !!value && typeof value === "object" && !Array.isArray(value);
8
+ const own = (value, key) => object(value) && Object.hasOwn(value, key) ? value[key] : undefined;
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
+ const fields = { production: "dependencies", development: "devDependencies", optional: "optionalDependencies" };
12
+ function input(file, root, inputs) {
13
+ inputs.add(file);
14
+ if (!within(root, fs.realpathSync(file)))
15
+ throw new Error("pnpm input is outside repository");
16
+ if (fs.statSync(file).size > 20 * 1024 * 1024)
17
+ throw new Error("pnpm input exceeds 20 MiB");
18
+ return fs.readFileSync(file, "utf8");
19
+ }
20
+ function yamlDocuments(text) {
21
+ try {
22
+ const docs = parseAllDocuments(text, { uniqueKeys: true, strict: true });
23
+ if (!docs.length || docs.length > 2 || docs.some(doc => doc.errors.length || doc.warnings.length))
24
+ throw new Error();
25
+ const values = docs.map(doc => doc.toJS({ maxAliasCount: 0 }));
26
+ if (!values.every(object))
27
+ throw new Error();
28
+ return values;
29
+ }
30
+ catch {
31
+ throw new Error("Malformed or unsupported pnpm YAML (duplicate keys, tags, aliases, or document layout)");
32
+ }
33
+ }
34
+ function projectLock(text) {
35
+ const docs = yamlDocuments(text);
36
+ if (docs.some(doc => doc.lockfileVersion !== "9.0" && doc.lockfileVersion !== 9))
37
+ throw new Error("Only pnpm lockfile version 9 is supported");
38
+ if (docs.length === 2) {
39
+ const env = docs[0], importers = env.importers;
40
+ if (!object(importers) || Object.keys(importers).length !== 1 || !object(importers["."]))
41
+ throw new Error("Unsupported pnpm environment lock document");
42
+ const importer = importers["."];
43
+ if (!("configDependencies" in importer || "packageManagerDependencies" in importer) || Object.values(fields).some(field => importer[field] !== undefined))
44
+ throw new Error("Ambiguous pnpm project lock documents");
45
+ }
46
+ const lock = docs.at(-1);
47
+ if (!object(lock.importers))
48
+ throw new Error("pnpm project lockfile has no importers map");
49
+ if (Object.values(lock.importers).some(importer => object(importer) && ("configDependencies" in importer || "packageManagerDependencies" in importer)))
50
+ throw new Error("pnpm environment document has no following project document");
51
+ if (lock.packages !== undefined && !object(lock.packages) || lock.snapshots !== undefined && !object(lock.snapshots))
52
+ throw new Error("Malformed pnpm package or snapshot map");
53
+ if (Object.keys(lock.importers).some(key => key !== "." && (!key || key.includes("\\") || key.includes(":") || path.posix.isAbsolute(key) || key.split("/").includes(".."))))
54
+ throw new Error("Unsafe pnpm importer location");
55
+ return { lock, document: docs.length };
56
+ }
57
+ function catalogRange(declaration, config, lock) {
58
+ if (!declaration.range.startsWith("catalog:"))
59
+ return;
60
+ const catalog = declaration.range.slice(8) || "default";
61
+ const configured = catalog === "default" ? config.catalog : own(config.catalogs, catalog);
62
+ const range = own(configured, declaration.requested), record = own(own(lock.catalogs, catalog), declaration.requested);
63
+ const overridden = record === undefined && typeof range === "string" && own(lock.overrides, declaration.requested) === range && [range, declaration.range].includes(own(config.overrides, declaration.requested));
64
+ if (typeof range !== "string" || !overridden && (!object(record) || record.specifier !== range))
65
+ throw new Error(`Catalog unavailable or stale for ${declaration.requested}`);
66
+ return range;
67
+ }
68
+ function importerRecord(importer, edge) {
69
+ const candidates = edge.kind === "peer" ? Object.values(fields).map(field => own(importer[field], edge.requested)) : [own(importer[fields[edge.kind]], edge.requested)];
70
+ return candidates.find(object);
71
+ }
72
+ function overrideRange(name, config, rootManifest, lock) {
73
+ const current = own(config.overrides, name) ?? own(own(rootManifest.pnpm, "overrides"), name);
74
+ if (typeof current !== "string")
75
+ return;
76
+ const resolved = catalogRange({ requested: name, range: current, kind: "production", optional: false }, config, lock) ?? current;
77
+ return own(lock.overrides, name) === resolved ? resolved : undefined;
78
+ }
79
+ function importerEdges(location, manifest, lock, config, rootManifest, includeDevelopment) {
80
+ const importer = own(lock.importers, location);
81
+ if (!object(importer))
82
+ throw new Error(`Selected/workspace manifest has no pnpm importer: ${location}`);
83
+ const all = declarations(manifest);
84
+ for (const [kind, field] of Object.entries(fields)) {
85
+ if (importer[field] !== undefined && !object(importer[field]))
86
+ throw new Error("Malformed pnpm importer dependencies");
87
+ for (const [name, record] of Object.entries(object(importer[field]) ? importer[field] : {})) {
88
+ if (!object(record) || typeof record.specifier !== "string" || typeof record.version !== "string")
89
+ throw new Error("Malformed pnpm importer resolution");
90
+ if (!all.some(edge => edge.requested === name && (edge.kind === kind || edge.kind === "peer")))
91
+ throw new Error(`Manifest and pnpm importer declarations differ: ${location}`);
92
+ }
93
+ }
94
+ const edges = [];
95
+ for (const edge of all) {
96
+ const record = importerRecord(importer, edge), resolvedRange = catalogRange(edge, config, lock);
97
+ if (edge.kind !== "peer" && (!record || record.specifier !== edge.range && record.specifier !== overrideRange(edge.requested, config, rootManifest, lock)))
98
+ throw new Error(`Manifest and pnpm importer declarations differ: ${location}`);
99
+ if (!includeDevelopment && edge.kind === "development")
100
+ continue;
101
+ edges.push({ ...edge, from: location, ref: typeof record?.version === "string" ? record.version : undefined, ...(resolvedRange ? { resolvedRange } : {}), role: edge.kind, direct: true, mayBeAbsent: edge.optional, importer: true });
102
+ }
103
+ return edges;
104
+ }
105
+ function identity(requested, ref) {
106
+ // Snapshot suffixes encode peer contexts (and patches); preserve the complete key as a location.
107
+ const key = semver.valid(ref.split("(")[0]) ? `${requested}@${ref}` : ref;
108
+ const base = key.split("(")[0], match = /^((?:@[^/\s]+\/)?[^@/:\s]+)@([^()]+)$/.exec(base);
109
+ if (!match || !semver.valid(match[2]) || key.includes("\\") || key.includes("://"))
110
+ return;
111
+ return { key, base, name: match[1], version: match[2] };
112
+ }
113
+ function sourceOf(record) {
114
+ const tarball = own(record.resolution, "tarball");
115
+ if (typeof tarball !== "string")
116
+ return "otherSource";
117
+ try {
118
+ const url = new URL(tarball);
119
+ return url.protocol === "https:" && url.hostname === "registry.npmjs.org" && !url.username && !url.password ? "publicRegistry" : "otherSource";
120
+ }
121
+ catch {
122
+ return "otherSource";
123
+ }
124
+ }
125
+ function children(from, snapshot, record, parent) {
126
+ const peers = declarations({ peerDependencies: record.peerDependencies, peerDependenciesMeta: record.peerDependenciesMeta });
127
+ const rows = [];
128
+ for (const field of ["dependencies", "optionalDependencies"]) {
129
+ const dependencies = snapshot[field];
130
+ if (dependencies === undefined)
131
+ continue;
132
+ if (!object(dependencies))
133
+ throw new Error("Malformed pnpm snapshot dependencies");
134
+ for (const [requested, ref] of Object.entries(dependencies)) {
135
+ if (typeof ref !== "string")
136
+ throw new Error("Malformed pnpm snapshot reference");
137
+ if (field === "dependencies" && own(snapshot.optionalDependencies, requested) !== undefined)
138
+ continue;
139
+ const peer = peers.find(edge => edge.requested === requested), optional = field === "optionalDependencies" || peer?.optional === true;
140
+ rows.push({ from, requested, range: peer?.range ?? ref, ref, kind: peer ? "peer" : optional ? "optional" : "production", role: parent.role, direct: false, optional: parent.optional || optional, mayBeAbsent: optional, importer: false });
141
+ }
142
+ }
143
+ for (const peer of peers)
144
+ if (!rows.some(edge => edge.requested === peer.requested))
145
+ rows.push({ ...peer, from, role: parent.role, direct: false, optional: parent.optional || peer.optional, mayBeAbsent: peer.optional, importer: false });
146
+ return rows;
147
+ }
148
+ /** pnpm v9 adapter, including pnpm 12 environment + project YAML documents. No target code executes. */
149
+ export function pnpmInventory(pkg, root, file, inventory, inputs, rows) {
150
+ const relative = (target) => slash(path.relative(root, target)), manifest = relative(pkg.entryPoint), directory = path.dirname(file);
151
+ const text = input(file, root, inputs), { lock, document } = projectLock(text);
152
+ if (!inventory.lockfiles.some(row => row.file === relative(file)))
153
+ inventory.lockfiles.push({ file: relative(file), version: 9, sha256: hash(text), projectDocument: document, ...(document === 2 ? { environmentDocument: "excluded" } : {}) });
154
+ const configFile = path.join(directory, "pnpm-workspace.yaml");
155
+ let config = {};
156
+ if (fs.existsSync(configFile)) {
157
+ const text = input(configFile, root, inputs), docs = yamlDocuments(text);
158
+ if (docs.length !== 1)
159
+ throw new Error("pnpm workspace configuration must be one YAML document");
160
+ config = docs[0];
161
+ inventory.configurationInputs ??= [];
162
+ if (!inventory.configurationInputs.some(row => row.file === relative(configFile)))
163
+ inventory.configurationInputs.push({ file: relative(configFile), sha256: hash(text) });
164
+ }
165
+ const rootFile = path.join(directory, "package.json"), rootManifest = fs.existsSync(rootFile) ? JSON.parse(input(rootFile, root, inputs)) : {};
166
+ const selectedManifest = JSON.parse(input(pkg.entryPoint, root, inputs));
167
+ const manifestRow = inventory.manifests.find(row => row.file === manifest);
168
+ manifestRow.overrides = { manifest: own(rootManifest.pnpm, "overrides") ?? {}, workspace: config.overrides ?? {}, lockfile: lock.overrides ?? {} };
169
+ const start = slash(path.relative(directory, pkg.root)) || ".";
170
+ const queue = importerEdges(start, selectedManifest, lock, config, rootManifest, true), visited = new Set();
171
+ const gap = (reason, edge) => inventory.gaps.push({ manifest, reason, dependency: edge.requested, from: edge.from, range: edge.range });
172
+ for (let index = 0; index < queue.length; index++) {
173
+ const edge = queue[index];
174
+ if (index >= 100000) {
175
+ gap("Dependency traversal limit exceeded", edge);
176
+ break;
177
+ }
178
+ if (!edge.ref) {
179
+ if (edge.mayBeAbsent)
180
+ 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" });
181
+ else
182
+ gap("Unresolved pnpm dependency/peer in lockfile", edge);
183
+ continue;
184
+ }
185
+ try {
186
+ let name, version, source, location, next;
187
+ if (edge.ref.startsWith("link:")) {
188
+ if (!edge.importer || edge.ref.includes("\\") || path.posix.isAbsolute(edge.ref.slice(5)) || edge.ref.slice(5).includes(":"))
189
+ throw new Error("Unsupported pnpm link reference");
190
+ const target = path.resolve(directory, edge.from, edge.ref.slice(5), "package.json");
191
+ if (!within(root, target) || !fs.existsSync(target))
192
+ throw new Error("pnpm workspace manifest unavailable or outside repository");
193
+ const data = JSON.parse(input(target, root, inputs));
194
+ if (!object(data) || typeof data.name !== "string" || typeof data.version !== "string" || !semver.valid(data.version))
195
+ throw new Error("Missing pnpm workspace name/version");
196
+ name = data.name;
197
+ version = data.version;
198
+ source = "workspace";
199
+ location = slash(path.relative(directory, path.dirname(target))) || ".";
200
+ const linked = importerEdges(location, data, lock, config, rootManifest, false);
201
+ next = () => linked.map(child => ({ ...child, direct: false, role: edge.role, optional: edge.optional || child.optional }));
202
+ }
203
+ else {
204
+ const id = identity(edge.requested, edge.ref);
205
+ if (!id)
206
+ throw new Error("Unsupported pnpm package reference (file/git/custom source or non-semver identity)");
207
+ const record = own(lock.packages, id.base), snapshot = own(lock.snapshots, id.key);
208
+ if (!object(record) || !object(snapshot))
209
+ throw new Error("Missing pnpm package record or exact peer-context snapshot");
210
+ name = id.name;
211
+ version = id.version;
212
+ source = sourceOf(record);
213
+ location = id.key;
214
+ next = () => children(location, snapshot, record, edge);
215
+ }
216
+ const spec = registrySpec(edge.requested, edge.resolvedRange ?? edge.range);
217
+ if (spec && (spec.name !== name || semver.validRange(spec.range) && !semver.satisfies(version, spec.range)))
218
+ inventory.notices.push({ manifest, from: edge.from, dependency: edge.requested, range: edge.range, version, reason: "Locked identity/version differs from requested range; alias/override or peer policy may explain this. Installation validity is not assessed" });
219
+ const id = hash(`${name}|${version}|${source}`), row = rows.get(id) ?? { id, name, version, source, locations: [], uses: [] };
220
+ rows.set(id, row);
221
+ const install = `${relative(file)}#${source === "workspace" ? "importers" : "snapshots"}/${location}`;
222
+ if (!row.locations.includes(install))
223
+ row.locations.push(install);
224
+ const use = { project: pkg.name, manifest, from: edge.from, requested: edge.requested, range: edge.range, ...(edge.resolvedRange ? { resolvedRange: edge.resolvedRange } : {}), kind: edge.role, direct: edge.direct, optional: edge.optional };
225
+ if (!row.uses.some(existing => JSON.stringify(existing) === JSON.stringify(use)))
226
+ row.uses.push(use);
227
+ const key = `${install}|${edge.role}|${edge.optional}`;
228
+ if (visited.has(key))
229
+ continue;
230
+ visited.add(key);
231
+ queue.push(...next());
232
+ }
233
+ catch (error) {
234
+ gap(error instanceof Error ? error.message : "pnpm dependency inventory unavailable", edge);
235
+ }
236
+ }
237
+ }
@@ -0,0 +1,13 @@
1
+ export type DependencyKind = "production" | "development" | "optional" | "peer";
2
+ type Json = Record<string, unknown>;
3
+ export declare function declarations(manifest: Json, includeDevelopment?: boolean): {
4
+ requested: string;
5
+ range: string;
6
+ kind: DependencyKind;
7
+ optional: boolean;
8
+ }[];
9
+ export declare function registrySpec(name: string, range: string): {
10
+ name: string;
11
+ range: string;
12
+ } | undefined;
13
+ export {};
@@ -0,0 +1,30 @@
1
+ import semver from "semver";
2
+ const object = (value) => !!value && typeof value === "object" && !Array.isArray(value);
3
+ const kinds = { dependencies: "production", devDependencies: "development", optionalDependencies: "optional", peerDependencies: "peer" };
4
+ export function declarations(manifest, includeDevelopment = true) {
5
+ const rows = [];
6
+ for (const [field, kind] of Object.entries(kinds)) {
7
+ if (!includeDevelopment && kind === "development")
8
+ continue;
9
+ if (manifest[field] === undefined)
10
+ continue;
11
+ if (!object(manifest[field]))
12
+ throw new Error(`Invalid ${field}`);
13
+ for (const [requested, range] of Object.entries(manifest[field])) {
14
+ if (typeof range !== "string")
15
+ throw new Error(`Invalid dependency range for ${requested}`);
16
+ if (kind === "production" && object(manifest.optionalDependencies) && requested in manifest.optionalDependencies)
17
+ continue;
18
+ const peer = object(manifest.peerDependenciesMeta) ? manifest.peerDependenciesMeta[requested] : undefined;
19
+ rows.push({ requested, range, kind, optional: kind === "optional" || kind === "peer" && object(peer) && peer.optional === true });
20
+ }
21
+ }
22
+ return rows.sort((a, b) => a.requested.localeCompare(b.requested, "en") || a.kind.localeCompare(b.kind, "en"));
23
+ }
24
+ export function registrySpec(name, range) {
25
+ if (range.startsWith("npm:")) {
26
+ const match = /^npm:((?:@[^/]+\/)?[^@/]+)(?:@(.+))?$/.exec(range);
27
+ return match ? { name: match[1], range: match[2] ?? "*" } : undefined;
28
+ }
29
+ return semver.validRange(range) ? { name, range } : undefined;
30
+ }
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import { Ajv2020 } from "ajv/dist/2020.js";
4
4
  import addFormats from "ajv-formats";
5
5
  import { validateEvidence } from "./evidence-tools.js";
6
+ import { supportsPythonComparison } from "./python-evidence.js";
6
7
  const ajv = new Ajv2020({ allErrors: true, strict: false });
7
8
  addFormats(ajv);
8
9
  let legacyValidator;
@@ -46,7 +47,8 @@ export function inspectEvidence(file, expected = {}) {
46
47
  const legacy = evidence.schemaVersion === 2;
47
48
  const failed = Object.values(evidence.dimensions).some(dimension => dimension.status === "failed");
48
49
  const incomplete = !legacy && evidence.analysis.status !== "complete";
49
- return { evidence, compatibility: { mode: legacy ? "legacy" : "native", comparisonAvailable: !legacy && !failed && !incomplete,
50
+ const experimental = !legacy && evidence.tool.ecosystem === "python" && !supportsPythonComparison(evidence);
51
+ return { evidence, compatibility: { mode: legacy ? "legacy" : "native", comparisonAvailable: !legacy && !failed && !incomplete && !experimental,
50
52
  analysisStatus: legacy ? "unknown" : evidence.analysis.status,
51
53
  scopeAvailable: !legacy && Object.values(evidence.dimensions).every(dimension => dimension.scope !== undefined) },
52
54
  usable: !failed && !incomplete };
@@ -4,6 +4,7 @@ import { pathToFileURL } from "node:url";
4
4
  import { Ajv2020 } from "ajv/dist/2020.js";
5
5
  import addFormats from "ajv-formats";
6
6
  import { dimensionKeys } from "./scorecard-contract.js";
7
+ import { validatePythonEvidence, supportsPythonComparison } from "./python-evidence.js";
7
8
  const ajv = new Ajv2020({ allErrors: true, strict: false });
8
9
  addFormats(ajv);
9
10
  let validator;
@@ -14,6 +15,7 @@ export function validateEvidence(value) {
14
15
  if (!validator(value))
15
16
  throw new Error("Invalid evidence: " + ajv.errorsText(validator.errors));
16
17
  const evidence = value;
18
+ validatePythonEvidence(evidence);
17
19
  if (evidence.filters.analyzedUnits > evidence.filters.totalUnits)
18
20
  throw new Error("Analyzed units exceed total units.");
19
21
  const ids = findings(evidence).map(finding => finding.fingerprint);
@@ -54,6 +56,8 @@ function entryPoint(evidence) {
54
56
  }
55
57
  export function compare(current, baseline, allowIncompatible = false) {
56
58
  const reasons = [];
59
+ if ([current, baseline].some(item => item.tool.ecosystem === "python" && !supportsPythonComparison(item)))
60
+ reasons.push("Python comparisons require supported Method Complexity scores; historical previews and empty populations cannot gate");
57
61
  for (const key of ["ecosystem", "version", "name"])
58
62
  if (current.tool[key] !== baseline.tool[key])
59
63
  reasons.push("tool." + key + " differs");
@@ -1,5 +1,6 @@
1
1
  import { type Dimension, type Finding } from "./evidence.js";
2
2
  import type { FunctionMeasurement } from "./function-measurements.js";
3
- export declare const complexityAnchors: readonly [readonly [3, 10], readonly [5, 8], readonly [10, 6], readonly [20, 4], readonly [40, 0]];
3
+ import { complexityAnchors } from "#source-facts/population.mjs";
4
+ export { complexityAnchors };
4
5
  export declare const maintainabilityAnchors: readonly [readonly [40, 0], readonly [52, 2], readonly [58, 4], readonly [65, 6], readonly [70, 8], readonly [75, 10]];
5
6
  export declare function scoreFunctions(functions: FunctionMeasurement[], dimension: "codeQuality" | "maintainability", findings?: Finding[]): Dimension;
@@ -1,6 +1,7 @@
1
1
  import { scored } from "./evidence.js";
2
- import { add, decimal, fraction, interpolate, multiply, numeric, roundScore, subtract } from "./score-arithmetic.js";
3
- export const complexityAnchors = [[3, 10], [5, 8], [10, 6], [20, 4], [40, 0]];
2
+ import { aggregatePopulation, complexityPopulation, complexityAnchors } from "#source-facts/population.mjs";
3
+ import { functionSourceFacts } from "./source-facts.js";
4
+ export { complexityAnchors };
4
5
  export const maintainabilityAnchors = [[40, 0], [52, 2], [58, 4], [65, 6], [70, 8], [75, 10]];
5
6
  export function scoreFunctions(functions, dimension, findings = []) {
6
7
  const complexity = dimension === "codeQuality";
@@ -11,29 +12,17 @@ export function scoreFunctions(functions, dimension, findings = []) {
11
12
  excludes: ["module-and-field-initializers", "parameter-initializers", "runtime-behavior", complexity ? "decomposition" : "change-cost"] };
12
13
  if (!functions.length)
13
14
  return { status: "skipped", basis: "No implemented functions; function score is unmeasured.", findings, scope };
14
- const ordered = functions.map(item => ({ item, score: interpolate(measured(item), anchors) }))
15
- .sort((a, b) => (complexity ? measured(b.item) - measured(a.item) : measured(a.item) - measured(b.item)) ||
16
- (a.item.id < b.item.id ? -1 : a.item.id > b.item.id ? 1 : 0));
17
- const weakCount = complexity ? 1 : Math.ceil(ordered.length / 5);
18
- const restCount = ordered.length - weakCount;
19
- const weakWeight = restCount ? fraction(2n, 5n * BigInt(weakCount)) : fraction(1n, BigInt(weakCount));
20
- const restWeight = restCount ? fraction(3n, 5n * BigInt(restCount)) : decimal(0);
21
- const losses = { weakest: decimal(0), remainder: decimal(0) };
22
- const contributions = ordered.map(({ item, score }, index) => {
23
- const group = index < weakCount ? "weakest" : "remainder";
24
- const weight = index < weakCount ? weakWeight : restWeight;
25
- const weightedLoss = multiply(subtract(decimal(10), score), weight);
26
- losses[group] = add(losses[group], weightedLoss);
27
- return { ...item, group, individualScore: numeric(score), weightNumerator: Number(weight.numerator),
28
- weightDenominator: Number(weight.denominator), weightedScoreLoss: numeric(weightedLoss) };
29
- });
30
- const total = subtract(decimal(10), add(losses.weakest, losses.remainder));
15
+ const population = complexity ? complexityPopulation(functionSourceFacts(functions))
16
+ : aggregatePopulation(functions.map(item => ({ id: item.id, value: measured(item) })), anchors, false, Math.ceil(functions.length / 5));
17
+ const { weakCount, restCount, losses } = population;
18
+ const byFunctionId = new Map(functions.map(item => [item.id, item]));
19
+ const contributions = population.contributions.map(({ id, ...contribution }) => ({ ...byFunctionId.get(id), ...contribution }));
31
20
  const decision = { version: 1, policy: `javascript-typescript/${dimension}/owned-functions-v2`, operation: "deductions",
32
- finalScore: roundScore(total), inputs: { baseline: 10, eligibleFunctions: ordered.length, weakCount, restCount,
21
+ finalScore: population.score, inputs: { baseline: 10, eligibleFunctions: functions.length, weakCount, restCount,
33
22
  anchors, aggregation: complexity ? "worst-40-remainder-60" : "weakest-quintile-40-remainder-60",
34
- rounding: "decimal-rational-half-up-one-decimal", unroundedScore: numeric(total) },
35
- steps: (restCount ? ["weakest", "remainder"] : ["weakest"]).map(group => ({ id: group, kind: "component", score: numeric(losses[group]),
36
- disposition: "applied", inputs: { weightedScoreLoss: numeric(losses[group]), functionCount: group === "weakest" ? weakCount : restCount },
23
+ rounding: "decimal-rational-half-up-one-decimal", unroundedScore: population.unroundedScore },
24
+ steps: (restCount ? ["weakest", "remainder"] : ["weakest"]).map(group => ({ id: group, kind: "component", score: losses[group],
25
+ disposition: "applied", inputs: { weightedScoreLoss: losses[group], functionCount: group === "weakest" ? weakCount : restCount },
37
26
  findingCategories: complexity ? ["highFunctionComplexity"] : [] })), findingEffects: [] };
38
27
  const result = scored(decision, `${complexity ? "Method complexity: one worst function" : "Maintainability: weakest ceil(N/5) functions"} receives 40%; remaining functions receive 60%. Single-function scopes use their own score. JS/TS remains uncalibrated across ecosystems.`, findings, { ...decision.inputs, measurement: "owned-function-body-v2-type-erasure", functionContributions: contributions, topOffenders: contributions.slice(0, 5) });
39
28
  // Findings identify population inputs, never additional independent penalties.
@@ -44,7 +33,7 @@ export function scoreFunctions(functions, dimension, findings = []) {
44
33
  effect: group ? "policyInput" : "noAdditionalReduction", stepIds: group ? [group] : [] };
45
34
  });
46
35
  return { ...result, scope, ...(complexity ? { displayName: "Method Complexity", componentDetails: {
47
- methodComplexity: { score: result.score, eligibleFunctions: ordered.length, topOffenders: contributions.slice(0, 5) },
36
+ methodComplexity: { score: result.score, eligibleFunctions: functions.length, topOffenders: contributions.slice(0, 5) },
48
37
  decomposition: { score: null, status: "unsupported", description: "JS/TS decomposition is not measured by this policy." }
49
38
  } } : {}) };
50
39
  }
package/dist/index.d.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  export { defaultEvidencePath, defaultMetricsPath, dimensionKeys, ecosystemId, metricsCsvHeader, type DimensionKey, } from "./scorecard-contract.js";
2
2
  export { analyze } from "./analyzer.js";
3
+ export { enrichDependencies } from "./dependency-evidence.js";
4
+ export type { RegistryReader } from "./dependency-metadata.js";
3
5
  export { compare, gate, sarif, validateEvidence, readEvidence } from "./evidence-tools.js";
4
6
  export type { Evidence, Finding, Dimension } from "./evidence.js";
5
7
  export { readCompatibleEvidence, inspectEvidence, type LegacyEvidence } from "./evidence-reader.js";
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export { defaultEvidencePath, defaultMetricsPath, dimensionKeys, ecosystemId, metricsCsvHeader, } from "./scorecard-contract.js";
2
2
  export { analyze } from "./analyzer.js";
3
+ export { enrichDependencies } from "./dependency-evidence.js";
3
4
  export { compare, gate, sarif, validateEvidence, readEvidence } from "./evidence-tools.js";
4
5
  export { readCompatibleEvidence, inspectEvidence } from "./evidence-reader.js";
@@ -0,0 +1,38 @@
1
+ # Python callback adapter context
2
+
3
+ `async_adapters.py` implements `python/async-callback-adapters-review-v1` as an experimental report embedded at `dimensions.performanceAsync.adapterReview` in development adapter 0.14.0, ruleset `python-2026-10-24-async-adapters-review-v1`. It supplements the [async context report](ASYNC_CONTEXT.md) without changing its original observations. The archive ships this guide and the producer; the companion skill reports the evidence without runtime claims. Historical 0.13.0 reports retain their contract without invented adapter observations. **Async/Blocking Usage and Error Handling remain unscored.** Method Complexity remains Python's only scored dimension.
4
+
5
+ ```powershell
6
+ py -3.12 analyzers/python/async_adapters.py E:/repos/example --output E:/temp/adapters.json
7
+ py -3.12 analyzers/python/async_adapters_corpus.py --repos E:/repos --output TestResults/python-adapters-new
8
+ ```
9
+
10
+ The same source-selection flags and optional run/audit UUIDs as the original async reviewer are supported. Output must be a new `.json` file with an existing parent; the corpus directory must be new. Target code is parsed, never imported or executed.
11
+
12
+ ## Partial construction
13
+
14
+ Import-matched `functools.partial` calls record the construction extent, first positional callback expression, and bound positional/keyword expression shapes and extents. Literal argument values are not copied. `*args` and `**kwargs` are explicit syntax, not known argument counts. With a missing or starred first positional argument, callback layout is unresolved; the tool does not interpret `func=` as a callback. Callback references use the existing conservative local-definition/import-spelling rules. Calls, lambdas, receiver methods, and nested partial results are not resolved to callback implementations.
15
+
16
+ These are construction observations, not proof that a partial was created, callable, executed, or offloaded successfully. Eager callback/argument calls retain their original owner. Callback and lambda bodies are never traversed to assign behavior to the async caller.
17
+
18
+ An inline partial passed to a recognized helper or used as a callee links to its original owned construction call ID. A stored partial supports a direct-suite, single-name assignment in the same async owner, ending before use, with exactly one binding and a first positional callback. Reassignment, parameters, aliases, conditional/annotated/chained assignments, deletion, reflective operations, cross-scope writes, and comprehension shadowing withhold that link. Stored links in deferred generator bodies are withheld. Inline constructions in deferred bodies remain separately labeled syntax.
19
+
20
+ For example, Starlette's `func = functools.partial(func, *args, **kwargs)` is a visible construction, but the parameter and assignment make `func` ambiguous. The subsequent helper callback remains unresolved. We do not infer the incoming callable's identity.
21
+
22
+ Supported stored `sync_to_async` wrappers also expose partial callback context evaluated at construction. Repeated callback references link to the same construction. Construction, callback references, and invocation expressions must never be added together as distinct offloaded operations.
23
+
24
+ ## Decorator links
25
+
26
+ A bare-name callee or helper callback can link to a unique direct lexical `def` with exactly one import-matched `asgiref.sync.sync_to_async` decorator. Supported syntax is `@sync_to_async`, `@sync_to_async()`, or a factory with one literal boolean `thread_sensitive` keyword. Import aliases are supported. The decorator is resolved in the definition's enclosing scope, not the caller's scope.
27
+
28
+ The definition must end before the reference, have a selected source-fact owner, and have no competing binding. Stacked or unknown decorators, unsupported arguments, starred arguments, async definitions, conditional definitions, late definitions, receiver methods, and ambiguous/mutated imports are unresolved. Class namespaces are not lexical closures for methods. A positive link records a synchronous source definition and decorator syntax; it does not reclassify that owner's body as async or validate passing a decorated callable to a thread helper. Decorator headers are not added to the original async call population.
29
+
30
+ ## Accounting and validation
31
+
32
+ Every original async call keeps its ID and direct/deferred execution context. Construction and reference counts are separate, as are production, test, support, and generated populations. Sync-only and empty populations are unavailable, with null measurements. Zero matches are not a clean async verdict. Existing unresolved import counts are unchanged.
33
+
34
+ The report carries source selection, source-facts producer/policy, original async-row hash, configuration fingerprint, and matching run/audit IDs. It regenerates the input report before collection and verifies source hashes and exact owner/call measurements while collecting. Stale, inconsistent, changed, over-budget, or failed inputs produce exit 2, diagnostics, empty rows, and a null summary. These checks are not report authentication, filesystem locking, control-flow proof, or runtime identity analysis.
35
+
36
+ No score, finding, deduction, gate, automatic concurrency recommendation, or judgment of developer intent is emitted.
37
+
38
+ The package validator checks internal consistency against validated async/context reports: invocation and source metadata, original-row hash, ordered owners/calls, extents, construction links and repeated references, selected local definitions, decorator options, and recomputed context/population summaries. It is not independent AST remeasurement or report authentication. Incomplete required evidence makes the combined scorecard unusable; observations cannot restore a score. See the [installed-package study](../../shared/scorecard-schema/calibration-runs/python-async-adapters-package-2026-10-24.md).