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.
Files changed (45) hide show
  1. package/README.md +99 -7
  2. package/dist/analyzer.js +35 -23
  3. package/dist/async-population.d.ts +12 -0
  4. package/dist/async-population.js +53 -0
  5. package/dist/async-probe.d.ts +8 -0
  6. package/dist/async-probe.js +52 -0
  7. package/dist/async-scoring.d.ts +3 -0
  8. package/dist/async-scoring.js +33 -0
  9. package/dist/complexity-breakdown.d.ts +21 -0
  10. package/dist/complexity-breakdown.js +30 -0
  11. package/dist/decomposition-evidence.d.ts +91 -0
  12. package/dist/decomposition-evidence.js +71 -0
  13. package/dist/error-handling.d.ts +30 -0
  14. package/dist/error-handling.js +114 -0
  15. package/dist/evidence-tools.js +20 -0
  16. package/dist/evidence.d.ts +3 -1
  17. package/dist/evidence.js +5 -2
  18. package/dist/evidence.schema.v3.json +171 -2
  19. package/dist/function-measurements.d.ts +17 -0
  20. package/dist/function-measurements.js +85 -0
  21. package/dist/function-nodes.d.ts +3 -0
  22. package/dist/function-nodes.js +5 -0
  23. package/dist/function-scoring.d.ts +5 -0
  24. package/dist/function-scoring.js +50 -0
  25. package/dist/handler-resolution.d.ts +26 -0
  26. package/dist/handler-resolution.js +99 -0
  27. package/dist/metrics.d.ts +3 -0
  28. package/dist/metrics.js +23 -45
  29. package/dist/module-forwarding.d.ts +4 -0
  30. package/dist/module-forwarding.js +153 -0
  31. package/dist/module-graph.d.ts +34 -0
  32. package/dist/module-graph.js +122 -0
  33. package/dist/module-references.d.ts +8 -0
  34. package/dist/module-references.js +51 -0
  35. package/dist/module-resolution.d.ts +10 -0
  36. package/dist/module-resolution.js +76 -0
  37. package/dist/module-topology.d.ts +72 -0
  38. package/dist/module-topology.js +72 -0
  39. package/dist/react-probe.d.ts +6 -0
  40. package/dist/react-probe.js +60 -0
  41. package/dist/score-arithmetic.d.ts +12 -0
  42. package/dist/score-arithmetic.js +40 -0
  43. package/dist/scoring-decision.d.ts +35 -0
  44. package/dist/scoring-decision.js +24 -0
  45. package/package.json +4 -3
