codemetrics-ai 0.4.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.
- package/README.md +51 -5
- package/dist/analyzer.d.ts +1 -0
- package/dist/analyzer.js +11 -5
- package/dist/cli.js +7 -3
- package/dist/dependency-evidence.d.ts +5 -0
- package/dist/dependency-evidence.js +30 -0
- package/dist/dependency-inventory.d.ts +67 -0
- package/dist/dependency-inventory.js +203 -0
- package/dist/dependency-metadata.d.ts +65 -0
- package/dist/dependency-metadata.js +125 -0
- package/dist/dependency-pnpm.d.ts +4 -0
- package/dist/dependency-pnpm.js +237 -0
- package/dist/dependency-spec.d.ts +13 -0
- package/dist/dependency-spec.js +30 -0
- package/dist/error-handling-scoring.d.ts +1 -0
- package/dist/error-handling-scoring.js +15 -0
- package/dist/error-handling.d.ts +4 -1
- package/dist/error-handling.js +50 -17
- package/dist/evidence-reader.js +3 -1
- package/dist/evidence-tools.js +4 -0
- package/dist/failure-contracts.d.ts +19 -0
- package/dist/failure-contracts.js +261 -0
- package/dist/function-scoring.d.ts +2 -1
- package/dist/function-scoring.js +13 -24
- package/dist/handler-disposition.d.ts +9 -0
- package/dist/handler-disposition.js +156 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/python/ASYNC_ADAPTERS.md +38 -0
- package/dist/python/ASYNC_CONTEXT.md +58 -0
- package/dist/python/ASYNC_SCORING.md +174 -0
- package/dist/python/ASYNC_USAGE.md +62 -0
- package/dist/python/BEHAVIOR_REVIEW.md +84 -0
- package/dist/python/COVERAGE.md +96 -0
- package/dist/python/DECOMPOSITION.md +46 -0
- package/dist/python/DUPLICATION.md +36 -0
- package/dist/python/ERROR_HANDLING.md +45 -0
- package/dist/python/ERROR_HANDLING_CONTEXT.md +61 -0
- package/dist/python/ERROR_HANDLING_OPERATIONS.md +54 -0
- package/dist/python/ERROR_HANDLING_TRANSFERS.md +64 -0
- package/dist/python/METHOD_COMPLEXITY.md +46 -0
- package/dist/python/README.md +162 -0
- package/dist/python/analysis.py +54 -0
- package/dist/python/async_adapters.py +243 -0
- package/dist/python/async_bindings.py +180 -0
- package/dist/python/async_context.py +143 -0
- package/dist/python/async_helper_paths.py +186 -0
- package/dist/python/async_provenance.py +129 -0
- package/dist/python/async_scoring.py +203 -0
- package/dist/python/async_usage.py +244 -0
- package/dist/python/coverage_collect.py +121 -0
- package/dist/python/coverage_provenance.py +77 -0
- package/dist/python/coverage_review.py +273 -0
- package/dist/python/coverage_worker.py +46 -0
- package/dist/python/decomposition.py +199 -0
- package/dist/python/duplication.py +177 -0
- package/dist/python/error_handling.py +249 -0
- package/dist/python/error_handling_context.py +294 -0
- package/dist/python/error_handling_operations.py +228 -0
- package/dist/python/error_handling_transfers.py +211 -0
- package/dist/python/review_cli.py +36 -0
- package/dist/python/source_facts.py +293 -0
- package/dist/python/source_selection.py +157 -0
- package/dist/python-analyzer.d.ts +21 -0
- package/dist/python-analyzer.js +123 -0
- package/dist/python-async-adapters.d.ts +67 -0
- package/dist/python-async-adapters.js +197 -0
- package/dist/python-async-context.d.ts +73 -0
- package/dist/python-async-context.js +181 -0
- package/dist/python-async-scoring.d.ts +89 -0
- package/dist/python-async-scoring.js +249 -0
- package/dist/python-async-usage.d.ts +56 -0
- package/dist/python-async-usage.js +143 -0
- package/dist/python-cli.d.ts +2 -0
- package/dist/python-cli.js +53 -0
- package/dist/python-collect-cli.d.ts +2 -0
- package/dist/python-collect-cli.js +28 -0
- package/dist/python-complexity.d.ts +18 -0
- package/dist/python-complexity.js +36 -0
- package/dist/python-coverage.d.ts +63 -0
- package/dist/python-coverage.js +173 -0
- package/dist/python-decomposition.d.ts +56 -0
- package/dist/python-decomposition.js +102 -0
- package/dist/python-duplication.d.ts +65 -0
- package/dist/python-duplication.js +89 -0
- package/dist/python-error-context.d.ts +84 -0
- package/dist/python-error-context.js +165 -0
- package/dist/python-error-handling.d.ts +56 -0
- package/dist/python-error-handling.js +120 -0
- package/dist/python-error-operations.d.ts +44 -0
- package/dist/python-error-operations.js +155 -0
- package/dist/python-error-transfers.d.ts +38 -0
- package/dist/python-error-transfers.js +125 -0
- package/dist/python-evidence.d.ts +23 -0
- package/dist/python-evidence.js +255 -0
- package/dist/score-arithmetic.d.ts +1 -12
- package/dist/score-arithmetic.js +1 -40
- package/dist/shared-source-facts/arithmetic.d.mts +9 -0
- package/dist/shared-source-facts/arithmetic.mjs +32 -0
- package/dist/shared-source-facts/contract.d.mts +35 -0
- package/dist/shared-source-facts/contract.mjs +95 -0
- package/dist/shared-source-facts/population.d.mts +12 -0
- package/dist/shared-source-facts/population.mjs +41 -0
- package/dist/source-facts.d.ts +4 -0
- package/dist/source-facts.js +16 -0
- 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
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function errorHandlingDecision(total: number, unexplained: number, unknown: number, unresolvedUses: number): import("./scoring-decision.js").ScoringDecision | undefined;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { firstMatch } from "./scoring-decision.js";
|
|
2
|
+
import { fraction, roundScore } from "./score-arithmetic.js";
|
|
3
|
+
export function errorHandlingDecision(total, unexplained, unknown, unresolvedUses) {
|
|
4
|
+
if (![total, unexplained, unknown, unresolvedUses].every(value => Number.isSafeInteger(value) && value >= 0) || unexplained + unknown > total)
|
|
5
|
+
throw new Error("Invalid handler population.");
|
|
6
|
+
if (!total || unknown || unresolvedUses)
|
|
7
|
+
return undefined;
|
|
8
|
+
const populationScore = roundScore(fraction(10n * BigInt(total - unexplained), BigInt(total)));
|
|
9
|
+
const cap = unexplained ? 9 : 10;
|
|
10
|
+
return firstMatch("javascript-typescript/errorHandling/failure-disposition-v2", { countingUnit: "distinctHandlerBody", totalHandlers: total, assessedHandlers: total, unexplainedSwallowing: unexplained,
|
|
11
|
+
unknownHandlers: unknown, unresolvedCallbackUses: unresolvedUses, populationScore, cap,
|
|
12
|
+
formula: "min(10 - 10 * unexplainedSwallowing / assessedHandlers, unexplainedSwallowing > 0 ? 9 : 10)", rounding: "half-up-one-decimal" }, [{ id: "issueCap", condition: "unexplainedSwallowing > 0 and populationScore > 9", matched: unexplained > 0 && populationScore > 9,
|
|
13
|
+
score: 9, categories: ["unexplainedSwallowing"] },
|
|
14
|
+
{ id: "population", condition: "otherwise", matched: true, score: populationScore, categories: ["unexplainedSwallowing"] }]);
|
|
15
|
+
}
|
package/dist/error-handling.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import ts from "typescript";
|
|
2
2
|
import { type Dimension } from "./evidence.js";
|
|
3
3
|
import { HandlerResolver } from "./handler-resolution.js";
|
|
4
|
+
import { type HandlerDisposition } from "./handler-disposition.js";
|
|
5
|
+
import type { FailureContracts } from "./failure-contracts.js";
|
|
4
6
|
type UsageKind = "catchClause" | "promiseCatch" | "promiseThenRejection";
|
|
5
7
|
interface Handler {
|
|
6
8
|
id: string;
|
|
@@ -10,6 +12,7 @@ interface Handler {
|
|
|
10
12
|
kind: "catchClause" | "promiseRejection";
|
|
11
13
|
classification: "unexplainedEmpty" | "documentedEmpty" | "containsCode";
|
|
12
14
|
comments: string[];
|
|
15
|
+
disposition: HandlerDisposition;
|
|
13
16
|
uses: {
|
|
14
17
|
file: string;
|
|
15
18
|
line: number;
|
|
@@ -17,7 +20,7 @@ interface Handler {
|
|
|
17
20
|
resolution: "inline" | "reference";
|
|
18
21
|
}[];
|
|
19
22
|
}
|
|
20
|
-
export declare function inspectErrorHandling(source: ts.SourceFile, checker: ts.TypeChecker, file: string, project: string, resolver: HandlerResolver): {
|
|
23
|
+
export declare function inspectErrorHandling(source: ts.SourceFile, checker: ts.TypeChecker, file: string, project: string, resolver: HandlerResolver, contracts: FailureContracts): {
|
|
21
24
|
handlers: Handler[];
|
|
22
25
|
uninspectedCallbacks: {
|
|
23
26
|
file: string;
|
package/dist/error-handling.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import ts from "typescript";
|
|
2
|
-
import { hash } from "./evidence.js";
|
|
2
|
+
import { hash, scored } from "./evidence.js";
|
|
3
3
|
import { unwrapHandler } from "./handler-resolution.js";
|
|
4
|
+
import { classifyDisposition } from "./handler-disposition.js";
|
|
5
|
+
import { errorHandlingDecision } from "./error-handling-scoring.js";
|
|
4
6
|
function undefinedValue(node, checker) {
|
|
5
7
|
node = unwrapHandler(node);
|
|
6
8
|
if (ts.isVoidExpression(node))
|
|
@@ -17,11 +19,22 @@ function emptyBody(body, checker) {
|
|
|
17
19
|
(ts.isReturnStatement(statement) && (!statement.expression || undefinedValue(statement.expression, checker))));
|
|
18
20
|
}
|
|
19
21
|
function bodyComments(body, source) {
|
|
22
|
+
const nested = [];
|
|
23
|
+
function visit(node) {
|
|
24
|
+
if (node !== body && (ts.isFunctionLike(node) || ts.isLiteralExpression(node) || ts.isTemplateExpression(node))) {
|
|
25
|
+
nested.push({ start: ts.isFunctionLike(node) ? node.pos : node.getStart(source), end: node.end });
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
ts.forEachChild(node, visit);
|
|
29
|
+
}
|
|
30
|
+
visit(body);
|
|
20
31
|
const scanner = ts.createScanner(ts.ScriptTarget.Latest, false, source.languageVariant, source.text.slice(body.pos, body.end));
|
|
21
32
|
const comments = [];
|
|
22
33
|
for (let token = scanner.scan(); token !== ts.SyntaxKind.EndOfFileToken; token = scanner.scan()) {
|
|
23
34
|
if (token !== ts.SyntaxKind.SingleLineCommentTrivia && token !== ts.SyntaxKind.MultiLineCommentTrivia)
|
|
24
35
|
continue;
|
|
36
|
+
if (nested.some(range => body.pos + scanner.getTokenPos() >= range.start && body.pos + scanner.getTokenPos() < range.end))
|
|
37
|
+
continue;
|
|
25
38
|
const text = scanner.getTokenText().replace(/^\/\/?\*?|\*\/$/g, "").trim();
|
|
26
39
|
if (/[\p{L}\p{N}]/u.test(text))
|
|
27
40
|
comments.push(text);
|
|
@@ -41,20 +54,24 @@ function rejectionArgument(call, checker) {
|
|
|
41
54
|
if (declaration.name.text === "then" && call.arguments.length > 1)
|
|
42
55
|
return { index: 1, kind: "promiseThenRejection" };
|
|
43
56
|
}
|
|
44
|
-
export function inspectErrorHandling(source, checker, file, project, resolver) {
|
|
57
|
+
export function inspectErrorHandling(source, checker, file, project, resolver, contracts) {
|
|
45
58
|
const handlers = new Map();
|
|
46
59
|
const uninspectedCallbacks = [];
|
|
47
60
|
function add(body, kind, at, resolution) {
|
|
48
61
|
const identity = resolver.identity(body);
|
|
49
62
|
const existing = handlers.get(identity.id);
|
|
50
63
|
const use = { file, line: source.getLineAndCharacterOfPosition(at.getStart(source)).line + 1, kind, resolution };
|
|
64
|
+
const empty = emptyBody(body, checker), comments = bodyComments(body, body.getSourceFile());
|
|
65
|
+
const disposition = classifyDisposition(body, checker, empty, comments, at, contracts);
|
|
51
66
|
if (existing) {
|
|
52
67
|
existing.uses.push(use);
|
|
68
|
+
if (existing.disposition.classification !== disposition.classification)
|
|
69
|
+
existing.disposition = { classification: "unknown", reason: "Uses of the shared handler have different failure contexts." };
|
|
53
70
|
return;
|
|
54
71
|
}
|
|
55
|
-
const empty = emptyBody(body, checker), comments = empty ? bodyComments(body, body.getSourceFile()) : [];
|
|
56
72
|
const classification = !empty ? "containsCode" : comments.length ? "documentedEmpty" : "unexplainedEmpty";
|
|
57
|
-
handlers.set(identity.id, { ...identity, kind: kind === "catchClause" ? "catchClause" : "promiseRejection", classification, comments,
|
|
73
|
+
handlers.set(identity.id, { ...identity, kind: kind === "catchClause" ? "catchClause" : "promiseRejection", classification, comments,
|
|
74
|
+
disposition, uses: [use] });
|
|
58
75
|
}
|
|
59
76
|
function visit(node) {
|
|
60
77
|
if (ts.isTypeNode(node) && !ts.isExpressionWithTypeArguments(node))
|
|
@@ -77,19 +94,25 @@ export function inspectErrorHandling(source, checker, file, project, resolver) {
|
|
|
77
94
|
visit(source);
|
|
78
95
|
return { handlers: [...handlers.values()], uninspectedCallbacks };
|
|
79
96
|
}
|
|
80
|
-
function handlerFinding(handler) {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
97
|
+
function handlerFinding(handler, scoreAvailable) {
|
|
98
|
+
const actionable = handler.disposition.classification === "unexplainedSwallowing";
|
|
99
|
+
const category = actionable ? "unexplainedSwallowing" : "unknownHandlerDisposition";
|
|
100
|
+
return { category, ruleId: `javascript-typescript/errorHandling/${category}`,
|
|
101
|
+
fingerprint: hash(`${handler.id}|${category}`), file: handler.file, project: handler.project, line: handler.line,
|
|
102
|
+
severity: actionable && scoreAvailable ? "warning" : "info", confidence: actionable ? "high" : "low",
|
|
103
|
+
message: handler.disposition.reason,
|
|
104
|
+
observations: { handlerId: handler.id, kind: handler.kind, uses: handler.uses, classification: handler.disposition.classification,
|
|
105
|
+
scoreDisposition: !actionable ? "excludedUnknown" : scoreAvailable ? "policyInput" : "excludedUnavailablePopulation" } };
|
|
86
106
|
}
|
|
87
107
|
export function errorHandlingEvidence(results, incomplete) {
|
|
88
108
|
const unique = new Map();
|
|
89
109
|
for (const row of results.flatMap(result => result.handlers)) {
|
|
90
110
|
const existing = unique.get(row.id);
|
|
91
|
-
if (existing)
|
|
111
|
+
if (existing) {
|
|
92
112
|
existing.uses.push(...row.uses);
|
|
113
|
+
if (existing.disposition.classification !== row.disposition.classification)
|
|
114
|
+
existing.disposition = { classification: "unknown", reason: "Selected project contexts disagree about the shared handler disposition." };
|
|
115
|
+
}
|
|
93
116
|
else
|
|
94
117
|
unique.set(row.id, { ...row, uses: [...row.uses] });
|
|
95
118
|
}
|
|
@@ -100,12 +123,22 @@ export function errorHandlingEvidence(results, incomplete) {
|
|
|
100
123
|
.sort((a, b) => a.file.localeCompare(b.file, "en") || a.line - b.line);
|
|
101
124
|
const count = (classification) => handlers.filter(handler => handler.classification === classification).length;
|
|
102
125
|
const unexplained = count("unexplainedEmpty");
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
126
|
+
const dispositions = Object.fromEntries(["propagated", "failureResult", "reported", "fallback", "documented", "unexplainedSwallowing", "unknown"]
|
|
127
|
+
.map(kind => [kind, handlers.filter(handler => handler.disposition.classification === kind).length]));
|
|
128
|
+
const decision = incomplete ? undefined : errorHandlingDecision(handlers.length, dispositions.unexplainedSwallowing, dispositions.unknown, uninspectedCallbacks.length);
|
|
129
|
+
const findings = handlers.filter(handler => ["unexplainedSwallowing", "unknown"].includes(handler.disposition.classification))
|
|
130
|
+
.map(handler => handlerFinding(handler, !!decision));
|
|
131
|
+
const scope = { id: "javascript-typescript/errorHandling/failure-disposition-v2", coverage: "partial",
|
|
132
|
+
includes: ["catch-clauses", "inline-standard-promise-rejection-handlers", "attached-intent", "selected-source-referenced-handlers", "bounded-failure-continuations"],
|
|
133
|
+
excludes: ["general-exception-flow", "arbitrary-callback-value-flow", "correctness-of-recovery", "semantic-rationale-judgment"] };
|
|
134
|
+
const dispositionEvidence = { version: 1, countingUnit: "distinctHandlerBody", totalHandlers: handlers.length,
|
|
135
|
+
assessedHandlers: handlers.length - dispositions.unknown, unknownHandlers: dispositions.unknown, counts: dispositions,
|
|
136
|
+
assessedPercent: handlers.length ? 100 * (handlers.length - dispositions.unknown) / handlers.length : null,
|
|
137
|
+
unresolvedCallbackUses: uninspectedCallbacks.length, populationComplete: !incomplete && !dispositions.unknown && !uninspectedCallbacks.length };
|
|
138
|
+
const dimension = decision ? scored(decision, "Partial Error Handling: unexplained swallowing among distinct assessed handler bodies. Documented intent is honored; failure outcomes are signals, not proof of correct recovery.", findings, dispositionEvidence) : { status: incomplete ? "failed" : "skipped", findings,
|
|
139
|
+
basis: incomplete ? "Incomplete source; handler observations are diagnostic only." : !handlers.length ? "No assessed handler population; error handling is unavailable." :
|
|
140
|
+
"Unknown handler dispositions or unresolved callback uses prevent a reliable population score." };
|
|
141
|
+
return { ...dimension, scope, dispositionEvidence, handlerEvidence: { version: 2, countingUnit: "distinctHandlerBody",
|
|
109
142
|
totalHandlers: handlers.length, unexplainedEmptyHandlers: unexplained, documentedEmptyHandlers: count("documentedEmpty"),
|
|
110
143
|
handlersContainingCode: count("containsCode"), unexplainedEmptyPercent: handlers.length ? 100 * unexplained / handlers.length : null,
|
|
111
144
|
handlerUseSites: handlers.reduce((sum, handler) => sum + handler.uses.length, 0),
|
package/dist/evidence-reader.js
CHANGED
|
@@ -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
|
-
|
|
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 };
|
package/dist/evidence-tools.js
CHANGED
|
@@ -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");
|