codemetrics-ai 0.2.0 → 0.4.0
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 +99 -7
- package/dist/analyzer.js +35 -23
- package/dist/async-population.d.ts +12 -0
- package/dist/async-population.js +53 -0
- package/dist/async-probe.d.ts +8 -0
- package/dist/async-probe.js +52 -0
- package/dist/async-scoring.d.ts +3 -0
- package/dist/async-scoring.js +33 -0
- package/dist/complexity-breakdown.d.ts +21 -0
- package/dist/complexity-breakdown.js +30 -0
- package/dist/decomposition-evidence.d.ts +91 -0
- package/dist/decomposition-evidence.js +71 -0
- package/dist/error-handling.d.ts +30 -0
- package/dist/error-handling.js +114 -0
- package/dist/evidence-tools.js +20 -0
- package/dist/evidence.d.ts +3 -1
- package/dist/evidence.js +5 -2
- package/dist/evidence.schema.v3.json +171 -2
- package/dist/function-measurements.d.ts +17 -0
- package/dist/function-measurements.js +85 -0
- package/dist/function-nodes.d.ts +3 -0
- package/dist/function-nodes.js +5 -0
- package/dist/function-scoring.d.ts +5 -0
- package/dist/function-scoring.js +50 -0
- package/dist/handler-resolution.d.ts +26 -0
- package/dist/handler-resolution.js +99 -0
- package/dist/metrics.d.ts +3 -0
- package/dist/metrics.js +23 -45
- package/dist/module-forwarding.d.ts +4 -0
- package/dist/module-forwarding.js +153 -0
- package/dist/module-graph.d.ts +34 -0
- package/dist/module-graph.js +122 -0
- package/dist/module-references.d.ts +8 -0
- package/dist/module-references.js +51 -0
- package/dist/module-resolution.d.ts +10 -0
- package/dist/module-resolution.js +76 -0
- package/dist/module-topology.d.ts +72 -0
- package/dist/module-topology.js +72 -0
- package/dist/react-probe.d.ts +6 -0
- package/dist/react-probe.js +60 -0
- package/dist/score-arithmetic.d.ts +12 -0
- package/dist/score-arithmetic.js +40 -0
- package/dist/scoring-decision.d.ts +35 -0
- package/dist/scoring-decision.js +24 -0
- package/package.json +4 -3
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import ts from "typescript";
|
|
3
|
+
export const canonicalFile = (file) => {
|
|
4
|
+
const resolved = path.resolve(ts.sys.realpath?.(file) ?? file).replaceAll("\\", "/");
|
|
5
|
+
return ts.sys.useCaseSensitiveFileNames ? resolved : resolved.toLowerCase();
|
|
6
|
+
};
|
|
7
|
+
// A failed installed-package lookup can mean an intentionally private subpath
|
|
8
|
+
// or missing build output. A same-named workspace is not a substitute version.
|
|
9
|
+
function hasInstalledPackage(specifier, importer) {
|
|
10
|
+
if (specifier.startsWith(".") || path.isAbsolute(specifier))
|
|
11
|
+
return false;
|
|
12
|
+
const parts = specifier.split("/");
|
|
13
|
+
const name = parts.slice(0, specifier.startsWith("@") ? 2 : 1).join("/");
|
|
14
|
+
for (let directory = path.dirname(importer);; directory = path.dirname(directory)) {
|
|
15
|
+
if (ts.sys.directoryExists(path.join(directory, "node_modules", name)))
|
|
16
|
+
return true;
|
|
17
|
+
if (path.dirname(directory) === directory)
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
// A read-only virtual node_modules view lets TypeScript interpret workspace
|
|
22
|
+
// manifests/exports without installing packages or guessing source entry points.
|
|
23
|
+
function workspaceHost(packages) {
|
|
24
|
+
const names = new Map();
|
|
25
|
+
const ambiguous = new Set();
|
|
26
|
+
for (const pkg of packages) {
|
|
27
|
+
if (names.has(pkg.name))
|
|
28
|
+
ambiguous.add(pkg.name);
|
|
29
|
+
else
|
|
30
|
+
names.set(pkg.name, pkg.root);
|
|
31
|
+
}
|
|
32
|
+
for (const name of ambiguous)
|
|
33
|
+
names.delete(name);
|
|
34
|
+
function mapped(file) {
|
|
35
|
+
const normalized = file.replaceAll("\\", "/");
|
|
36
|
+
const index = normalized.lastIndexOf("/node_modules/");
|
|
37
|
+
if (index < 0)
|
|
38
|
+
return undefined;
|
|
39
|
+
const tail = normalized.slice(index + 14).split("/");
|
|
40
|
+
const count = tail[0].startsWith("@") ? 2 : 1;
|
|
41
|
+
const root = names.get(tail.slice(0, count).join("/"));
|
|
42
|
+
return root ? path.join(root, ...tail.slice(count)) : undefined;
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
fileExists: file => ts.sys.fileExists(mapped(file) ?? file),
|
|
46
|
+
readFile: file => ts.sys.readFile(mapped(file) ?? file),
|
|
47
|
+
directoryExists: directory => {
|
|
48
|
+
const normalized = directory.replaceAll("\\", "/").replace(/\/$/, "");
|
|
49
|
+
if (normalized.endsWith("/node_modules"))
|
|
50
|
+
return names.size > 0;
|
|
51
|
+
const scope = normalized.match(/\/node_modules\/(@[^/]+)$/)?.[1];
|
|
52
|
+
if (scope && [...names.keys()].some(name => name.startsWith(scope + "/")))
|
|
53
|
+
return true;
|
|
54
|
+
return ts.sys.directoryExists(mapped(directory) ?? directory);
|
|
55
|
+
},
|
|
56
|
+
realpath: file => ts.sys.realpath?.(mapped(file) ?? file) ?? mapped(file) ?? file,
|
|
57
|
+
getCurrentDirectory: ts.sys.getCurrentDirectory,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
export function moduleResolver(pkg, packages, program) {
|
|
61
|
+
const options = pkg.selection === "default-source-scan" ? { ...pkg.options, moduleResolution: ts.ModuleResolutionKind.Node10 } : pkg.options;
|
|
62
|
+
const canonical = (file) => ts.sys.useCaseSensitiveFileNames ? file : file.toLowerCase();
|
|
63
|
+
const cache = ts.createModuleResolutionCache(pkg.root, canonical, options);
|
|
64
|
+
const workspaceCache = ts.createModuleResolutionCache(pkg.root, canonical, options);
|
|
65
|
+
const host = workspaceHost(packages);
|
|
66
|
+
return (source, literal) => {
|
|
67
|
+
const mode = program.getModeForUsageLocation(source, literal);
|
|
68
|
+
const ordinary = ts.resolveModuleName(literal.text, source.fileName, options, ts.sys, cache, undefined, mode).resolvedModule;
|
|
69
|
+
if (ordinary)
|
|
70
|
+
return { resolved: ordinary, via: "typescript" };
|
|
71
|
+
if (hasInstalledPackage(literal.text, source.fileName))
|
|
72
|
+
return { resolved: undefined, via: "unresolved" };
|
|
73
|
+
const workspace = ts.resolveModuleName(literal.text, source.fileName, options, host, workspaceCache, undefined, mode).resolvedModule;
|
|
74
|
+
return { resolved: workspace, via: workspace ? "workspaceManifest" : "unresolved" };
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
export declare function cyclicComponents(graph: Map<string, Set<string>>): string[][];
|
|
2
|
+
interface DependencyEdge {
|
|
3
|
+
from: string;
|
|
4
|
+
target?: string;
|
|
5
|
+
usage: string;
|
|
6
|
+
resolution: string;
|
|
7
|
+
form: string;
|
|
8
|
+
roles: string[];
|
|
9
|
+
}
|
|
10
|
+
export declare function moduleTopology(ids: string[], edges: DependencyEdge[]): {
|
|
11
|
+
cycles: string[][];
|
|
12
|
+
dependencyViews: {
|
|
13
|
+
version: number;
|
|
14
|
+
classification: string;
|
|
15
|
+
implementation: {
|
|
16
|
+
modules: {
|
|
17
|
+
id: string;
|
|
18
|
+
internalValueFanOut: number;
|
|
19
|
+
internalValueFanIn: number;
|
|
20
|
+
}[];
|
|
21
|
+
internalValueEdgeCount: number;
|
|
22
|
+
highestFanOut: {
|
|
23
|
+
id: string;
|
|
24
|
+
internalValueFanOut: number;
|
|
25
|
+
internalValueFanIn: number;
|
|
26
|
+
}[];
|
|
27
|
+
highestFanIn: {
|
|
28
|
+
id: string;
|
|
29
|
+
internalValueFanOut: number;
|
|
30
|
+
internalValueFanIn: number;
|
|
31
|
+
}[];
|
|
32
|
+
basis: string;
|
|
33
|
+
};
|
|
34
|
+
reExports: {
|
|
35
|
+
modules: {
|
|
36
|
+
id: string;
|
|
37
|
+
internalValueFanOut: number;
|
|
38
|
+
internalValueFanIn: number;
|
|
39
|
+
}[];
|
|
40
|
+
internalValueEdgeCount: number;
|
|
41
|
+
highestFanOut: {
|
|
42
|
+
id: string;
|
|
43
|
+
internalValueFanOut: number;
|
|
44
|
+
internalValueFanIn: number;
|
|
45
|
+
}[];
|
|
46
|
+
highestFanIn: {
|
|
47
|
+
id: string;
|
|
48
|
+
internalValueFanOut: number;
|
|
49
|
+
internalValueFanIn: number;
|
|
50
|
+
}[];
|
|
51
|
+
basis: string;
|
|
52
|
+
};
|
|
53
|
+
totalInternalValueEdgeCount: number;
|
|
54
|
+
sharedInternalValueEdgeCount: number;
|
|
55
|
+
};
|
|
56
|
+
modules: {
|
|
57
|
+
id: string;
|
|
58
|
+
internalValueFanOut: number;
|
|
59
|
+
internalValueFanIn: number;
|
|
60
|
+
}[];
|
|
61
|
+
highestFanOut: {
|
|
62
|
+
id: string;
|
|
63
|
+
internalValueFanOut: number;
|
|
64
|
+
internalValueFanIn: number;
|
|
65
|
+
}[];
|
|
66
|
+
highestFanIn: {
|
|
67
|
+
id: string;
|
|
68
|
+
internalValueFanOut: number;
|
|
69
|
+
internalValueFanIn: number;
|
|
70
|
+
}[];
|
|
71
|
+
};
|
|
72
|
+
export {};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Iterative SCC traversal avoids stack overflow on long import chains and emits
|
|
2
|
+
// one cyclic component, not an exponential list of individual cycle paths.
|
|
3
|
+
export function cyclicComponents(graph) {
|
|
4
|
+
const visited = new Set(), order = [];
|
|
5
|
+
for (const root of graph.keys()) {
|
|
6
|
+
const stack = [[root, false]];
|
|
7
|
+
while (stack.length) {
|
|
8
|
+
const [node, finishing] = stack.pop();
|
|
9
|
+
if (finishing) {
|
|
10
|
+
order.push(node);
|
|
11
|
+
continue;
|
|
12
|
+
}
|
|
13
|
+
if (visited.has(node))
|
|
14
|
+
continue;
|
|
15
|
+
visited.add(node);
|
|
16
|
+
stack.push([node, true]);
|
|
17
|
+
for (const target of graph.get(node) ?? [])
|
|
18
|
+
if (!visited.has(target))
|
|
19
|
+
stack.push([target, false]);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
const reverse = new Map([...graph.keys()].map(node => [node, new Set()]));
|
|
23
|
+
for (const [node, targets] of graph)
|
|
24
|
+
for (const target of targets)
|
|
25
|
+
reverse.get(target).add(node);
|
|
26
|
+
visited.clear();
|
|
27
|
+
const components = [];
|
|
28
|
+
for (const root of order.reverse()) {
|
|
29
|
+
if (visited.has(root))
|
|
30
|
+
continue;
|
|
31
|
+
const component = [], stack = [root];
|
|
32
|
+
visited.add(root);
|
|
33
|
+
while (stack.length) {
|
|
34
|
+
const node = stack.pop();
|
|
35
|
+
component.push(node);
|
|
36
|
+
for (const target of reverse.get(node) ?? [])
|
|
37
|
+
if (!visited.has(target)) {
|
|
38
|
+
visited.add(target);
|
|
39
|
+
stack.push(target);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (component.length > 1 || graph.get(root)?.has(root))
|
|
43
|
+
components.push(component.sort());
|
|
44
|
+
}
|
|
45
|
+
return components.sort((a, b) => a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0);
|
|
46
|
+
}
|
|
47
|
+
function referenceView(ids, edges) {
|
|
48
|
+
const graph = new Map(ids.map(id => [id, new Set()]));
|
|
49
|
+
const incoming = new Map(ids.map(id => [id, new Set()]));
|
|
50
|
+
for (const edge of edges)
|
|
51
|
+
if (edge.resolution === "internal" && edge.usage !== "typeOnly" && edge.target) {
|
|
52
|
+
graph.get(edge.from).add(edge.target);
|
|
53
|
+
incoming.get(edge.target).add(edge.from);
|
|
54
|
+
}
|
|
55
|
+
const modules = ids.map(id => ({ id, internalValueFanOut: graph.get(id).size, internalValueFanIn: incoming.get(id).size }));
|
|
56
|
+
const rank = (key) => modules.filter(item => item[key] > 0)
|
|
57
|
+
.sort((a, b) => b[key] - a[key] || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)).slice(0, 10);
|
|
58
|
+
return { graph, modules, internalValueEdgeCount: modules.reduce((sum, node) => sum + node.internalValueFanOut, 0),
|
|
59
|
+
highestFanOut: rank("internalValueFanOut"), highestFanIn: rank("internalValueFanIn") };
|
|
60
|
+
}
|
|
61
|
+
export function moduleTopology(ids, edges) {
|
|
62
|
+
const { graph, internalValueEdgeCount, ...overall } = referenceView(ids, edges);
|
|
63
|
+
const { graph: _imports, ...implementation } = referenceView(ids, edges.filter(edge => edge.roles.includes("implementation")));
|
|
64
|
+
const { graph: _exports, ...reExports } = referenceView(ids, edges.filter(edge => edge.roles.includes("reExport")));
|
|
65
|
+
return { ...overall, cycles: cyclicComponents(graph), dependencyViews: {
|
|
66
|
+
version: 2, classification: "explicit-forwarding-v2",
|
|
67
|
+
implementation: { basis: "Imports/loads with local uses or no recognized forwarding; unsupported alias flow remains here.", ...implementation },
|
|
68
|
+
reExports: { basis: "Direct re-exports and explicit forwarding through an unmodified local import/require binding or direct require call.", ...reExports },
|
|
69
|
+
totalInternalValueEdgeCount: internalValueEdgeCount,
|
|
70
|
+
sharedInternalValueEdgeCount: implementation.internalValueEdgeCount + reExports.internalValueEdgeCount - internalValueEdgeCount
|
|
71
|
+
} };
|
|
72
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import type { Finding } from "./evidence.js";
|
|
3
|
+
type ReactObservation = Pick<Finding, "category" | "message" | "observations" | "confidence">;
|
|
4
|
+
export declare function importedHookName(call: ts.CallExpression, checker: ts.TypeChecker): string | undefined;
|
|
5
|
+
export declare function inspectReactCall(call: ts.CallExpression, body: ts.ConciseBody, checker: ts.TypeChecker): ReactObservation[];
|
|
6
|
+
export {};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import { isFunction } from "./function-nodes.js";
|
|
3
|
+
function isReactImport(declaration) {
|
|
4
|
+
if (ts.isImportSpecifier(declaration) && declaration.isTypeOnly)
|
|
5
|
+
return false;
|
|
6
|
+
let cursor = declaration;
|
|
7
|
+
while (cursor && !ts.isImportDeclaration(cursor))
|
|
8
|
+
cursor = cursor.parent;
|
|
9
|
+
return !!cursor && !cursor.importClause?.isTypeOnly && ts.isStringLiteral(cursor.moduleSpecifier) && cursor.moduleSpecifier.text === "react";
|
|
10
|
+
}
|
|
11
|
+
export function importedHookName(call, checker) {
|
|
12
|
+
const symbol = checker.getSymbolAtLocation(ts.isPropertyAccessExpression(call.expression) ? call.expression.name : call.expression);
|
|
13
|
+
const declarations = symbol?.declarations ?? [];
|
|
14
|
+
const reactHook = declarations.some(isReactImport);
|
|
15
|
+
const hookName = declarations.find(ts.isImportSpecifier);
|
|
16
|
+
const calledName = hookName ? (hookName.propertyName ?? hookName.name).text : ts.isPropertyAccessExpression(call.expression) ? call.expression.name.text : "";
|
|
17
|
+
const namespaceSymbol = ts.isPropertyAccessExpression(call.expression) ? checker.getSymbolAtLocation(call.expression.expression) : undefined;
|
|
18
|
+
const reactNamespace = namespaceSymbol?.declarations?.some(isReactImport);
|
|
19
|
+
return (reactHook || reactNamespace) && /^use[A-Z]/.test(calledName) ? calledName : undefined;
|
|
20
|
+
}
|
|
21
|
+
function isConditionalOrLoop(node, child) {
|
|
22
|
+
if (ts.isIfStatement(node))
|
|
23
|
+
return child !== node.expression;
|
|
24
|
+
if (ts.isConditionalExpression(node))
|
|
25
|
+
return child !== node.condition;
|
|
26
|
+
if (ts.isBinaryExpression(node))
|
|
27
|
+
return child === node.right &&
|
|
28
|
+
[ts.SyntaxKind.AmpersandAmpersandToken, ts.SyntaxKind.BarBarToken, ts.SyntaxKind.QuestionQuestionToken].includes(node.operatorToken.kind);
|
|
29
|
+
if (ts.isForStatement(node))
|
|
30
|
+
return child !== node.initializer;
|
|
31
|
+
if (ts.isForOfStatement(node) || ts.isForInStatement(node))
|
|
32
|
+
return child !== node.expression;
|
|
33
|
+
return ts.isWhileStatement(node) || ts.isDoStatement(node);
|
|
34
|
+
}
|
|
35
|
+
// The metric visitor supplies only calls owned by the current function. Keeping
|
|
36
|
+
// traversal and identity there preserves nested-function boundaries and ordering.
|
|
37
|
+
export function inspectReactCall(call, body, checker) {
|
|
38
|
+
const hook = importedHookName(call, checker);
|
|
39
|
+
if (!hook)
|
|
40
|
+
return [];
|
|
41
|
+
const findings = [];
|
|
42
|
+
let ancestor = call.parent;
|
|
43
|
+
let child = call;
|
|
44
|
+
while (ancestor && ancestor !== body) {
|
|
45
|
+
if (isConditionalOrLoop(ancestor, child)) {
|
|
46
|
+
findings.push({ category: "conditionalHook", message: `${hook} is called within a conditional or loop.`, observations: { hook }, confidence: "high" });
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
child = ancestor;
|
|
50
|
+
ancestor = ancestor.parent;
|
|
51
|
+
}
|
|
52
|
+
if (["useEffect", "useLayoutEffect"].includes(hook)) {
|
|
53
|
+
const callback = call.arguments[0];
|
|
54
|
+
if (callback && isFunction(callback) && callback.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.AsyncKeyword))
|
|
55
|
+
findings.push({ category: "asyncEffectCallback", message: "An effect callback is async and returns a Promise instead of cleanup or undefined.", observations: { hook }, confidence: "high" });
|
|
56
|
+
if (call.arguments.length < 2)
|
|
57
|
+
findings.push({ category: "effectWithoutDependencies", message: "Effect runs after every render; verify that this is intentional.", observations: { hook }, confidence: "low" });
|
|
58
|
+
}
|
|
59
|
+
return findings;
|
|
60
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface Fraction {
|
|
2
|
+
numerator: bigint;
|
|
3
|
+
denominator: bigint;
|
|
4
|
+
}
|
|
5
|
+
export declare function fraction(numerator: bigint, denominator?: bigint): Fraction;
|
|
6
|
+
export declare function decimal(value: number): Fraction;
|
|
7
|
+
export declare const add: (a: Fraction, b: Fraction) => Fraction;
|
|
8
|
+
export declare const multiply: (a: Fraction, b: Fraction) => Fraction;
|
|
9
|
+
export declare const subtract: (a: Fraction, b: Fraction) => Fraction;
|
|
10
|
+
export declare const numeric: (a: Fraction) => number;
|
|
11
|
+
export declare function roundScore(a: Fraction): number;
|
|
12
|
+
export declare function interpolate(value: number, anchors: readonly (readonly [number, number])[]): Fraction;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export function fraction(numerator, denominator = 1n) {
|
|
2
|
+
if (denominator <= 0n)
|
|
3
|
+
throw new Error("Positive denominator required.");
|
|
4
|
+
let a = numerator < 0n ? -numerator : numerator, b = denominator;
|
|
5
|
+
while (b) {
|
|
6
|
+
const remainder = a % b;
|
|
7
|
+
a = b;
|
|
8
|
+
b = remainder;
|
|
9
|
+
}
|
|
10
|
+
return { numerator: numerator / a, denominator: denominator / a };
|
|
11
|
+
}
|
|
12
|
+
export function decimal(value) {
|
|
13
|
+
if (!Number.isFinite(value))
|
|
14
|
+
throw new Error("Finite score measurement required.");
|
|
15
|
+
const [mantissa, exponent = "0"] = String(value).toLowerCase().split("e");
|
|
16
|
+
const places = (mantissa.split(".")[1] ?? "").length - Number(exponent);
|
|
17
|
+
const numerator = BigInt(mantissa.replace(".", ""));
|
|
18
|
+
return places >= 0 ? fraction(numerator, 10n ** BigInt(places)) : fraction(numerator * 10n ** BigInt(-places));
|
|
19
|
+
}
|
|
20
|
+
export const add = (a, b) => fraction(a.numerator * b.denominator + b.numerator * a.denominator, a.denominator * b.denominator);
|
|
21
|
+
export const multiply = (a, b) => fraction(a.numerator * b.numerator, a.denominator * b.denominator);
|
|
22
|
+
export const subtract = (a, b) => add(a, { ...b, numerator: -b.numerator });
|
|
23
|
+
export const numeric = (a) => Number(a.numerator) / Number(a.denominator);
|
|
24
|
+
export function roundScore(a) {
|
|
25
|
+
if (a.numerator < 0n)
|
|
26
|
+
throw new Error("Score must be nonnegative.");
|
|
27
|
+
return Number((a.numerator * 20n + a.denominator) / (2n * a.denominator)) / 10;
|
|
28
|
+
}
|
|
29
|
+
export function interpolate(value, anchors) {
|
|
30
|
+
if (!Number.isFinite(value))
|
|
31
|
+
throw new Error("Finite score measurement required.");
|
|
32
|
+
if (value <= anchors[0][0])
|
|
33
|
+
return decimal(anchors[0][1]);
|
|
34
|
+
for (let index = 1; index < anchors.length; index++) {
|
|
35
|
+
const [x, y] = anchors[index], [previousX, previousY] = anchors[index - 1];
|
|
36
|
+
if (value <= x)
|
|
37
|
+
return add(decimal(previousY), multiply(subtract(decimal(value), decimal(previousX)), fraction(BigInt(y - previousY), BigInt(x - previousX))));
|
|
38
|
+
}
|
|
39
|
+
return decimal(anchors[anchors.length - 1][1]);
|
|
40
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Finding } from "./evidence.js";
|
|
2
|
+
export interface ScoringStep {
|
|
3
|
+
id: string;
|
|
4
|
+
kind: "rule" | "component" | "cap";
|
|
5
|
+
score: number;
|
|
6
|
+
condition?: string | null;
|
|
7
|
+
matched?: boolean | null;
|
|
8
|
+
disposition: "selected" | "shadowed" | "notMatched" | "contributing" | "notLimiting" | "applied";
|
|
9
|
+
inputs: Record<string, unknown>;
|
|
10
|
+
findingCategories: string[];
|
|
11
|
+
decision?: ScoringDecision | null;
|
|
12
|
+
}
|
|
13
|
+
export interface ScoringDecision {
|
|
14
|
+
version: 1;
|
|
15
|
+
policy: string;
|
|
16
|
+
operation: "firstMatch" | "minimum" | "mean" | "deductions";
|
|
17
|
+
finalScore: number;
|
|
18
|
+
inputs: Record<string, unknown>;
|
|
19
|
+
steps: ScoringStep[];
|
|
20
|
+
findingEffects: {
|
|
21
|
+
fingerprint: string;
|
|
22
|
+
category: string;
|
|
23
|
+
effect: "policyInput" | "excluded" | "noAdditionalReduction";
|
|
24
|
+
stepIds: string[];
|
|
25
|
+
}[];
|
|
26
|
+
}
|
|
27
|
+
export declare function firstMatch(policy: string, inputs: Record<string, unknown>, rules: {
|
|
28
|
+
id: string;
|
|
29
|
+
condition: string;
|
|
30
|
+
matched: boolean;
|
|
31
|
+
score: number;
|
|
32
|
+
categories?: string[];
|
|
33
|
+
}[]): ScoringDecision;
|
|
34
|
+
export declare function thresholdDecision(policy: string, measured: number, thresholds: number[], descending: boolean, categories?: string[]): ScoringDecision;
|
|
35
|
+
export declare function attachFindings(decision: ScoringDecision, findings: Finding[], eligible?: (finding: Finding) => boolean): void;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export function firstMatch(policy, inputs, rules) {
|
|
2
|
+
const selected = rules.findIndex(rule => rule.matched);
|
|
3
|
+
if (selected < 0)
|
|
4
|
+
throw new Error("Scoring ladder requires a matching default.");
|
|
5
|
+
return { version: 1, policy, operation: "firstMatch", finalScore: rules[selected].score, inputs, findingEffects: [],
|
|
6
|
+
steps: rules.map((rule, index) => ({ id: rule.id, kind: "rule", condition: rule.condition, matched: rule.matched, score: rule.score,
|
|
7
|
+
disposition: index === selected ? "selected" : rule.matched ? "shadowed" : "notMatched", inputs: {}, findingCategories: rule.categories ?? [] })) };
|
|
8
|
+
}
|
|
9
|
+
export function thresholdDecision(policy, measured, thresholds, descending, categories = []) {
|
|
10
|
+
return firstMatch(policy, { measured, thresholds }, [
|
|
11
|
+
...thresholds.map((threshold, index) => ({ id: `band${index}`, condition: `measured ${descending ? ">=" : "<="} threshold[${index}]`,
|
|
12
|
+
matched: descending ? measured >= threshold : measured <= threshold, score: 10 - index * 2, categories })),
|
|
13
|
+
{ id: "otherwise", condition: "otherwise", matched: true, score: 2, categories }
|
|
14
|
+
]);
|
|
15
|
+
}
|
|
16
|
+
export function attachFindings(decision, findings, eligible = () => true) {
|
|
17
|
+
decision.findingEffects = findings.map(finding => {
|
|
18
|
+
const steps = decision.steps.filter(step => step.findingCategories.includes(finding.category));
|
|
19
|
+
const excluded = String(finding.observations.scoreDisposition ?? "").startsWith("excluded");
|
|
20
|
+
return { fingerprint: finding.fingerprint, category: finding.category,
|
|
21
|
+
effect: excluded ? "excluded" : eligible(finding) && steps.some(step => step.disposition === "selected") ? "policyInput" : "noAdditionalReduction",
|
|
22
|
+
stepIds: steps.map(step => step.id).sort() };
|
|
23
|
+
}).sort((a, b) => a.fingerprint.localeCompare(b.fingerprint, "en"));
|
|
24
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "codemetrics-ai",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Deterministic code metrics and scorecard evidence for JavaScript, TypeScript, and React projects",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -15,8 +15,9 @@
|
|
|
15
15
|
"scripts": {
|
|
16
16
|
"build": "tsc -p tsconfig.json && node scripts/copy-schema.mjs",
|
|
17
17
|
"prepack": "npm run build",
|
|
18
|
-
"test": "npm run build && vitest run",
|
|
19
|
-
"test:package": "node scripts/package-smoke.mjs"
|
|
18
|
+
"test": "npm run build && vitest run --maxWorkers=2",
|
|
19
|
+
"test:package": "node scripts/package-smoke.mjs",
|
|
20
|
+
"test:architecture": "npm run build && node scripts/architecture-corpus.mjs"
|
|
20
21
|
},
|
|
21
22
|
"engines": {
|
|
22
23
|
"node": ">=20"
|