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,144 @@
|
|
|
1
|
+
// coverage-gap — find source files without a matching test file (#A15).
|
|
2
|
+
//
|
|
3
|
+
// Zero-dep static pass: we don't try to read c8 / istanbul coverage JSON
|
|
4
|
+
// (that lives in a later PR). Instead we use filename-convention matching —
|
|
5
|
+
// for every `src/foo/bar.ts`, check whether any of the standard test file
|
|
6
|
+
// names exists. Misses catches the most valuable 80% of coverage gaps while
|
|
7
|
+
// staying fast and self-contained.
|
|
8
|
+
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
|
|
11
|
+
import { createFinding, isTestFile, toPosix, walkRepoFiles } from "./base.js";
|
|
12
|
+
|
|
13
|
+
const SOURCE_EXTENSIONS = new Set([
|
|
14
|
+
".js",
|
|
15
|
+
".jsx",
|
|
16
|
+
".ts",
|
|
17
|
+
".tsx",
|
|
18
|
+
".mjs",
|
|
19
|
+
".cjs",
|
|
20
|
+
".py",
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
// Generate plausible test-file locations for a source file. For
|
|
24
|
+
// src/foo/bar.ts, try tests/foo/bar.test.ts, src/foo/bar.test.ts,
|
|
25
|
+
// __tests__/foo/bar.test.ts, tests/foo/test_bar.py (Python), …
|
|
26
|
+
function candidateTestPaths(sourceRelativePath) {
|
|
27
|
+
const posix = toPosix(sourceRelativePath);
|
|
28
|
+
const ext = path.extname(posix).toLowerCase();
|
|
29
|
+
const base = posix.slice(0, posix.length - ext.length);
|
|
30
|
+
const fileName = path.posix.basename(base);
|
|
31
|
+
const dir = path.posix.dirname(base);
|
|
32
|
+
const candidates = new Set();
|
|
33
|
+
|
|
34
|
+
if (ext === ".py") {
|
|
35
|
+
candidates.add(`${dir}/${fileName}_test.py`);
|
|
36
|
+
candidates.add(`${dir}/test_${fileName}.py`);
|
|
37
|
+
candidates.add(`tests/${dir}/${fileName}_test.py`);
|
|
38
|
+
candidates.add(`tests/${dir}/test_${fileName}.py`);
|
|
39
|
+
} else {
|
|
40
|
+
const testExts = [ext, `.test${ext}`];
|
|
41
|
+
for (const testExt of testExts) {
|
|
42
|
+
candidates.add(`${base}.test${ext}`);
|
|
43
|
+
candidates.add(`${base}.spec${ext}`);
|
|
44
|
+
candidates.add(`${dir}/__tests__/${fileName}.test${ext}`);
|
|
45
|
+
candidates.add(`${dir}/__tests__/${fileName}${ext}`);
|
|
46
|
+
candidates.add(`tests/${base}.test${ext}`);
|
|
47
|
+
candidates.add(`tests/${dir}/${fileName}.test${ext}`);
|
|
48
|
+
candidates.add(`test/${dir}/${fileName}.test${ext}`);
|
|
49
|
+
candidates.add(`test/${dir}/${fileName}.spec${ext}`);
|
|
50
|
+
// mjs test convention: tests/unit.{name}.test.mjs
|
|
51
|
+
candidates.add(`tests/unit.${fileName}.test.mjs`);
|
|
52
|
+
candidates.add(`tests/unit.${fileName}.test.js`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return candidates;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isLikelyEntryFile(relativePath) {
|
|
59
|
+
const p = toPosix(relativePath);
|
|
60
|
+
return (
|
|
61
|
+
/(^|\/)(index|main)\.[jt]sx?$/.test(p) ||
|
|
62
|
+
/(^|\/)(bin|scripts)\//.test(p) ||
|
|
63
|
+
/(^|\/)cli\.[jt]s$/.test(p)
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isLikelyConfig(relativePath) {
|
|
68
|
+
const p = toPosix(relativePath);
|
|
69
|
+
return (
|
|
70
|
+
/(^|\/)(config|constants|types?|schema|\.d\.ts)(\.[jt]sx?)?$/.test(p) ||
|
|
71
|
+
/\.d\.ts$/.test(p)
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function runCoverageGap({ rootPath, files = null } = {}) {
|
|
76
|
+
const resolvedRoot = path.resolve(String(rootPath || "."));
|
|
77
|
+
|
|
78
|
+
// Pass 1: walk the repo once, collect source + test file lists.
|
|
79
|
+
const sourceFiles = [];
|
|
80
|
+
const testFiles = new Set();
|
|
81
|
+
const iterator =
|
|
82
|
+
Array.isArray(files) && files.length > 0
|
|
83
|
+
? iterateExplicitFiles(resolvedRoot, files)
|
|
84
|
+
: walkRepoFiles({ rootPath: resolvedRoot, extensions: SOURCE_EXTENSIONS });
|
|
85
|
+
|
|
86
|
+
for await (const { relativePath } of iterator) {
|
|
87
|
+
if (isTestFile(relativePath)) {
|
|
88
|
+
testFiles.add(toPosix(relativePath));
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
sourceFiles.push(toPosix(relativePath));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const findings = [];
|
|
95
|
+
for (const source of sourceFiles) {
|
|
96
|
+
if (isLikelyEntryFile(source) || isLikelyConfig(source)) {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const candidates = candidateTestPaths(source);
|
|
100
|
+
const covered = Array.from(testFiles).some((test) => {
|
|
101
|
+
for (const candidate of candidates) {
|
|
102
|
+
if (test === candidate || test.endsWith(`/${path.posix.basename(candidate)}`)) {
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return false;
|
|
107
|
+
});
|
|
108
|
+
if (covered) {
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
findings.push(
|
|
112
|
+
createFinding({
|
|
113
|
+
tool: "coverage-gap",
|
|
114
|
+
kind: "testing.coverage-gap",
|
|
115
|
+
severity: "P2",
|
|
116
|
+
file: source,
|
|
117
|
+
line: 1,
|
|
118
|
+
evidence: `No test file found for source: ${source}`,
|
|
119
|
+
rootCause:
|
|
120
|
+
"Source file has no corresponding test under standard naming conventions (`*.test.*`, `*.spec.*`, `test_*.py`, `__tests__/…`).",
|
|
121
|
+
recommendedFix:
|
|
122
|
+
"Add a unit test covering the file's exports, or add an explicit `.notest` marker / coverage-ignore annotation if this file is intentionally untested.",
|
|
123
|
+
confidence: 0.6,
|
|
124
|
+
})
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
return findings;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function* iterateExplicitFiles(resolvedRoot, files) {
|
|
131
|
+
for (const file of files) {
|
|
132
|
+
const trimmed = String(file || "").trim();
|
|
133
|
+
if (!trimmed) {
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
const fullPath = path.isAbsolute(trimmed)
|
|
137
|
+
? trimmed
|
|
138
|
+
: path.join(resolvedRoot, trimmed);
|
|
139
|
+
const relativePath = path
|
|
140
|
+
.relative(resolvedRoot, fullPath)
|
|
141
|
+
.replace(/\\/g, "/");
|
|
142
|
+
yield { fullPath, relativePath };
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// flake-detect — flag flakiness smells in test files (#A15).
|
|
2
|
+
//
|
|
3
|
+
// The usual suspects: tests that sleep, rely on wall-clock arithmetic, hit
|
|
4
|
+
// the real network, or seed randomness without a fixed seed. We scan test
|
|
5
|
+
// files specifically (the coverage-gap heuristic for "is this a test") so
|
|
6
|
+
// the tool doesn't flag production code that legitimately uses setTimeout.
|
|
7
|
+
|
|
8
|
+
import fsp from "node:fs/promises";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
|
|
11
|
+
import { createFinding, findLineMatches, getLineContent, isTestFile, toPosix, walkRepoFiles } from "./base.js";
|
|
12
|
+
|
|
13
|
+
const TEST_EXTENSIONS = new Set([
|
|
14
|
+
".js",
|
|
15
|
+
".jsx",
|
|
16
|
+
".ts",
|
|
17
|
+
".tsx",
|
|
18
|
+
".mjs",
|
|
19
|
+
".cjs",
|
|
20
|
+
".py",
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
const RULES = [
|
|
24
|
+
{
|
|
25
|
+
id: "flake.sleep-in-test",
|
|
26
|
+
// setTimeout(fn, 500) or sleep(2) in a test file — schedule-based flake
|
|
27
|
+
pattern: /\b(?:setTimeout|setInterval|sleep|time\.sleep|asyncio\.sleep)\s*\(\s*(?:\w+\s*,\s*)?\d{3,}\s*[,)]/,
|
|
28
|
+
severity: "P2",
|
|
29
|
+
rootCause:
|
|
30
|
+
"Test sleeps for a fixed wall-clock duration — slow on CI, flaky on loaded machines.",
|
|
31
|
+
recommendedFix:
|
|
32
|
+
"Use fake timers (jest.useFakeTimers, vi.useFakeTimers, freezegun) or event-based waits (await page.waitForSelector / waitForResponse).",
|
|
33
|
+
confidence: 0.7,
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
id: "flake.wall-clock-assertion",
|
|
37
|
+
pattern: /expect\s*\(\s*(?:Date\.now\(\)|new\s+Date\(\)\.getTime\(\))\s*\)/,
|
|
38
|
+
severity: "P1",
|
|
39
|
+
rootCause:
|
|
40
|
+
"Assertion compares against the live wall clock — value drifts between runs.",
|
|
41
|
+
recommendedFix:
|
|
42
|
+
"Freeze time (jest.setSystemTime, vi.setSystemTime, freezegun) or pass a Date supplier the SUT reads from.",
|
|
43
|
+
confidence: 0.8,
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
id: "flake.unstubbed-network",
|
|
47
|
+
// fetch / axios / requests in a test file — likely reaching out to real
|
|
48
|
+
// network. Real-network hits are the #1 flake source.
|
|
49
|
+
pattern: /\b(?:fetch|axios(?:\.[a-z]+)?|got(?:\.[a-z]+)?|requests\.(?:get|post|put|patch|delete|request))\s*\(/,
|
|
50
|
+
severity: "P1",
|
|
51
|
+
rootCause:
|
|
52
|
+
"Test makes a live network call. Real-network tests flake on DNS / TLS / rate limits and make CI unreliable.",
|
|
53
|
+
recommendedFix:
|
|
54
|
+
"Mock the client with msw / nock / vcr-py, or inject an HTTP transport and pass a fake in tests.",
|
|
55
|
+
confidence: 0.65,
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
id: "flake.unseeded-random",
|
|
59
|
+
pattern: /\b(?:Math\.random|random\.(?:random|uniform|shuffle|choice))\s*\(/,
|
|
60
|
+
severity: "P2",
|
|
61
|
+
rootCause:
|
|
62
|
+
"Test uses unseeded randomness — two runs can take different branches and produce different results.",
|
|
63
|
+
recommendedFix:
|
|
64
|
+
"Seed the generator or pass a stub random() into the SUT via DI. For Jest / Vitest you can mock Math.random.",
|
|
65
|
+
confidence: 0.55,
|
|
66
|
+
},
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
export async function runFlakeDetect({ rootPath, files = null } = {}) {
|
|
70
|
+
const resolvedRoot = path.resolve(String(rootPath || "."));
|
|
71
|
+
const iterator =
|
|
72
|
+
Array.isArray(files) && files.length > 0
|
|
73
|
+
? iterateExplicitFiles(resolvedRoot, files)
|
|
74
|
+
: walkRepoFiles({ rootPath: resolvedRoot, extensions: TEST_EXTENSIONS });
|
|
75
|
+
|
|
76
|
+
const findings = [];
|
|
77
|
+
for await (const { fullPath, relativePath } of iterator) {
|
|
78
|
+
const relPos = toPosix(relativePath);
|
|
79
|
+
if (!isTestFile(relPos)) {
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
let content;
|
|
83
|
+
try {
|
|
84
|
+
content = await fsp.readFile(fullPath, "utf-8");
|
|
85
|
+
} catch {
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
for (const rule of RULES) {
|
|
89
|
+
for (const match of findLineMatches(content, rule.pattern)) {
|
|
90
|
+
findings.push(
|
|
91
|
+
createFinding({
|
|
92
|
+
tool: "flake-detect",
|
|
93
|
+
kind: rule.id,
|
|
94
|
+
severity: rule.severity,
|
|
95
|
+
file: relPos,
|
|
96
|
+
line: match.line,
|
|
97
|
+
evidence: getLineContent(content, match.line),
|
|
98
|
+
rootCause: rule.rootCause,
|
|
99
|
+
recommendedFix: rule.recommendedFix,
|
|
100
|
+
confidence: rule.confidence,
|
|
101
|
+
})
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return findings;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function* iterateExplicitFiles(resolvedRoot, files) {
|
|
110
|
+
for (const file of files) {
|
|
111
|
+
const trimmed = String(file || "").trim();
|
|
112
|
+
if (!trimmed) {
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const fullPath = path.isAbsolute(trimmed)
|
|
116
|
+
? trimmed
|
|
117
|
+
: path.join(resolvedRoot, trimmed);
|
|
118
|
+
const relativePath = path
|
|
119
|
+
.relative(resolvedRoot, fullPath)
|
|
120
|
+
.replace(/\\/g, "/");
|
|
121
|
+
yield { fullPath, relativePath };
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export { RULES as FLAKE_RULES };
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Priya (testing persona) domain-tool registry (#A15).
|
|
2
|
+
|
|
3
|
+
import { runCoverageGap } from "./coverage-gap.js";
|
|
4
|
+
import { runFlakeDetect } from "./flake-detect.js";
|
|
5
|
+
import { runMutationTest } from "./mutation-test.js";
|
|
6
|
+
import { runSnapshotDiff } from "./snapshot-diff.js";
|
|
7
|
+
|
|
8
|
+
export const TESTING_TOOLS = Object.freeze({
|
|
9
|
+
"coverage-gap": {
|
|
10
|
+
id: "coverage-gap",
|
|
11
|
+
description:
|
|
12
|
+
"Walk the repo and flag source files that have no matching test file under standard naming conventions (*.test.*, *.spec.*, test_*.py, __tests__/…).",
|
|
13
|
+
schema: {
|
|
14
|
+
type: "object",
|
|
15
|
+
properties: {
|
|
16
|
+
rootPath: { type: "string" },
|
|
17
|
+
files: { type: "array", items: { type: "string" } },
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
handler: runCoverageGap,
|
|
21
|
+
},
|
|
22
|
+
"flake-detect": {
|
|
23
|
+
id: "flake-detect",
|
|
24
|
+
description:
|
|
25
|
+
"Scan test files for flakiness smells: fixed-duration sleeps, wall-clock assertions, live network calls (fetch / axios / requests), unseeded randomness.",
|
|
26
|
+
schema: {
|
|
27
|
+
type: "object",
|
|
28
|
+
properties: {
|
|
29
|
+
rootPath: { type: "string" },
|
|
30
|
+
files: { type: "array", items: { type: "string" } },
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
handler: runFlakeDetect,
|
|
34
|
+
},
|
|
35
|
+
"snapshot-diff": {
|
|
36
|
+
id: "snapshot-diff",
|
|
37
|
+
description:
|
|
38
|
+
"Walk *.snap / *.ambr files and flag stale (> 90 days untouched) or oversized (> 64 KiB) snapshots.",
|
|
39
|
+
schema: {
|
|
40
|
+
type: "object",
|
|
41
|
+
properties: {
|
|
42
|
+
rootPath: { type: "string" },
|
|
43
|
+
staleDays: { type: "number" },
|
|
44
|
+
files: { type: "array", items: { type: "string" } },
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
handler: runSnapshotDiff,
|
|
48
|
+
},
|
|
49
|
+
"mutation-test": {
|
|
50
|
+
id: "mutation-test",
|
|
51
|
+
description:
|
|
52
|
+
"Configuration-check pass: verify Stryker / mutmut is wired up and the latest mutation report is fresh (< 30 days).",
|
|
53
|
+
schema: {
|
|
54
|
+
type: "object",
|
|
55
|
+
properties: { rootPath: { type: "string" } },
|
|
56
|
+
},
|
|
57
|
+
handler: runMutationTest,
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
export const TESTING_TOOL_IDS = Object.freeze(Object.keys(TESTING_TOOLS));
|
|
62
|
+
|
|
63
|
+
export async function dispatchTestingTool(toolId, args = {}) {
|
|
64
|
+
const tool = TESTING_TOOLS[toolId];
|
|
65
|
+
if (!tool) {
|
|
66
|
+
throw new Error(`Unknown testing tool: ${toolId}`);
|
|
67
|
+
}
|
|
68
|
+
return tool.handler(args);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function runAllTestingTools({ rootPath, files = null } = {}) {
|
|
72
|
+
const findings = [];
|
|
73
|
+
for (const toolId of TESTING_TOOL_IDS) {
|
|
74
|
+
const out = await dispatchTestingTool(toolId, { rootPath, files });
|
|
75
|
+
findings.push(...out);
|
|
76
|
+
}
|
|
77
|
+
return findings;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export {
|
|
81
|
+
runCoverageGap,
|
|
82
|
+
runFlakeDetect,
|
|
83
|
+
runMutationTest,
|
|
84
|
+
runSnapshotDiff,
|
|
85
|
+
};
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// mutation-test — check for mutation-testing configuration (#A15).
|
|
2
|
+
//
|
|
3
|
+
// Priya wants mutation testing (Stryker / pitest / mutmut) as the ceiling
|
|
4
|
+
// signal: do the tests actually assert anything, or is coverage a green
|
|
5
|
+
// but empty number? True mutation runs are expensive — this tool ships as
|
|
6
|
+
// a configuration check first (is Stryker wired up? is there an up-to-date
|
|
7
|
+
// report?). The LLM / operator can dispatch a real run from the resulting
|
|
8
|
+
// advisory.
|
|
9
|
+
|
|
10
|
+
import fsp from "node:fs/promises";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
|
|
13
|
+
import { createFinding, toPosix } from "./base.js";
|
|
14
|
+
|
|
15
|
+
const CONFIG_CANDIDATES = [
|
|
16
|
+
"stryker.conf.js",
|
|
17
|
+
"stryker.conf.cjs",
|
|
18
|
+
"stryker.conf.mjs",
|
|
19
|
+
"stryker.config.json",
|
|
20
|
+
".stryker-tmp",
|
|
21
|
+
"setup.cfg", // Python mutmut section
|
|
22
|
+
"mutmut_config.py",
|
|
23
|
+
"pyproject.toml", // check for [tool.mutmut]
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
const REPORT_CANDIDATES = [
|
|
27
|
+
"reports/mutation/mutation.html",
|
|
28
|
+
"reports/mutation/mutation.json",
|
|
29
|
+
"mutmut_results.json",
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
const REPORT_FRESH_DAYS = 30;
|
|
33
|
+
|
|
34
|
+
async function fileExists(fullPath) {
|
|
35
|
+
try {
|
|
36
|
+
const stat = await fsp.stat(fullPath);
|
|
37
|
+
return { exists: true, mtimeMs: Number(stat.mtimeMs || 0) };
|
|
38
|
+
} catch {
|
|
39
|
+
return { exists: false };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function readTextIfExists(fullPath) {
|
|
44
|
+
try {
|
|
45
|
+
return await fsp.readFile(fullPath, "utf-8");
|
|
46
|
+
} catch {
|
|
47
|
+
return "";
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function runMutationTest({ rootPath } = {}) {
|
|
52
|
+
const resolvedRoot = path.resolve(String(rootPath || "."));
|
|
53
|
+
const findings = [];
|
|
54
|
+
|
|
55
|
+
// Config presence check
|
|
56
|
+
let configFound = false;
|
|
57
|
+
for (const candidate of CONFIG_CANDIDATES) {
|
|
58
|
+
const fullPath = path.join(resolvedRoot, candidate);
|
|
59
|
+
const result = await fileExists(fullPath);
|
|
60
|
+
if (result.exists) {
|
|
61
|
+
if (candidate === "pyproject.toml" || candidate === "setup.cfg") {
|
|
62
|
+
const text = await readTextIfExists(fullPath);
|
|
63
|
+
if (!/\[tool\.mutmut\]|\[mutmut\]/.test(text)) {
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
configFound = true;
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (!configFound) {
|
|
73
|
+
findings.push(
|
|
74
|
+
createFinding({
|
|
75
|
+
tool: "mutation-test",
|
|
76
|
+
kind: "testing.no-mutation-config",
|
|
77
|
+
severity: "P3",
|
|
78
|
+
file: toPosix("pyproject.toml"),
|
|
79
|
+
line: 0,
|
|
80
|
+
evidence: "No Stryker / mutmut / pitest configuration file found.",
|
|
81
|
+
rootCause:
|
|
82
|
+
"Without mutation testing, the test suite's assertions could be vacuous — 90% line coverage means nothing if the tests don't fail when the code changes.",
|
|
83
|
+
recommendedFix:
|
|
84
|
+
"Wire up @stryker-mutator/core (JS/TS) or mutmut (Python). Start with a single critical module and let the score guide new tests.",
|
|
85
|
+
confidence: 0.5,
|
|
86
|
+
})
|
|
87
|
+
);
|
|
88
|
+
return findings;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Report freshness check
|
|
92
|
+
let reportFound = false;
|
|
93
|
+
let latestReport = 0;
|
|
94
|
+
for (const candidate of REPORT_CANDIDATES) {
|
|
95
|
+
const fullPath = path.join(resolvedRoot, candidate);
|
|
96
|
+
const result = await fileExists(fullPath);
|
|
97
|
+
if (result.exists) {
|
|
98
|
+
reportFound = true;
|
|
99
|
+
latestReport = Math.max(latestReport, result.mtimeMs);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (!reportFound) {
|
|
103
|
+
findings.push(
|
|
104
|
+
createFinding({
|
|
105
|
+
tool: "mutation-test",
|
|
106
|
+
kind: "testing.no-mutation-report",
|
|
107
|
+
severity: "P3",
|
|
108
|
+
file: toPosix("reports/mutation/"),
|
|
109
|
+
line: 0,
|
|
110
|
+
evidence: "Stryker / mutmut config present but no mutation report on disk.",
|
|
111
|
+
rootCause:
|
|
112
|
+
"Config without a report suggests mutation testing is configured but not actually run.",
|
|
113
|
+
recommendedFix:
|
|
114
|
+
"Wire a mutation run into CI on a cadence (weekly is reasonable) so drift in assertion quality is visible.",
|
|
115
|
+
confidence: 0.55,
|
|
116
|
+
})
|
|
117
|
+
);
|
|
118
|
+
return findings;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const ageDays = Math.floor((Date.now() - latestReport) / (24 * 60 * 60 * 1000));
|
|
122
|
+
if (ageDays > REPORT_FRESH_DAYS) {
|
|
123
|
+
findings.push(
|
|
124
|
+
createFinding({
|
|
125
|
+
tool: "mutation-test",
|
|
126
|
+
kind: "testing.mutation-report-stale",
|
|
127
|
+
severity: "P3",
|
|
128
|
+
file: toPosix("reports/mutation/"),
|
|
129
|
+
line: 0,
|
|
130
|
+
evidence: `Latest mutation report is ${ageDays} days old (threshold ${REPORT_FRESH_DAYS})`,
|
|
131
|
+
rootCause:
|
|
132
|
+
"Stale mutation reports mean we're not actually watching assertion quality — drift goes undetected until it matters.",
|
|
133
|
+
recommendedFix:
|
|
134
|
+
"Schedule a recurring mutation job (weekly) and file an issue auto-generated from the diff vs. the prior run.",
|
|
135
|
+
confidence: 0.55,
|
|
136
|
+
})
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return findings;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export { CONFIG_CANDIDATES, REPORT_CANDIDATES, REPORT_FRESH_DAYS };
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// snapshot-diff — flag stale / oversized / obsolete snapshots (#A15).
|
|
2
|
+
//
|
|
3
|
+
// We walk *.snap files (Jest) and *.raw.snap / *.ambr (Ariadne) and flag:
|
|
4
|
+
// 1. Snapshots that haven't been touched in > STALE_DAYS days — stale
|
|
5
|
+
// values are a legitimate concern.
|
|
6
|
+
// 2. Snapshots larger than LARGE_SIZE_BYTES — huge blobs are an anti-
|
|
7
|
+
// pattern (unreviewable diffs, hide regressions).
|
|
8
|
+
// 3. Python doctest / pytest-snapshot *.ambr files that reference a
|
|
9
|
+
// stored block. Same staleness / size rules.
|
|
10
|
+
//
|
|
11
|
+
// We don't try to diff against the producing code — that's the job of the
|
|
12
|
+
// test runner. We only flag maintenance smells.
|
|
13
|
+
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
|
|
16
|
+
import { createFinding, toPosix, walkRepoFiles } from "./base.js";
|
|
17
|
+
|
|
18
|
+
const SNAPSHOT_EXTENSIONS = new Set([
|
|
19
|
+
".snap",
|
|
20
|
+
".ambr",
|
|
21
|
+
]);
|
|
22
|
+
const STALE_DAYS = 90;
|
|
23
|
+
const LARGE_SIZE_BYTES = 64 * 1024; // 64 KiB
|
|
24
|
+
|
|
25
|
+
export async function runSnapshotDiff({ rootPath, files = null, staleDays = STALE_DAYS } = {}) {
|
|
26
|
+
const resolvedRoot = path.resolve(String(rootPath || "."));
|
|
27
|
+
const now = Date.now();
|
|
28
|
+
const staleThreshold = now - staleDays * 24 * 60 * 60 * 1000;
|
|
29
|
+
const iterator =
|
|
30
|
+
Array.isArray(files) && files.length > 0
|
|
31
|
+
? iterateExplicitFiles(resolvedRoot, files)
|
|
32
|
+
: walkRepoFiles({ rootPath: resolvedRoot, extensions: SNAPSHOT_EXTENSIONS });
|
|
33
|
+
|
|
34
|
+
const findings = [];
|
|
35
|
+
for await (const { relativePath, stat } of iterator) {
|
|
36
|
+
const mtime = stat ? Number(stat.mtimeMs || 0) : 0;
|
|
37
|
+
const size = stat ? Number(stat.size || 0) : 0;
|
|
38
|
+
const rel = toPosix(relativePath);
|
|
39
|
+
|
|
40
|
+
if (mtime && mtime < staleThreshold) {
|
|
41
|
+
const days = Math.floor((now - mtime) / (24 * 60 * 60 * 1000));
|
|
42
|
+
findings.push(
|
|
43
|
+
createFinding({
|
|
44
|
+
tool: "snapshot-diff",
|
|
45
|
+
kind: "testing.snapshot-stale",
|
|
46
|
+
severity: "P3",
|
|
47
|
+
file: rel,
|
|
48
|
+
line: 0,
|
|
49
|
+
evidence: `Last modified ${days} days ago (threshold ${staleDays})`,
|
|
50
|
+
rootCause:
|
|
51
|
+
"Snapshot has been unchanged for longer than the staleness threshold — a stale snapshot can hide regressions silently.",
|
|
52
|
+
recommendedFix:
|
|
53
|
+
"Re-run the test suite with `--updateSnapshot` (or equivalent) after verifying the current output is actually correct. Delete if the underlying code has been removed.",
|
|
54
|
+
confidence: 0.5,
|
|
55
|
+
})
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (size > LARGE_SIZE_BYTES) {
|
|
60
|
+
findings.push(
|
|
61
|
+
createFinding({
|
|
62
|
+
tool: "snapshot-diff",
|
|
63
|
+
kind: "testing.snapshot-oversized",
|
|
64
|
+
severity: "P2",
|
|
65
|
+
file: rel,
|
|
66
|
+
line: 0,
|
|
67
|
+
evidence: `Snapshot is ${Math.round(size / 1024)} KiB (threshold ${Math.round(LARGE_SIZE_BYTES / 1024)} KiB)`,
|
|
68
|
+
rootCause:
|
|
69
|
+
"Oversized snapshots are unreviewable in PRs and hide meaningful regressions inside unrelated noise.",
|
|
70
|
+
recommendedFix:
|
|
71
|
+
"Split the snapshot into smaller focused tests, switch to a structural assertion, or mask non-essential fields (timestamps, IDs) before snapshotting.",
|
|
72
|
+
confidence: 0.7,
|
|
73
|
+
})
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return findings;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function* iterateExplicitFiles(resolvedRoot, files) {
|
|
81
|
+
const fsp = await import("node:fs/promises");
|
|
82
|
+
for (const file of files) {
|
|
83
|
+
const trimmed = String(file || "").trim();
|
|
84
|
+
if (!trimmed) {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const fullPath = path.isAbsolute(trimmed)
|
|
88
|
+
? trimmed
|
|
89
|
+
: path.join(resolvedRoot, trimmed);
|
|
90
|
+
const relativePath = path
|
|
91
|
+
.relative(resolvedRoot, fullPath)
|
|
92
|
+
.replace(/\\/g, "/");
|
|
93
|
+
let stat = null;
|
|
94
|
+
try {
|
|
95
|
+
stat = await fsp.stat(fullPath);
|
|
96
|
+
} catch {
|
|
97
|
+
stat = null;
|
|
98
|
+
}
|
|
99
|
+
yield { fullPath, relativePath, stat };
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export { LARGE_SIZE_BYTES, STALE_DAYS };
|