codemetrics-ai 0.2.0 → 0.3.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 +3 -1
- package/dist/analyzer.js +7 -3
- 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/scoring-decision.d.ts +35 -0
- package/dist/scoring-decision.js +24 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# codemetrics-ai
|
|
2
2
|
|
|
3
|
-
Deterministic source analysis for JavaScript, TypeScript, JSX and TSX. Version 0.
|
|
3
|
+
Deterministic source analysis for JavaScript, TypeScript, JSX and TSX. Version 0.3.0 implements source metrics and React hook/effect checks and emits schema-v3 evidence, including executed scoring decisions. Scores are **uncalibrated across ecosystems**; compare them only with compatible runs of this analyzer.
|
|
4
|
+
|
|
5
|
+
Each scored dimension includes `scoringDecision`: policy inputs, the selected threshold/condition, other matching conditions, and finding attribution. These are aggregate policy decisions, not independent deductions. See [the decision contract](../../shared/scorecard-schema/scoring-decisions.md). Existing 0.2.0 scores and thresholds are unchanged.
|
|
4
6
|
|
|
5
7
|
```sh
|
|
6
8
|
npx codemetrics-ai --project package.json
|
package/dist/analyzer.js
CHANGED
|
@@ -3,6 +3,7 @@ import { discover } from "./discovery.js";
|
|
|
3
3
|
import { analyzeFile } from "./metrics.js";
|
|
4
4
|
import { hash, invocationIds, scored, skippedDimensions } from "./evidence.js";
|
|
5
5
|
import { metricsCsvHeader } from "./scorecard-contract.js";
|
|
6
|
+
import { firstMatch, thresholdDecision } from "./scoring-decision.js";
|
|
6
7
|
export function analyze(options, version) {
|
|
7
8
|
const identity = invocationIds(options.runId, options.auditId);
|
|
8
9
|
const discovery = discover(options.project, options.tsconfig);
|
|
@@ -38,15 +39,18 @@ export function analyze(options, version) {
|
|
|
38
39
|
diagnostics.push({ kind: "emptyPopulation", message: "No production source files were analyzed." });
|
|
39
40
|
if (metrics.length) {
|
|
40
41
|
const maximum = Math.max(...metrics.map(metric => metric.complexity));
|
|
41
|
-
const quality = maximum
|
|
42
|
+
const quality = thresholdDecision("javascript-typescript/codeQuality/v1", maximum, [5, 10, 20, 40], false, [...new Set(collected.filter(item => item.dimension === "codeQuality").map(item => item.finding.category))]);
|
|
42
43
|
dimensions.codeQuality = scored(quality, `Maximum member cyclomatic complexity=${maximum}; uncalibrated JS/TS policy.`, collected.filter(item => item.dimension === "codeQuality").map(item => item.finding), { maxMemberComplexity: maximum, thresholds: [5, 10, 20, 40] });
|
|
43
44
|
const values = metrics.map(metric => metric.maintainabilityIndex).sort((a, b) => a - b);
|
|
44
45
|
const median = (values[Math.floor((values.length - 1) / 2)] + values[Math.ceil((values.length - 1) / 2)]) / 2;
|
|
45
|
-
dimensions.maintainability = scored(median
|
|
46
|
+
dimensions.maintainability = scored(thresholdDecision("javascript-typescript/maintainability/v1", median, [85, 65, 40, 20], true), `Median member maintainability index=${median}; uncalibrated JS/TS policy.`, [], { median, thresholds: [85, 65, 40, 20] });
|
|
46
47
|
const performance = collected.filter(item => item.dimension === "performanceAsync").map(item => item.finding);
|
|
47
48
|
const actionable = performance.filter(finding => finding.confidence !== "low");
|
|
48
49
|
if (reactSupported)
|
|
49
|
-
dimensions.performanceAsync = scored(
|
|
50
|
+
dimensions.performanceAsync = scored(firstMatch("javascript-typescript/performanceAsync/react-hooks/v1", { actionableFindings: actionable.length, advisoryFindings: performance.length - actionable.length }, [
|
|
51
|
+
{ id: "actionableHooks", condition: "actionableFindings > 0", matched: actionable.length > 0, score: 6, categories: [...new Set(actionable.map(f => f.category))] },
|
|
52
|
+
{ id: "noActionableHooks", condition: "otherwise", matched: true, score: 10 }
|
|
53
|
+
]), "Limited scope: React hook placement and effect callbacks. General async and concurrency analysis is not implemented.", performance, { actionableFindings: actionable.length, advisoryFindings: performance.length - actionable.length, scope: "react-hooks" }, finding => finding.confidence !== "low");
|
|
50
54
|
}
|
|
51
55
|
if (diagnostics.length)
|
|
52
56
|
for (const key of ["codeQuality", "maintainability", "performanceAsync"])
|
package/dist/evidence-tools.js
CHANGED
|
@@ -19,6 +19,26 @@ export function validateEvidence(value) {
|
|
|
19
19
|
const ids = findings(evidence).map(finding => finding.fingerprint);
|
|
20
20
|
if (new Set(ids).size !== ids.length)
|
|
21
21
|
throw new Error("Duplicate finding fingerprints.");
|
|
22
|
+
for (const dimension of Object.values(evidence.dimensions)) {
|
|
23
|
+
const decision = dimension.scoringDecision;
|
|
24
|
+
if (!decision)
|
|
25
|
+
continue; // Older schema-v3 producers remain supported.
|
|
26
|
+
if (decision.finalScore !== dimension.score)
|
|
27
|
+
throw new Error("Scoring decision differs from dimension score.");
|
|
28
|
+
const steps = (items) => items.flatMap(step => [step, ...steps(step.decision?.steps ?? [])]);
|
|
29
|
+
const stepIds = steps(decision.steps).map(step => step.id);
|
|
30
|
+
if (new Set(stepIds).size !== stepIds.length)
|
|
31
|
+
throw new Error("Duplicate scoring step identifiers.");
|
|
32
|
+
const effectIds = decision.findingEffects.map(effect => effect.fingerprint);
|
|
33
|
+
if (new Set(effectIds).size !== effectIds.length || effectIds.length !== dimension.findings.length)
|
|
34
|
+
throw new Error("Scoring effects must identify each finding exactly once.");
|
|
35
|
+
for (const effect of decision.findingEffects) {
|
|
36
|
+
if (!dimension.findings.some(finding => finding.fingerprint === effect.fingerprint && finding.category === effect.category))
|
|
37
|
+
throw new Error("Scoring effect references an unknown finding.");
|
|
38
|
+
if (effect.stepIds.some(id => !stepIds.includes(id)))
|
|
39
|
+
throw new Error("Scoring effect references an unknown step.");
|
|
40
|
+
}
|
|
41
|
+
}
|
|
22
42
|
}
|
|
23
43
|
export function readEvidence(file) {
|
|
24
44
|
const value = JSON.parse(fs.readFileSync(file, "utf8"));
|
package/dist/evidence.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type DimensionKey } from "./scorecard-contract.js";
|
|
2
|
+
import { type ScoringDecision } from "./scoring-decision.js";
|
|
2
3
|
export declare const hash: (text: string) => string;
|
|
3
4
|
export declare function invocationIds(runId?: string, auditId?: string): {
|
|
4
5
|
runId: string;
|
|
@@ -20,6 +21,7 @@ export interface Finding {
|
|
|
20
21
|
observations: Record<string, unknown>;
|
|
21
22
|
}
|
|
22
23
|
export interface Dimension {
|
|
24
|
+
scoringDecision?: ScoringDecision;
|
|
23
25
|
scope?: {
|
|
24
26
|
id: string;
|
|
25
27
|
coverage: "partial" | "unsupported";
|
|
@@ -81,4 +83,4 @@ export interface Evidence {
|
|
|
81
83
|
};
|
|
82
84
|
}
|
|
83
85
|
export declare function skippedDimensions(): Record<DimensionKey, Dimension>;
|
|
84
|
-
export declare function scored(
|
|
86
|
+
export declare function scored(decision: ScoringDecision, basis: string, findings: Finding[], observations: Record<string, unknown>, eligible?: (finding: Finding) => boolean): Dimension;
|
package/dist/evidence.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import { dimensionKeys } from "./scorecard-contract.js";
|
|
3
|
+
import { attachFindings } from "./scoring-decision.js";
|
|
3
4
|
export const hash = (text) => createHash("sha256").update(text).digest("hex");
|
|
4
5
|
export function invocationIds(runId = randomUUID(), auditId = runId) {
|
|
5
6
|
for (const [name, value] of Object.entries({ runId, auditId }))
|
|
@@ -13,8 +14,10 @@ export function skippedDimensions() {
|
|
|
13
14
|
scope: { id: `javascript-typescript/${key}/unsupported`, coverage: "unsupported", includes: [], excludes: [key] }
|
|
14
15
|
}]));
|
|
15
16
|
}
|
|
16
|
-
export function scored(
|
|
17
|
-
|
|
17
|
+
export function scored(decision, basis, findings, observations, eligible) {
|
|
18
|
+
const score = decision.finalScore;
|
|
19
|
+
attachFindings(decision, findings, eligible);
|
|
20
|
+
return { status: "scored", score, basis, scoringDecision: decision, findings: findings.sort((a, b) => a.fingerprint.localeCompare(b.fingerprint, "en")),
|
|
18
21
|
scoring: { algorithm: "dimension-policy", finalScore: score, aggregateScoreLoss: 10 - score,
|
|
19
22
|
contributionMode: "aggregate; findings are not independent deductions", observations } };
|
|
20
23
|
}
|
|
@@ -347,6 +347,9 @@
|
|
|
347
347
|
}
|
|
348
348
|
}
|
|
349
349
|
}
|
|
350
|
+
},
|
|
351
|
+
"scoringDecision": {
|
|
352
|
+
"$ref": "#/$defs/scoringDecision"
|
|
350
353
|
}
|
|
351
354
|
},
|
|
352
355
|
"additionalProperties": true,
|
|
@@ -367,8 +370,17 @@
|
|
|
367
370
|
},
|
|
368
371
|
"else": {
|
|
369
372
|
"not": {
|
|
370
|
-
"
|
|
371
|
-
|
|
373
|
+
"anyOf": [
|
|
374
|
+
{
|
|
375
|
+
"required": [
|
|
376
|
+
"score"
|
|
377
|
+
]
|
|
378
|
+
},
|
|
379
|
+
{
|
|
380
|
+
"required": [
|
|
381
|
+
"scoringDecision"
|
|
382
|
+
]
|
|
383
|
+
}
|
|
372
384
|
]
|
|
373
385
|
}
|
|
374
386
|
}
|
|
@@ -439,6 +451,163 @@
|
|
|
439
451
|
"type": "object"
|
|
440
452
|
}
|
|
441
453
|
}
|
|
454
|
+
},
|
|
455
|
+
"scoringDecision": {
|
|
456
|
+
"type": "object",
|
|
457
|
+
"additionalProperties": false,
|
|
458
|
+
"required": [
|
|
459
|
+
"version",
|
|
460
|
+
"policy",
|
|
461
|
+
"operation",
|
|
462
|
+
"finalScore",
|
|
463
|
+
"inputs",
|
|
464
|
+
"steps",
|
|
465
|
+
"findingEffects"
|
|
466
|
+
],
|
|
467
|
+
"properties": {
|
|
468
|
+
"version": {
|
|
469
|
+
"const": 1
|
|
470
|
+
},
|
|
471
|
+
"policy": {
|
|
472
|
+
"type": "string",
|
|
473
|
+
"minLength": 1
|
|
474
|
+
},
|
|
475
|
+
"operation": {
|
|
476
|
+
"enum": [
|
|
477
|
+
"firstMatch",
|
|
478
|
+
"minimum",
|
|
479
|
+
"mean",
|
|
480
|
+
"deductions"
|
|
481
|
+
]
|
|
482
|
+
},
|
|
483
|
+
"finalScore": {
|
|
484
|
+
"type": "number",
|
|
485
|
+
"minimum": 0,
|
|
486
|
+
"maximum": 10
|
|
487
|
+
},
|
|
488
|
+
"inputs": {
|
|
489
|
+
"type": "object"
|
|
490
|
+
},
|
|
491
|
+
"steps": {
|
|
492
|
+
"type": "array",
|
|
493
|
+
"minItems": 1,
|
|
494
|
+
"items": {
|
|
495
|
+
"$ref": "#/$defs/scoringStep"
|
|
496
|
+
}
|
|
497
|
+
},
|
|
498
|
+
"findingEffects": {
|
|
499
|
+
"type": "array",
|
|
500
|
+
"items": {
|
|
501
|
+
"$ref": "#/$defs/findingScoreEffect"
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
},
|
|
506
|
+
"scoringStep": {
|
|
507
|
+
"type": "object",
|
|
508
|
+
"additionalProperties": false,
|
|
509
|
+
"required": [
|
|
510
|
+
"id",
|
|
511
|
+
"kind",
|
|
512
|
+
"score",
|
|
513
|
+
"disposition",
|
|
514
|
+
"inputs",
|
|
515
|
+
"findingCategories"
|
|
516
|
+
],
|
|
517
|
+
"properties": {
|
|
518
|
+
"id": {
|
|
519
|
+
"type": "string",
|
|
520
|
+
"minLength": 1
|
|
521
|
+
},
|
|
522
|
+
"kind": {
|
|
523
|
+
"enum": [
|
|
524
|
+
"rule",
|
|
525
|
+
"component",
|
|
526
|
+
"cap"
|
|
527
|
+
]
|
|
528
|
+
},
|
|
529
|
+
"score": {
|
|
530
|
+
"type": "number",
|
|
531
|
+
"minimum": 0,
|
|
532
|
+
"maximum": 10
|
|
533
|
+
},
|
|
534
|
+
"condition": {
|
|
535
|
+
"type": [
|
|
536
|
+
"string",
|
|
537
|
+
"null"
|
|
538
|
+
]
|
|
539
|
+
},
|
|
540
|
+
"matched": {
|
|
541
|
+
"type": [
|
|
542
|
+
"boolean",
|
|
543
|
+
"null"
|
|
544
|
+
]
|
|
545
|
+
},
|
|
546
|
+
"disposition": {
|
|
547
|
+
"enum": [
|
|
548
|
+
"selected",
|
|
549
|
+
"shadowed",
|
|
550
|
+
"notMatched",
|
|
551
|
+
"contributing",
|
|
552
|
+
"notLimiting",
|
|
553
|
+
"applied"
|
|
554
|
+
]
|
|
555
|
+
},
|
|
556
|
+
"inputs": {
|
|
557
|
+
"type": "object"
|
|
558
|
+
},
|
|
559
|
+
"findingCategories": {
|
|
560
|
+
"type": "array",
|
|
561
|
+
"items": {
|
|
562
|
+
"type": "string",
|
|
563
|
+
"minLength": 1
|
|
564
|
+
}
|
|
565
|
+
},
|
|
566
|
+
"decision": {
|
|
567
|
+
"anyOf": [
|
|
568
|
+
{
|
|
569
|
+
"$ref": "#/$defs/scoringDecision"
|
|
570
|
+
},
|
|
571
|
+
{
|
|
572
|
+
"type": "null"
|
|
573
|
+
}
|
|
574
|
+
]
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
},
|
|
578
|
+
"findingScoreEffect": {
|
|
579
|
+
"type": "object",
|
|
580
|
+
"additionalProperties": false,
|
|
581
|
+
"required": [
|
|
582
|
+
"fingerprint",
|
|
583
|
+
"category",
|
|
584
|
+
"effect",
|
|
585
|
+
"stepIds"
|
|
586
|
+
],
|
|
587
|
+
"properties": {
|
|
588
|
+
"fingerprint": {
|
|
589
|
+
"type": "string",
|
|
590
|
+
"pattern": "^[0-9a-f]{64}$"
|
|
591
|
+
},
|
|
592
|
+
"category": {
|
|
593
|
+
"type": "string",
|
|
594
|
+
"minLength": 1
|
|
595
|
+
},
|
|
596
|
+
"effect": {
|
|
597
|
+
"enum": [
|
|
598
|
+
"policyInput",
|
|
599
|
+
"excluded",
|
|
600
|
+
"noAdditionalReduction"
|
|
601
|
+
]
|
|
602
|
+
},
|
|
603
|
+
"stepIds": {
|
|
604
|
+
"type": "array",
|
|
605
|
+
"items": {
|
|
606
|
+
"type": "string",
|
|
607
|
+
"minLength": 1
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
}
|
|
442
611
|
}
|
|
443
612
|
}
|
|
444
613
|
}
|
|
@@ -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
|
+
}
|