pr-checkmate 1.23.23 → 2.0.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 (101) hide show
  1. package/README.md +68 -15
  2. package/cspell.json +3 -8
  3. package/dist/cli.js +37 -62
  4. package/dist/config/constants.d.ts +21 -3
  5. package/dist/config/constants.js +33 -6
  6. package/dist/config/pr-checkmate-workflow.yml +33 -327
  7. package/dist/core/checks/dependencies/banned-imports.js +23 -24
  8. package/dist/core/checks/dependencies/circular-deps.js +1 -0
  9. package/dist/core/checks/dependencies/dependency-check.js +1 -0
  10. package/dist/core/checks/dependencies/grype-scan.js +1 -0
  11. package/dist/core/checks/dependencies/npm-audit.js +6 -1
  12. package/dist/core/checks/dependencies/vuln-scan.js +1 -0
  13. package/dist/core/checks/findings.d.ts +198 -19
  14. package/dist/core/checks/findings.js +474 -22
  15. package/dist/core/checks/git/custom-rules.js +21 -16
  16. package/dist/core/checks/git/diff-smell.d.ts +17 -0
  17. package/dist/core/checks/git/diff-smell.js +43 -24
  18. package/dist/core/checks/git/large-files.js +14 -2
  19. package/dist/core/checks/git/leftover-debug.js +23 -14
  20. package/dist/core/checks/git/lockfile-drift.js +2 -1
  21. package/dist/core/checks/git/merge-conflict.js +19 -8
  22. package/dist/core/checks/git/missing-tests.js +22 -6
  23. package/dist/core/checks/git/todo-fixme.js +10 -9
  24. package/dist/core/checks/languages/shellcheck.d.ts +8 -0
  25. package/dist/core/checks/languages/shellcheck.js +55 -0
  26. package/dist/core/checks/languages/typecheck.js +1 -0
  27. package/dist/core/checks/pr/pr-size-check.js +2 -1
  28. package/dist/core/checks/quality/config-validation.d.ts +14 -4
  29. package/dist/core/checks/quality/config-validation.js +119 -17
  30. package/dist/core/checks/quality/dead-code.js +1 -0
  31. package/dist/core/checks/quality/duplicate-check.js +1 -0
  32. package/dist/core/checks/quality/license-header.js +12 -2
  33. package/dist/core/checks/quality/spellcheck.d.ts +50 -0
  34. package/dist/core/checks/quality/spellcheck.js +141 -13
  35. package/dist/core/checks/quality/symlinks.d.ts +16 -0
  36. package/dist/core/checks/quality/symlinks.js +62 -0
  37. package/dist/core/checks/quality/yaml-lint.js +22 -9
  38. package/dist/core/checks/security/diff-security.js +111 -28
  39. package/dist/core/checks/security/dockerfile-security.js +23 -20
  40. package/dist/core/checks/security/migration-safety.js +19 -17
  41. package/dist/core/checks/security/sensitive-file-guard.js +37 -5
  42. package/dist/core/checks/security/workflow-security.d.ts +10 -0
  43. package/dist/core/checks/security/workflow-security.js +48 -26
  44. package/dist/core/commands.d.ts +27 -0
  45. package/dist/core/commands.js +91 -0
  46. package/dist/core/context.d.ts +12 -3
  47. package/dist/core/context.js +118 -16
  48. package/dist/core/install/cache.d.ts +39 -0
  49. package/dist/core/install/cache.js +99 -0
  50. package/dist/core/install/doctor.d.ts +35 -0
  51. package/dist/core/install/doctor.js +145 -0
  52. package/dist/core/install/ensure.d.ts +60 -0
  53. package/dist/core/install/ensure.js +169 -0
  54. package/dist/core/install/lock.d.ts +33 -0
  55. package/dist/core/install/lock.js +99 -0
  56. package/dist/core/install/profiles.d.ts +64 -0
  57. package/dist/core/install/profiles.js +170 -0
  58. package/dist/core/registry.js +4 -0
  59. package/dist/core/reporters.d.ts +13 -2
  60. package/dist/core/reporters.js +145 -104
  61. package/dist/core/run-checks.js +91 -14
  62. package/dist/core/targets.js +26 -4
  63. package/dist/core/types.d.ts +49 -1
  64. package/dist/init.d.ts +29 -14
  65. package/dist/init.js +204 -262
  66. package/dist/interfaces/index.d.ts +130 -0
  67. package/dist/scripts/generate-install-manifests.d.ts +39 -0
  68. package/dist/scripts/generate-install-manifests.js +121 -0
  69. package/dist/scripts/publish-github.js +18 -4
  70. package/dist/utils/detect-language.d.ts +37 -4
  71. package/dist/utils/detect-language.js +113 -19
  72. package/dist/utils/http.d.ts +59 -0
  73. package/dist/utils/http.js +119 -0
  74. package/dist/utils/logger.d.ts +52 -6
  75. package/dist/utils/logger.js +125 -33
  76. package/dist/utils/pool.d.ts +49 -0
  77. package/dist/utils/pool.js +124 -0
  78. package/dist/utils/update-notifier.d.ts +9 -0
  79. package/dist/utils/update-notifier.js +57 -3
  80. package/eslint.config.mjs +14 -2
  81. package/install-manifests/commitlint/package-lock.json +1028 -0
  82. package/install-manifests/commitlint/package.json +9 -0
  83. package/install-manifests/core/package-lock.json +1335 -0
  84. package/install-manifests/core/package.json +11 -0
  85. package/install-manifests/cpp/package-lock.json +29 -0
  86. package/install-manifests/cpp/package.json +8 -0
  87. package/install-manifests/duplicate/package-lock.json +113 -0
  88. package/install-manifests/duplicate/package.json +8 -0
  89. package/install-manifests/js/package-lock.json +5099 -0
  90. package/install-manifests/js/package.json +26 -0
  91. package/install-manifests/markdown/package-lock.json +1281 -0
  92. package/install-manifests/markdown/package.json +8 -0
  93. package/install-manifests/python/package-lock.json +21 -0
  94. package/install-manifests/python/package.json +8 -0
  95. package/install-manifests/spellcheck/package-lock.json +1047 -0
  96. package/install-manifests/spellcheck/package.json +8 -0
  97. package/package.json +13 -28
  98. package/pr-checkmate.schema.json +1070 -96
  99. package/src/config/cspell.json +3 -8
  100. package/src/config/pr-checkmate-workflow.yml +33 -327
  101. package/src/config/pr-checkmate.schema.json +1070 -96
