Skip to content

Commit af3b007

Browse files
feat(card): add exclude input to skip vendored trees during analysis
The card action analyzed every file in the consuming repo with no way to skip vendored or generated code. A repo that absorbed a large third-party tree (e.g. bidwright vendoring a CAD editor: +1,262 files, +318k lines incl. 35k-line minified workers) pushed buildAnalysisData from ~3 seconds to hours, burning 6h runners on every push since the analysis phase is super-linear in function count. Add an `exclude` input (comma/newline-separated patterns, same syntax and semantics as the web app's custom excludes) that prunes matching paths during the collect walk — before Parser.extract or buildAnalysisData ever see them — and records the patterns in the analysis metadata. Helpers are ported from index.html because they live outside the CODEFLOW_ANALYZER_START/END extraction block. Default is empty: existing consumers see byte-identical behavior. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 51ab970 commit af3b007

5 files changed

Lines changed: 120 additions & 7 deletions

File tree

card/action.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ branding:
55
icon: 'activity'
66
color: 'purple'
77
inputs:
8+
exclude:
9+
description: 'Comma- or newline-separated path patterns to skip during analysis, same syntax as the web app''s custom excludes (e.g. `vendor/**, apps/web/public/**, *.min.js`, or a bare directory name). Use for vendored/generated trees that would otherwise dominate the stats or slow the analysis.'
10+
required: false
11+
default: ''
812
output:
913
description: 'Path to write the card SVG.'
1014
required: false

card/index.js

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ const path = require('path');
1111
const { loadInputs } = require('./lib/inputs.js');
1212
const { loadAnalyzer, locateIndexHtml } = require('./lib/analyzer.js');
1313
const { buildAnalyzed } = require('./lib/collect.js');
14+
const { compileExcludePatterns } = require('./lib/exclude.js');
1415
const { readState, appendRun, writeState, snapshotFromAnalysis } = require('./lib/state.js');
1516
const { renderCard } = require('./render/card.js');
1617
const { renderReceiptMarkdown } = require('./render/receipt-md.js');
@@ -52,13 +53,18 @@ async function run() {
5253

5354
const { Parser, buildAnalysisData, calcBlast, calcHealth } = loadAnalyzer(indexHtmlPath);
5455

55-
const { analyzed, allFns } = await buildAnalyzed(repoRoot, Parser);
56+
const excludePatterns = compileExcludePatterns(inputs.exclude);
57+
if (excludePatterns.length > 0) {
58+
log('exclude patterns: ' + excludePatterns.map((p) => p.raw).join(', '));
59+
}
60+
61+
const { analyzed, allFns } = await buildAnalyzed(repoRoot, Parser, excludePatterns);
5662
log('collected ' + analyzed.length + ' files (' + allFns.length + ' functions)');
5763

5864
const data = await buildAnalysisData({
5965
analyzed,
6066
allFns,
61-
excludePatterns: [],
67+
excludePatterns: excludePatterns.map((p) => p.raw),
6268
progress: () => {},
6369
yieldFn: async () => {},
6470
});

card/lib/collect.js

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
const fs = require('fs');
77
const path = require('path');
88

9+
const { matchesExcludePattern } = require('./exclude.js');
10+
911
const DEFAULT_IGNORES = new Set([
1012
'.git',
1113
'node_modules',
@@ -29,7 +31,7 @@ const DEFAULT_IGNORES = new Set([
2931
'obj',
3032
]);
3133

32-
function walk(root, current, files, Parser) {
34+
function walk(root, current, files, Parser, excludePatterns) {
3335
let entries;
3436
try {
3537
entries = fs.readdirSync(current, { withFileTypes: true });
@@ -40,13 +42,18 @@ function walk(root, current, files, Parser) {
4042
if (entry.name.startsWith('.git')) continue;
4143
if (DEFAULT_IGNORES.has(entry.name)) continue;
4244
const full = path.join(current, entry.name);
45+
const repoPath = path.relative(root, full).split(path.sep).join('/');
46+
// Prune excluded paths during the walk — exclusion must happen before
47+
// Parser.extract/buildAnalysisData ever see the files, or huge vendored
48+
// trees (minified bundles, absorbed third-party code) blow the analysis
49+
// phase up from seconds to hours.
50+
if (matchesExcludePattern(excludePatterns, repoPath, entry.name)) continue;
4351
if (entry.isDirectory()) {
44-
walk(root, full, files, Parser);
52+
walk(root, full, files, Parser, excludePatterns);
4553
continue;
4654
}
4755
if (!entry.isFile()) continue;
4856
if (!Parser.isIncluded(entry.name)) continue;
49-
const repoPath = path.relative(root, full).split(path.sep).join('/');
5057
files.push({
5158
fullPath: full,
5259
path: repoPath,
@@ -57,9 +64,9 @@ function walk(root, current, files, Parser) {
5764
}
5865
}
5966

60-
async function buildAnalyzed(repoRoot, Parser) {
67+
async function buildAnalyzed(repoRoot, Parser, excludePatterns) {
6168
const files = [];
62-
walk(repoRoot, repoRoot, files, Parser);
69+
walk(repoRoot, repoRoot, files, Parser, excludePatterns || []);
6370
files.sort((a, b) => a.path.localeCompare(b.path));
6471

6572
const analyzed = [];

card/lib/exclude.js

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
// Exclude-pattern support for the card action. Ports the browser app's
2+
// pattern helpers (index.html: normalizeExcludePath / parseExcludePatterns /
3+
// globToRegex / matchesExcludePattern) so `exclude:` input patterns behave
4+
// exactly like the web UI's custom excludes: `vendor/**`, `**/cache/**`,
5+
// `*.min.js`, or a bare name that matches any path segment.
6+
//
7+
// Ported rather than extracted because these helpers live OUTSIDE the
8+
// CODEFLOW_ANALYZER_START/END block that analyzer.js slices out of
9+
// index.html. Keep semantics in sync with the app if they ever change.
10+
11+
'use strict';
12+
13+
function normalizeExcludePath(value) {
14+
return (value || '').replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/{2,}/g, '/');
15+
}
16+
17+
function parseExcludePatterns(input) {
18+
const seen = new Set();
19+
return (input || '')
20+
.split(/\r?\n|,/)
21+
.map((item) => normalizeExcludePath(item.trim()).replace(/\/$/, ''))
22+
.filter((item) => {
23+
if (!item || seen.has(item.toLowerCase())) return false;
24+
seen.add(item.toLowerCase());
25+
return true;
26+
});
27+
}
28+
29+
function escapeRegexChar(ch) {
30+
return /[|\\{}()[\]^$+?.]/.test(ch) ? '\\' + ch : ch;
31+
}
32+
33+
function globToRegex(pattern) {
34+
const normalized = normalizeExcludePath(pattern).toLowerCase();
35+
let out = '^';
36+
for (let i = 0; i < normalized.length; i++) {
37+
const ch = normalized[i];
38+
if (ch === '*') {
39+
if (normalized[i + 1] === '*') {
40+
if (normalized[i + 2] === '/') {
41+
out += '(?:[^/]+/)*';
42+
i += 2;
43+
} else {
44+
out += '.*';
45+
i++;
46+
}
47+
} else {
48+
out += '[^/]*';
49+
}
50+
} else if (ch === '?') {
51+
out += '[^/]';
52+
} else {
53+
out += escapeRegexChar(ch);
54+
}
55+
}
56+
out += '$';
57+
return new RegExp(out, 'i');
58+
}
59+
60+
function compileExcludePatterns(input) {
61+
return parseExcludePatterns(input).map((pattern) => {
62+
const lower = pattern.toLowerCase();
63+
const hasGlob = pattern.includes('*') || pattern.includes('?');
64+
const hasPath = pattern.includes('/');
65+
return {
66+
raw: pattern,
67+
lower,
68+
regex: hasGlob || hasPath ? globToRegex(pattern) : null,
69+
};
70+
});
71+
}
72+
73+
function matchesExcludePattern(compiledPatterns, path, name) {
74+
if (!compiledPatterns || !compiledPatterns.length) return false;
75+
const normalizedPath = normalizeExcludePath(path || name).replace(/\/$/, '');
76+
const lowerPath = normalizedPath.toLowerCase();
77+
const lowerName = (name || normalizedPath.split('/').pop() || '').toLowerCase();
78+
const lowerPathWithSlash = lowerPath ? lowerPath + '/' : '';
79+
const segments = lowerPath.split('/').filter(Boolean);
80+
return compiledPatterns.some((pattern) => {
81+
if (!pattern.regex) {
82+
return lowerName === pattern.lower || segments.includes(pattern.lower);
83+
}
84+
return (
85+
pattern.regex.test(lowerPath) ||
86+
pattern.regex.test(lowerPathWithSlash) ||
87+
pattern.regex.test(lowerName)
88+
);
89+
});
90+
}
91+
92+
module.exports = { compileExcludePatterns, matchesExcludePattern, parseExcludePatterns };

card/lib/inputs.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ function loadInputs() {
5757
'codeflow-card[bot]@users.noreply.github.com'
5858
);
5959
const token = readInput('github-token', process.env.GITHUB_TOKEN || '');
60+
// Raw exclude string; compiled by the caller (see lib/exclude.js). Supports
61+
// comma- or newline-separated glob patterns like `vendor/**` or `*.min.js`.
62+
const exclude = readInput('exclude', '');
6063
return {
6164
output,
6265
state,
@@ -73,6 +76,7 @@ function loadInputs() {
7376
commitAuthorName,
7477
commitAuthorEmail,
7578
token,
79+
exclude,
7680
};
7781
}
7882

0 commit comments

Comments
 (0)