CodeIssuesPull RequestsActionsSecurityInsights
✨ AI
More
Settings

fix(health): the credential rule read the variable name, not the value #5613

MergedXSccantynz wants to mergefix/credential-value-precisionmainopened 58m ago
4 changed files+541−61
Addedsrc/__tests__/manifest-parse-honesty.test.ts+72−0View fileUnifiedSplit
1/**
2 * `manifestsScanned` must count manifests READ AND PARSED, never attempted.
3 *
4 * The field was added in the same session that fixed the dependency walk,
5 * specifically so a partial walk could not pass as a complete one — and it
6 * shipped as `toRead.length`: the number of manifests the walk DECIDED to
7 * open. A manifest that failed to parse contributed nothing to `total` and
8 * still counted toward the figure claiming how much of the tree was read.
9 *
10 * It had no test, which is how it got through. The honesty field needed the
11 * same audit as the thing it audits — so the parser is split out and the
12 * "returns null, and null is not counted" contract is pinned here.
13 */
14import { describe, it, expect } from "bun:test";
15import { parseManifestDeps } from "../lib/intelligence";
16
17describe("parseManifestDeps — a failure is null, never an empty success", () => {
18 it("returns null on unparseable JSON rather than zero dependencies", () => {
19 // The distinction that matters: null means "not read", {names: []} would
20 // mean "read, and it has none" — a package.json with no deps is a real
21 // and different answer from one we could not parse.
22 expect(parseManifestDeps("package.json", "{ not json ")).toBeNull();
23 });
24
25 it("returns an empty-but-present result for a manifest with no dependencies", () => {
26 expect(parseManifestDeps("package.json", '{"name":"x"}')).toEqual({
27 names: [],
28 unnamed: 0,
29 });
30 });
31
32 it("returns null for a manifest kind with no parser", () => {
33 // Cargo.toml / go.mod / Gemfile are recognised so `manifestFound` stays
34 // honest, but contribute nothing and must not count as scanned. On a real
35 // repo this is exactly the 80-found / 76-scanned gap.
36 for (const base of ["Cargo.toml", "go.mod", "Gemfile", "composer.json", "Pipfile"]) {
37 expect(parseManifestDeps(base, "anything")).toBeNull();
38 }
39 });
40
41 it("collects dependencies and devDependencies by name", () => {
42 const r = parseManifestDeps(
43 "package.json",
44 '{"dependencies":{"hono":"1"},"devDependencies":{"typescript":"5"}}'
45 );
46 expect(r?.names.sort()).toEqual(["hono", "typescript"]);
47 });
48
49 it("counts requirements.txt entries, ignoring comments and blanks", () => {
50 expect(parseManifestDeps("requirements.txt", "flask\n# a comment\n\nrequests\n")).toEqual({
51 names: [],
52 unnamed: 2,
53 });
54 });
55});
56
57describe("the four unparsed Cargo.toml files are the honest gap", () => {
58 it("a workspace of 5 manifests where 4 have no parser scans 1", () => {
59 // Mirrors the measured case: 80 manifests found, 76 scanned, the four
60 // Cargo.toml files having no parser. Before the fix this reported 80.
61 const manifests = [
62 ["package.json", '{"dependencies":{"zod":"1"}}'],
63 ["Cargo.toml", "[package]"],
64 ["Cargo.toml", "[package]"],
65 ["Cargo.toml", "[package]"],
66 ["Cargo.toml", "[package]"],
67 ] as const;
68 const scanned = manifests.filter(([b, c]) => parseManifestDeps(b, c) !== null).length;
69 expect(scanned).toBe(1);
70 expect(scanned).toBeLessThan(manifests.length);
71 });
72});
Addedsrc/__tests__/security-credential-values.test.ts+107−0View fileUnifiedSplit
1/**
2 * The credential rule must read the VALUE, not the variable name.
3 *
4 * Every case below is real. The Vapron instance opened all nine `critical`
5 * findings this scanner reported against its repository and checked them
6 * against running production: five were false, and the reason each was false
7 * is the same — the rule matched a line because the VARIABLE was called
8 * `..._TOKEN_URL` or `..._API_KEY`, then judged the literal only by whether
9 * it looked "unlike a lowercase identifier". A URL, an angle-bracket
10 * placeholder and a documentation snippet all pass that test.
11 *
12 * The four that were real are here too, and they matter more: a rule that
13 * loses them to kill the false ones has made things worse. They are all the
14 * `?? "<dev literal>"` shape on a security-critical value, which fails OPEN —
15 * if the env var is ever absent the service comes up with a secret that is
16 * committed to the repository, and nothing says so.
17 */
18import { describe, it, expect } from "bun:test";
19import { detectSecurityIssuesInLine } from "../lib/intelligence";
20
21const secretFindings = (line: string) =>
22 detectSecurityIssuesInLine({ filePath: "src/x.ts", line, lineNumber: 1 }).filter(
23 (i) => i.rule === "no-hardcoded-secrets" || i.rule === "no-api-keys"
24 );
25
26describe("verified FALSE positives — the literal cannot be a credential", () => {
27 it("a URL is not a secret, however the variable is named", () => {
28 // celitech-client.ts:54 — matched only because the var is `..._TOKEN_URL`.
29 expect(
30 secretFindings(
31 'const url = process.env.CELITECH_TOKEN_URL ?? "https://api.celitech.com/oauth2/token";'
32 )
33 ).toEqual([]);
34 });
35
36 it("an angle-bracket placeholder in a docs guide is not a secret", () => {
37 // build-a-saas.tsx:23 — copy-paste instructions shown to the reader.
38 expect(secretFindings('vapron env set VAPRON_API_KEY="<your-api-key>"')).toEqual([]);
39 });
40
41 it("a YOUR_..._HERE placeholder is not a secret", () => {
42 // platforms/index.tsx:212 — rendered in a <pre> as the snippet we SHOW.
43 expect(secretFindings('const example = "vpk_YOUR_API_KEY_HERE";')).toEqual([]);
44 });
45
46 it("a docs line teaching people to GENERATE a secret is not a leak", () => {
47 expect(
48 secretFindings('SESSION_SECRET="$(openssl rand -hex 32)"')
49 ).toEqual([]);
50 });
51});
52
53describe("verified TRUE positives — these must still fire", () => {
54 // Losing these to kill the false ones would make the rule worse than
55 // before. Each is a committed default on a security-critical value.
56
57 it("catches a session-signing fallback", () => {
58 expect(
59 secretFindings(
60 'const sessionSecret = process.env.SESSION_SECRET ?? "vapron-default-key-change-me";'
61 ).length
62 ).toBeGreaterThan(0);
63 });
64
65 it("catches webhook verification secrets, verbatim from the real source", () => {
66 // services/sms/src/index.ts:84-86 — an object-literal config block, which
67 // is why the credential word precedes a `:` rather than an `=`.
68 for (const line of [
69 'twilioInboundSecret: env.SMS_TWILIO_INBOUND_SECRET ?? "twilio-dev-secret",',
70 'messagebirdInboundSecret: env.SMS_MESSAGEBIRD_INBOUND_SECRET ?? "messagebird-dev-secret",',
71 'bandwidthInboundSecret: env.SMS_BANDWIDTH_INBOUND_SECRET ?? "bandwidth-dev-secret",',
72 ]) {
73 expect(secretFindings(line).length).toBeGreaterThan(0);
74 }
75 });
76
77 it("catches an auth-token fallback, verbatim from the real source", () => {
78 // services/voice/src/index.ts:61
79 expect(
80 secretFindings('const authToken = process.env.VOICE_TOKEN ?? "dev-token";').length
81 ).toBeGreaterThan(0);
82 });
83
84 it("does NOT fire on an empty-string default in the same block", () => {
85 // services/sms/src/index.ts:83 — the adjacent line. An empty default is
86 // not a committed credential, and the rule requires 4+ characters.
87 expect(
88 secretFindings('sinchInboundSecret: env.SINCH_INBOUND_SECRET ?? "",')
89 ).toEqual([]);
90 });
91
92 it("catches the classic committed default", () => {
93 expect(
94 secretFindings(
95 'const SECRET = process.env.AUTH_SECRET ?? "dev-secret-change-before-launch";'
96 ).length
97 ).toBeGreaterThan(0);
98 });
99
100 it("does not reject 'change-me' style values as placeholders", () => {
101 // The tempting over-correction: "changeme" LOOKS like a placeholder and
102 // is in fact the single most dangerous committed default there is.
103 expect(
104 secretFindings('const adminPassword = process.env.ADMIN_PASSWORD ?? "please-changeme-now";').length
105 ).toBeGreaterThan(0);
106 });
107});
Addedsrc/__tests__/security-findings-summary.test.ts+135−0View fileUnifiedSplit
1/**
2 * The findings list is capped per rule; the COUNTS are never capped.
3 *
4 * A real repository produced 199 findings of which 162 were one
5 * medium-severity rule, and the nine criticals were somewhere in the middle
6 * of that list. A rule firing 162 times is telling the reader one thing, not
7 * 162 things. But capping a list without reporting the totals is the same
8 * "partial result presented as whole" this module keeps coming back from —
9 * so both numbers are reported and `byRule` is never truncated.
10 *
11 * Tests assert the DIRECTION of each term, not merely that the shape is
12 * well-formed, after the GateTest session's observation that its own inverted
13 * scorer would have passed any test asserting "a number in 0..100".
14 */
15import { describe, it, expect } from "bun:test";
16import {
17 summariseFindings,
18 MAX_ISSUES_PER_RULE,
19 scoreSecurityIssues,
20} from "../lib/intelligence";
21
22const issue = (rule: string, severity: string, n: number) =>
23 Array.from({ length: n }, (_, i) => ({
24 severity: severity as any,
25 file: `src/f${i}.ts`,
26 line: i + 1,
27 message: `${rule} hit`,
28 rule,
29 }));
30
31describe("summariseFindings — counts are honest", () => {
32 it("reports the TRUE total, not the listed count", () => {
33 const issues = [...issue("no-inner-html", "medium", 162), ...issue("no-hardcoded-secrets", "critical", 9)];
34 const { listed, findings } = summariseFindings(issues);
35 expect(findings.total).toBe(171);
36 expect(findings.listed).toBe(listed.length);
37 expect(findings.listed).toBeLessThan(findings.total);
38 expect(findings.truncated).toBe(true);
39 });
40
41 it("byRule is never truncated, however many were listed", () => {
42 const issues = [...issue("no-inner-html", "medium", 162), ...issue("no-eval", "high", 5)];
43 const { findings } = summariseFindings(issues);
44 expect(findings.byRule["no-inner-html"]).toBe(162);
45 expect(findings.byRule["no-eval"]).toBe(5);
46 });
47
48 it("bySeverity counts every issue, not the listed subset", () => {
49 const issues = [...issue("no-inner-html", "medium", 162), ...issue("no-hardcoded-secrets", "critical", 9)];
50 const { findings } = summariseFindings(issues);
51 expect(findings.bySeverity.medium).toBe(162);
52 expect(findings.bySeverity.critical).toBe(9);
53 });
54
55 it("does not truncate when nothing exceeds the cap", () => {
56 const { listed, findings } = summariseFindings(issue("no-eval", "high", 3));
57 expect(listed).toHaveLength(3);
58 expect(findings.truncated).toBe(false);
59 });
60
61 it("is safe on an empty issue list", () => {
62 const { listed, findings } = summariseFindings([]);
63 expect(listed).toEqual([]);
64 expect(findings).toMatchObject({ total: 0, listed: 0, truncated: false });
65 });
66});
67
68describe("summariseFindings — the cap can never bury a critical", () => {
69 it("lists every critical even when a noisy rule dominates", () => {
70 // The failure this guards: 9 criticals inside 162 mediums, and a naive
71 // "first N" cap listing 10 mediums and no criticals at all.
72 const issues = [...issue("no-inner-html", "medium", 162), ...issue("no-hardcoded-secrets", "critical", 9)];
73 const { listed } = summariseFindings(issues);
74 expect(listed.filter((i) => i.severity === "critical")).toHaveLength(9);
75 });
76
77 it("caps each rule independently", () => {
78 const issues = [...issue("a", "medium", 50), ...issue("b", "medium", 50)];
79 const { listed } = summariseFindings(issues);
80 expect(listed.filter((i) => i.rule === "a")).toHaveLength(MAX_ISSUES_PER_RULE);
81 expect(listed.filter((i) => i.rule === "b")).toHaveLength(MAX_ISSUES_PER_RULE);
82 });
83
84 it("orders by severity, so a cap drops the least severe first", () => {
85 const issues = [...issue("r", "info", 20), ...issue("r", "critical", 2)];
86 const { listed } = summariseFindings(issues);
87 expect(listed[0].severity).toBe("critical");
88 });
89});
90
91describe("the cap is presentational — it must not change the verdict", () => {
92 it("scores on every issue, not the listed subset", () => {
93 // Direction check with a positive control: 162 findings must score
94 // strictly worse than 3 of the same rule. If scoring ever moved onto the
95 // capped list, these would converge.
96 const many = issue("no-inner-html", "medium", 162);
97 const few = issue("no-inner-html", "medium", 3);
98 expect(scoreSecurityIssues(many)).toBeLessThan(scoreSecurityIssues(few));
99 });
100
101 it("more criticals always score lower — the term is not inverted", () => {
102 expect(scoreSecurityIssues(issue("no-hardcoded-secrets", "critical", 4))).toBeLessThan(
103 scoreSecurityIssues(issue("no-hardcoded-secrets", "critical", 1))
104 );
105 });
106
107 it("a clean repo scores higher than any repo with findings", () => {
108 expect(scoreSecurityIssues([])).toBeGreaterThan(
109 scoreSecurityIssues(issue("no-eval", "high", 1))
110 );
111 });
112});
113
114describe("both scan paths must actually scan", () => {
115 it("the fallback path reads files, it does not return an empty result", async () => {
116 // Regression pin for a bug introduced by the git-grep change and caught
117 // before it could matter: the rewrite deleted the fallback's per-file
118 // loop, leaving it to slice the file list and return without reading any
119 // of it — zero findings, score 100, `method: "capped"`. A clean bill of
120 // health from never looking, in the module that exists to prevent exactly
121 // that. It was latent (the grep path answers on every real repo) and it
122 // was still wrong.
123 //
124 // Structural rather than behavioural because exercising the fallback
125 // needs a git tree AND a failing `git grep`. It pins the property that
126 // actually broke: code deleted, not logic altered.
127 const src = await Bun.file("src/lib/intelligence.ts").text();
128 const fn = src.slice(
129 src.indexOf("async function analyzeSecurityScore("),
130 src.indexOf("const SECURITY_PENALTIES")
131 );
132 const calls = fn.split("detectSecurityIssuesInLine(").length - 1;
133 expect(calls).toBeGreaterThanOrEqual(2);
134 });
135});
Modifiedsrc/lib/intelligence.ts+227−61View fileUnifiedSplit
4747 */
4848 method?: "grep" | "capped";
4949 };
50 /**
51 * Full counts, independent of how many issues are LISTED.
52 *
53 * `issues` is capped per rule so one noisy pattern cannot bury the
54 * actionable findings: a real repository produced 199 findings of which
55 * 162 were a single medium-severity rule, and the nine criticals were
56 * somewhere in the middle of that list. Capping the list without
57 * reporting the totals would be the same "partial result presented as
58 * whole" this module keeps coming back from, so both numbers are here
59 * and `byRule` is never truncated.
60 */
61 findings?: {
62 total: number;
63 listed: number;
64 truncated: boolean;
65 bySeverity: Record<string, number>;
66 byRule: Record<string, number>;
67 };
5068 };
5169 testing: { score: number; hasTests: boolean; testFileCount: number; estimatedCoverage: string };
5270 complexity: { score: number; avgFileSize: number; largestFiles: FileMetric[]; sourceFiles: number };
543561const CREDENTIAL_WORDS =
544562 /(secret|password|passwd|admin|token|apikey|api[_-]key|credential|changeme|change[_-]?me|default|dev[_-]|test[_-]|placeholder)/i;
545563
564/**
565 * Literals that are structurally incapable of being a credential.
566 *
567 * The rule reads the VALUE. It kept reading the variable name instead: the
568 * fallback rule matches on a line mentioning secret/token/key/password, and
569 * everything after that judged only whether the literal looked "unlike a
570 * lowercase identifier" — which a URL, an angle-bracket placeholder and a
571 * doc snippet all satisfy. Verified against a real repository, this fired
572 * `critical` on:
573 *
574 * process.env.CELITECH_TOKEN_URL ?? "https://api.celitech.com/oauth2/token"
575 * vapron env set VAPRON_API_KEY="<your-api-key>"
576 * <pre>vpk_YOUR_API_KEY_HERE</pre> (a copy-paste snippet we SHOW)
577 *
578 * The first is the clearest: the literal is a URL and matched only because
579 * the variable is called `..._TOKEN_URL`. Three of nine reported criticals
580 * against that repo were this, and a rule whose top severity is routinely
581 * wrong teaches people to skim the whole list.
582 *
583 * Pure syntax, no dataflow. Deliberately does NOT reject `changeme`,
584 * `change-me`, `dev-secret` or similar: those are the classic dangerous
585 * defaults and the whole point of the rule.
586 */
587function isStructurallyNotASecret(value: string): boolean {
588 // A URL. `scheme://` cannot be a credential value.
589 if (/^[a-z][a-z0-9+.-]*:\/\//i.test(value)) return true;
590 // An angle-bracket placeholder: <your-api-key>, <secret>, <TOKEN>.
591 if (/^<[^>]*>$/.test(value)) return true;
592 // Documentation placeholders: YOUR_API_KEY_HERE, vpk_YOUR_API_KEY_HERE.
593 if (/your[_-].*[_-]here/i.test(value)) return true;
594 return false;
595}
596
546597function looksLikeRealSecret(value: string): boolean {
547598 if (!value) return false;
548 // Interpolated or templated — the literal is not the secret.
599 // Interpolated or templated — the literal is not the secret. This already
600 // covers `"$(openssl rand -hex 32)"` in a docs guide teaching people to
601 // GENERATE a secret, via the `$`.
549602 if (/[$`{]/.test(value)) return false;
550603 if (value.length < 8) return false;
604 if (isStructurallyNotASecret(value)) return false;
551605
552606 // Mixed case, digits-with-symbols, or unusual characters: high-entropy
553607 // enough to be a real key regardless of what words it contains.
824878 return found;
825879}
826880
881
882/**
883 * Cap how many instances of any ONE rule are listed, keeping the most severe.
884 *
885 * A rule that fires 162 times is telling the reader one thing, not 162
886 * things, and the nine criticals in that report were buried inside it. The
887 * per-rule SCORE cap already exists (SECURITY_PENALTIES.ruleCap) — this is
888 * the same idea for the list. Nothing is hidden: `findings.byRule` carries
889 * every count in full.
890 */
891export const MAX_ISSUES_PER_RULE = 10;
892
893export function summariseFindings(issues: SecurityIssue[]): {
894 listed: SecurityIssue[];
895 findings: {
896 total: number;
897 listed: number;
898 truncated: boolean;
899 bySeverity: Record<string, number>;
900 byRule: Record<string, number>;
901 };
902} {
903 const bySeverity: Record<string, number> = {};
904 const byRule: Record<string, number> = {};
905 for (const i of issues) {
906 bySeverity[i.severity] = (bySeverity[i.severity] ?? 0) + 1;
907 byRule[i.rule] = (byRule[i.rule] ?? 0) + 1;
908 }
909
910 const ORDER = ["critical", "high", "medium", "low", "info"];
911 const rank = (s: string) => {
912 const i = ORDER.indexOf(s);
913 return i === -1 ? ORDER.length : i;
914 };
915 // Severity first, so a cap can never drop a critical in favour of an info.
916 const sorted = [...issues].sort((a, b) => rank(a.severity) - rank(b.severity));
917
918 const perRule: Record<string, number> = {};
919 const listed: SecurityIssue[] = [];
920 for (const i of sorted) {
921 const n = (perRule[i.rule] ?? 0) + 1;
922 perRule[i.rule] = n;
923 if (n <= MAX_ISSUES_PER_RULE) listed.push(i);
924 }
925
926 return {
927 listed,
928 findings: {
929 total: issues.length,
930 listed: listed.length,
931 truncated: listed.length < issues.length,
932 bySeverity,
933 byRule,
934 },
935 };
936}
937
827938async function analyzeSecurityScore(
828939 repoDir: string,
829940 ref: string
9151026 );
9161027 }
9171028
1029 const summary = summariseFindings(issues);
9181030 return {
1031 // Scored on EVERY issue, never on the listed subset — the cap is a
1032 // presentation limit and must not change the verdict.
9191033 score: scoreSecurityIssues(issues),
920 issues,
1034 issues: summary.listed,
1035 findings: summary.findings,
9211036 coverage: {
9221037 sourceFilesEligible: eligibleFiles.length,
9231038 // Every eligible file was covered by the grep. Files absent from the
9311046 };
9321047 }
9331048
1049 // FALLBACK PATH — git grep could not answer, so read files directly.
1050 //
1051 // This loop was accidentally deleted when the grep path was introduced,
1052 // leaving the fallback to slice the file list and return without reading
1053 // any of it: zero findings, score 100, `method: "capped"`. A clean bill of
1054 // health from never looking — the exact failure this module exists to
1055 // prevent, introduced by the change that fixed it elsewhere. It was latent
1056 // (the grep path answers on every real repo) and it was still wrong.
9341057 const sourceFiles = eligibleFiles.slice(0, SCAN_FILE_LIMIT);
1058 for (const filePath of sourceFiles) {
1059 const { stdout: content, exitCode } = await exec(
1060 ["git", "show", `${ref}:${filePath}`],
1061 repoDir
1062 );
1063 if (exitCode !== 0) continue;
1064 if (isTestOrFixtureFile(filePath)) continue;
1065
1066 const lines = content.split("\n");
1067 for (let i = 0; i < lines.length; i++) {
1068 const line = lines[i];
1069 if (line.includes("secrets-ok")) continue;
1070 issues.push(
1071 ...detectSecurityIssuesInLine({
1072 filePath,
1073 line,
1074 lineNumber: i + 1,
1075 statement: /innerHTML\s*=/.test(line)
1076 ? joinStatement(lines, i)
1077 : undefined,
1078 })
1079 );
1080 }
1081 }
1082
1083 const fallbackSummary = summariseFindings(issues);
9351084 return {
9361085 score: scoreSecurityIssues(issues),
937 issues,
1086 issues: fallbackSummary.listed,
1087 findings: fallbackSummary.findings,
9381088 coverage: {
9391089 sourceFilesEligible: eligibleFiles.length,
9401090 sourceFilesScanned: sourceFiles.length,
10771227 return Math.min(30, megaCount * 10, Math.round(share * 300));
10781228}
10791229
1230/**
1231 * Parse one dependency manifest. Returns null when it cannot be parsed.
1232 *
1233 * Split out so `manifestsScanned` can be ASSERTED to count completions rather
1234 * than attempts. That field was added in the same session that fixed the
1235 * dependency walk, specifically so a partial walk could not pass as a
1236 * complete one — and it shipped counting manifests the walk had DECIDED to
1237 * open. A manifest that contributes nothing to `total` must not inflate the
1238 * number reporting how much of the tree was read. The honesty field needed
1239 * the same audit as the thing it audits, and it had no test, which is how it
1240 * got through.
1241 *
1242 * `names` is used for distinct-name counting; `unnamed` is a count only, for
1243 * the manifest kinds whose parsers are too rough to dedupe on.
1244 */
1245export function parseManifestDeps(
1246 base: string,
1247 content: string
1248): { names: string[]; unnamed: number } | null {
1249 if (base === "package.json") {
1250 try {
1251 const pkg = JSON.parse(content);
1252 return {
1253 names: [
1254 ...Object.keys(pkg.dependencies || {}),
1255 ...Object.keys(pkg.devDependencies || {}),
1256 ],
1257 unnamed: 0,
1258 };
1259 } catch {
1260 return null;
1261 }
1262 }
1263
1264 if (base === "pyproject.toml") {
1265 // Count entries in [project] dependencies = [...] arrays and
1266 // [tool.poetry.dependencies]-style tables. A rough count is enough —
1267 // the score only cares about magnitude bands.
1268 const arrayDeps =
1269 content.match(/^\s*["'][a-zA-Z0-9_.\[\]-]+[^"']*["']\s*,?\s*$/gm)?.length ?? 0;
1270 const tableDeps =
1271 content.match(/^[a-zA-Z0-9_-]+\s*=\s*["'{^~>=<]/gm)?.length ?? 0;
1272 return { names: [], unnamed: Math.max(arrayDeps, tableDeps) };
1273 }
1274
1275 if (base === "requirements.txt") {
1276 return {
1277 names: [],
1278 unnamed: content
1279 .split("\n")
1280 .filter((l) => l.trim() && !l.trim().startsWith("#")).length,
1281 };
1282 }
1283
1284 // Pipfile / Cargo.toml / go.mod / Gemfile / composer.json are recognised as
1285 // manifests (so `manifestFound` stays honest) but have no parser yet. They
1286 // contribute nothing to `total` rather than a guessed number — and, because
1287 // this returns null, they are not counted as scanned either.
1288 return null;
1289}
1290
10801291async function analyzeComplexityScore(
10811292 repoDir: string,
10821293 ref: string
12311442 for (const manifestPath of toRead) {
12321443 const base = manifestPath.split("/").pop() || "";
12331444
1234 if (base === "package.json") {
1235 try {
1236 const { stdout: content } = await exec(
1237 ["git", "show", `${ref}:${manifestPath}`],
1238 repoDir
1239 );
1240 const pkg = JSON.parse(content);
1241 for (const name of [
1242 ...Object.keys(pkg.dependencies || {}),
1243 ...Object.keys(pkg.devDependencies || {}),
1244 ]) {
1245 depNames.add(name);
1246 }
1247 parsedCount++;
1248 } catch {
1249 // Unparseable or unreadable manifest — skipped, and it stays counted
1250 // in `manifests` so the gap between found and parsed stays visible.
1251 }
1252 continue;
1253 }
1254
1255 if (base === "pyproject.toml") {
1256 try {
1257 const { stdout: content } = await exec(
1258 ["git", "show", `${ref}:${manifestPath}`],
1259 repoDir
1260 );
1261 // Count entries in [project] dependencies = [...] arrays and
1262 // [tool.poetry.dependencies]-style tables. A rough count is enough —
1263 // the score only cares about magnitude bands.
1264 const arrayDeps =
1265 content.match(/^\s*["'][a-zA-Z0-9_.\[\]-]+[^"']*["']\s*,?\s*$/gm)
1266 ?.length ?? 0;
1267 const tableDeps =
1268 content.match(/^[a-zA-Z0-9_-]+\s*=\s*["'{^~>=<]/gm)?.length ?? 0;
1269 unnamedTotal += Math.max(arrayDeps, tableDeps);
1270 parsedCount++;
1271 } catch {
1272 // parse error
1273 }
1445 let content: string;
1446 try {
1447 const res = await exec(["git", "show", `${ref}:${manifestPath}`], repoDir);
1448 if (res.exitCode !== 0) continue;
1449 content = res.stdout;
1450 } catch {
1451 // Unreadable manifest — stays counted in `manifests` so the gap between
1452 // found and parsed remains visible, and is NOT counted as scanned.
12741453 continue;
12751454 }
12761455
1277 if (base === "requirements.txt") {
1278 try {
1279 const { stdout: content } = await exec(
1280 ["git", "show", `${ref}:${manifestPath}`],
1281 repoDir
1282 );
1283 unnamedTotal += content
1284 .split("\n")
1285 .filter((l) => l.trim() && !l.trim().startsWith("#")).length;
1286 parsedCount++;
1287 } catch {
1288 // read error
1289 }
1290 continue;
1291 }
1456 const parsed = parseManifestDeps(base, content);
1457 if (!parsed) continue;
12921458
1293 // Pipfile / Cargo.toml / go.mod / Gemfile / composer.json are recognised
1294 // as manifests (so `manifestFound` is honest) but have no parser yet.
1295 // They contribute nothing to `total` rather than a guessed number.
1459 for (const name of parsed.names) depNames.add(name);
1460 unnamedTotal += parsed.unnamed;
1461 parsedCount++;
12961462 }
12971463
12981464 const total = depNames.size + unnamedTotal;
12991465
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts