sentinelayer-cli 0.8.0 → 0.8.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/README.md +13 -0
- package/package.json +4 -4
- package/src/agents/ai-governance/index.js +12 -0
- package/src/agents/ai-governance/tools/base.js +171 -0
- package/src/agents/ai-governance/tools/eval-regression.js +47 -0
- package/src/agents/ai-governance/tools/hitl-audit.js +81 -0
- package/src/agents/ai-governance/tools/index.js +52 -0
- package/src/agents/ai-governance/tools/prompt-drift.js +42 -0
- package/src/agents/ai-governance/tools/provenance-check.js +69 -0
- package/src/agents/backend/index.js +12 -0
- package/src/agents/backend/tools/base.js +189 -0
- package/src/agents/backend/tools/circuit-breaker-check.js +123 -0
- package/src/agents/backend/tools/idempotency-audit.js +105 -0
- package/src/agents/backend/tools/index.js +87 -0
- package/src/agents/backend/tools/retry-audit.js +132 -0
- package/src/agents/backend/tools/timeout-audit.js +144 -0
- package/src/agents/code-quality/index.js +12 -0
- package/src/agents/code-quality/tools/base.js +159 -0
- package/src/agents/code-quality/tools/complexity-measure.js +197 -0
- package/src/agents/code-quality/tools/coupling-analysis.js +81 -0
- package/src/agents/code-quality/tools/cycle-detect.js +49 -0
- package/src/agents/code-quality/tools/dep-graph.js +196 -0
- package/src/agents/code-quality/tools/index.js +89 -0
- package/src/agents/data-layer/index.js +12 -0
- package/src/agents/data-layer/tools/base.js +181 -0
- package/src/agents/data-layer/tools/index-audit.js +165 -0
- package/src/agents/data-layer/tools/index.js +83 -0
- package/src/agents/data-layer/tools/migration-scan.js +135 -0
- package/src/agents/data-layer/tools/query-explain.js +120 -0
- package/src/agents/data-layer/tools/tenancy-scan.js +166 -0
- package/src/agents/documentation/index.js +12 -0
- package/src/agents/documentation/tools/api-diff.js +91 -0
- package/src/agents/documentation/tools/base.js +151 -0
- package/src/agents/documentation/tools/dead-link-check.js +58 -0
- package/src/agents/documentation/tools/docstring-coverage.js +78 -0
- package/src/agents/documentation/tools/index.js +52 -0
- package/src/agents/documentation/tools/readme-freshness.js +61 -0
- package/src/agents/envelope/fix-cycle.js +45 -0
- package/src/agents/envelope/index.js +31 -0
- package/src/agents/envelope/loop.js +150 -0
- package/src/agents/envelope/pulse.js +18 -0
- package/src/agents/envelope/stream.js +40 -0
- package/src/agents/infrastructure/index.js +12 -0
- package/src/agents/infrastructure/tools/base.js +171 -0
- package/src/agents/infrastructure/tools/checkov-run.js +32 -0
- package/src/agents/infrastructure/tools/drift-detect.js +59 -0
- package/src/agents/infrastructure/tools/iam-least-priv-check.js +78 -0
- package/src/agents/infrastructure/tools/index.js +52 -0
- package/src/agents/infrastructure/tools/tflint-run.js +31 -0
- package/src/agents/jules/loop.js +7 -4
- package/src/agents/jules/swarm/sub-agent.js +5 -1
- package/src/agents/jules/tools/auth-audit.js +10 -1
- package/src/agents/mode.js +113 -0
- package/src/agents/observability/index.js +12 -0
- package/src/agents/observability/tools/alert-audit.js +39 -0
- package/src/agents/observability/tools/base.js +181 -0
- package/src/agents/observability/tools/dashboard-gap.js +42 -0
- package/src/agents/observability/tools/index.js +54 -0
- package/src/agents/observability/tools/log-schema-check.js +74 -0
- package/src/agents/observability/tools/span-coverage.js +74 -0
- package/src/agents/persona-visuals.js +38 -0
- package/src/agents/release/index.js +12 -0
- package/src/agents/release/tools/base.js +181 -0
- package/src/agents/release/tools/changelog-diff.js +86 -0
- package/src/agents/release/tools/feature-flag-audit.js +126 -0
- package/src/agents/release/tools/index.js +61 -0
- package/src/agents/release/tools/rollback-verify.js +129 -0
- package/src/agents/release/tools/semver-check.js +109 -0
- package/src/agents/reliability/index.js +12 -0
- package/src/agents/reliability/tools/backpressure-check.js +129 -0
- package/src/agents/reliability/tools/base.js +181 -0
- package/src/agents/reliability/tools/chaos-probe.js +109 -0
- package/src/agents/reliability/tools/graceful-degradation-check.js +114 -0
- package/src/agents/reliability/tools/health-check-audit.js +111 -0
- package/src/agents/reliability/tools/index.js +87 -0
- package/src/agents/run-persona.js +109 -0
- package/src/agents/security/index.js +12 -0
- package/src/agents/security/tools/authz-audit.js +134 -0
- package/src/agents/security/tools/base.js +190 -0
- package/src/agents/security/tools/crypto-review.js +175 -0
- package/src/agents/security/tools/index.js +97 -0
- package/src/agents/security/tools/sast-scan.js +175 -0
- package/src/agents/security/tools/secrets-scan.js +216 -0
- package/src/agents/supply-chain/index.js +12 -0
- package/src/agents/supply-chain/tools/attestation-check.js +42 -0
- package/src/agents/supply-chain/tools/base.js +151 -0
- package/src/agents/supply-chain/tools/index.js +52 -0
- package/src/agents/supply-chain/tools/lockfile-integrity.js +73 -0
- package/src/agents/supply-chain/tools/package-verify.js +56 -0
- package/src/agents/supply-chain/tools/sbom-diff.js +34 -0
- package/src/agents/testing/index.js +12 -0
- package/src/agents/testing/tools/base.js +202 -0
- package/src/agents/testing/tools/coverage-gap.js +144 -0
- package/src/agents/testing/tools/flake-detect.js +125 -0
- package/src/agents/testing/tools/index.js +85 -0
- package/src/agents/testing/tools/mutation-test.js +143 -0
- package/src/agents/testing/tools/snapshot-diff.js +103 -0
- package/src/auth/gate.js +65 -37
- package/src/cli.js +1 -1
- package/src/commands/chat.js +3 -10
- package/src/commands/legacy-args.js +10 -0
- package/src/commands/omargate.js +36 -2
- package/src/commands/persona.js +46 -1
- package/src/commands/scan.js +3 -10
- package/src/commands/session.js +654 -6
- package/src/commands/spec.js +3 -10
- package/src/coord/events-log.js +141 -0
- package/src/coord/handshake.js +719 -0
- package/src/coord/index.js +35 -0
- package/src/coord/paths.js +84 -0
- package/src/coord/priority.js +62 -0
- package/src/coord/tarjan.js +157 -0
- package/src/cost/tokenizer.js +160 -0
- package/src/cost/tracker.js +61 -0
- package/src/daemon/artifact-lineage.js +362 -0
- package/src/daemon/assignment-ledger.js +117 -0
- package/src/daemon/ast-drift.js +496 -0
- package/src/daemon/ingest-refresh.js +69 -2
- package/src/ingest/engine.js +15 -0
- package/src/ingest/ownership.js +380 -0
- package/src/legacy-cli.js +68 -1
- package/src/orchestrator/kai-chen.js +126 -0
- package/src/review/ai-review.js +3 -10
- package/src/review/compliance-pack.js +389 -0
- package/src/review/investor-dd-config.js +54 -0
- package/src/review/investor-dd-file-loop.js +303 -0
- package/src/review/investor-dd-file-router.js +406 -0
- package/src/review/investor-dd-html-report.js +233 -0
- package/src/review/investor-dd-notification.js +120 -0
- package/src/review/investor-dd-orchestrator.js +405 -0
- package/src/review/investor-dd-persona-runner.js +275 -0
- package/src/review/live-validator.js +253 -0
- package/src/review/omargate-orchestrator.js +90 -2
- package/src/review/persona-prompts.js +244 -56
- package/src/review/reconciliation-rules.js +329 -0
- package/src/review/reproducibility-chain.js +136 -0
- package/src/review/scan-modes.js +102 -3
- package/src/session/agent-registry.js +7 -0
- package/src/session/analytics.js +479 -0
- package/src/session/daemon.js +609 -14
- package/src/session/file-locks.js +666 -0
- package/src/session/paths.js +4 -0
- package/src/session/recap.js +567 -0
- package/src/session/redact.js +82 -0
- package/src/session/runtime-bridge.js +24 -1
- package/src/session/scoring.js +406 -0
- package/src/session/setup-guides.js +304 -0
- package/src/session/store.js +318 -2
- package/src/session/stream.js +9 -1
- package/src/session/sync.js +753 -0
- package/src/session/tasks.js +1054 -0
- package/src/session/templates.js +188 -0
- package/src/swarm/runtime.js +1 -8
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// dep-graph — extract a module-level import graph from JS/TS sources (#A16).
|
|
2
|
+
//
|
|
3
|
+
// We use @babel/parser (already a dep) to collect every ImportDeclaration /
|
|
4
|
+
// dynamic import / require call per file. Local imports (starting with
|
|
5
|
+
// '.' or the repo's src root) are resolved against the filesystem so we
|
|
6
|
+
// get a directed graph with fully-qualified node keys. External packages
|
|
7
|
+
// are kept under a synthetic 'npm:<pkg>' / 'npm:@scope/pkg' key so the
|
|
8
|
+
// graph isn't polluted by node_modules paths but still captures the fan-out.
|
|
9
|
+
|
|
10
|
+
import fsp from "node:fs/promises";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
|
|
13
|
+
import { parse } from "@babel/parser";
|
|
14
|
+
|
|
15
|
+
import { toPosix, walkRepoFiles } from "./base.js";
|
|
16
|
+
|
|
17
|
+
const DEFAULT_EXTENSIONS = new Set([
|
|
18
|
+
".js",
|
|
19
|
+
".jsx",
|
|
20
|
+
".ts",
|
|
21
|
+
".tsx",
|
|
22
|
+
".mjs",
|
|
23
|
+
".cjs",
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
function pickBabelPlugins(filePath) {
|
|
27
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
28
|
+
const plugins = ["importAttributes", "dynamicImport"];
|
|
29
|
+
if (ext === ".ts" || ext === ".tsx" || ext === ".mts" || ext === ".cts") {
|
|
30
|
+
plugins.push("typescript");
|
|
31
|
+
}
|
|
32
|
+
if (ext === ".jsx" || ext === ".tsx") {
|
|
33
|
+
plugins.push("jsx");
|
|
34
|
+
}
|
|
35
|
+
return plugins;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function collectSpecifiers(astRoot) {
|
|
39
|
+
const specifiers = new Set();
|
|
40
|
+
const queue = [astRoot];
|
|
41
|
+
while (queue.length > 0) {
|
|
42
|
+
const node = queue.shift();
|
|
43
|
+
if (!node || typeof node !== "object") {
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (Array.isArray(node)) {
|
|
47
|
+
for (const value of node) {
|
|
48
|
+
queue.push(value);
|
|
49
|
+
}
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (
|
|
53
|
+
(node.type === "ImportDeclaration" ||
|
|
54
|
+
node.type === "ExportAllDeclaration" ||
|
|
55
|
+
node.type === "ExportNamedDeclaration") &&
|
|
56
|
+
node.source &&
|
|
57
|
+
typeof node.source.value === "string"
|
|
58
|
+
) {
|
|
59
|
+
specifiers.add(node.source.value);
|
|
60
|
+
}
|
|
61
|
+
if (
|
|
62
|
+
node.type === "CallExpression" &&
|
|
63
|
+
node.callee &&
|
|
64
|
+
node.callee.type === "Identifier" &&
|
|
65
|
+
node.callee.name === "require" &&
|
|
66
|
+
Array.isArray(node.arguments) &&
|
|
67
|
+
node.arguments[0]?.type === "StringLiteral"
|
|
68
|
+
) {
|
|
69
|
+
specifiers.add(node.arguments[0].value);
|
|
70
|
+
}
|
|
71
|
+
if (
|
|
72
|
+
node.type === "CallExpression" &&
|
|
73
|
+
node.callee?.type === "Import" &&
|
|
74
|
+
Array.isArray(node.arguments) &&
|
|
75
|
+
node.arguments[0]?.type === "StringLiteral"
|
|
76
|
+
) {
|
|
77
|
+
specifiers.add(node.arguments[0].value);
|
|
78
|
+
}
|
|
79
|
+
for (const value of Object.values(node)) {
|
|
80
|
+
if (value && typeof value === "object") {
|
|
81
|
+
queue.push(value);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return Array.from(specifiers);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function normalizeImportSpec(spec, sourceRelativePath) {
|
|
89
|
+
const raw = String(spec || "").trim();
|
|
90
|
+
if (!raw) {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
if (raw.startsWith(".")) {
|
|
94
|
+
const sourceDir = path.posix.dirname(sourceRelativePath);
|
|
95
|
+
const resolved = path.posix.normalize(`${sourceDir}/${raw}`);
|
|
96
|
+
return { kind: "local", key: resolved };
|
|
97
|
+
}
|
|
98
|
+
if (raw.startsWith("/")) {
|
|
99
|
+
return { kind: "absolute", key: raw };
|
|
100
|
+
}
|
|
101
|
+
const parts = raw.split("/");
|
|
102
|
+
if (parts[0].startsWith("@") && parts.length > 1) {
|
|
103
|
+
return { kind: "npm", key: `npm:${parts[0]}/${parts[1]}` };
|
|
104
|
+
}
|
|
105
|
+
return { kind: "npm", key: `npm:${parts[0]}` };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function buildDependencyGraph({ rootPath, files = null } = {}) {
|
|
109
|
+
const resolvedRoot = path.resolve(String(rootPath || "."));
|
|
110
|
+
const iterator =
|
|
111
|
+
Array.isArray(files) && files.length > 0
|
|
112
|
+
? iterateExplicitFiles(resolvedRoot, files)
|
|
113
|
+
: walkRepoFiles({ rootPath: resolvedRoot, extensions: DEFAULT_EXTENSIONS });
|
|
114
|
+
|
|
115
|
+
const graph = {};
|
|
116
|
+
for await (const { fullPath, relativePath } of iterator) {
|
|
117
|
+
const relPos = toPosix(relativePath);
|
|
118
|
+
let content;
|
|
119
|
+
try {
|
|
120
|
+
content = await fsp.readFile(fullPath, "utf-8");
|
|
121
|
+
} catch {
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
let ast;
|
|
125
|
+
try {
|
|
126
|
+
ast = parse(content, {
|
|
127
|
+
sourceType: "unambiguous",
|
|
128
|
+
errorRecovery: true,
|
|
129
|
+
plugins: pickBabelPlugins(fullPath),
|
|
130
|
+
});
|
|
131
|
+
} catch {
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
const specifiers = collectSpecifiers(ast);
|
|
135
|
+
const edges = new Set();
|
|
136
|
+
for (const spec of specifiers) {
|
|
137
|
+
const normalized = normalizeImportSpec(spec, relPos);
|
|
138
|
+
if (!normalized) {
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
edges.add(normalized.key);
|
|
142
|
+
}
|
|
143
|
+
graph[relPos] = Array.from(edges).sort();
|
|
144
|
+
}
|
|
145
|
+
return graph;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Thin wrapper so the tool conforms to the persona-tool contract (returns
|
|
149
|
+
// Finding[]). The graph itself is returned on the single "report" finding's
|
|
150
|
+
// rootCause payload — the LLM layer can reach in for detail.
|
|
151
|
+
export async function runDepGraph({ rootPath, files = null } = {}) {
|
|
152
|
+
const graph = await buildDependencyGraph({ rootPath, files });
|
|
153
|
+
const moduleCount = Object.keys(graph).length;
|
|
154
|
+
const edgeCount = Object.values(graph).reduce(
|
|
155
|
+
(acc, list) => acc + list.length,
|
|
156
|
+
0
|
|
157
|
+
);
|
|
158
|
+
const densestFile = Object.entries(graph).reduce(
|
|
159
|
+
(acc, [file, edges]) => (edges.length > acc.edges ? { file, edges: edges.length } : acc),
|
|
160
|
+
{ file: "", edges: 0 }
|
|
161
|
+
);
|
|
162
|
+
return [
|
|
163
|
+
{
|
|
164
|
+
persona: "code-quality",
|
|
165
|
+
tool: "dep-graph",
|
|
166
|
+
kind: "code-quality.dep-graph-report",
|
|
167
|
+
severity: "P3",
|
|
168
|
+
file: densestFile.file || "",
|
|
169
|
+
line: 0,
|
|
170
|
+
evidence: `modules=${moduleCount}, edges=${edgeCount}, densest=${densestFile.file || "n/a"} (${densestFile.edges} imports)`,
|
|
171
|
+
rootCause: "Module-level dependency graph summary",
|
|
172
|
+
recommendedFix:
|
|
173
|
+
"Inspect the densest modules first. Consider splitting or introducing an indirection layer if fan-out is > 20.",
|
|
174
|
+
confidence: 0.9,
|
|
175
|
+
graph,
|
|
176
|
+
},
|
|
177
|
+
];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function* iterateExplicitFiles(resolvedRoot, files) {
|
|
181
|
+
for (const file of files) {
|
|
182
|
+
const trimmed = String(file || "").trim();
|
|
183
|
+
if (!trimmed) {
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
const fullPath = path.isAbsolute(trimmed)
|
|
187
|
+
? trimmed
|
|
188
|
+
: path.join(resolvedRoot, trimmed);
|
|
189
|
+
const relativePath = path
|
|
190
|
+
.relative(resolvedRoot, fullPath)
|
|
191
|
+
.replace(/\\/g, "/");
|
|
192
|
+
yield { fullPath, relativePath };
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export { collectSpecifiers, normalizeImportSpec };
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Ethan (code-quality persona) domain-tool registry (#A16).
|
|
2
|
+
|
|
3
|
+
import { runComplexityMeasure } from "./complexity-measure.js";
|
|
4
|
+
import { runCouplingAnalysis } from "./coupling-analysis.js";
|
|
5
|
+
import { runCycleDetect } from "./cycle-detect.js";
|
|
6
|
+
import { runDepGraph } from "./dep-graph.js";
|
|
7
|
+
|
|
8
|
+
export const CODE_QUALITY_TOOLS = Object.freeze({
|
|
9
|
+
"dep-graph": {
|
|
10
|
+
id: "dep-graph",
|
|
11
|
+
description:
|
|
12
|
+
"Build the module-level import graph (local modules + npm: synthetic nodes for external packages). Returns a summary Finding; the graph is available on the finding's `graph` field.",
|
|
13
|
+
schema: {
|
|
14
|
+
type: "object",
|
|
15
|
+
properties: {
|
|
16
|
+
rootPath: { type: "string" },
|
|
17
|
+
files: { type: "array", items: { type: "string" } },
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
handler: runDepGraph,
|
|
21
|
+
},
|
|
22
|
+
"coupling-analysis": {
|
|
23
|
+
id: "coupling-analysis",
|
|
24
|
+
description:
|
|
25
|
+
"Flag files with high fan-out (many imports) or high fan-in (many importers). Uses the dep-graph under the hood.",
|
|
26
|
+
schema: {
|
|
27
|
+
type: "object",
|
|
28
|
+
properties: {
|
|
29
|
+
rootPath: { type: "string" },
|
|
30
|
+
files: { type: "array", items: { type: "string" } },
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
handler: runCouplingAnalysis,
|
|
34
|
+
},
|
|
35
|
+
"cycle-detect": {
|
|
36
|
+
id: "cycle-detect",
|
|
37
|
+
description:
|
|
38
|
+
"Find import cycles between local modules (npm: and absolute nodes are stripped before SCC).",
|
|
39
|
+
schema: {
|
|
40
|
+
type: "object",
|
|
41
|
+
properties: {
|
|
42
|
+
rootPath: { type: "string" },
|
|
43
|
+
files: { type: "array", items: { type: "string" } },
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
handler: runCycleDetect,
|
|
47
|
+
},
|
|
48
|
+
"complexity-measure": {
|
|
49
|
+
id: "complexity-measure",
|
|
50
|
+
description:
|
|
51
|
+
"Estimate per-function cyclomatic complexity via AST branching-node count. Defaults: P2 at CC ≥ 15, P1 at CC ≥ 30.",
|
|
52
|
+
schema: {
|
|
53
|
+
type: "object",
|
|
54
|
+
properties: {
|
|
55
|
+
rootPath: { type: "string" },
|
|
56
|
+
p1Threshold: { type: "number" },
|
|
57
|
+
p2Threshold: { type: "number" },
|
|
58
|
+
files: { type: "array", items: { type: "string" } },
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
handler: runComplexityMeasure,
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
export const CODE_QUALITY_TOOL_IDS = Object.freeze(Object.keys(CODE_QUALITY_TOOLS));
|
|
66
|
+
|
|
67
|
+
export async function dispatchCodeQualityTool(toolId, args = {}) {
|
|
68
|
+
const tool = CODE_QUALITY_TOOLS[toolId];
|
|
69
|
+
if (!tool) {
|
|
70
|
+
throw new Error(`Unknown code-quality tool: ${toolId}`);
|
|
71
|
+
}
|
|
72
|
+
return tool.handler(args);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function runAllCodeQualityTools({ rootPath, files = null } = {}) {
|
|
76
|
+
const findings = [];
|
|
77
|
+
for (const toolId of CODE_QUALITY_TOOL_IDS) {
|
|
78
|
+
const out = await dispatchCodeQualityTool(toolId, { rootPath, files });
|
|
79
|
+
findings.push(...out);
|
|
80
|
+
}
|
|
81
|
+
return findings;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export {
|
|
85
|
+
runComplexityMeasure,
|
|
86
|
+
runCouplingAnalysis,
|
|
87
|
+
runCycleDetect,
|
|
88
|
+
runDepGraph,
|
|
89
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Linh (data-layer persona) — barrel export (#A17).
|
|
2
|
+
|
|
3
|
+
export {
|
|
4
|
+
DATA_LAYER_TOOLS,
|
|
5
|
+
DATA_LAYER_TOOL_IDS,
|
|
6
|
+
dispatchDataLayerTool,
|
|
7
|
+
runAllDataLayerTools,
|
|
8
|
+
runIndexAudit,
|
|
9
|
+
runMigrationScan,
|
|
10
|
+
runQueryExplain,
|
|
11
|
+
runTenancyScan,
|
|
12
|
+
} from "./tools/index.js";
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// Shared helpers for Linh's (data-layer) domain tools (#A17).
|
|
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 = "data-layer",
|
|
51
|
+
} = {}) {
|
|
52
|
+
return {
|
|
53
|
+
persona,
|
|
54
|
+
tool: String(tool || "").trim(),
|
|
55
|
+
kind: String(kind || "").trim() || "data-layer",
|
|
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 };
|
|
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 { DEFAULT_IGNORED_DIRS, MAX_FILE_SIZE_BYTES, SEVERITIES };
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// index-audit — flag WHERE / JOIN columns that probably lack indexes (#A17).
|
|
2
|
+
//
|
|
3
|
+
// Without DB access we can't know what's actually indexed, but we can
|
|
4
|
+
// cross-reference source-level migrations:
|
|
5
|
+
// - Find every `WHERE col = :x` and `JOIN t ON a.col = b.col` in source.
|
|
6
|
+
// - Collect every `CREATE INDEX` / `@Index` / `db_index=True` declaration
|
|
7
|
+
// in the repo.
|
|
8
|
+
// - Flag WHERE / JOIN columns that don't appear in an index declaration.
|
|
9
|
+
//
|
|
10
|
+
// Conservative: we only consider columns that appear under standard
|
|
11
|
+
// migration directories so we don't cross-pollute with JS variable names
|
|
12
|
+
// that coincidentally look SQL-ish.
|
|
13
|
+
|
|
14
|
+
import fsp from "node:fs/promises";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
|
|
17
|
+
import { createFinding, toPosix, walkRepoFiles } from "./base.js";
|
|
18
|
+
import { isMigrationPath } from "./migration-scan.js";
|
|
19
|
+
|
|
20
|
+
const CODE_EXTENSIONS = new Set([
|
|
21
|
+
".js",
|
|
22
|
+
".jsx",
|
|
23
|
+
".ts",
|
|
24
|
+
".tsx",
|
|
25
|
+
".mjs",
|
|
26
|
+
".cjs",
|
|
27
|
+
".py",
|
|
28
|
+
".go",
|
|
29
|
+
".rb",
|
|
30
|
+
".sql",
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
function collectIndexDecls(content) {
|
|
34
|
+
const declared = new Set();
|
|
35
|
+
const patterns = [
|
|
36
|
+
/CREATE\s+(?:UNIQUE\s+)?INDEX\s+[`"]?\w*[`"]?\s+ON\s+[`"]?\w+[`"]?\s*\(\s*[`"]?(\w+)[`"]?/gi,
|
|
37
|
+
/@Index\s*\(\s*[`"]?(\w+)[`"]?/g, // TypeORM
|
|
38
|
+
/db_index\s*=\s*True/g, // Django: presence implies indexed
|
|
39
|
+
/index=True/g, // SQLAlchemy: per-column index
|
|
40
|
+
/\.index\s*\(\s*['"`](\w+)['"`]/g, // Knex migrations
|
|
41
|
+
];
|
|
42
|
+
for (const pattern of patterns) {
|
|
43
|
+
let match;
|
|
44
|
+
while ((match = pattern.exec(content)) !== null) {
|
|
45
|
+
const name = match[1];
|
|
46
|
+
if (name) {
|
|
47
|
+
declared.add(name);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return declared;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function collectLookupColumns(content) {
|
|
55
|
+
const columns = new Map(); // col -> first line
|
|
56
|
+
const whereRegex = /WHERE\s+(?:[\w.`"]+\.)?[`"]?(\w+)[`"]?\s*(?:=|IN|LIKE|>|<|>=|<=|BETWEEN)/gi;
|
|
57
|
+
const joinRegex = /JOIN\s+[\w.]+\s+(?:AS\s+\w+\s+)?ON\s+[\w.`"]+\.[`"]?(\w+)[`"]?\s*=/gi;
|
|
58
|
+
const knexRegex = /\.(?:where|orWhere|join)\(\s*['"`](\w+)['"`]/g;
|
|
59
|
+
const lines = content.split(/\r?\n/);
|
|
60
|
+
for (const regex of [whereRegex, joinRegex, knexRegex]) {
|
|
61
|
+
let match;
|
|
62
|
+
while ((match = regex.exec(content)) !== null) {
|
|
63
|
+
const name = match[1];
|
|
64
|
+
if (!name) {
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
const lineIndex = content.slice(0, match.index).split(/\r?\n/).length;
|
|
68
|
+
if (!columns.has(name)) {
|
|
69
|
+
columns.set(name, { line: lineIndex, lineContent: (lines[lineIndex - 1] || "").trim() });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return columns;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function runIndexAudit({ rootPath, files = null } = {}) {
|
|
77
|
+
const resolvedRoot = path.resolve(String(rootPath || "."));
|
|
78
|
+
const declared = new Set();
|
|
79
|
+
// Pass 1: collect index declarations from migrations + schema files.
|
|
80
|
+
for await (const { fullPath, relativePath } of walkRepoFiles({
|
|
81
|
+
rootPath: resolvedRoot,
|
|
82
|
+
extensions: CODE_EXTENSIONS,
|
|
83
|
+
})) {
|
|
84
|
+
const isMigration = isMigrationPath(relativePath);
|
|
85
|
+
const isOrmModel = /\/models?\/|schema\.(?:prisma|ts|js|py)$/i.test(
|
|
86
|
+
toPosix(relativePath)
|
|
87
|
+
);
|
|
88
|
+
if (!isMigration && !isOrmModel) {
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
let content;
|
|
92
|
+
try {
|
|
93
|
+
content = await fsp.readFile(fullPath, "utf-8");
|
|
94
|
+
} catch {
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
for (const name of collectIndexDecls(content)) {
|
|
98
|
+
declared.add(name);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Pass 2: collect lookup columns from application code.
|
|
103
|
+
const findings = [];
|
|
104
|
+
const iterator =
|
|
105
|
+
Array.isArray(files) && files.length > 0
|
|
106
|
+
? iterateExplicitFiles(resolvedRoot, files)
|
|
107
|
+
: walkRepoFiles({ rootPath: resolvedRoot, extensions: CODE_EXTENSIONS });
|
|
108
|
+
|
|
109
|
+
const reported = new Set();
|
|
110
|
+
for await (const { fullPath, relativePath } of iterator) {
|
|
111
|
+
const relPos = toPosix(relativePath);
|
|
112
|
+
if (isMigrationPath(relPos)) {
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
let content;
|
|
116
|
+
try {
|
|
117
|
+
content = await fsp.readFile(fullPath, "utf-8");
|
|
118
|
+
} catch {
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
const lookups = collectLookupColumns(content);
|
|
122
|
+
for (const [name, meta] of lookups) {
|
|
123
|
+
if (declared.has(name)) {
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
const key = `${relPos}#${name}`;
|
|
127
|
+
if (reported.has(key)) {
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
reported.add(key);
|
|
131
|
+
findings.push(
|
|
132
|
+
createFinding({
|
|
133
|
+
tool: "index-audit",
|
|
134
|
+
kind: "data.missing-index",
|
|
135
|
+
severity: "P2",
|
|
136
|
+
file: relPos,
|
|
137
|
+
line: meta.line,
|
|
138
|
+
evidence: meta.lineContent,
|
|
139
|
+
rootCause: `Column '${name}' is used in a WHERE / JOIN predicate but no matching CREATE INDEX / @Index / db_index=True declaration was found in migrations or models.`,
|
|
140
|
+
recommendedFix: `Add an index on ${name} in the next migration. Test with EXPLAIN on production-like data before merging.`,
|
|
141
|
+
confidence: 0.45,
|
|
142
|
+
})
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return findings;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function* iterateExplicitFiles(resolvedRoot, files) {
|
|
150
|
+
for (const file of files) {
|
|
151
|
+
const trimmed = String(file || "").trim();
|
|
152
|
+
if (!trimmed) {
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
const fullPath = path.isAbsolute(trimmed)
|
|
156
|
+
? trimmed
|
|
157
|
+
: path.join(resolvedRoot, trimmed);
|
|
158
|
+
const relativePath = path
|
|
159
|
+
.relative(resolvedRoot, fullPath)
|
|
160
|
+
.replace(/\\/g, "/");
|
|
161
|
+
yield { fullPath, relativePath };
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export { collectIndexDecls, collectLookupColumns };
|