sentinelayer-cli 0.38.0 → 0.39.1
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 +1 -1
- package/src/agents/code-quality/index.js +1 -0
- package/src/agents/code-quality/tools/index.js +16 -0
- package/src/agents/code-quality/tools/layering-check.js +195 -0
- package/src/agents/mode.js +3 -2
- package/src/agents/performance/index.js +12 -0
- package/src/agents/performance/tools/base.js +208 -0
- package/src/agents/performance/tools/blocking-io-audit.js +85 -0
- package/src/agents/performance/tools/bundle-budget-check.js +93 -0
- package/src/agents/performance/tools/cache-policy-audit.js +69 -0
- package/src/agents/performance/tools/index.js +87 -0
- package/src/agents/performance/tools/n-plus-one-detect.js +54 -0
- package/src/agents/run-persona.js +2 -0
- package/src/commands/session.js +17 -1
- package/src/review/investor-dd-persona-runner.js +5 -3
package/package.json
CHANGED
|
@@ -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
|
+
}
|
package/src/agents/mode.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
+
};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// cache-policy-audit - flag expensive handler work with no cache signal (#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 HANDLER_PATH_PATTERN =
|
|
21
|
+
/(?:^|\/)(?:api|app|routes|controllers|handlers|server|pages)(?:\/|$)/i;
|
|
22
|
+
const EXPENSIVE_CALL_PATTERN =
|
|
23
|
+
/\b(?:fetch|axios(?:\.[a-z]+)?|got(?:\.[a-z]+)?|requests\.(?:get|post)|httpx\.(?:get|post)|db\.\w+|prisma\.\w+|repository\.\w+|query|execute)\s*\(/i;
|
|
24
|
+
const CACHE_SIGNAL_PATTERN =
|
|
25
|
+
/\b(?:cache|cached|memoize|ttl|revalidate|stale-while-revalidate|Cache-Control|ETag|If-None-Match|max-age)\b/i;
|
|
26
|
+
|
|
27
|
+
export async function runCachePolicyAudit({ rootPath, files = null } = {}) {
|
|
28
|
+
const resolvedRoot = path.resolve(String(rootPath || "."));
|
|
29
|
+
const findings = [];
|
|
30
|
+
for await (const { fullPath, relativePath } of iterateFiles(resolvedRoot, files, CODE_EXTENSIONS)) {
|
|
31
|
+
const normalizedPath = toPosix(relativePath);
|
|
32
|
+
if (!HANDLER_PATH_PATTERN.test(normalizedPath)) {
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
let content;
|
|
36
|
+
try {
|
|
37
|
+
content = await fsp.readFile(fullPath, "utf-8");
|
|
38
|
+
} catch {
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (!EXPENSIVE_CALL_PATTERN.test(content) || CACHE_SIGNAL_PATTERN.test(content)) {
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
const match = findLineMatches(content, EXPENSIVE_CALL_PATTERN)[0];
|
|
45
|
+
const line = match?.line || 1;
|
|
46
|
+
findings.push(
|
|
47
|
+
createFinding({
|
|
48
|
+
tool: "cache-policy-audit",
|
|
49
|
+
kind: "performance.missing-cache-policy",
|
|
50
|
+
severity: "P2",
|
|
51
|
+
file: normalizedPath,
|
|
52
|
+
line,
|
|
53
|
+
evidence: getLineContent(content, line),
|
|
54
|
+
rootCause:
|
|
55
|
+
"Request handler performs remote/database work without an explicit cache, TTL, revalidation, or HTTP cache-control signal.",
|
|
56
|
+
recommendedFix:
|
|
57
|
+
"Add an explicit cache policy: bounded TTL, request coalescing, stale-while-revalidate, ETag, or documented no-store rationale.",
|
|
58
|
+
confidence: 0.58,
|
|
59
|
+
})
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
return findings;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export {
|
|
66
|
+
CACHE_SIGNAL_PATTERN,
|
|
67
|
+
EXPENSIVE_CALL_PATTERN,
|
|
68
|
+
HANDLER_PATH_PATTERN,
|
|
69
|
+
};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Arjun (performance persona) domain-tool registry (#A16).
|
|
2
|
+
|
|
3
|
+
import { runBlockingIoAudit } from "./blocking-io-audit.js";
|
|
4
|
+
import { runBundleBudgetCheck } from "./bundle-budget-check.js";
|
|
5
|
+
import { runCachePolicyAudit } from "./cache-policy-audit.js";
|
|
6
|
+
import { runNPlusOneDetect } from "./n-plus-one-detect.js";
|
|
7
|
+
|
|
8
|
+
export const PERFORMANCE_TOOLS = Object.freeze({
|
|
9
|
+
"n-plus-one-detect": {
|
|
10
|
+
id: "n-plus-one-detect",
|
|
11
|
+
description:
|
|
12
|
+
"Flag loop-scoped HTTP, database, repository, or query calls that can become N+1 fan-out under load.",
|
|
13
|
+
schema: {
|
|
14
|
+
type: "object",
|
|
15
|
+
properties: {
|
|
16
|
+
rootPath: { type: "string" },
|
|
17
|
+
files: { type: "array", items: { type: "string" } },
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
handler: runNPlusOneDetect,
|
|
21
|
+
},
|
|
22
|
+
"blocking-io-audit": {
|
|
23
|
+
id: "blocking-io-audit",
|
|
24
|
+
description:
|
|
25
|
+
"Flag synchronous filesystem, child-process, or sleep calls that block hot runtime paths.",
|
|
26
|
+
schema: {
|
|
27
|
+
type: "object",
|
|
28
|
+
properties: {
|
|
29
|
+
rootPath: { type: "string" },
|
|
30
|
+
files: { type: "array", items: { type: "string" } },
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
handler: runBlockingIoAudit,
|
|
34
|
+
},
|
|
35
|
+
"cache-policy-audit": {
|
|
36
|
+
id: "cache-policy-audit",
|
|
37
|
+
description:
|
|
38
|
+
"Flag request handlers that do expensive remote/database work without a cache, TTL, revalidation, or no-store policy.",
|
|
39
|
+
schema: {
|
|
40
|
+
type: "object",
|
|
41
|
+
properties: {
|
|
42
|
+
rootPath: { type: "string" },
|
|
43
|
+
files: { type: "array", items: { type: "string" } },
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
handler: runCachePolicyAudit,
|
|
47
|
+
},
|
|
48
|
+
"bundle-budget-check": {
|
|
49
|
+
id: "bundle-budget-check",
|
|
50
|
+
description:
|
|
51
|
+
"Flag oversized client files and heavyweight module-scope imports that can inflate frontend bundles.",
|
|
52
|
+
schema: {
|
|
53
|
+
type: "object",
|
|
54
|
+
properties: {
|
|
55
|
+
rootPath: { type: "string" },
|
|
56
|
+
files: { type: "array", items: { type: "string" } },
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
handler: runBundleBudgetCheck,
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
export const PERFORMANCE_TOOL_IDS = Object.freeze(Object.keys(PERFORMANCE_TOOLS));
|
|
64
|
+
|
|
65
|
+
export async function dispatchPerformanceTool(toolId, args = {}) {
|
|
66
|
+
const tool = PERFORMANCE_TOOLS[toolId];
|
|
67
|
+
if (!tool) {
|
|
68
|
+
throw new Error(`Unknown performance tool: ${toolId}`);
|
|
69
|
+
}
|
|
70
|
+
return tool.handler(args);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function runAllPerformanceTools({ rootPath, files = null } = {}) {
|
|
74
|
+
const findings = [];
|
|
75
|
+
for (const toolId of PERFORMANCE_TOOL_IDS) {
|
|
76
|
+
const out = await dispatchPerformanceTool(toolId, { rootPath, files });
|
|
77
|
+
findings.push(...out);
|
|
78
|
+
}
|
|
79
|
+
return findings;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export {
|
|
83
|
+
runBlockingIoAudit,
|
|
84
|
+
runBundleBudgetCheck,
|
|
85
|
+
runCachePolicyAudit,
|
|
86
|
+
runNPlusOneDetect,
|
|
87
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// n-plus-one-detect - flag loop-scoped I/O and query calls (#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
|
+
".rb",
|
|
17
|
+
".go",
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
const LOOP_IO_PATTERN =
|
|
21
|
+
/(?:for\s*(?:await\s*)?\([^)]*\)|while\s*\([^)]*\)|\.forEach\s*\([^=]*=>)\s*\{?[\s\S]{0,650}?\b(?:await\s+)?(?:fetch|axios(?:\.[a-z]+)?|got(?:\.[a-z]+)?|requests\.(?:get|post)|httpx\.(?:get|post)|db\.\w+|prisma\.\w+|repository\.\w+|Model\.\w+|findAll|findMany|find_one|find_all|query|execute)\s*\(/gi;
|
|
22
|
+
|
|
23
|
+
export async function runNPlusOneDetect({ rootPath, files = null } = {}) {
|
|
24
|
+
const resolvedRoot = path.resolve(String(rootPath || "."));
|
|
25
|
+
const findings = [];
|
|
26
|
+
for await (const { fullPath, relativePath } of iterateFiles(resolvedRoot, files, CODE_EXTENSIONS)) {
|
|
27
|
+
let content;
|
|
28
|
+
try {
|
|
29
|
+
content = await fsp.readFile(fullPath, "utf-8");
|
|
30
|
+
} catch {
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
for (const match of findLineMatches(content, LOOP_IO_PATTERN)) {
|
|
34
|
+
findings.push(
|
|
35
|
+
createFinding({
|
|
36
|
+
tool: "n-plus-one-detect",
|
|
37
|
+
kind: "performance.n-plus-one-loop",
|
|
38
|
+
severity: "P1",
|
|
39
|
+
file: toPosix(relativePath),
|
|
40
|
+
line: match.line,
|
|
41
|
+
evidence: getLineContent(content, match.line),
|
|
42
|
+
rootCause:
|
|
43
|
+
"Loop body performs a database, HTTP, or repository call. Runtime cost grows linearly with item count and can become an N+1 query or fan-out storm.",
|
|
44
|
+
recommendedFix:
|
|
45
|
+
"Batch the work outside the loop, prefetch related data, use `WHERE id IN (...)`, or add a bounded concurrency pool with request coalescing.",
|
|
46
|
+
confidence: 0.68,
|
|
47
|
+
})
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return findings;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export { LOOP_IO_PATTERN };
|
|
@@ -35,6 +35,8 @@ const PERSONA_LOADERS = Object.freeze({
|
|
|
35
35
|
(await import("./infrastructure/index.js")).runAllInfrastructureTools,
|
|
36
36
|
"observability": async () =>
|
|
37
37
|
(await import("./observability/index.js")).runAllObservabilityTools,
|
|
38
|
+
"performance": async () =>
|
|
39
|
+
(await import("./performance/index.js")).runAllPerformanceTools,
|
|
38
40
|
"release": async () =>
|
|
39
41
|
(await import("./release/index.js")).runAllReleaseTools,
|
|
40
42
|
"reliability": async () =>
|
package/src/commands/session.js
CHANGED
|
@@ -100,6 +100,7 @@ import {
|
|
|
100
100
|
listSessionMessageActions,
|
|
101
101
|
listSessionsFromApi,
|
|
102
102
|
probeSessionAccess,
|
|
103
|
+
pollHumanMessages,
|
|
103
104
|
pollSessionEvents,
|
|
104
105
|
pollSessionEventsBefore,
|
|
105
106
|
searchSessionEvents,
|
|
@@ -237,6 +238,8 @@ const SESSION_SAY_LOCAL_ONLY_REASONS = new Set([
|
|
|
237
238
|
"remote_sync_disabled_env",
|
|
238
239
|
]);
|
|
239
240
|
const SESSION_READ_AUTO_VIEW_TIMEOUT_MS = 500;
|
|
241
|
+
const SESSION_RECAP_REMOTE_REQUEST_TIMEOUT_MS = 5_000;
|
|
242
|
+
const SESSION_RECAP_REMOTE_HYDRATE_MAX_PAGES = 1;
|
|
240
243
|
|
|
241
244
|
function isLocalOnlySessionSayReason(reason) {
|
|
242
245
|
return SESSION_SAY_LOCAL_ONLY_REASONS.has(normalizeString(reason));
|
|
@@ -5565,11 +5568,24 @@ export function registerSessionCommand(program) {
|
|
|
5565
5568
|
hydration = await hydrateSessionFromRemote({
|
|
5566
5569
|
sessionId: normalizedSessionId,
|
|
5567
5570
|
targetPath,
|
|
5571
|
+
eventPageLimit: maxEvents,
|
|
5572
|
+
maxEventPages: SESSION_RECAP_REMOTE_HYDRATE_MAX_PAGES,
|
|
5573
|
+
_poll: (pollSessionId, pollOptions) =>
|
|
5574
|
+
pollHumanMessages(pollSessionId, {
|
|
5575
|
+
...pollOptions,
|
|
5576
|
+
limit: maxEvents,
|
|
5577
|
+
timeoutMs: SESSION_RECAP_REMOTE_REQUEST_TIMEOUT_MS,
|
|
5578
|
+
}),
|
|
5579
|
+
_pollEvents: (pollSessionId, pollOptions) =>
|
|
5580
|
+
pollSessionEvents(pollSessionId, {
|
|
5581
|
+
...pollOptions,
|
|
5582
|
+
timeoutMs: SESSION_RECAP_REMOTE_REQUEST_TIMEOUT_MS,
|
|
5583
|
+
}),
|
|
5568
5584
|
});
|
|
5569
5585
|
remoteTail = await pollSessionEventsBefore(normalizedSessionId, {
|
|
5570
5586
|
targetPath,
|
|
5571
5587
|
limit: maxEvents,
|
|
5572
|
-
timeoutMs:
|
|
5588
|
+
timeoutMs: SESSION_RECAP_REMOTE_REQUEST_TIMEOUT_MS,
|
|
5573
5589
|
});
|
|
5574
5590
|
if (remoteTail?.ok && Array.isArray(remoteTail.events) && remoteTail.events.length > 0) {
|
|
5575
5591
|
remoteAppend = await appendMissingRemoteEvents(normalizedSessionId, remoteTail.events, {
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Unified per-persona runner for investor-DD (#investor-dd-4..15).
|
|
3
3
|
*
|
|
4
|
-
* Wires the
|
|
5
|
-
* data-layer, reliability, release, observability,
|
|
6
|
-
* supply-chain, frontend, documentation, ai-governance) into the review
|
|
4
|
+
* Wires the 14 domain personas (security, backend, code-quality, testing,
|
|
5
|
+
* data-layer, performance, reliability, release, observability,
|
|
6
|
+
* infrastructure, supply-chain, frontend, documentation, ai-governance) into the review
|
|
7
7
|
* loop. Each persona dispatches its declared domain tools against every
|
|
8
8
|
* file the router assigns to it, collects findings, and returns a
|
|
9
9
|
* per-persona coverage proof.
|
|
@@ -23,6 +23,7 @@ import { DATA_LAYER_TOOLS } from "../agents/data-layer/tools/index.js";
|
|
|
23
23
|
import { DOCUMENTATION_TOOLS } from "../agents/documentation/tools/index.js";
|
|
24
24
|
import { INFRASTRUCTURE_TOOLS } from "../agents/infrastructure/tools/index.js";
|
|
25
25
|
import { OBSERVABILITY_TOOLS } from "../agents/observability/tools/index.js";
|
|
26
|
+
import { PERFORMANCE_TOOLS } from "../agents/performance/tools/index.js";
|
|
26
27
|
import { RELEASE_TOOLS } from "../agents/release/tools/index.js";
|
|
27
28
|
import { RELIABILITY_TOOLS } from "../agents/reliability/tools/index.js";
|
|
28
29
|
import { SECURITY_TOOLS } from "../agents/security/tools/index.js";
|
|
@@ -44,6 +45,7 @@ export const INVESTOR_DD_PERSONA_TOOL_REGISTRY = Object.freeze({
|
|
|
44
45
|
reliability: RELIABILITY_TOOLS,
|
|
45
46
|
release: RELEASE_TOOLS,
|
|
46
47
|
observability: OBSERVABILITY_TOOLS,
|
|
48
|
+
performance: PERFORMANCE_TOOLS,
|
|
47
49
|
infrastructure: INFRASTRUCTURE_TOOLS,
|
|
48
50
|
"supply-chain": SUPPLY_CHAIN_TOOLS,
|
|
49
51
|
frontend: FRONTEND_TOOLS,
|