@@ -0,0 +1,71 @@
1
+ import ts from "typescript";
2
+ import { isFunction } from "./function-nodes.js";
3
+ function ownedShape(root) {
4
+ // A concise function body has an implicit return, independent of formatting.
5
+ let statements = ts.isExpression(root) ? 1 : 0, dataLiteralEntries = 0;
6
+ function visit(node) {
7
+ if (ts.isExpressionWithTypeArguments(node)) {
8
+ visit(node.expression);
9
+ return;
10
+ }
11
+ if (isFunction(node) || ts.isTypeNode(node) || ts.isClassDeclaration(node) || ts.isClassExpression(node) ||
12
+ ts.isInterfaceDeclaration(node) || ts.isTypeAliasDeclaration(node) || ts.isEnumDeclaration(node))
13
+ return;
14
+ if (ts.isImportDeclaration(node) || ts.isImportEqualsDeclaration(node) || ts.isExportDeclaration(node))
15
+ return;
16
+ if (ts.isVariableStatement(node) && !node.declarationList.declarations.some(declaration => declaration.initializer))
17
+ return;
18
+ if (ts.isStatement(node) && !ts.isBlock(node) && !ts.isEmptyStatement(node) && !ts.isLabeledStatement(node))
19
+ statements++;
20
+ if (ts.isObjectLiteralExpression(node))
21
+ dataLiteralEntries += node.properties.length;
22
+ if (ts.isArrayLiteralExpression(node))
23
+ dataLiteralEntries += node.elements.filter(element => !ts.isOmittedExpression(element)).length;
24
+ ts.forEachChild(node, visit);
25
+ }
26
+ visit(root);
27
+ return { ownedStatements: statements, dataLiteralEntries };
28
+ }
29
+ export function decompositionModule(source, file, project, functions) {
30
+ const byStart = new Map(functions.map(item => [item.sourceSpanStart, item]));
31
+ const rows = [];
32
+ function visit(node) {
33
+ if (isFunction(node) && node.body) {
34
+ const item = byStart.get(node.getStart(source));
35
+ if (item)
36
+ rows.push({ id: item.id, member: item.member, line: item.line, ownedSourceLines: item.ownSourceLines, ...ownedShape(node.body) });
37
+ }
38
+ if (!ts.isTypeNode(node) || ts.isExpressionWithTypeArguments(node))
39
+ ts.forEachChild(node, visit);
40
+ }
41
+ visit(source);
42
+ return { file, project, implementedFunctions: rows.length, sumOwnedFunctionLines: rows.reduce((sum, row) => sum + row.ownedSourceLines, 0),
43
+ sumOwnedFunctionStatements: rows.reduce((sum, row) => sum + row.ownedStatements, 0),
44
+ initializer: ownedShape(source), directReExports: source.statements.filter(node => ts.isExportDeclaration(node) && node.moduleSpecifier).length,
45
+ typeDeclarations: source.statements.filter(node => ts.isTypeAliasDeclaration(node) || ts.isInterfaceDeclaration(node)).length,
46
+ enums: source.statements.filter(ts.isEnumDeclaration).length, functions: rows };
47
+ }
48
+ export function decompositionEvidence(modules, incomplete) {
49
+ const ordered = [...modules].sort((a, b) => a.file.localeCompare(b.file, "en") || a.project.localeCompare(b.project, "en"));
50
+ const functions = ordered.flatMap(module => module.functions.map(row => ({ ...row, file: module.file, project: module.project })));
51
+ const sizes = functions.map(row => row.ownedSourceLines).sort((a, b) => a - b);
52
+ const sum = sizes.reduce((total, size) => total + size, 0);
53
+ const statements = functions.map(row => row.ownedStatements).sort((a, b) => a - b);
54
+ const statementSum = statements.reduce((total, size) => total + size, 0);
55
+ return { version: 2, status: incomplete ? "failed" : "unscored", score: null,
56
+ measurement: "owned-executable-statements-v2", primaryMeasure: "ownedStatements",
57
+ basis: "Descriptive size evidence; no responsibility inference, threshold, finding or score contribution.",
58
+ exclusions: ["nested-function-double-counting", "class-and-enum-initializers", "parameter-initializers", "semantic-responsibility"],
59
+ population: { modules: ordered.length, functions: functions.length, sumOwnedFunctionLines: sum,
60
+ sumOwnedFunctionStatements: statementSum,
61
+ medianFunctionStatements: statements.length ? statements[Math.ceil(statements.length * 0.5) - 1] : null,
62
+ p90FunctionStatements: statements.length ? statements[Math.ceil(statements.length * 0.9) - 1] : null,
63
+ medianFunctionLines: sizes.length ? sizes[Math.ceil(sizes.length * 0.5) - 1] : null,
64
+ p90FunctionLines: sizes.length ? sizes[Math.ceil(sizes.length * 0.9) - 1] : null }, modules: ordered,
65
+ largestFunctions: [...functions].sort((a, b) => b.ownedStatements - a.ownedStatements || a.id.localeCompare(b.id, "en")).slice(0, 10),
66
+ largestFunctionContainers: ordered.filter(module => module.sumOwnedFunctionStatements > 0)
67
+ .sort((a, b) => b.sumOwnedFunctionStatements - a.sumOwnedFunctionStatements || a.file.localeCompare(b.file, "en")).slice(0, 10)
68
+ .map(({ functions: _functions, ...module }) => ({ ...module,
69
+ shareOfOwnedFunctionStatements: statementSum ? module.sumOwnedFunctionStatements / statementSum : null,
70
+ shareOfOwnedFunctionLines: sum ? module.sumOwnedFunctionLines / sum : null })) };
71
+ }
@@ -0,0 +1,30 @@
1
+ import ts from "typescript";
2
+ import { type Dimension } from "./evidence.js";
3
+ import { HandlerResolver } from "./handler-resolution.js";
4
+ type UsageKind = "catchClause" | "promiseCatch" | "promiseThenRejection";
5
+ interface Handler {
6
+ id: string;
7
+ file: string;
8
+ project: string;
9
+ line: number;
10
+ kind: "catchClause" | "promiseRejection";
11
+ classification: "unexplainedEmpty" | "documentedEmpty" | "containsCode";
12
+ comments: string[];
13
+ uses: {
14
+ file: string;
15
+ line: number;
16
+ kind: UsageKind;
17
+ resolution: "inline" | "reference";
18
+ }[];
19
+ }
20
+ export declare function inspectErrorHandling(source: ts.SourceFile, checker: ts.TypeChecker, file: string, project: string, resolver: HandlerResolver): {
21
+ handlers: Handler[];
22
+ uninspectedCallbacks: {
23
+ file: string;
24
+ line: number;
25
+ kind: UsageKind;
26
+ reason: string;
27
+ }[];
28
+ };
29
+ export declare function errorHandlingEvidence(results: ReturnType<typeof inspectErrorHandling>[], incomplete: boolean): Dimension;
30
+ export {};
@@ -0,0 +1,114 @@
1
+ import ts from "typescript";
2
+ import { hash } from "./evidence.js";
3
+ import { unwrapHandler } from "./handler-resolution.js";
4
+ function undefinedValue(node, checker) {
5
+ node = unwrapHandler(node);
6
+ if (ts.isVoidExpression(node))
7
+ return ts.isNumericLiteral(node.expression);
8
+ if (!ts.isIdentifier(node) || node.text !== "undefined")
9
+ return false;
10
+ const declarations = checker.getSymbolAtLocation(node)?.declarations ?? [];
11
+ return declarations.length === 0 || declarations.every(declaration => declaration.getSourceFile().hasNoDefaultLib);
12
+ }
13
+ function emptyBody(body, checker) {
14
+ if (!ts.isBlock(body))
15
+ return undefinedValue(body, checker);
16
+ return body.statements.every(statement => ts.isEmptyStatement(statement) ||
17
+ (ts.isReturnStatement(statement) && (!statement.expression || undefinedValue(statement.expression, checker))));
18
+ }
19
+ function bodyComments(body, source) {
20
+ const scanner = ts.createScanner(ts.ScriptTarget.Latest, false, source.languageVariant, source.text.slice(body.pos, body.end));
21
+ const comments = [];
22
+ for (let token = scanner.scan(); token !== ts.SyntaxKind.EndOfFileToken; token = scanner.scan()) {
23
+ if (token !== ts.SyntaxKind.SingleLineCommentTrivia && token !== ts.SyntaxKind.MultiLineCommentTrivia)
24
+ continue;
25
+ const text = scanner.getTokenText().replace(/^\/\/?\*?|\*\/$/g, "").trim();
26
+ if (/[\p{L}\p{N}]/u.test(text))
27
+ comments.push(text);
28
+ }
29
+ return comments;
30
+ }
31
+ function rejectionArgument(call, checker) {
32
+ const declaration = checker.getResolvedSignature(call)?.declaration;
33
+ if (!declaration || !ts.isMethodSignature(declaration) || !ts.isIdentifier(declaration.name) ||
34
+ !ts.isInterfaceDeclaration(declaration.parent) || !["Promise", "PromiseLike"].includes(declaration.parent.name.text))
35
+ return;
36
+ const source = declaration.getSourceFile();
37
+ if (!source.hasNoDefaultLib || !/(?:^|[/\\])lib\.[^/\\]+\.d\.ts$/.test(source.fileName))
38
+ return;
39
+ if (declaration.name.text === "catch")
40
+ return { index: 0, kind: "promiseCatch" };
41
+ if (declaration.name.text === "then" && call.arguments.length > 1)
42
+ return { index: 1, kind: "promiseThenRejection" };
43
+ }
44
+ export function inspectErrorHandling(source, checker, file, project, resolver) {
45
+ const handlers = new Map();
46
+ const uninspectedCallbacks = [];
47
+ function add(body, kind, at, resolution) {
48
+ const identity = resolver.identity(body);
49
+ const existing = handlers.get(identity.id);
50
+ const use = { file, line: source.getLineAndCharacterOfPosition(at.getStart(source)).line + 1, kind, resolution };
51
+ if (existing) {
52
+ existing.uses.push(use);
53
+ return;
54
+ }
55
+ const empty = emptyBody(body, checker), comments = empty ? bodyComments(body, body.getSourceFile()) : [];
56
+ const classification = !empty ? "containsCode" : comments.length ? "documentedEmpty" : "unexplainedEmpty";
57
+ handlers.set(identity.id, { ...identity, kind: kind === "catchClause" ? "catchClause" : "promiseRejection", classification, comments, uses: [use] });
58
+ }
59
+ function visit(node) {
60
+ if (ts.isTypeNode(node) && !ts.isExpressionWithTypeArguments(node))
61
+ return;
62
+ if (ts.isCatchClause(node))
63
+ add(node.block, "catchClause", node, "inline");
64
+ if (ts.isCallExpression(node)) {
65
+ const rejection = rejectionArgument(node, checker);
66
+ if (rejection) {
67
+ const resolved = resolver.resolve(node.arguments[rejection.index]);
68
+ if ("body" in resolved)
69
+ add(resolved.body, rejection.kind, node, resolved.resolution);
70
+ else
71
+ uninspectedCallbacks.push({ file, line: source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1,
72
+ kind: rejection.kind, reason: resolved.reason });
73
+ }
74
+ }
75
+ ts.forEachChild(node, visit);
76
+ }
77
+ visit(source);
78
+ return { handlers: [...handlers.values()], uninspectedCallbacks };
79
+ }
80
+ function handlerFinding(handler) {
81
+ return { category: "unexplainedEmptyHandler", ruleId: "javascript-typescript/errorHandling/unexplainedEmptyHandler",
82
+ fingerprint: hash(`${handler.id}|unexplainedEmptyHandler`), file: handler.file, project: handler.project, line: handler.line,
83
+ severity: "info", confidence: "high",
84
+ message: "Handler contains only empty syntax or an undefined return, without a body comment. Review whether discarding the failure is intentional.",
85
+ observations: { handlerId: handler.id, kind: handler.kind, uses: handler.uses, classification: "reviewLead", scoreDisposition: "excludedUncalibrated" } };
86
+ }
87
+ export function errorHandlingEvidence(results, incomplete) {
88
+ const unique = new Map();
89
+ for (const row of results.flatMap(result => result.handlers)) {
90
+ const existing = unique.get(row.id);
91
+ if (existing)
92
+ existing.uses.push(...row.uses);
93
+ else
94
+ unique.set(row.id, { ...row, uses: [...row.uses] });
95
+ }
96
+ const handlers = [...unique.values()].sort((a, b) => a.file.localeCompare(b.file, "en") || a.line - b.line);
97
+ for (const handler of handlers)
98
+ handler.uses.sort((a, b) => a.file.localeCompare(b.file, "en") || a.line - b.line || a.kind.localeCompare(b.kind, "en"));
99
+ const uninspectedCallbacks = results.flatMap(result => result.uninspectedCallbacks)
100
+ .sort((a, b) => a.file.localeCompare(b.file, "en") || a.line - b.line);
101
+ const count = (classification) => handlers.filter(handler => handler.classification === classification).length;
102
+ const unexplained = count("unexplainedEmpty");
103
+ return { status: incomplete ? "failed" : "skipped", basis: incomplete ? "Incomplete source; handler observations are diagnostic only." :
104
+ "Handler syntax and documented intent are observed; error-handling scoring is not calibrated. Code presence is not proof of recovery.",
105
+ scope: { id: "javascript-typescript/errorHandling/handler-evidence-v2", coverage: "partial",
106
+ includes: ["catch-clauses", "inline-standard-promise-rejection-handlers", "body-comments", "selected-source-referenced-handlers"],
107
+ excludes: ["error-handling-score", "general-exception-flow", "arbitrary-callback-value-flow", "correctness-of-recovery", "semantic-rationale-judgment"] },
108
+ findings: handlers.filter(handler => handler.classification === "unexplainedEmpty").map(handlerFinding), handlerEvidence: { version: 2, countingUnit: "distinctHandlerBody",
109
+ totalHandlers: handlers.length, unexplainedEmptyHandlers: unexplained, documentedEmptyHandlers: count("documentedEmpty"),
110
+ handlersContainingCode: count("containsCode"), unexplainedEmptyPercent: handlers.length ? 100 * unexplained / handlers.length : null,
111
+ handlerUseSites: handlers.reduce((sum, handler) => sum + handler.uses.length, 0),
112
+ referencedCallbackUseSites: handlers.reduce((sum, handler) => sum + handler.uses.filter(use => use.resolution === "reference").length, 0),
113
+ uninspectedRejectionCallbacks: uninspectedCallbacks.length, uninspectedCallbacks, handlers } };
114
+ }
@@ -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"));
@@ -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(score: number, basis: string, findings: Finding[], observations: Record<string, unknown>): Dimension;
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(score, basis, findings, observations) {
17
- return { status: "scored", score, basis, findings: findings.sort((a, b) => a.fingerprint.localeCompare(b.fingerprint, "en")),
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
- "required": [
371
- "score"
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,17 @@
1
+ import ts from "typescript";
2
+ import { type FunctionNode } from "./function-nodes.js";
3
+ import { type ComplexityBreakdown } from "./complexity-breakdown.js";
4
+ export interface FunctionMeasurement {
5
+ id: string;
6
+ file: string;
7
+ project: string;
8
+ member: string;
9
+ line: number;
10
+ sourceSpanStart: number;
11
+ ownComplexity: number;
12
+ ownSourceLines: number;
13
+ ownHalsteadVolume: number;
14
+ ownMaintainabilityIndex: number;
15
+ complexityBreakdown?: ComplexityBreakdown;
16
+ }
17
+ export declare function measureFunction(node: FunctionNode, source: ts.SourceFile, file: string, project: string, member: string): FunctionMeasurement;
@@ -0,0 +1,85 @@
1
+ import ts from "typescript";
2
+ import { hash } from "./evidence.js";
3
+ import { isFunction } from "./function-nodes.js";
4
+ import { complexityBreakdown, decisionCategory, emptyDecisionCounts } from "./complexity-breakdown.js";
5
+ function excluded(node) {
6
+ return isFunction(node) || ts.isTypeNode(node) || ts.isInterfaceDeclaration(node) ||
7
+ ts.isTypeAliasDeclaration(node) || ts.isClassDeclaration(node) || ts.isClassExpression(node) || ts.isEnumDeclaration(node);
8
+ }
9
+ function assertedExpression(node) {
10
+ if (ts.isAsExpression(node) || ts.isTypeAssertionExpression(node) || ts.isSatisfiesExpression(node) ||
11
+ ts.isNonNullExpression(node) || ts.isExpressionWithTypeArguments(node))
12
+ return node.expression;
13
+ return undefined;
14
+ }
15
+ function runtimeChildren(node, source) {
16
+ const children = node.getChildren(source);
17
+ const typed = node;
18
+ const argumentsIndex = typed.typeArguments ? children.findIndex(child => child.kind === ts.SyntaxKind.SyntaxList &&
19
+ child.pos === typed.typeArguments.pos && child.end === typed.typeArguments.end) : -1;
20
+ return children.filter((child, index) => {
21
+ // Delimiters and commas around erased type arguments are not runtime tokens.
22
+ if (argumentsIndex >= 0 && index >= argumentsIndex - 1 && index <= argumentsIndex + 1)
23
+ return false;
24
+ if (typed.type && (child === typed.type || (child.kind === ts.SyntaxKind.ColonToken && children[index + 1] === typed.type)))
25
+ return false;
26
+ return child !== typed.exclamationToken;
27
+ });
28
+ }
29
+ // This scored measurement is deliberately separate from the historical CSV metric.
30
+ export function measureFunction(node, source, file, project, member) {
31
+ const body = node.body;
32
+ const decisionCounts = emptyDecisionCounts();
33
+ const operators = [], operands = [], lines = new Set();
34
+ function decisions(current) {
35
+ const expression = assertedExpression(current);
36
+ if (expression) {
37
+ decisions(expression);
38
+ return;
39
+ }
40
+ if (excluded(current))
41
+ return;
42
+ const category = decisionCategory(current);
43
+ if (category)
44
+ decisionCounts[category]++;
45
+ ts.forEachChild(current, decisions);
46
+ }
47
+ function tokens(current) {
48
+ const expression = assertedExpression(current);
49
+ if (expression) {
50
+ tokens(expression);
51
+ return;
52
+ }
53
+ if (excluded(current) || ts.isJsxText(current))
54
+ return;
55
+ const children = runtimeChildren(current, source);
56
+ if (children.length) {
57
+ children.forEach(tokens);
58
+ return;
59
+ }
60
+ if (current.kind === ts.SyntaxKind.EndOfFileToken || current.getWidth(source) === 0)
61
+ return;
62
+ const text = current.getText(source);
63
+ if (!["{", "}", ";"].includes(text)) {
64
+ const start = source.getLineAndCharacterOfPosition(current.getStart(source)).line;
65
+ const end = source.getLineAndCharacterOfPosition(current.end - 1).line;
66
+ for (let line = start; line <= end; line++)
67
+ lines.add(line);
68
+ }
69
+ if (ts.isIdentifier(current) || ts.isLiteralExpression(current))
70
+ operands.push(text);
71
+ else
72
+ operators.push(text);
73
+ }
74
+ decisions(body);
75
+ const breakdown = complexityBreakdown(decisionCounts);
76
+ const complexity = breakdown.total;
77
+ tokens(body);
78
+ const vocabulary = new Set(operators).size + new Set(operands).size;
79
+ const volume = vocabulary > 1 ? (operators.length + operands.length) * Math.log2(vocabulary) : 0;
80
+ const mi = lines.size === 0 ? 100 : Math.min(100, Math.max(0, (171 - 5.2 * Math.log(Math.max(volume, 1)) - 0.23 * complexity - 16.2 * Math.log(Math.max(lines.size, 1))) * 100 / 171));
81
+ const start = node.getStart(source);
82
+ return { id: hash(`${file}|${start}|${node.kind}`), file, project, member, sourceSpanStart: start,
83
+ line: source.getLineAndCharacterOfPosition(start).line + 1, ownComplexity: complexity,
84
+ ownSourceLines: lines.size, ownHalsteadVolume: volume, ownMaintainabilityIndex: mi, complexityBreakdown: breakdown };
85
+ }
@@ -0,0 +1,3 @@
1
+ import ts from "typescript";
2
+ export type FunctionNode = ts.FunctionDeclaration | ts.FunctionExpression | ts.ArrowFunction | ts.MethodDeclaration | ts.ConstructorDeclaration | ts.GetAccessorDeclaration | ts.SetAccessorDeclaration;
3
+ export declare function isFunction(node: ts.Node): node is FunctionNode;
@@ -0,0 +1,5 @@
1
+ import ts from "typescript";
2
+ export function isFunction(node) {
3
+ return ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node) || ts.isArrowFunction(node) ||
4
+ ts.isMethodDeclaration(node) || ts.isConstructorDeclaration(node) || ts.isGetAccessorDeclaration(node) || ts.isSetAccessorDeclaration(node);
5
+ }
@@ -0,0 +1,5 @@
1
+ import { type Dimension, type Finding } from "./evidence.js";
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]];
4
+ export declare const maintainabilityAnchors: readonly [readonly [40, 0], readonly [52, 2], readonly [58, 4], readonly [65, 6], readonly [70, 8], readonly [75, 10]];
5
+ export declare function scoreFunctions(functions: FunctionMeasurement[], dimension: "codeQuality" | "maintainability", findings?: Finding[]): Dimension;
@@ -0,0 +1,50 @@
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]];
4
+ export const maintainabilityAnchors = [[40, 0], [52, 2], [58, 4], [65, 6], [70, 8], [75, 10]];
5
+ export function scoreFunctions(functions, dimension, findings = []) {
6
+ const complexity = dimension === "codeQuality";
7
+ const anchors = complexity ? complexityAnchors : maintainabilityAnchors;
8
+ const measured = (item) => complexity ? item.ownComplexity : item.ownMaintainabilityIndex;
9
+ const scope = { id: `javascript-typescript/${dimension}/v2`, coverage: "partial",
10
+ includes: [complexity ? "owned-function-cyclomatic-complexity" : "owned-function-maintainability-index"],
11
+ excludes: ["module-and-field-initializers", "parameter-initializers", "runtime-behavior", complexity ? "decomposition" : "change-cost"] };
12
+ if (!functions.length)
13
+ 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));
31
+ 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,
33
+ 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 },
37
+ findingCategories: complexity ? ["highFunctionComplexity"] : [] })), findingEffects: [] };
38
+ 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
+ // Findings identify population inputs, never additional independent penalties.
40
+ const byId = new Map(contributions.map(item => [item.id, item.group]));
41
+ decision.findingEffects = result.findings.map(finding => {
42
+ const group = byId.get(String(finding.observations.functionId));
43
+ return { fingerprint: finding.fingerprint, category: finding.category,
44
+ effect: group ? "policyInput" : "noAdditionalReduction", stepIds: group ? [group] : [] };
45
+ });
46
+ return { ...result, scope, ...(complexity ? { displayName: "Method Complexity", componentDetails: {
47
+ methodComplexity: { score: result.score, eligibleFunctions: ordered.length, topOffenders: contributions.slice(0, 5) },
48
+ decomposition: { score: null, status: "unsupported", description: "JS/TS decomposition is not measured by this policy." }
49
+ } } : {}) };
50
+ }