codemetrics-ai 0.2.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 ADDED
@@ -0,0 +1,70 @@
1
+ # codemetrics-ai
2
+
3
+ Deterministic source analysis for JavaScript, TypeScript, JSX and TSX. Version 0.2.0 implements source metrics and React hook/effect checks and emits schema-v3 evidence. Scores are **uncalibrated across ecosystems**; compare them only with compatible runs of this analyzer.
4
+
5
+ ```sh
6
+ npx codemetrics-ai --project package.json
7
+ npx codemetrics-ai --project package.json --tsconfig tsconfig.json
8
+ npx codemetrics-ai --output results/metrics.csv --scorecard-output results/evidence.json
9
+ ```
10
+
11
+ Default output paths are `.scorecard/javascript-typescript/metrics.csv` and `.scorecard/javascript-typescript/evidence.json`, relative to the current directory. Node 20 or later is required. The TypeScript compiler API is a pinned runtime dependency, so an installed package can analyze source without a development checkout.
12
+
13
+ ## Implemented scope
14
+
15
+ | Dimension | Implementation |
16
+ |---|---|
17
+ | codeQuality | Maximum member cyclomatic complexity and findings above 10 |
18
+ | maintainability | Median member maintainability index |
19
+ | performanceAsync | Imported React hooks inside conditionals/loops, async effect callbacks, and advisory effects without dependency arrays |
20
+ | Other dimensions | Explicitly skipped |
21
+
22
+ React checks run only when a source file imports React. Aliased named imports and namespace imports are recognized; local functions shadowing imports are excluded. Missing dependency arrays are advisory because running after every render can be intentional. The implementation does not claim exhaustive Rules of Hooks, dependency-array correctness, or general concurrency analysis. Source syntax errors make the run incomplete and source dimensions unscored. Semantic type errors are outside this AST-focused version's completeness check.
23
+
24
+ ## Discovery
25
+
26
+ The CLI discovers files from each package's tsconfig when present, otherwise scans source extensions. Positive npm workspace globs (including object-form `workspaces.packages`) are supported. Each child package owns its files and exclusions; the root cannot re-include a child's excluded files. External tsconfig references are not recursively analyzed as separate packages: include them in the workspace manifest. Explicit missing manifests/configs and unsupported negative workspace patterns are errors.
27
+
28
+ Build output, node_modules, metadata, declarations, test/fixture directories, `.test`/`.spec` source, conventional generated filenames, and `@generated`/`<auto-generated` headers are excluded. Discovery does not follow directory symlinks. Skipped candidate files appear in filters; pruned directories are not counted as candidate files.
29
+
30
+ ## Metric policy
31
+
32
+ A member is a function with a body: declarations, expressions, arrows, methods, constructors and accessors. Nested functions are separate members and do not inflate their parent's complexity. Named JSX-producing PascalCase functions are components; `useX` functions are hooks. Types group class members or standalone functions and their callbacks within a module. TypeScript interfaces, type aliases and overload signatures without bodies are not runtime members.
33
+
34
+ Cyclomatic complexity starts at 1 per member and adds 1 for `if`, loops, `catch`, non-default switch cases, ternaries, `&&`, `||` and `??`. JSX markup itself contributes no decisions; expressions inside JSX do. Type-only conditions contribute none. Source lines count distinct lines with non-brace/non-semicolon tokens owned by the member, excluding nested functions and type nodes. Executable counts count owned statements (excluding blocks/empty statements), with one for expression-bodied functions.
35
+
36
+ Halstead volume is `N * log2(n)`: token count times vocabulary size. Identifiers/literals are operands; other runtime tokens are operators. Nested function and type tokens are excluded. MI is rounded to the nearest integer using:
37
+
38
+ ```text
39
+ max(0, (171 - 5.2*ln(max(V,1)) - 0.23*CC - 16.2*ln(max(LOC,1))) * 100/171)
40
+ ```
41
+
42
+ For hand-checking, V=64, CC=4, LOC=10 produces MI=65. Quality scores use maximum CC thresholds 5/10/20/40 for scores 10/8/6/4 (otherwise 2). Maintainability scores use median MI thresholds 85/65/40/20 for 10/8/6/4 (otherwise 2). These policies are provisional, not a claim of parity with .NET score ladders.
43
+
44
+ CSV preserves the shared header. Namespace is the repository-relative module path; Project is the package name. Coupling currently counts distinct imported module specifiers per file, and is repeated on its member/type rows; it is not .NET class-coupling parity. Inheritance follows compiler-resolved class base types. Type rows sum owned member complexity/lines and average member MI; these are documented JS/TS aggregates, not Visual Studio type metrics.
45
+
46
+ ## Baselines and SARIF
47
+
48
+ ```sh
49
+ npx codemetrics-ai --baseline quality-baselines/javascript-typescript.json \
50
+ --comparison-output results/comparison.json --fail-on-new warning \
51
+ --max-score-drop 0 --sarif results/analysis.sarif
52
+
53
+ # Works with either .NET or JS/TS evidence:
54
+ npx --package codemetrics-ai codemetrics-evidence --input results/evidence.json \
55
+ --baseline quality-baselines/javascript-typescript.json --output results/comparison.json
56
+ ```
57
+
58
+ Exit codes: 0 success, 1 requested quality gate failed, 2 invalid input or incomplete/incompatible evidence. Without gate flags, findings do not fail an otherwise complete analysis. See [evidence workflows](../../docs/evidence-workflows.md) and [v3 migration](../../shared/scorecard-schema/migration-v3.md).
59
+
60
+ ## Development
61
+
62
+ ```sh
63
+ npm ci
64
+ npm test
65
+ npm run test:package
66
+ ```
67
+
68
+ Tests include parser/React/workspace fixtures, formula assertions, schema validation, comparison compatibility, gate behavior, and compiled CLI execution. The package smoke test packs and installs the package with production dependencies in an isolated directory and exercises analysis, comparison and SARIF. The shared corpus adds pinned accuracy and score-distribution checks.
69
+
70
+ The packaged `codemetrics-evidence --inspect-output <path>` command reads v2/v3 and validates optional expected provenance; historical v2 cannot be compared, gated or exported as SARIF. Structured dimension scope identifies implemented coverage, including React-hook-only performance checks. See [consumer integration](../../docs/evidence-workflows.md#skill-and-other-evidence-consumers).
@@ -0,0 +1,13 @@
1
+ import { type Metric } from "./metrics.js";
2
+ import { type Evidence } from "./evidence.js";
3
+ export declare function analyze(options: {
4
+ project?: string;
5
+ tsconfig?: string;
6
+ runId?: string;
7
+ auditId?: string;
8
+ }, version: string): {
9
+ evidence: Evidence;
10
+ metrics: Metric[];
11
+ csv: string;
12
+ inputs: string[];
13
+ };
@@ -0,0 +1,102 @@
1
+ import ts from "typescript";
2
+ import { discover } from "./discovery.js";
3
+ import { analyzeFile } from "./metrics.js";
4
+ import { hash, invocationIds, scored, skippedDimensions } from "./evidence.js";
5
+ import { metricsCsvHeader } from "./scorecard-contract.js";
6
+ export function analyze(options, version) {
7
+ const identity = invocationIds(options.runId, options.auditId);
8
+ const discovery = discover(options.project, options.tsconfig);
9
+ const dimensions = skippedDimensions();
10
+ const diagnostics = [];
11
+ const metrics = [];
12
+ const collected = [];
13
+ let analyzedFiles = 0;
14
+ let reactSupported = false;
15
+ for (const pkg of discovery.packages) {
16
+ const program = ts.createProgram(pkg.files, { ...pkg.options, noEmit: true });
17
+ const checker = program.getTypeChecker();
18
+ for (const filename of pkg.files) {
19
+ const source = program.getSourceFile(filename);
20
+ if (!source) {
21
+ diagnostics.push({ kind: "sourceUnavailable", message: filename, project: pkg.name });
22
+ continue;
23
+ }
24
+ const errors = program.getSyntacticDiagnostics(source);
25
+ if (errors.length) {
26
+ diagnostics.push(...errors.map(error => ({ kind: "parseError", message: `${filename}: ${ts.flattenDiagnosticMessageText(error.messageText, " ")}`, project: pkg.name })));
27
+ continue;
28
+ }
29
+ analyzedFiles++;
30
+ const result = analyzeFile(source, checker, pkg.name, discovery.repositoryRoot);
31
+ reactSupported ||= result.reactSupported;
32
+ metrics.push(...result.metrics);
33
+ collected.push(...result.findings);
34
+ }
35
+ }
36
+ metrics.sort((a, b) => a.file < b.file ? -1 : a.file > b.file ? 1 : a.line - b.line);
37
+ if (analyzedFiles === 0)
38
+ diagnostics.push({ kind: "emptyPopulation", message: "No production source files were analyzed." });
39
+ if (metrics.length) {
40
+ const maximum = Math.max(...metrics.map(metric => metric.complexity));
41
+ const quality = maximum <= 5 ? 10 : maximum <= 10 ? 8 : maximum <= 20 ? 6 : maximum <= 40 ? 4 : 2;
42
+ 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
+ const values = metrics.map(metric => metric.maintainabilityIndex).sort((a, b) => a - b);
44
+ const median = (values[Math.floor((values.length - 1) / 2)] + values[Math.ceil((values.length - 1) / 2)]) / 2;
45
+ dimensions.maintainability = scored(median >= 85 ? 10 : median >= 65 ? 8 : median >= 40 ? 6 : median >= 20 ? 4 : 2, `Median member maintainability index=${median}; uncalibrated JS/TS policy.`, [], { median, thresholds: [85, 65, 40, 20] });
46
+ const performance = collected.filter(item => item.dimension === "performanceAsync").map(item => item.finding);
47
+ const actionable = performance.filter(finding => finding.confidence !== "low");
48
+ if (reactSupported)
49
+ dimensions.performanceAsync = scored(actionable.length ? 6 : 10, "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" });
50
+ }
51
+ if (diagnostics.length)
52
+ for (const key of ["codeQuality", "maintainability", "performanceAsync"])
53
+ dimensions[key] = { status: "failed", basis: "Incomplete source analysis; partial findings are unscored.", findings: dimensions[key].findings };
54
+ for (const [key, includes, excludes] of [
55
+ ["codeQuality", ["maximum-member-cyclomatic-complexity"], ["design-quality", "runtime-behavior"]],
56
+ ["maintainability", ["median-member-maintainability-index"], ["change-cost", "comprehensive-human-review"]],
57
+ ["performanceAsync", ["react-hook-placement", "react-effect-callbacks"], ["general-async", "concurrency", "runtime-performance"]]
58
+ ])
59
+ dimensions[key].scope = { id: `javascript-typescript/${key}/v1`, coverage: "partial", includes: [...includes], excludes: [...excludes] };
60
+ const evidence = {
61
+ schemaVersion: 3, generatedAtUtc: new Date().toISOString(), tool: { name: "codemetrics-ai", version, ecosystem: "javascript-typescript" },
62
+ subject: { root: discovery.repositoryRoot, entryPoint: discovery.entryPoint, name: discovery.name, variant: "source" },
63
+ filters: { totalUnits: discovery.packages.reduce((sum, pkg) => sum + pkg.files.length, 0) + discovery.skipped.length,
64
+ analyzedUnits: analyzedFiles, skipped: discovery.skipped },
65
+ population: { types: new Set(metrics.map(metric => `${metric.project}|${metric.file}|${metric.type}`)).size, members: metrics.length },
66
+ dimensions, analysis: { ...identity, status: diagnostics.length ? "incomplete" : "complete", ruleset: "javascript-typescript-2026-09-05",
67
+ calibration: "uncalibrated", configurationFingerprint: hash(JSON.stringify(canonicalConfiguration(discovery.packages.map(pkg => ({ name: pkg.name,
68
+ options: pkg.options, selection: pkg.selection })), discovery.repositoryRoot))), diagnostics, suppressions: [] }
69
+ };
70
+ return { evidence, metrics, csv: toCsv(metrics), inputs: discovery.packages.flatMap(pkg => [pkg.entryPoint, ...pkg.files]) };
71
+ }
72
+ function canonicalConfiguration(value, root) {
73
+ if (typeof value === "string")
74
+ return value.replaceAll("\\", "/").replace(root.replaceAll("\\", "/") + "/", "<root>/");
75
+ if (Array.isArray(value))
76
+ return value.map(item => canonicalConfiguration(item, root));
77
+ if (value && typeof value === "object")
78
+ return Object.fromEntries(Object.entries(value).sort(([a], [b]) => a < b ? -1 : 1)
79
+ .map(([key, item]) => [key, canonicalConfiguration(item, root)]));
80
+ return value;
81
+ }
82
+ function toCsv(metrics) {
83
+ const escape = (value) => `"${String(value).replaceAll('"', '""')}"`;
84
+ const rows = [];
85
+ const types = new Map();
86
+ for (const metric of metrics) {
87
+ const key = `${metric.project}|${metric.file}|${metric.type}`;
88
+ const group = types.get(key) ?? [];
89
+ group.push(metric);
90
+ types.set(key, group);
91
+ }
92
+ for (const members of types.values()) {
93
+ const first = members[0];
94
+ rows.push(["Type", first.project, first.file, first.type, "", Math.round(members.reduce((sum, m) => sum + m.maintainabilityIndex, 0) / members.length),
95
+ members.reduce((sum, m) => sum + m.complexity, 0), Math.max(...members.map(m => m.inheritance)), first.coupling,
96
+ members.reduce((sum, m) => sum + m.sourceLines, 0), members.reduce((sum, m) => sum + m.executableLines, 0)]);
97
+ for (const member of members)
98
+ rows.push(["Member", member.project, member.file, member.type, member.member, member.maintainabilityIndex,
99
+ member.complexity, member.inheritance, member.coupling, member.sourceLines, member.executableLines]);
100
+ }
101
+ return metricsCsvHeader + "\n" + rows.map(row => row.map(escape).join(",")).join("\n") + "\n";
102
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs";
3
+ import { parseArgs } from "node:util";
4
+ import { analyze } from "./analyzer.js";
5
+ import { defaultEvidencePath, defaultMetricsPath } from "./scorecard-contract.js";
6
+ import { compare, gate, readEvidence, sarif, validateEvidence } from "./evidence-tools.js";
7
+ import { distinctOutputs, write } from "./io.js";
8
+ try {
9
+ const { values } = parseArgs({ options: {
10
+ project: { type: "string" }, tsconfig: { type: "string" }, "run-id": { type: "string" }, "audit-id": { type: "string" }, output: { type: "string", default: defaultMetricsPath },
11
+ "scorecard-output": { type: "string", default: defaultEvidencePath }, baseline: { type: "string" },
12
+ "comparison-output": { type: "string" }, sarif: { type: "string" }, "fail-on-new": { type: "string" },
13
+ "max-score-drop": { type: "string" }, help: { type: "boolean", short: "h" }, version: { type: "boolean" }
14
+ } });
15
+ const version = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
16
+ if (values.help)
17
+ console.log([
18
+ "codemetrics-ai — JavaScript, TypeScript and React source analysis",
19
+ "--project <package.json> --tsconfig <tsconfig.json>",
20
+ "--run-id <uuid> --audit-id <uuid> (generated when omitted)",
21
+ "--output <metrics.csv> --scorecard-output <evidence.json>",
22
+ "--baseline <evidence.json> --comparison-output <comparison.json>",
23
+ "--fail-on-new <info|warning|error> --max-score-drop <number> --sarif <results.sarif>",
24
+ "Scores are uncalibrated across ecosystems. Unsupported dimensions are skipped.",
25
+ "Exit codes: 0 success, 1 quality gate, 2 invalid input/incomplete analysis."
26
+ ].join("\n"));
27
+ else if (values.version)
28
+ console.log(version);
29
+ else {
30
+ if ((values["fail-on-new"] || values["max-score-drop"] || values["comparison-output"]) && !values.baseline)
31
+ throw new Error("Comparison and quality gates require --baseline.");
32
+ distinctOutputs([values.project ?? "package.json", values.tsconfig, values.baseline], [values.output, values["scorecard-output"], values["comparison-output"], values.sarif]);
33
+ const result = analyze({ ...values, runId: values["run-id"], auditId: values["audit-id"] }, version);
34
+ validateEvidence(result.evidence);
35
+ distinctOutputs(result.inputs, [values.output, values["scorecard-output"], values["comparison-output"], values.sarif]);
36
+ const comparison = values.baseline ? compare(result.evidence, readEvidence(values.baseline)) : undefined;
37
+ const failed = comparison ? gate(comparison, values["fail-on-new"], values["max-score-drop"] === undefined ? undefined : Number(values["max-score-drop"])) : false;
38
+ write(values.output, result.csv);
39
+ write(values["scorecard-output"], result.evidence);
40
+ if (values.sarif)
41
+ write(values.sarif, sarif(result.evidence));
42
+ if (values["comparison-output"])
43
+ write(values["comparison-output"], comparison);
44
+ console.log("Analyzed " + result.evidence.filters.analyzedUnits + " files; " + result.metrics.length + " members. Evidence: " + values["scorecard-output"]);
45
+ process.exitCode = result.evidence.analysis.status === "incomplete" ? 2 : failed ? 1 : 0;
46
+ }
47
+ }
48
+ catch (error) {
49
+ console.error(error instanceof Error ? error.message : error);
50
+ process.exitCode = 2;
51
+ }
@@ -0,0 +1,37 @@
1
+ {
2
+ "schemaVersion": 2,
3
+ "generatedAtUtc": "2026-06-10T00:00:00.0000000Z",
4
+ "tool": {
5
+ "name": "CodeMetrics.AI",
6
+ "version": "1.1.0",
7
+ "ecosystem": "dotnet"
8
+ },
9
+ "subject": {
10
+ "root": "/repo",
11
+ "entryPoint": "/repo/MyApp.slnx",
12
+ "name": "MyApp",
13
+ "variant": "Debug"
14
+ },
15
+ "filters": {
16
+ "totalUnits": 2,
17
+ "analyzedUnits": 1,
18
+ "skipped": [
19
+ { "name": "MyApp.Tests", "reason": "Test project" }
20
+ ]
21
+ },
22
+ "population": {
23
+ "types": 1,
24
+ "members": 1
25
+ },
26
+ "dimensions": {
27
+ "codeQuality": { "status": "scored", "score": 10, "basis": "Example.", "findings": [] },
28
+ "maintainability": { "status": "scored", "score": 10, "basis": "Example.", "findings": [] },
29
+ "errorHandling": { "status": "scored", "score": 10, "basis": "Example.", "findings": [] },
30
+ "performanceAsync": { "status": "scored", "score": 10, "basis": "Example.", "findings": [] },
31
+ "security": { "status": "scored", "score": 10, "basis": "Example.", "findings": [] },
32
+ "testing": { "status": "scored", "score": 10, "basis": "Example.", "findings": [] },
33
+ "documentation": { "status": "scored", "score": 10, "basis": "Example.", "findings": [] },
34
+ "dependencyManagement": { "status": "skipped", "basis": "Dependency probe skipped.", "findings": [] },
35
+ "architecture": { "status": "scored", "score": 10, "basis": "Example.", "findings": [] }
36
+ }
37
+ }
@@ -0,0 +1,35 @@
1
+ {
2
+ "schemaVersion": 2,
3
+ "generatedAtUtc": "2026-06-10T00:00:00.0000000Z",
4
+ "tool": {
5
+ "name": "codemetrics-ai",
6
+ "version": "0.1.0",
7
+ "ecosystem": "javascript-typescript"
8
+ },
9
+ "subject": {
10
+ "root": "/repo",
11
+ "entryPoint": "/repo/package.json",
12
+ "name": "my-app",
13
+ "variant": "tsconfig.json"
14
+ },
15
+ "filters": {
16
+ "totalUnits": 1,
17
+ "analyzedUnits": 1,
18
+ "skipped": []
19
+ },
20
+ "population": {
21
+ "types": 1,
22
+ "members": 1
23
+ },
24
+ "dimensions": {
25
+ "codeQuality": { "status": "scored", "score": 10, "basis": "Example.", "findings": [] },
26
+ "maintainability": { "status": "scored", "score": 10, "basis": "Example.", "findings": [] },
27
+ "errorHandling": { "status": "scored", "score": 10, "basis": "Example.", "findings": [] },
28
+ "performanceAsync": { "status": "scored", "score": 10, "basis": "Example.", "findings": [] },
29
+ "security": { "status": "scored", "score": 10, "basis": "Example.", "findings": [] },
30
+ "testing": { "status": "scored", "score": 10, "basis": "Example.", "findings": [] },
31
+ "documentation": { "status": "scored", "score": 10, "basis": "Example.", "findings": [] },
32
+ "dependencyManagement": { "status": "skipped", "basis": "Dependency checks not implemented in scaffold.", "findings": [] },
33
+ "architecture": { "status": "scored", "score": 10, "basis": "Example.", "findings": [] }
34
+ }
35
+ }
@@ -0,0 +1,24 @@
1
+ import ts from "typescript";
2
+ export declare const excludedDirectories: Set<string>;
3
+ export declare function exclusion(file: string): string | undefined;
4
+ export declare function walk(root: string): string[];
5
+ export interface PackageInput {
6
+ name: string;
7
+ root: string;
8
+ entryPoint: string;
9
+ files: string[];
10
+ options: ts.CompilerOptions;
11
+ selection: unknown;
12
+ }
13
+ export declare function repositoryRoot(root: string): string;
14
+ export declare function discover(project?: string, config?: string): {
15
+ root: string;
16
+ repositoryRoot: string;
17
+ entryPoint: string;
18
+ name: any;
19
+ packages: PackageInput[];
20
+ skipped: {
21
+ name: string;
22
+ reason: string;
23
+ }[];
24
+ };
@@ -0,0 +1,101 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import ts from "typescript";
4
+ export const excludedDirectories = new Set(["node_modules", ".git", ".worktrees", ".scorecard", "dist", "build", ".next", "out", "coverage", "vendor"]);
5
+ const slash = (file) => file.replaceAll("\\", "/");
6
+ export function exclusion(file) {
7
+ const name = slash(file);
8
+ if (name.split("/").some(part => excludedDirectories.has(part)))
9
+ return "Build output, dependencies, or metadata";
10
+ if (/\.d\.[cm]?ts$/.test(name))
11
+ return "Type declarations";
12
+ if (/(^|\/)(__tests__|tests?|fixtures)(\/|$)|\.(test|spec)\.[cm]?[jt]sx?$/.test(name))
13
+ return "Test or fixture source";
14
+ if (/(\.generated\.|\.g\.|\.min\.)/.test(name))
15
+ return "Generated source";
16
+ return undefined;
17
+ }
18
+ export function walk(root) {
19
+ const files = [];
20
+ for (const entry of fs.readdirSync(root, { withFileTypes: true }).sort((a, b) => a.name < b.name ? -1 : 1)) {
21
+ if (entry.isSymbolicLink())
22
+ continue;
23
+ const full = path.join(root, entry.name);
24
+ if (entry.isDirectory() && !excludedDirectories.has(entry.name))
25
+ files.push(...walk(full));
26
+ else if (entry.isFile())
27
+ files.push(full);
28
+ }
29
+ return files;
30
+ }
31
+ export function repositoryRoot(root) {
32
+ for (let directory = root;; directory = path.dirname(directory)) {
33
+ if (fs.existsSync(path.join(directory, ".git")))
34
+ return directory;
35
+ if (path.dirname(directory) === directory)
36
+ return root;
37
+ }
38
+ }
39
+ export function discover(project, config) {
40
+ const entryPoint = path.resolve(project ?? (config ? path.join(path.dirname(config), "package.json") : "package.json"));
41
+ if (!fs.existsSync(entryPoint))
42
+ throw new Error(`Package manifest does not exist: ${entryPoint}`);
43
+ const root = path.dirname(entryPoint);
44
+ const manifest = JSON.parse(fs.readFileSync(entryPoint, "utf8"));
45
+ const workspacePatterns = Array.isArray(manifest.workspaces) ? manifest.workspaces : manifest.workspaces?.packages;
46
+ const manifests = [entryPoint];
47
+ if (Array.isArray(workspacePatterns)) {
48
+ if (!workspacePatterns.every(pattern => typeof pattern === "string" && !pattern.startsWith("!") && !pattern.includes("..")))
49
+ throw new Error("Workspace patterns must be relative positive globs without '..'.");
50
+ manifests.push(...ts.sys.readDirectory(root, [".json"], ["**/node_modules/**", "**/.git/**", "**/.worktrees/**"], workspacePatterns.map(pattern => `${pattern.replace(/\/$/, "")}/package.json`)));
51
+ }
52
+ const packages = [];
53
+ const skipped = [];
54
+ const owned = new Set();
55
+ // Children own their files before the workspace root is scanned.
56
+ const manifestPaths = [...new Set(manifests.map(file => path.resolve(file)))];
57
+ for (const manifestPath of manifestPaths.sort((a, b) => b.length - a.length || a.localeCompare(b, "en"))) {
58
+ const packageRoot = path.dirname(manifestPath);
59
+ const data = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
60
+ const configPath = config && manifestPath === entryPoint ? path.resolve(config) : path.join(packageRoot, "tsconfig.json");
61
+ if (config && manifestPath === entryPoint && !fs.existsSync(configPath))
62
+ throw new Error(`tsconfig does not exist: ${configPath}`);
63
+ let files;
64
+ let selection = "default-source-scan";
65
+ let options = { allowJs: true, jsx: ts.JsxEmit.Preserve, target: ts.ScriptTarget.Latest, noEmit: true };
66
+ if (fs.existsSync(configPath)) {
67
+ const read = ts.readConfigFile(configPath, ts.sys.readFile);
68
+ if (read.error)
69
+ throw new Error(ts.flattenDiagnosticMessageText(read.error.messageText, "\n"));
70
+ const parsed = ts.parseJsonConfigFileContent(read.config, ts.sys, path.dirname(configPath), { noEmit: true }, configPath);
71
+ const errors = parsed.errors.filter(error => error.code !== 18003);
72
+ if (errors.length)
73
+ throw new Error(errors.map(error => ts.flattenDiagnosticMessageText(error.messageText, "\n")).join("\n"));
74
+ files = parsed.fileNames;
75
+ options = parsed.options;
76
+ selection = { include: parsed.raw.include, exclude: parsed.raw.exclude, files: parsed.raw.files, references: parsed.raw.references };
77
+ }
78
+ else
79
+ files = walk(packageRoot).filter(file => /\.[cm]?[jt]sx?$/.test(file));
80
+ const accepted = [];
81
+ for (const file of files.map(file => path.resolve(file)).sort()) {
82
+ if (manifestPaths.some(other => other !== manifestPath && path.dirname(other).startsWith(packageRoot + path.sep) && file.startsWith(path.dirname(other) + path.sep)))
83
+ continue;
84
+ const relative = slash(path.relative(root, file));
85
+ if (owned.has(file))
86
+ continue;
87
+ owned.add(file);
88
+ const reason = relative.startsWith("../") ? "Outside repository" : exclusion(relative);
89
+ if (reason) {
90
+ skipped.push({ name: relative, reason });
91
+ continue;
92
+ }
93
+ if (/^\s*(?:\/\/|\/\*)[^\n]*(?:@generated|<auto-generated)/im.test(fs.readFileSync(file, "utf8")))
94
+ skipped.push({ name: relative, reason: "Generated source marker" });
95
+ else
96
+ accepted.push(file);
97
+ }
98
+ packages.push({ name: data.name ?? path.basename(packageRoot), root: packageRoot, entryPoint: manifestPath, files: accepted, options, selection });
99
+ }
100
+ return { root, repositoryRoot: repositoryRoot(root), entryPoint, name: manifest.name ?? path.basename(root), packages, skipped };
101
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from "node:util";
3
+ import { compare, gate, readEvidence, sarif } from "./evidence-tools.js";
4
+ import { distinctOutputs, write } from "./io.js";
5
+ import { inspectEvidence } from "./evidence-reader.js";
6
+ try {
7
+ const { values } = parseArgs({ options: { input: { type: "string" }, baseline: { type: "string" }, output: { type: "string" },
8
+ sarif: { type: "string" }, "allow-incompatible": { type: "boolean" }, "fail-on-new": { type: "string" },
9
+ "max-score-drop": { type: "string" }, "inspect-output": { type: "string" },
10
+ "expected-ecosystem": { type: "string" }, "expected-version": { type: "string" }, "expected-run-id": { type: "string" }, "expected-audit-id": { type: "string" },
11
+ "expected-entry-point": { type: "string" }, "expected-variant": { type: "string" }, "expected-root": { type: "string" },
12
+ help: { type: "boolean", short: "h" } } });
13
+ if (values.help)
14
+ console.log([
15
+ "codemetrics-evidence — validate v2/v3, compare and export v3 evidence from any ecosystem",
16
+ "--input <evidence.json> [--baseline <baseline.json> --output <comparison.json>]",
17
+ "--sarif <results.sarif> --fail-on-new <info|warning|error> --max-score-drop <number>",
18
+ "--inspect-output <inspection.json> reads v2/v3 without upgrading historical evidence.",
19
+ "--expected-ecosystem <id> --expected-version <version> --expected-entry-point <path> --expected-variant <variant> --expected-root <path>",
20
+ "--expected-run-id <uuid> --expected-audit-id <uuid> reject findings from another invocation, including files with missing IDs.",
21
+ "--allow-incompatible permits exploratory comparison with null score deltas; gates remain disabled.",
22
+ "Exit codes: 0 success, 1 quality gate, 2 invalid/incompatible/incomplete evidence."
23
+ ].join("\n"));
24
+ else {
25
+ if (!values.input)
26
+ throw new Error("--input is required.");
27
+ distinctOutputs([values.input, values.baseline], [values.output, values.sarif, values["inspect-output"]]);
28
+ const inspection = inspectEvidence(values.input, { ecosystem: values["expected-ecosystem"], version: values["expected-version"],
29
+ entryPoint: values["expected-entry-point"], variant: values["expected-variant"], root: values["expected-root"],
30
+ runId: values["expected-run-id"], auditId: values["expected-audit-id"] });
31
+ if (values["inspect-output"])
32
+ write(values["inspect-output"], inspection);
33
+ if (inspection.evidence.schemaVersion === 2) {
34
+ if (values.baseline || values.output || values.sarif || values["fail-on-new"] !== undefined || values["max-score-drop"] !== undefined)
35
+ throw new Error("V2 is compatibility-only: comparison, gates and SARIF require v3. Re-run the pinned analyzer.");
36
+ console.log("V2 evidence is valid; completeness, scope and comparison provenance are unknown.");
37
+ process.exitCode = inspection.usable ? 0 : 2;
38
+ }
39
+ else {
40
+ const current = readEvidence(values.input);
41
+ if (!values.baseline && (values.output || values["fail-on-new"] || values["max-score-drop"]))
42
+ throw new Error("Comparison and quality gates require --baseline.");
43
+ let failed = false;
44
+ if (values.baseline) {
45
+ const result = compare(current, readEvidence(values.baseline), values["allow-incompatible"]);
46
+ if (values["fail-on-new"] !== undefined || values["max-score-drop"] !== undefined)
47
+ failed = gate(result, values["fail-on-new"], values["max-score-drop"] === undefined ? undefined : Number(values["max-score-drop"]));
48
+ if (values.output)
49
+ write(values.output, result);
50
+ else
51
+ console.log(JSON.stringify(result, null, 2));
52
+ }
53
+ if (values.sarif)
54
+ write(values.sarif, sarif(current));
55
+ process.exitCode = current.analysis.status !== "complete" || Object.values(current.dimensions).some(d => d.status === "failed") ? 2 : failed ? 1 : 0;
56
+ if (!values.baseline)
57
+ console.log("Evidence is valid.");
58
+ }
59
+ }
60
+ }
61
+ catch (error) {
62
+ console.error(error instanceof Error ? error.message : error);
63
+ process.exitCode = 2;
64
+ }
@@ -0,0 +1,27 @@
1
+ import type { Evidence, Dimension, Finding } from "./evidence.js";
2
+ import type { DimensionKey } from "./scorecard-contract.js";
3
+ export type LegacyEvidence = Omit<Evidence, "analysis" | "dimensions"> & {
4
+ dimensions: Record<DimensionKey, Omit<Dimension, "findings"> & {
5
+ findings: Omit<Finding, "ruleId" | "fingerprint" | "confidence" | "observations">[];
6
+ }>;
7
+ };
8
+ /** Read historical evidence without fabricating v3 metadata or enabling comparisons. */
9
+ export declare function readCompatibleEvidence(file: string): Evidence | LegacyEvidence;
10
+ export declare function inspectEvidence(file: string, expected?: {
11
+ ecosystem?: string;
12
+ version?: string;
13
+ entryPoint?: string;
14
+ variant?: string;
15
+ root?: string;
16
+ runId?: string;
17
+ auditId?: string;
18
+ }): {
19
+ evidence: Evidence | LegacyEvidence;
20
+ compatibility: {
21
+ mode: string;
22
+ comparisonAvailable: boolean;
23
+ analysisStatus: string;
24
+ scopeAvailable: boolean;
25
+ };
26
+ usable: boolean;
27
+ };
@@ -0,0 +1,53 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { Ajv2020 } from "ajv/dist/2020.js";
4
+ import addFormats from "ajv-formats";
5
+ import { validateEvidence } from "./evidence-tools.js";
6
+ const ajv = new Ajv2020({ allErrors: true, strict: false });
7
+ addFormats(ajv);
8
+ let legacyValidator;
9
+ /** Read historical evidence without fabricating v3 metadata or enabling comparisons. */
10
+ export function readCompatibleEvidence(file) {
11
+ const value = JSON.parse(fs.readFileSync(file, "utf8"));
12
+ if (value?.schemaVersion === 3)
13
+ validateEvidence(value);
14
+ else if (value?.schemaVersion === 2) {
15
+ legacyValidator ??= ajv.compile(JSON.parse(fs.readFileSync(new URL("./evidence.schema.v2.json", import.meta.url), "utf8")));
16
+ if (!legacyValidator(value))
17
+ throw new Error("Invalid v2 evidence: " + ajv.errorsText(legacyValidator.errors));
18
+ if (value.filters.analyzedUnits > value.filters.totalUnits)
19
+ throw new Error("Analyzed units exceed total units.");
20
+ }
21
+ else
22
+ throw new Error("Unsupported evidence schema; supported versions are 2 and 3.");
23
+ return value;
24
+ }
25
+ export function inspectEvidence(file, expected = {}) {
26
+ const evidence = readCompatibleEvidence(file);
27
+ for (const key of ["runId", "auditId"])
28
+ if (expected[key] !== undefined && evidence.analysis?.[key]?.toLowerCase() !== expected[key]?.toLowerCase())
29
+ throw new Error(`Stale or unrelated evidence: analysis.${key} does not match this invocation.`);
30
+ for (const key of ["ecosystem", "version"])
31
+ if (expected[key] !== undefined && evidence.tool[key] !== expected[key])
32
+ throw new Error(`Provenance mismatch: tool.${key}`);
33
+ if (expected.variant !== undefined && evidence.subject.variant !== expected.variant)
34
+ throw new Error("Provenance mismatch: subject.variant");
35
+ const normalize = (value) => {
36
+ const absolute = path.resolve(value);
37
+ // Native resolution handles Windows 8.3 names as well as junctions/symlinks.
38
+ // Historical paths may no longer exist; retain lexical matching for those.
39
+ const resolved = fs.existsSync(absolute) ? fs.realpathSync.native(absolute) : absolute;
40
+ return process.platform === "win32" ? resolved.toLowerCase() : resolved;
41
+ };
42
+ if (expected.root !== undefined && normalize(evidence.subject.root) !== normalize(expected.root))
43
+ throw new Error("Provenance mismatch: subject.root");
44
+ if (expected.entryPoint !== undefined && normalize(path.resolve(evidence.subject.root, evidence.subject.entryPoint)) !== normalize(expected.entryPoint))
45
+ throw new Error("Provenance mismatch: subject.entryPoint");
46
+ const legacy = evidence.schemaVersion === 2;
47
+ const failed = Object.values(evidence.dimensions).some(dimension => dimension.status === "failed");
48
+ const incomplete = !legacy && evidence.analysis.status !== "complete";
49
+ return { evidence, compatibility: { mode: legacy ? "legacy" : "native", comparisonAvailable: !legacy && !failed && !incomplete,
50
+ analysisStatus: legacy ? "unknown" : evidence.analysis.status,
51
+ scopeAvailable: !legacy && Object.values(evidence.dimensions).every(dimension => dimension.scope !== undefined) },
52
+ usable: !failed && !incomplete };
53
+ }