@@ -22,10 +22,81 @@ export interface CspellConfig {
22
22
  dictionaries?: string[];
23
23
  enabled?: boolean;
24
24
  }
25
+ /**
26
+ * Client `reporter` block: where the summary goes and what happens if it cannot
27
+ * get there.
28
+ *
29
+ * Lives here rather than beside the other run types in `core/types.ts` because
30
+ * it is part of the config file's shape, and `core/types.ts` already imports
31
+ * this module — declaring it in both is what let the two copies drift.
32
+ */
33
+ /**
34
+ * Client `install` block: which tooling is fetched, when, and what a failure to
35
+ * fetch it means.
36
+ *
37
+ * The defaults are chosen so that an existing installation behaves exactly as it
38
+ * did before this block existed. `strategy: "eager"` is the documented way back
39
+ * to the old behaviour without waiting for a release from us.
40
+ */
41
+ export interface InstallConfig {
42
+ strategy?: 'adaptive' | 'eager' | 'manual';
43
+ profiles?: string[];
44
+ excludeProfiles?: string[];
45
+ minFilesPerLanguage?: number;
46
+ reuseLocal?: 'compatible' | 'always' | 'off';
47
+ onMissingTool?: 'auto' | 'skip' | 'error';
48
+ offline?: boolean;
49
+ waitTimeoutMs?: number;
50
+ installTimeoutMs?: number;
51
+ cacheDir?: string;
52
+ prewarm?: boolean;
53
+ deny?: string[];
54
+ }
55
+ export interface ReporterConfig {
56
+ /**
57
+ * What a failed post means for the run.
58
+ * - `warn` (default) — log it, exit code unchanged.
59
+ * - `error` — fail the run, for teams whose review depends on the comment.
60
+ * - `off` — stay silent.
61
+ */
62
+ onPostFailure?: 'warn' | 'error' | 'off';
63
+ /**
64
+ * `update` (default) edits the previous summary in place; `append` leaves one
65
+ * comment per run, giving a history in the thread.
66
+ */
67
+ mode?: 'update' | 'append';
68
+ /** Per-request timeout in ms (default 10000). A hung API must not hold the run open. */
69
+ timeoutMs?: number;
70
+ /** Attempts per request for retryable failures — 5xx, 429, network (default 3). */
71
+ retries?: number;
72
+ /**
73
+ * Pages of existing comments searched for our marker before giving up
74
+ * (default 10, 100 comments per page). Raise it on very long threads.
75
+ */
76
+ maxPages?: number;
77
+ /** Trim the comment: a table of forty green rows helps nobody. */
78
+ include?: {
79
+ passed?: boolean;
80
+ skipped?: boolean;
81
+ details?: boolean;
82
+ docsLink?: boolean;
83
+ };
84
+ /**
85
+ * Post nothing when the run produced no warnings and no failures, so a clean
86
+ * PR does not notify every subscriber.
87
+ */
88
+ skipWhenClean?: boolean;
89
+ }
25
90
  export interface PRCheckMateConfig {
26
91
  sourcePath: string | string[];
92
+ excludePaths?: string[];
27
93
  ignoreDirs?: string[];
28
94
  languages?: Language[];
95
+ detect?: {
96
+ minFiles?: number;
97
+ excludePaths?: string[];
98
+ disable?: Language[];
99
+ };
29
100
  customRules?: Array<{
30
101
  name: string;
31
102
  pattern: string;
@@ -36,6 +107,28 @@ export interface PRCheckMateConfig {
36
107
  flags?: string;
37
108
  }>;
38
109
  severity?: Record<string, 'error' | 'warn' | 'off'>;
110
+ log?: {
111
+ level?: 'error' | 'warn' | 'info' | 'log' | 'debug';
112
+ format?: 'pretty' | 'ci' | 'json';
113
+ color?: 'auto' | 'always' | 'never';
114
+ timestamps?: boolean;
115
+ stderrLevels?: Array<'error' | 'warn' | 'info' | 'log' | 'debug'>;
116
+ sampleLimit?: number;
117
+ };
118
+ install?: InstallConfig;
119
+ reporter?: ReporterConfig;
120
+ run?: {
121
+ reportSkipped?: 'auto' | 'always' | 'never';
122
+ onDegradedScope?: 'warn' | 'error' | 'off';
123
+ concurrency?: number | 'auto' | 'unlimited';
124
+ heavyConcurrency?: number;
125
+ checkTimeoutMs?: number;
126
+ timings?: boolean;
127
+ fullScanBehaviour?: 'scan' | 'skip';
128
+ };
129
+ severityDefaults?: {
130
+ failOn?: 'error' | 'warn' | 'never';
131
+ };
39
132
  lint?: {
40
133
  rules?: Record<string, any>;
41
134
  ignorePatterns?: string[];
@@ -89,6 +182,7 @@ export interface PRCheckMateConfig {
89
182
  };
90
183
  diffSmell?: {
91
184
  enabled?: boolean;
185
+ ignorePaths?: string[];
92
186
  };
93
187
  prSize?: {
94
188
  enabled?: boolean;
@@ -98,6 +192,7 @@ export interface PRCheckMateConfig {
98
192
  leftoverDebug?: {
99
193
  enabled?: boolean;
100
194
  severity?: 'error' | 'warn';
195
+ commentHandling?: 'language' | 'legacy' | 'off';
101
196
  };
102
197
  todoFixme?: {
103
198
  enabled?: boolean;
@@ -192,6 +287,18 @@ export interface PRCheckMateConfig {
192
287
  diffSecurity?: {
193
288
  enabled?: boolean;
194
289
  ignore?: string[];
290
+ failOn?: 'error' | 'warn' | 'never';
291
+ severityOverrides?: Record<string, 'error' | 'warn' | 'off'>;
292
+ ignorePaths?: string[];
293
+ disablePatterns?: string[];
294
+ extraPatterns?: Array<{
295
+ label: string;
296
+ pattern: string;
297
+ severity?: 'error' | 'warn';
298
+ view?: 'raw' | 'code';
299
+ flags?: string;
300
+ }>;
301
+ commentHandling?: 'language' | 'legacy' | 'off';
195
302
  };
196
303
  mergeConflict?: {
197
304
  enabled?: boolean;
@@ -200,16 +307,35 @@ export interface PRCheckMateConfig {
200
307
  workflowSecurity?: {
201
308
  enabled?: boolean;
202
309
  ignore?: string[];
310
+ failOn?: 'error' | 'warn' | 'never';
311
+ severityOverrides?: Record<string, 'error' | 'warn' | 'off'>;
312
+ ignorePaths?: string[];
203
313
  };
204
314
  dockerfileSecurity?: {
205
315
  enabled?: boolean;
206
316
  ignore?: string[];
317
+ failOn?: 'error' | 'warn' | 'never';
318
+ severityOverrides?: Record<string, 'error' | 'warn' | 'off'>;
319
+ ignorePaths?: string[];
207
320
  };
208
321
  caseCollision?: {
209
322
  enabled?: boolean;
210
323
  };
211
324
  configValidation?: {
212
325
  enabled?: boolean;
326
+ severity?: 'error' | 'warn';
327
+ validateCheckNames?: boolean;
328
+ allowUnknownKeys?: string[];
329
+ };
330
+ symlinks?: {
331
+ enabled?: boolean;
332
+ severity?: 'error' | 'warn';
333
+ ignore?: string[];
334
+ };
335
+ shellcheck?: {
336
+ enabled?: boolean;
337
+ minSeverity?: 'error' | 'warning' | 'info' | 'style';
338
+ ignore?: string[];
213
339
  };
214
340
  yamlLint?: {
215
341
  enabled?: boolean;
@@ -250,6 +376,10 @@ export interface PRCheckMateConfig {
250
376
  };
251
377
  sensitiveFileGuard?: {
252
378
  enabled?: boolean;
379
+ ignorePaths?: string[];
380
+ ignore?: string[];
381
+ failOn?: 'error' | 'warn' | 'never';
382
+ severityOverrides?: Record<string, 'error' | 'warn' | 'off'>;
253
383
  };
254
384
  swift?: {
255
385
  enabled?: boolean;
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Build step: turn our own lockfile into one installable manifest per profile.
3
+ *
4
+ * Why the manifests are cut out of `package-lock.json` rather than resolved
5
+ * fresh from the registry:
6
+ *
7
+ * - **What clients fetch is then exactly what we tested.** Resolving at build
8
+ * time would drift the moment a transitive dependency publishes a patch, and
9
+ * the tooling clients run would stop being the tooling that passed our suite.
10
+ * - **The build stays offline.** A release pipeline that needs the network to
11
+ * produce a manifest is a release pipeline that fails when the registry does.
12
+ * - **Integrity comes free.** Every entry carries the hash npm recorded, so the
13
+ * runtime install is a plain `npm ci`, and npm refuses a tampered tarball
14
+ * itself — over the whole transitive tree, not just the packages we name.
15
+ */
16
+ interface LockEntry {
17
+ version?: string;
18
+ resolved?: string;
19
+ integrity?: string;
20
+ dependencies?: Record<string, string>;
21
+ optionalDependencies?: Record<string, string>;
22
+ peerDependencies?: Record<string, string>;
23
+ peerDependenciesMeta?: Record<string, {
24
+ optional?: boolean;
25
+ }>;
26
+ dev?: boolean;
27
+ link?: boolean;
28
+ [key: string]: unknown;
29
+ }
30
+ export interface Lock {
31
+ lockfileVersion: number;
32
+ packages: Record<string, LockEntry>;
33
+ }
34
+ /** Build the `package.json` + `package-lock.json` pair for one profile. */
35
+ export declare function buildManifest(lock: Lock, profile: string, packages: string[], overrides?: Record<string, unknown>): {
36
+ pkg: object;
37
+ lockfile: object;
38
+ };
39
+ export {};
@@ -0,0 +1,121 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.buildManifest = buildManifest;
7
+ const node_fs_1 = __importDefault(require("node:fs"));
8
+ const node_path_1 = __importDefault(require("node:path"));
9
+ const profiles_1 = require("../core/install/profiles");
10
+ /**
11
+ * Find the entry npm would resolve `name` to from inside `fromPath`.
12
+ *
13
+ * This reproduces node's own lookup: nearest `node_modules` first, then upwards
14
+ * to the root. Getting it wrong shows up as a package silently resolved to the
15
+ * wrong copy — `dpdm` bundles its own TypeScript, so "nearest wins" is not
16
+ * theoretical here.
17
+ */
18
+ function resolveEntry(lock, name, fromPath) {
19
+ const segments = fromPath === '' ? [] : fromPath.split('/');
20
+ for (let depth = segments.length; depth >= 0; depth--) {
21
+ const prefix = segments.slice(0, depth).join('/');
22
+ const candidate = prefix ? `${prefix}/node_modules/${name}` : `node_modules/${name}`;
23
+ if (lock.packages[candidate])
24
+ return candidate;
25
+ }
26
+ return null;
27
+ }
28
+ /** Every dependency path reachable from the given top-level packages. */
29
+ function closureOf(lock, roots) {
30
+ const seen = new Set();
31
+ const queue = [];
32
+ for (const name of roots) {
33
+ const entryPath = resolveEntry(lock, name, '');
34
+ if (entryPath)
35
+ queue.push(entryPath);
36
+ }
37
+ while (queue.length > 0) {
38
+ const current = queue.shift();
39
+ if (seen.has(current))
40
+ continue;
41
+ seen.add(current);
42
+ const entry = lock.packages[current];
43
+ if (!entry)
44
+ continue;
45
+ // Optional AND peer dependencies are included, both for the same reason:
46
+ // npm 7+ installs peers automatically, so a peer absent from the manifest is
47
+ // not simply skipped — npm resolves it fresh against the registry, gets the
48
+ // latest version, and then rejects the manifest as out of sync. That is
49
+ // exactly how the commitlint profile failed: `Missing: typescript@7.0.2 from
50
+ // lock file`, a version we never shipped and never tested.
51
+ const names = [
52
+ ...Object.keys(entry.dependencies ?? {}),
53
+ ...Object.keys(entry.optionalDependencies ?? {}),
54
+ ...Object.keys(entry.peerDependencies ?? {}),
55
+ ];
56
+ for (const name of names) {
57
+ const resolved = resolveEntry(lock, name, current);
58
+ if (resolved && !seen.has(resolved))
59
+ queue.push(resolved);
60
+ }
61
+ }
62
+ return seen;
63
+ }
64
+ /** Build the `package.json` + `package-lock.json` pair for one profile. */
65
+ function buildManifest(lock, profile, packages, overrides) {
66
+ const closure = closureOf(lock, packages);
67
+ const dependencies = {};
68
+ for (const name of packages) {
69
+ const entryPath = resolveEntry(lock, name, '');
70
+ const version = entryPath ? lock.packages[entryPath]?.version : undefined;
71
+ if (!version)
72
+ throw new Error(`profile "${profile}": ${name} is not in package-lock.json`);
73
+ // Pinned, not ranged: a range would let the client resolve a version we
74
+ // never ran a single check against.
75
+ dependencies[name] = version;
76
+ }
77
+ const name = `pr-checkmate-profile-${profile}`;
78
+ // `overrides` are carried over — without them npm re-checks each declared
79
+ // range against the tree and rejects the manifest: `dpdm` asks for
80
+ // `typescript@^5.9.3`, our override points it at the copy we ship, and a
81
+ // manifest that dropped the override fails with `Missing: typescript@5.9.3`.
82
+ //
83
+ // Only the ones that apply, though. These manifests are the list of what a
84
+ // client's machine downloads at run time, so they are meant to be read; an
85
+ // override naming a package this profile never installs is noise in exactly
86
+ // the artefact that has to be scrutable.
87
+ const applicable = Object.fromEntries(Object.entries(overrides ?? {}).filter(([pkg]) => closure.has(resolveEntry(lock, pkg, '') ?? '')));
88
+ const scoped = Object.keys(applicable).length > 0 ? { overrides: applicable } : {};
89
+ const root = { name, version: '1.0.0', dependencies, ...scoped };
90
+ const pkg = { ...root, private: true };
91
+ const packagesOut = { '': root };
92
+ for (const entryPath of [...closure].sort()) {
93
+ const entry = { ...lock.packages[entryPath] };
94
+ // `dev` is about our repository, not about this manifest; leaving it set
95
+ // would make `npm ci --omit=dev` skip tooling the client needs.
96
+ delete entry.dev;
97
+ packagesOut[entryPath] = entry;
98
+ }
99
+ return {
100
+ pkg,
101
+ lockfile: { name, version: '1.0.0', lockfileVersion: 3, requires: true, packages: packagesOut },
102
+ };
103
+ }
104
+ function main() {
105
+ const root = process.cwd();
106
+ const lock = JSON.parse(node_fs_1.default.readFileSync(node_path_1.default.join(root, 'package-lock.json'), 'utf-8'));
107
+ const manifest = JSON.parse(node_fs_1.default.readFileSync(node_path_1.default.join(root, 'package.json'), 'utf-8'));
108
+ const outDir = node_path_1.default.join(root, 'install-manifests');
109
+ node_fs_1.default.rmSync(outDir, { recursive: true, force: true });
110
+ for (const profile of profiles_1.PROFILES) {
111
+ const { pkg, lockfile } = buildManifest(lock, profile.name, profile.packages, manifest.overrides);
112
+ const dir = node_path_1.default.join(outDir, profile.name);
113
+ node_fs_1.default.mkdirSync(dir, { recursive: true });
114
+ node_fs_1.default.writeFileSync(node_path_1.default.join(dir, 'package.json'), `${JSON.stringify(pkg, null, 2)}\n`);
115
+ node_fs_1.default.writeFileSync(node_path_1.default.join(dir, 'package-lock.json'), `${JSON.stringify(lockfile, null, 2)}\n`);
116
+ const count = Object.keys(lockfile.packages).length - 1;
117
+ process.stdout.write(`[manifests]: ${profile.name} — ${count} package(s)\n`);
118
+ }
119
+ }
120
+ if (require.main === module)
121
+ main();
@@ -21,16 +21,30 @@ function publishToGithub() {
21
21
  delete pkg.scripts?.prepublishOnly;
22
22
  fs_1.default.writeFileSync(tempPath, JSON.stringify(pkg, null, 2));
23
23
  try {
24
- (0, child_process_1.execSync)(`mv ${pkgPath} ${backupPath}`);
25
- (0, child_process_1.execSync)(`mv ${tempPath} ${pkgPath}`);
26
- (0, child_process_1.execSync)('npm publish --registry=https://npm.pkg.github.com');
24
+ // `fs.renameSync` rather than shelling out to `mv`: the paths are built here
25
+ // and are not attacker-controlled, but a template literal inside execSync is
26
+ // a shell invocation regardless, and one that runs holding npm credentials.
27
+ // Found by our own Diff Security once `--full` began reviewing whole files
28
+ // (ticket 067) — the delta mode could never see it, since the file is old.
29
+ fs_1.default.renameSync(pkgPath, backupPath);
30
+ fs_1.default.renameSync(tempPath, pkgPath);
31
+ (0, child_process_1.execSync)('npm publish --registry=https://npm.pkg.github.com', { stdio: 'inherit' });
27
32
  utils_1.logger.log('[publishToGithub]: ✅ Published to GitHub Packages');
28
33
  }
29
34
  catch (err) {
30
35
  utils_1.logger.error(`[publishToGithub]: ${err}`);
31
36
  }
32
37
  finally {
33
- (0, child_process_1.execSync)(`mv ${backupPath} ${pkgPath} || true`);
38
+ // The backup only exists if the first rename succeeded; restoring is
39
+ // best-effort so a failure here cannot mask the original error.
40
+ if (fs_1.default.existsSync(backupPath)) {
41
+ try {
42
+ fs_1.default.renameSync(backupPath, pkgPath);
43
+ }
44
+ catch (err) {
45
+ utils_1.logger.error(`[publishToGithub]: could not restore package.json: ${err}`);
46
+ }
47
+ }
34
48
  }
35
49
  }
36
50
  publishToGithub();
@@ -1,15 +1,48 @@
1
+ /** Client `detect` block: how strictly a language is inferred from the tree. */
2
+ export interface DetectOptions {
3
+ /** Source files needed to count a language as present. A marker file bypasses it. */
4
+ minFiles?: number;
5
+ /** Extra paths excluded from the count, on top of the built-in list. */
6
+ excludePaths?: string[];
7
+ /** Languages never reported, whatever the tree looks like. */
8
+ disable?: Language[];
9
+ }
1
10
  export type Language = 'typescript' | 'javascript' | 'python' | 'cpp' | 'swift' | 'kotlin' | 'go' | 'rust' | 'csharp' | 'ruby' | 'php';
11
+ /** One detected language, with the evidence that detected it. */
12
+ export interface LanguageUsage {
13
+ lang: Language;
14
+ /** Tracked source files, excluding generated, vendored and manifest files. */
15
+ files: number;
16
+ /** A marker file declares the language regardless of the file count. */
17
+ marker: boolean;
18
+ }
2
19
  /**
3
- * Detects which languages a repository uses, combining marker files (definitive)
4
- * with tracked source-file counts (breadth). A language is reported when it has a
5
- * marker file OR at least one tracked source file.
20
+ * Detects the languages a repository uses, and *why* each one was detected.
21
+ *
22
+ * Two different decisions read this, and they should not read it the same way:
23
+ *
24
+ * - **Which checks to run.** A marker is enough. A repo scaffolded from a
25
+ * template has `pyproject.toml` and no `.py` yet, and running Python checks on
26
+ * it costs nothing but a skip.
27
+ * - **Which toolchains to download** (ticket 064). A marker is *not* enough — a
28
+ * `Cargo.toml` beside zero `.rs` files would pull a Rust profile on every CI
29
+ * run for a language nobody has written yet. That decision wants `files > 0`.
30
+ *
31
+ * Returning the evidence rather than a bare list is what lets each caller pick.
6
32
  *
7
33
  * @param root Repository root.
8
34
  * @param ignoreDirs Directory names whose contents do not count as the repo's own
9
35
  * code. Defaults to the curated list; `buildContext` passes the client's
10
36
  * `ignoreDirs` so a repo that vendors dependencies is described by its own
11
37
  * sources. Marker files are unaffected — they are only ever read from `root`.
38
+ * @param detect Client overrides for the heuristics.
12
39
  * @returns languages ordered by tracked file count (most-used first). `.h` headers
13
40
  * are intentionally excluded from the C++ count since they are ambiguous with C.
14
41
  */
15
- export declare function detectLanguages(root?: string, ignoreDirs?: string[]): Promise<Language[]>;
42
+ export declare function detectLanguageUsage(root?: string, ignoreDirs?: string[], detect?: DetectOptions): Promise<LanguageUsage[]>;
43
+ /**
44
+ * The languages a repository uses, most-used first — the answer to "which checks
45
+ * should run". See {@link detectLanguageUsage} when the file counts matter, as
46
+ * they do for deciding what to download.
47
+ */
48
+ export declare function detectLanguages(root?: string, ignoreDirs?: string[], detect?: DetectOptions): Promise<Language[]>;
@@ -3,6 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.detectLanguageUsage = detectLanguageUsage;
6
7
  exports.detectLanguages = detectLanguages;
7
8
  const node_fs_1 = __importDefault(require("node:fs"));
8
9
  const node_path_1 = __importDefault(require("node:path"));
@@ -36,6 +37,35 @@ const MARKER_FILES = {
36
37
  ruby: ['Gemfile', '.rubocop.yml'],
37
38
  php: ['composer.json'],
38
39
  };
40
+ /**
41
+ * Paths whose files never count towards a language, on top of `ignoreDirs`.
42
+ *
43
+ * Every entry here comes from a real client repository, not from imagination:
44
+ *
45
+ * - `**\/*UnitTest*\/**` — Gradle/Android writes HTML+JS test reports into
46
+ * `tests/testProdReleaseUnitTest_25.4.11/js/`. One generated `report.js` was
47
+ * enough to make a 302-file Kotlin project detect as JavaScript.
48
+ * - `get-pip.py` — vendored bootstrap script, not the project's Python.
49
+ * - `vendor` / `third_party` — dependencies someone committed; the repo did not
50
+ * write them and should not be judged by them.
51
+ */
52
+ const DEFAULT_DETECT_EXCLUDES = [
53
+ '**/*UnitTest*/**',
54
+ '**/get-pip.py',
55
+ '**/vendor/**',
56
+ '**/third_party/**',
57
+ ];
58
+ /**
59
+ * Extensions that mean a different language depending on where the file sits.
60
+ *
61
+ * `.rs` is Rust almost everywhere — but in an Android project `src/main/rs/`
62
+ * holds RenderScript, and `BrainEyeAndroid/src/main/rs/yuv2rgb.rs` made a mobile
63
+ * SDK detect as a Rust project. Real Rust (`build.rs`, `src/main.rs` in a Tauri
64
+ * app) is unaffected, which is why this is a path rule rather than a count threshold.
65
+ */
66
+ const EXTENSION_COLLISIONS = [
67
+ { ext: '.rs', notUnder: /(^|\/)src\/main\/rs\//, lang: 'rust' },
68
+ ];
39
69
  /**
40
70
  * Source extensions per language, used to count tracked files via `git ls-files`.
41
71
  */
@@ -67,11 +97,39 @@ function hasMarker(root, lang) {
67
97
  });
68
98
  }
69
99
  /**
70
- * Counts tracked files per language using `git ls-files`. Returns an empty map
71
- * when git is unavailable or the directory is not a repo — callers then fall
72
- * back to marker files alone.
100
+ * Every marker filename that is also a source file, so it is never counted as
101
+ * one. `Package.swift` is a Swift *manifest*: counting it made a C++ repository
102
+ * report one Swift source it does not have. Same shape for `setup.py` and
103
+ * `conanfile.py`.
104
+ *
105
+ * The marker still detects the language on its own — that path is untouched.
106
+ * What changes is only that a manifest no longer inflates the file count, which
107
+ * is what the install decision in ticket 064 will read.
73
108
  */
74
- async function countTrackedFiles(root, ignoreDirs) {
109
+ function markerFilenames() {
110
+ const names = new Set();
111
+ for (const list of Object.values(MARKER_FILES)) {
112
+ for (const name of list) {
113
+ if (!name.includes('*') && node_path_1.default.extname(name))
114
+ names.add(name);
115
+ }
116
+ }
117
+ return names;
118
+ }
119
+ /** Which language a file counts towards, or null when it counts towards none. */
120
+ function languageOf(file, ext) {
121
+ for (const collision of EXTENSION_COLLISIONS) {
122
+ if (ext === collision.ext) {
123
+ return collision.notUnder.test(file) ? null : collision.lang;
124
+ }
125
+ }
126
+ for (const lang of Object.keys(EXTENSIONS)) {
127
+ if (EXTENSIONS[lang].includes(ext))
128
+ return lang;
129
+ }
130
+ return null;
131
+ }
132
+ async function countTrackedFiles(root, ignoreDirs, excludePaths) {
75
133
  const counts = {
76
134
  typescript: 0,
77
135
  javascript: 0,
@@ -89,6 +147,7 @@ async function countTrackedFiles(root, ignoreDirs) {
89
147
  const { stdout } = await (0, execa_1.execa)('git', ['ls-files'], { cwd: root });
90
148
  const files = stdout.split('\n').map(f => f.trim());
91
149
  const ignored = new Set(ignoreDirs);
150
+ const markers = markerFilenames();
92
151
  for (const file of files) {
93
152
  if (!file)
94
153
  continue;
@@ -100,13 +159,16 @@ async function countTrackedFiles(root, ignoreDirs) {
100
159
  // check's file list already applies via `excludeIgnoredDirs`.
101
160
  if ((0, glob_1.isUnderIgnoredDir)(file, ignored))
102
161
  continue;
103
- const ext = node_path_1.default.extname(file).toLowerCase();
104
- for (const lang of Object.keys(EXTENSIONS)) {
105
- if (EXTENSIONS[lang].includes(ext)) {
106
- counts[lang] += 1;
107
- break;
108
- }
109
- }
162
+ // Generated reports and vendored bootstrap scripts are not the repo's code.
163
+ if ((0, glob_1.matchesAnyGlob)(file, excludePaths))
164
+ continue;
165
+ // A manifest that happens to be source-shaped (`Package.swift`) declares a
166
+ // language; it is not a line of it.
167
+ if (markers.has(node_path_1.default.basename(file)))
168
+ continue;
169
+ const lang = languageOf(file, node_path_1.default.extname(file).toLowerCase());
170
+ if (lang)
171
+ counts[lang] += 1;
110
172
  }
111
173
  }
112
174
  catch {
@@ -115,23 +177,55 @@ async function countTrackedFiles(root, ignoreDirs) {
115
177
  return counts;
116
178
  }
117
179
  /**
118
- * Detects which languages a repository uses, combining marker files (definitive)
119
- * with tracked source-file counts (breadth). A language is reported when it has a
120
- * marker file OR at least one tracked source file.
180
+ * Detects the languages a repository uses, and *why* each one was detected.
181
+ *
182
+ * Two different decisions read this, and they should not read it the same way:
183
+ *
184
+ * - **Which checks to run.** A marker is enough. A repo scaffolded from a
185
+ * template has `pyproject.toml` and no `.py` yet, and running Python checks on
186
+ * it costs nothing but a skip.
187
+ * - **Which toolchains to download** (ticket 064). A marker is *not* enough — a
188
+ * `Cargo.toml` beside zero `.rs` files would pull a Rust profile on every CI
189
+ * run for a language nobody has written yet. That decision wants `files > 0`.
190
+ *
191
+ * Returning the evidence rather than a bare list is what lets each caller pick.
121
192
  *
122
193
  * @param root Repository root.
123
194
  * @param ignoreDirs Directory names whose contents do not count as the repo's own
124
195
  * code. Defaults to the curated list; `buildContext` passes the client's
125
196
  * `ignoreDirs` so a repo that vendors dependencies is described by its own
126
197
  * sources. Marker files are unaffected — they are only ever read from `root`.
198
+ * @param detect Client overrides for the heuristics.
127
199
  * @returns languages ordered by tracked file count (most-used first). `.h` headers
128
200
  * are intentionally excluded from the C++ count since they are ambiguous with C.
129
201
  */
130
- async function detectLanguages(root = process.cwd(), ignoreDirs = constants_1.IGNORED_DIRS) {
131
- const counts = await countTrackedFiles(root, ignoreDirs);
132
- const detected = Object.keys(EXTENSIONS).filter(lang => hasMarker(root, lang) || counts[lang] > 0);
133
- detected.sort((a, b) => counts[b] - counts[a]);
202
+ async function detectLanguageUsage(root = process.cwd(), ignoreDirs = constants_1.IGNORED_DIRS, detect = {}) {
203
+ const excludePaths = [...DEFAULT_DETECT_EXCLUDES, ...(detect.excludePaths ?? [])];
204
+ const counts = await countTrackedFiles(root, ignoreDirs, excludePaths);
205
+ // 1 by default: with the causes of the false positives fixed above, a single
206
+ // real source file is a real language. The knob exists for repositories that
207
+ // carry stray one-off scripts they would rather not have checked.
208
+ const minFiles = Math.max(1, detect.minFiles ?? 1);
209
+ const disabled = new Set(detect.disable ?? []);
210
+ const detected = [];
211
+ for (const lang of Object.keys(EXTENSIONS)) {
212
+ if (disabled.has(lang))
213
+ continue;
214
+ const marker = hasMarker(root, lang);
215
+ if (marker || counts[lang] >= minFiles)
216
+ detected.push({ lang, files: counts[lang], marker });
217
+ }
218
+ detected.sort((a, b) => b.files - a.files);
134
219
  const countStr = Object.keys(counts).map(l => `${l}=${counts[l]}`).join(', ');
135
- logger_1.logger.info(`[detectLanguages]: Detected ${detected.length ? detected.join(', ') : 'none'} (counts: ${countStr})`);
220
+ const names = detected.map(u => u.lang).join(', ');
221
+ logger_1.logger.info(`[detectLanguages]: Detected ${names || 'none'} (counts: ${countStr})`);
136
222
  return detected;
137
223
  }
224
+ /**
225
+ * The languages a repository uses, most-used first — the answer to "which checks
226
+ * should run". See {@link detectLanguageUsage} when the file counts matter, as
227
+ * they do for deciding what to download.
228
+ */
229
+ async function detectLanguages(root = process.cwd(), ignoreDirs = constants_1.IGNORED_DIRS, detect = {}) {
230
+ return (await detectLanguageUsage(root, ignoreDirs, detect)).map(u => u.lang);
231
+ }