sentinelayer-cli 0.37.1 → 0.39.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sentinelayer-cli",
3
- "version": "0.37.1",
3
+ "version": "0.39.0",
4
4
  "description": "Scaffold Sentinelayer spec/prompt/guide artifacts with secure browser auth and token bootstrap.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -48,6 +48,7 @@
48
48
  "url": "https://github.com/mrrCarter/create-sentinelayer/issues"
49
49
  },
50
50
  "dependencies": {
51
+ "@anthropic-ai/tokenizer": "0.0.4",
51
52
  "@axe-core/playwright": "4.11.2",
52
53
  "@babel/parser": "7.29.2",
53
54
  "axe-core": "4.11.3",
@@ -60,6 +61,7 @@
60
61
  "picocolors": "1.1.1",
61
62
  "playwright": "1.59.1",
62
63
  "prompts": "2.4.2",
64
+ "tiktoken": "1.0.22",
63
65
  "yaml": "2.8.3",
64
66
  "zod": "4.1.12"
65
67
  },
@@ -9,4 +9,5 @@ export {
9
9
  runCouplingAnalysis,
10
10
  runCycleDetect,
11
11
  runDepGraph,
12
+ runLayeringCheck,
12
13
  } from "./tools/index.js";
@@ -4,6 +4,7 @@ import { runComplexityMeasure } from "./complexity-measure.js";
4
4
  import { runCouplingAnalysis } from "./coupling-analysis.js";
5
5
  import { runCycleDetect } from "./cycle-detect.js";
6
6
  import { runDepGraph } from "./dep-graph.js";
7
+ import { runLayeringCheck } from "./layering-check.js";
7
8
 
8
9
  export const CODE_QUALITY_TOOLS = Object.freeze({
9
10
  "dep-graph": {
@@ -60,6 +61,20 @@ export const CODE_QUALITY_TOOLS = Object.freeze({
60
61
  },
61
62
  handler: runComplexityMeasure,
62
63
  },
64
+ "layering-check": {
65
+ id: "layering-check",
66
+ description:
67
+ "Flag lower architectural layers importing higher-level app/UI layers. Uses the dep-graph under the hood and ignores external or unknown-layer imports.",
68
+ schema: {
69
+ type: "object",
70
+ properties: {
71
+ rootPath: { type: "string" },
72
+ files: { type: "array", items: { type: "string" } },
73
+ layers: { type: "array" },
74
+ },
75
+ },
76
+ handler: runLayeringCheck,
77
+ },
63
78
  });
64
79
 
65
80
  export const CODE_QUALITY_TOOL_IDS = Object.freeze(Object.keys(CODE_QUALITY_TOOLS));
@@ -86,4 +101,5 @@ export {
86
101
  runCouplingAnalysis,
87
102
  runCycleDetect,
88
103
  runDepGraph,
104
+ runLayeringCheck,
89
105
  };
@@ -0,0 +1,195 @@
1
+ // layering-check — flag lower architectural layers importing higher layers (#A14).
2
+
3
+ import { createFinding, toPosix } from "./base.js";
4
+ import { buildDependencyGraph } from "./dep-graph.js";
5
+
6
+ const RESOLUTION_EXTENSIONS = Object.freeze([
7
+ ".js",
8
+ ".jsx",
9
+ ".ts",
10
+ ".tsx",
11
+ ".mjs",
12
+ ".cjs",
13
+ ]);
14
+
15
+ export const DEFAULT_LAYER_RULES = Object.freeze([
16
+ Object.freeze({
17
+ name: "shared",
18
+ rank: 0,
19
+ prefixes: Object.freeze(["src/shared/", "src/lib/", "shared/", "lib/"]),
20
+ }),
21
+ Object.freeze({
22
+ name: "domain",
23
+ rank: 1,
24
+ prefixes: Object.freeze(["src/domain/", "src/core/", "domain/", "core/"]),
25
+ }),
26
+ Object.freeze({
27
+ name: "data",
28
+ rank: 2,
29
+ prefixes: Object.freeze([
30
+ "src/data/",
31
+ "src/db/",
32
+ "src/models/",
33
+ "data/",
34
+ "db/",
35
+ "models/",
36
+ ]),
37
+ }),
38
+ Object.freeze({
39
+ name: "services",
40
+ rank: 3,
41
+ prefixes: Object.freeze(["src/services/", "src/server/", "services/", "server/"]),
42
+ }),
43
+ Object.freeze({
44
+ name: "app",
45
+ rank: 4,
46
+ prefixes: Object.freeze([
47
+ "src/app/",
48
+ "src/api/",
49
+ "src/controllers/",
50
+ "src/handlers/",
51
+ "src/pages/",
52
+ "src/routes/",
53
+ "app/",
54
+ "api/",
55
+ "controllers/",
56
+ "handlers/",
57
+ "pages/",
58
+ "routes/",
59
+ ]),
60
+ }),
61
+ Object.freeze({
62
+ name: "ui",
63
+ rank: 5,
64
+ prefixes: Object.freeze(["src/components/", "src/ui/", "components/", "ui/"]),
65
+ }),
66
+ ]);
67
+
68
+ function normalizePathKey(value) {
69
+ return toPosix(value).replace(/^\.\//, "").replace(/\/{2,}/g, "/");
70
+ }
71
+
72
+ function normalizePrefix(value) {
73
+ const normalized = normalizePathKey(value);
74
+ if (!normalized) {
75
+ return "";
76
+ }
77
+ return normalized.endsWith("/") ? normalized : `${normalized}/`;
78
+ }
79
+
80
+ function normalizeLayers(layers) {
81
+ const source = Array.isArray(layers) && layers.length > 0 ? layers : DEFAULT_LAYER_RULES;
82
+ return source
83
+ .map((layer, index) => ({
84
+ name: String(layer?.name || `layer-${index}`).trim(),
85
+ rank: Number.isFinite(Number(layer?.rank)) ? Number(layer.rank) : index,
86
+ prefixes: Array.isArray(layer?.prefixes)
87
+ ? layer.prefixes.map(normalizePrefix).filter(Boolean)
88
+ : [],
89
+ }))
90
+ .filter((layer) => layer.name && layer.prefixes.length > 0);
91
+ }
92
+
93
+ export function classifyLayer(filePath, layers = DEFAULT_LAYER_RULES) {
94
+ const normalized = normalizePathKey(filePath);
95
+ if (!normalized) {
96
+ return null;
97
+ }
98
+ let best = null;
99
+ for (const layer of normalizeLayers(layers)) {
100
+ for (const prefix of layer.prefixes) {
101
+ if (normalized.startsWith(prefix) && (!best || prefix.length > best.prefix.length)) {
102
+ best = { ...layer, prefix };
103
+ }
104
+ }
105
+ }
106
+ return best;
107
+ }
108
+
109
+ function isExternalEdge(edge) {
110
+ const normalized = normalizePathKey(edge);
111
+ return (
112
+ !normalized ||
113
+ normalized.startsWith("npm:") ||
114
+ normalized.startsWith("/") ||
115
+ /^[a-z][a-z0-9+.-]*:/i.test(normalized)
116
+ );
117
+ }
118
+ export function resolveLocalEdge(edge, graphKeys = []) {
119
+ const normalized = normalizePathKey(edge);
120
+ if (!normalized || isExternalEdge(normalized)) {
121
+ return null;
122
+ }
123
+ const keys = graphKeys instanceof Set ? graphKeys : new Set(graphKeys);
124
+ if (keys.size === 0 || keys.has(normalized)) {
125
+ return normalized;
126
+ }
127
+
128
+ const candidates = [];
129
+ const hasExtension = /\.[^/.]+$/.test(normalized);
130
+ if (!hasExtension) {
131
+ for (const ext of RESOLUTION_EXTENSIONS) {
132
+ candidates.push(`${normalized}${ext}`);
133
+ }
134
+ for (const ext of RESOLUTION_EXTENSIONS) {
135
+ candidates.push(`${normalized}/index${ext}`);
136
+ }
137
+ }
138
+
139
+ for (const candidate of candidates) {
140
+ if (keys.has(candidate)) {
141
+ return candidate;
142
+ }
143
+ }
144
+ return normalized;
145
+ }
146
+
147
+ export function findLayerViolations({ graph, layers = DEFAULT_LAYER_RULES } = {}) {
148
+ const graphKeys = new Set(Object.keys(graph || {}));
149
+ const violations = [];
150
+ for (const [source, edges] of Object.entries(graph || {}).sort(([a], [b]) =>
151
+ a.localeCompare(b)
152
+ )) {
153
+ const sourceLayer = classifyLayer(source, layers);
154
+ if (!sourceLayer || !Array.isArray(edges)) {
155
+ continue;
156
+ }
157
+ for (const edge of edges.slice().sort()) {
158
+ const target = resolveLocalEdge(edge, graphKeys);
159
+ if (!target) {
160
+ continue;
161
+ }
162
+ const targetLayer = classifyLayer(target, layers);
163
+ if (!targetLayer) {
164
+ continue;
165
+ }
166
+ if (targetLayer.rank > sourceLayer.rank) {
167
+ violations.push({ source, target, sourceLayer, targetLayer });
168
+ }
169
+ }
170
+ }
171
+ return violations;
172
+ }
173
+
174
+ export async function runLayeringCheck({
175
+ rootPath,
176
+ files = null,
177
+ layers = DEFAULT_LAYER_RULES,
178
+ } = {}) {
179
+ const graph = await buildDependencyGraph({ rootPath, files });
180
+ return findLayerViolations({ graph, layers }).map(
181
+ ({ source, target, sourceLayer, targetLayer }) =>
182
+ createFinding({
183
+ tool: "layering-check",
184
+ kind: "code-quality.layer-violation",
185
+ severity: "P2",
186
+ file: source,
187
+ evidence: `${sourceLayer.name} imports higher ${targetLayer.name}: ${source} -> ${target}`,
188
+ rootCause:
189
+ "A lower-level module depends on a higher-level layer, which inverts the intended architecture boundary.",
190
+ recommendedFix:
191
+ "Move the shared contract into a lower layer or invert the dependency through an interface owned by the lower layer.",
192
+ confidence: 0.78,
193
+ })
194
+ );
195
+ }
@@ -16,7 +16,7 @@
16
16
 
17
17
  // Persona tool id lists are inlined here instead of imported from each
18
18
  // persona module. Rationale: this module has to be importable before all
19
- // 12 persona PRs (#A13-A24) have merged to main. Once they do, a future
19
+ // persona PRs (#A13-A24) have merged to main. Once they do, a future
20
20
  // refactor can swap these for real imports — the tool-id strings are
21
21
  // stable anyway.
22
22
 
@@ -40,11 +40,12 @@ const CODEGEN_EXTRA_TOOLS = Object.freeze([
40
40
  const DOMAIN_TOOL_IDS_BY_PERSONA = Object.freeze({
41
41
  "ai-governance": Object.freeze(["eval-regression", "hitl-audit", "prompt-drift", "provenance-check"]),
42
42
  "backend": Object.freeze(["circuit-breaker-check", "idempotency-audit", "retry-audit", "timeout-audit"]),
43
- "code-quality": Object.freeze(["complexity-measure", "coupling-analysis", "cycle-detect", "dep-graph"]),
43
+ "code-quality": Object.freeze(["complexity-measure", "coupling-analysis", "cycle-detect", "dep-graph", "layering-check"]),
44
44
  "data-layer": Object.freeze(["index-audit", "migration-scan", "query-explain", "tenancy-scan"]),
45
45
  "documentation": Object.freeze(["api-diff", "dead-link-check", "docstring-coverage", "readme-freshness"]),
46
46
  "infrastructure": Object.freeze(["checkov-run", "drift-detect", "iam-least-priv-check", "tflint-run"]),
47
47
  "observability": Object.freeze(["alert-audit", "dashboard-gap", "log-schema-check", "span-coverage"]),
48
+ "performance": Object.freeze(["blocking-io-audit", "bundle-budget-check", "cache-policy-audit", "n-plus-one-detect"]),
48
49
  "release": Object.freeze(["changelog-diff", "feature-flag-audit", "rollback-verify", "semver-check"]),
49
50
  "reliability": Object.freeze(["backpressure-check", "chaos-probe", "graceful-degradation-check", "health-check-audit"]),
50
51
  "security": Object.freeze(["authz-audit", "crypto-review", "sast-scan", "secrets-scan"]),
@@ -0,0 +1,12 @@
1
+ // Arjun (performance persona) - barrel export (#A16).
2
+
3
+ export {
4
+ PERFORMANCE_TOOLS,
5
+ PERFORMANCE_TOOL_IDS,
6
+ dispatchPerformanceTool,
7
+ runAllPerformanceTools,
8
+ runBlockingIoAudit,
9
+ runBundleBudgetCheck,
10
+ runCachePolicyAudit,
11
+ runNPlusOneDetect,
12
+ } from "./tools/index.js";
@@ -0,0 +1,208 @@
1
+ // Shared helpers for Arjun's (performance) domain tools (#A16).
2
+
3
+ import fsp from "node:fs/promises";
4
+ import path from "node:path";
5
+ import process from "node:process";
6
+
7
+ import ignore from "ignore";
8
+
9
+ const DEFAULT_IGNORED_DIRS = new Set([
10
+ ".git",
11
+ "node_modules",
12
+ ".venv",
13
+ ".next",
14
+ "dist",
15
+ "build",
16
+ "coverage",
17
+ ".sentinelayer",
18
+ ".sentinel",
19
+ ".turbo",
20
+ ".idea",
21
+ ".vscode",
22
+ "__pycache__",
23
+ ".cache",
24
+ ]);
25
+ const MAX_FILE_SIZE_BYTES = 1024 * 1024;
26
+ const SEVERITIES = Object.freeze(["P0", "P1", "P2", "P3"]);
27
+
28
+ export function toPosix(value) {
29
+ return String(value || "").replace(/\\/g, "/");
30
+ }
31
+
32
+ export function normalizeSeverity(value) {
33
+ const normalized = String(value || "").trim().toUpperCase();
34
+ if (SEVERITIES.includes(normalized)) {
35
+ return normalized;
36
+ }
37
+ return "P2";
38
+ }
39
+
40
+ export function createFinding({
41
+ severity,
42
+ kind,
43
+ file,
44
+ line = 0,
45
+ evidence = "",
46
+ rootCause = "",
47
+ recommendedFix = "",
48
+ confidence = null,
49
+ tool = "",
50
+ persona = "performance",
51
+ } = {}) {
52
+ return {
53
+ persona,
54
+ tool: String(tool || "").trim(),
55
+ kind: String(kind || "").trim() || "performance",
56
+ severity: normalizeSeverity(severity),
57
+ file: toPosix(file || ""),
58
+ line: Number.isFinite(Number(line)) ? Math.max(0, Math.floor(Number(line))) : 0,
59
+ evidence: String(evidence || "").trim().slice(0, 400),
60
+ rootCause: String(rootCause || "").trim(),
61
+ recommendedFix: String(recommendedFix || "").trim(),
62
+ confidence:
63
+ confidence === null || confidence === undefined
64
+ ? null
65
+ : Math.max(0, Math.min(1, Number(confidence) || 0)),
66
+ };
67
+ }
68
+
69
+ async function readIgnorePatterns(filePath) {
70
+ try {
71
+ const raw = await fsp.readFile(filePath, "utf-8");
72
+ return String(raw || "")
73
+ .split(/\r?\n/)
74
+ .map((line) => line.trim())
75
+ .filter((line) => line && !line.startsWith("#"));
76
+ } catch (err) {
77
+ if (err && typeof err === "object" && err.code === "ENOENT") {
78
+ return [];
79
+ }
80
+ throw err;
81
+ }
82
+ }
83
+
84
+ async function createIgnoreMatcher(rootPath) {
85
+ const matcher = ignore();
86
+ const gitignore = await readIgnorePatterns(path.join(rootPath, ".gitignore"));
87
+ const sentinel = await readIgnorePatterns(
88
+ path.join(rootPath, ".sentinelayerignore")
89
+ );
90
+ matcher.add([...gitignore, ...sentinel]);
91
+ return (relativePath, isDirectory) => {
92
+ const normalized = toPosix(relativePath);
93
+ if (!normalized) {
94
+ return false;
95
+ }
96
+ const candidate = isDirectory ? `${normalized}/` : normalized;
97
+ return matcher.ignores(candidate);
98
+ };
99
+ }
100
+
101
+ export async function* walkRepoFiles({
102
+ rootPath = process.cwd(),
103
+ extensions = new Set(),
104
+ maxFileSize = MAX_FILE_SIZE_BYTES,
105
+ } = {}) {
106
+ const resolvedRoot = path.resolve(rootPath);
107
+ const ignoreMatcher = await createIgnoreMatcher(resolvedRoot);
108
+ const wantedExtensions =
109
+ extensions instanceof Set
110
+ ? extensions
111
+ : new Set(Array.isArray(extensions) ? extensions : []);
112
+ const stack = [resolvedRoot];
113
+ while (stack.length > 0) {
114
+ const current = stack.pop();
115
+ let entries = [];
116
+ try {
117
+ entries = await fsp.readdir(current, { withFileTypes: true });
118
+ } catch {
119
+ continue;
120
+ }
121
+ for (const entry of entries) {
122
+ const fullPath = path.join(current, entry.name);
123
+ const relativePath = toPosix(path.relative(resolvedRoot, fullPath));
124
+ if (entry.isDirectory()) {
125
+ if (!relativePath || DEFAULT_IGNORED_DIRS.has(entry.name)) {
126
+ continue;
127
+ }
128
+ if (ignoreMatcher(relativePath, true)) {
129
+ continue;
130
+ }
131
+ stack.push(fullPath);
132
+ continue;
133
+ }
134
+ if (!entry.isFile()) {
135
+ continue;
136
+ }
137
+ if (ignoreMatcher(relativePath, false)) {
138
+ continue;
139
+ }
140
+ const ext = path.extname(entry.name).toLowerCase();
141
+ if (wantedExtensions.size > 0 && !wantedExtensions.has(ext) && !wantedExtensions.has("")) {
142
+ continue;
143
+ }
144
+ let stat = null;
145
+ try {
146
+ stat = await fsp.stat(fullPath);
147
+ } catch {
148
+ stat = null;
149
+ }
150
+ if (!stat || stat.size > maxFileSize) {
151
+ continue;
152
+ }
153
+ yield { fullPath, relativePath, sizeBytes: stat.size };
154
+ }
155
+ }
156
+ }
157
+
158
+ export function findLineMatches(content, pattern) {
159
+ const text = String(content || "");
160
+ if (!pattern) {
161
+ return [];
162
+ }
163
+ const global = new RegExp(
164
+ pattern.source,
165
+ pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`
166
+ );
167
+ const matches = [];
168
+ let match;
169
+ while ((match = global.exec(text)) !== null) {
170
+ const lineIndex = text.slice(0, match.index).split(/\r?\n/).length;
171
+ matches.push({ index: match.index, line: lineIndex, match: match[0] });
172
+ }
173
+ return matches;
174
+ }
175
+
176
+ export function getLineContent(content, line) {
177
+ const lines = String(content || "").split(/\r?\n/);
178
+ return (lines[Math.max(0, (Number(line) || 1) - 1)] || "").trim();
179
+ }
180
+
181
+ export async function* iterateFiles(resolvedRoot, files, extensions) {
182
+ if (Array.isArray(files) && files.length > 0) {
183
+ for (const file of files) {
184
+ const trimmed = String(file || "").trim();
185
+ if (!trimmed) {
186
+ continue;
187
+ }
188
+ const fullPath = path.isAbsolute(trimmed)
189
+ ? trimmed
190
+ : path.join(resolvedRoot, trimmed);
191
+ const relativePath = path
192
+ .relative(resolvedRoot, fullPath)
193
+ .replace(/\\/g, "/");
194
+ let stat = null;
195
+ try {
196
+ stat = await fsp.stat(fullPath);
197
+ } catch {
198
+ stat = null;
199
+ }
200
+ yield { fullPath, relativePath, sizeBytes: stat?.size || 0 };
201
+ }
202
+ return;
203
+ }
204
+
205
+ yield* walkRepoFiles({ rootPath: resolvedRoot, extensions });
206
+ }
207
+
208
+ export { DEFAULT_IGNORED_DIRS, MAX_FILE_SIZE_BYTES, SEVERITIES };
@@ -0,0 +1,85 @@
1
+ // blocking-io-audit - flag synchronous I/O on request/runtime paths (#A16).
2
+
3
+ import fsp from "node:fs/promises";
4
+ import path from "node:path";
5
+
6
+ import { createFinding, findLineMatches, getLineContent, iterateFiles, toPosix } from "./base.js";
7
+
8
+ const CODE_EXTENSIONS = new Set([
9
+ ".js",
10
+ ".jsx",
11
+ ".ts",
12
+ ".tsx",
13
+ ".mjs",
14
+ ".cjs",
15
+ ".py",
16
+ ".go",
17
+ ".rb",
18
+ ]);
19
+
20
+ const BLOCKING_IO_RULES = Object.freeze([
21
+ {
22
+ kind: "performance.blocking-sync-fs",
23
+ pattern:
24
+ /\b(?:fs\.)?(?:readFileSync|writeFileSync|appendFileSync|readdirSync|statSync|existsSync)\s*\(/,
25
+ severity: "P2",
26
+ rootCause:
27
+ "Synchronous filesystem call blocks the event loop or request worker while disk I/O completes.",
28
+ recommendedFix:
29
+ "Use async filesystem APIs and move startup-only reads outside hot request paths.",
30
+ confidence: 0.74,
31
+ },
32
+ {
33
+ kind: "performance.blocking-child-process",
34
+ pattern: /\b(?:execSync|spawnSync|execFileSync)\s*\(/,
35
+ severity: "P1",
36
+ rootCause:
37
+ "Synchronous child process execution blocks the runtime and can stall all concurrent requests.",
38
+ recommendedFix:
39
+ "Use async process APIs, queue the work, or precompute the artifact before serving traffic.",
40
+ confidence: 0.82,
41
+ },
42
+ {
43
+ kind: "performance.blocking-sleep",
44
+ pattern: /\b(?:sleepSync|Atomics\.wait|time\.sleep)\s*\(/,
45
+ severity: "P2",
46
+ rootCause:
47
+ "Blocking sleep pauses the worker instead of yielding, which turns transient waits into throughput loss.",
48
+ recommendedFix:
49
+ "Use async timers, scheduled jobs, or backoff that yields to the runtime event loop.",
50
+ confidence: 0.7,
51
+ },
52
+ ]);
53
+
54
+ export async function runBlockingIoAudit({ rootPath, files = null } = {}) {
55
+ const resolvedRoot = path.resolve(String(rootPath || "."));
56
+ const findings = [];
57
+ for await (const { fullPath, relativePath } of iterateFiles(resolvedRoot, files, CODE_EXTENSIONS)) {
58
+ let content;
59
+ try {
60
+ content = await fsp.readFile(fullPath, "utf-8");
61
+ } catch {
62
+ continue;
63
+ }
64
+ for (const rule of BLOCKING_IO_RULES) {
65
+ for (const match of findLineMatches(content, rule.pattern)) {
66
+ findings.push(
67
+ createFinding({
68
+ tool: "blocking-io-audit",
69
+ kind: rule.kind,
70
+ severity: rule.severity,
71
+ file: toPosix(relativePath),
72
+ line: match.line,
73
+ evidence: getLineContent(content, match.line),
74
+ rootCause: rule.rootCause,
75
+ recommendedFix: rule.recommendedFix,
76
+ confidence: rule.confidence,
77
+ })
78
+ );
79
+ }
80
+ }
81
+ }
82
+ return findings;
83
+ }
84
+
85
+ export { BLOCKING_IO_RULES };
@@ -0,0 +1,93 @@
1
+ // bundle-budget-check - flag heavy client imports and oversized assets (#A16).
2
+
3
+ import fsp from "node:fs/promises";
4
+ import path from "node:path";
5
+
6
+ import { createFinding, findLineMatches, getLineContent, iterateFiles, toPosix } from "./base.js";
7
+
8
+ const CLIENT_EXTENSIONS = new Set([
9
+ ".js",
10
+ ".jsx",
11
+ ".ts",
12
+ ".tsx",
13
+ ".mjs",
14
+ ".css",
15
+ ]);
16
+ const FRONTEND_PATH_PATTERN =
17
+ /(?:^|\/)(?:app|pages|components|ui|frontend|client|public)(?:\/|$)/i;
18
+ const LARGE_SOURCE_THRESHOLD_BYTES = 150 * 1024;
19
+ const LARGE_STYLE_THRESHOLD_BYTES = 100 * 1024;
20
+ const HEAVY_IMPORT_PATTERN =
21
+ /\bimport\s+[^;]*?\bfrom\s+["'](?:moment|lodash|chart\.js(?:\/auto)?|monaco-editor|mapbox-gl|three)["']|require\s*\(\s*["'](?:moment|lodash|chart\.js(?:\/auto)?|monaco-editor|mapbox-gl|three)["']\s*\)/g;
22
+
23
+ function budgetForFile(relativePath) {
24
+ return path.extname(relativePath).toLowerCase() === ".css"
25
+ ? LARGE_STYLE_THRESHOLD_BYTES
26
+ : LARGE_SOURCE_THRESHOLD_BYTES;
27
+ }
28
+
29
+ export async function runBundleBudgetCheck({ rootPath, files = null } = {}) {
30
+ const resolvedRoot = path.resolve(String(rootPath || "."));
31
+ const findings = [];
32
+ for await (const { fullPath, relativePath, sizeBytes } of iterateFiles(
33
+ resolvedRoot,
34
+ files,
35
+ CLIENT_EXTENSIONS
36
+ )) {
37
+ const normalizedPath = toPosix(relativePath);
38
+ if (!FRONTEND_PATH_PATTERN.test(normalizedPath)) {
39
+ continue;
40
+ }
41
+ let content = "";
42
+ try {
43
+ content = await fsp.readFile(fullPath, "utf-8");
44
+ } catch {
45
+ continue;
46
+ }
47
+
48
+ const budget = budgetForFile(normalizedPath);
49
+ if (sizeBytes > budget) {
50
+ findings.push(
51
+ createFinding({
52
+ tool: "bundle-budget-check",
53
+ kind: "performance.bundle-budget-exceeded",
54
+ severity: "P2",
55
+ file: normalizedPath,
56
+ line: 1,
57
+ evidence: `size=${sizeBytes} bytes budget=${budget} bytes`,
58
+ rootCause:
59
+ "Client-side source/style file exceeds the local budget, increasing parse, transfer, and hydration cost.",
60
+ recommendedFix:
61
+ "Split the module, lazy-load non-critical code, move static data out of the client bundle, or add an explicit budget exception.",
62
+ confidence: 0.72,
63
+ })
64
+ );
65
+ }
66
+
67
+ for (const match of findLineMatches(content, HEAVY_IMPORT_PATTERN)) {
68
+ findings.push(
69
+ createFinding({
70
+ tool: "bundle-budget-check",
71
+ kind: "performance.heavy-client-import",
72
+ severity: "P2",
73
+ file: normalizedPath,
74
+ line: match.line,
75
+ evidence: getLineContent(content, match.line),
76
+ rootCause:
77
+ "Client code imports a known heavyweight package at module scope, which can inflate initial JavaScript payloads.",
78
+ recommendedFix:
79
+ "Use scoped imports, dynamic import(), route-level code splitting, or a lighter package.",
80
+ confidence: 0.66,
81
+ })
82
+ );
83
+ }
84
+ }
85
+ return findings;
86
+ }
87
+
88
+ export {
89
+ FRONTEND_PATH_PATTERN,
90
+ HEAVY_IMPORT_PATTERN,
91
+ LARGE_SOURCE_THRESHOLD_BYTES,
92
+ LARGE_STYLE_THRESHOLD_BYTES,
93
+ };