sequant 1.15.2 → 1.15.4
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/.claude-plugin/plugin.json +1 -1
- package/dist/bin/cli.js +4 -0
- package/dist/src/commands/logs.js +15 -0
- package/dist/src/commands/run.d.ts +150 -1
- package/dist/src/commands/run.js +642 -31
- package/dist/src/commands/stats.js +48 -0
- package/dist/src/lib/scope/index.d.ts +1 -0
- package/dist/src/lib/scope/index.js +2 -0
- package/dist/src/lib/scope/settings-converter.d.ts +28 -0
- package/dist/src/lib/scope/settings-converter.js +53 -0
- package/dist/src/lib/settings.d.ts +45 -0
- package/dist/src/lib/settings.js +30 -0
- package/dist/src/lib/test-tautology-detector.d.ts +122 -0
- package/dist/src/lib/test-tautology-detector.js +488 -0
- package/dist/src/lib/workflow/git-diff-utils.d.ts +39 -0
- package/dist/src/lib/workflow/git-diff-utils.js +142 -0
- package/dist/src/lib/workflow/log-writer.d.ts +13 -2
- package/dist/src/lib/workflow/log-writer.js +25 -3
- package/dist/src/lib/workflow/metrics-schema.d.ts +9 -0
- package/dist/src/lib/workflow/metrics-schema.js +10 -1
- package/dist/src/lib/workflow/phase-detection.d.ts +3 -0
- package/dist/src/lib/workflow/phase-detection.js +27 -1
- package/dist/src/lib/workflow/qa-cache.d.ts +3 -1
- package/dist/src/lib/workflow/qa-cache.js +2 -0
- package/dist/src/lib/workflow/run-log-schema.d.ts +90 -3
- package/dist/src/lib/workflow/run-log-schema.js +44 -2
- package/dist/src/lib/workflow/state-utils.d.ts +46 -0
- package/dist/src/lib/workflow/state-utils.js +167 -0
- package/dist/src/lib/workflow/token-utils.d.ts +92 -0
- package/dist/src/lib/workflow/token-utils.js +170 -0
- package/dist/src/lib/workflow/types.d.ts +10 -0
- package/dist/src/lib/workflow/types.js +1 -0
- package/package.json +1 -1
- package/templates/hooks/pre-tool.sh +4 -0
- package/templates/skills/assess/SKILL.md +1 -1
- package/templates/skills/exec/SKILL.md +6 -4
- package/templates/skills/improve/SKILL.md +37 -24
- package/templates/skills/loop/SKILL.md +3 -3
- package/templates/skills/qa/references/code-review-checklist.md +10 -11
- package/templates/skills/qa/scripts/quality-checks.sh +16 -0
- package/templates/skills/security-review/references/security-checklists.md +89 -36
- package/templates/skills/solve/SKILL.md +3 -1
- package/templates/skills/spec/SKILL.md +8 -4
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test Tautology Detector
|
|
3
|
+
*
|
|
4
|
+
* Detects tautological tests — tests that pass but don't call any production code.
|
|
5
|
+
* These tests provide zero regression protection as they only assert on local values.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```typescript
|
|
9
|
+
* import { detectTautologicalTests, formatTautologyResults } from './test-tautology-detector';
|
|
10
|
+
*
|
|
11
|
+
* const results = detectTautologicalTests([
|
|
12
|
+
* { path: 'src/lib/foo.test.ts', content: fileContent },
|
|
13
|
+
* ]);
|
|
14
|
+
* console.log(formatTautologyResults(results));
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* Test library imports to exclude from production function detection
|
|
19
|
+
*/
|
|
20
|
+
const TEST_LIBRARY_PATTERNS = [
|
|
21
|
+
/^vitest$/,
|
|
22
|
+
/^@vitest\//,
|
|
23
|
+
/^jest$/,
|
|
24
|
+
/^@jest\//,
|
|
25
|
+
/^@testing-library\//,
|
|
26
|
+
/^react-test-renderer/,
|
|
27
|
+
/^enzyme/,
|
|
28
|
+
/^sinon/,
|
|
29
|
+
/^chai/,
|
|
30
|
+
/^mocha/,
|
|
31
|
+
/^node:test/,
|
|
32
|
+
/^assert$/,
|
|
33
|
+
];
|
|
34
|
+
/**
|
|
35
|
+
* Mock/fixture path patterns to exclude
|
|
36
|
+
*/
|
|
37
|
+
const MOCK_FIXTURE_PATTERNS = [
|
|
38
|
+
/mock/i,
|
|
39
|
+
/fixture/i,
|
|
40
|
+
/stub/i,
|
|
41
|
+
/fake/i,
|
|
42
|
+
/__mocks__/,
|
|
43
|
+
/__fixtures__/,
|
|
44
|
+
/test-utils?/i,
|
|
45
|
+
/test-helper/i,
|
|
46
|
+
];
|
|
47
|
+
/**
|
|
48
|
+
* Check if an import path is from a source module (not a test library or mock)
|
|
49
|
+
*/
|
|
50
|
+
export function isSourceModule(modulePath) {
|
|
51
|
+
// Check if it's a test library
|
|
52
|
+
for (const pattern of TEST_LIBRARY_PATTERNS) {
|
|
53
|
+
if (pattern.test(modulePath)) {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
// Check if it's a mock/fixture
|
|
58
|
+
for (const pattern of MOCK_FIXTURE_PATTERNS) {
|
|
59
|
+
if (pattern.test(modulePath)) {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// Check if it's a Node.js built-in
|
|
64
|
+
if (modulePath.startsWith("node:")) {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
// Source modules typically start with ./ or ../ or are absolute imports
|
|
68
|
+
// For this detector, we consider relative imports as production code
|
|
69
|
+
if (modulePath.startsWith("./") || modulePath.startsWith("../")) {
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
// Absolute imports from the project (non-node_modules) are also production code
|
|
73
|
+
// We can't reliably detect this without filesystem access, so we're conservative
|
|
74
|
+
// and only count relative imports as production code
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Extract imports from a test file
|
|
79
|
+
*
|
|
80
|
+
* Handles:
|
|
81
|
+
* - Named imports: `import { foo, bar } from './module'`
|
|
82
|
+
* - Default imports: `import foo from './module'`
|
|
83
|
+
* - Namespace imports: `import * as foo from './module'` (extracts the namespace name)
|
|
84
|
+
*/
|
|
85
|
+
export function extractImports(content) {
|
|
86
|
+
const imports = [];
|
|
87
|
+
// Named imports: import { foo, bar, baz as qux } from './module'
|
|
88
|
+
const namedImportPattern = /import\s*\{([^}]+)\}\s*from\s*['"]([^'"]+)['"]/g;
|
|
89
|
+
let match;
|
|
90
|
+
while ((match = namedImportPattern.exec(content)) !== null) {
|
|
91
|
+
const names = match[1];
|
|
92
|
+
const modulePath = match[2];
|
|
93
|
+
if (!isSourceModule(modulePath)) {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
// Parse individual imports, handling aliases (foo as bar)
|
|
97
|
+
const importedNames = names.split(",").map((n) => n.trim());
|
|
98
|
+
for (const name of importedNames) {
|
|
99
|
+
if (!name)
|
|
100
|
+
continue;
|
|
101
|
+
// Handle aliased imports: "originalName as aliasName"
|
|
102
|
+
const aliasMatch = name.match(/(\w+)\s+as\s+(\w+)/);
|
|
103
|
+
if (aliasMatch) {
|
|
104
|
+
// Use the alias (the name actually used in code)
|
|
105
|
+
imports.push({ name: aliasMatch[2], modulePath });
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
// No alias, use the name directly
|
|
109
|
+
const cleanName = name.replace(/\s+/g, "");
|
|
110
|
+
if (cleanName) {
|
|
111
|
+
imports.push({ name: cleanName, modulePath });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
// Default imports: import foo from './module'
|
|
117
|
+
const defaultImportPattern = /import\s+(\w+)\s+from\s*['"]([^'"]+)['"]/g;
|
|
118
|
+
while ((match = defaultImportPattern.exec(content)) !== null) {
|
|
119
|
+
const name = match[1];
|
|
120
|
+
const modulePath = match[2];
|
|
121
|
+
if (isSourceModule(modulePath)) {
|
|
122
|
+
imports.push({ name, modulePath });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
// Namespace imports: import * as foo from './module'
|
|
126
|
+
const namespaceImportPattern = /import\s*\*\s*as\s+(\w+)\s+from\s*['"]([^'"]+)['"]/g;
|
|
127
|
+
while ((match = namespaceImportPattern.exec(content)) !== null) {
|
|
128
|
+
const name = match[1];
|
|
129
|
+
const modulePath = match[2];
|
|
130
|
+
if (isSourceModule(modulePath)) {
|
|
131
|
+
imports.push({ name, modulePath });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return imports;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Extract test blocks (it() and test()) from content
|
|
138
|
+
*
|
|
139
|
+
* Returns the description, line number, body content, and style of each test block.
|
|
140
|
+
*/
|
|
141
|
+
export function extractTestBlocks(content) {
|
|
142
|
+
const blocks = [];
|
|
143
|
+
// Find test block starts with their line numbers
|
|
144
|
+
// Pattern matches: it("...", ...) or test("...", ...)
|
|
145
|
+
// Including variations like it.skip, it.only, test.skip, test.only
|
|
146
|
+
const testBlockStartPattern = /\b(it|test)(?:\.skip|\.only)?\s*\(\s*(['"`])(.+?)\2/g;
|
|
147
|
+
let match;
|
|
148
|
+
while ((match = testBlockStartPattern.exec(content)) !== null) {
|
|
149
|
+
const style = match[1];
|
|
150
|
+
const description = match[3];
|
|
151
|
+
const startIndex = match.index;
|
|
152
|
+
// Skip matches inside string literals (e.g., test code embedded in template literals)
|
|
153
|
+
if (isInsideString(content, startIndex)) {
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
// Calculate line number
|
|
157
|
+
const contentBeforeMatch = content.substring(0, startIndex);
|
|
158
|
+
const lineNumber = contentBeforeMatch.split("\n").length;
|
|
159
|
+
// Find the matching closing brace for the test block
|
|
160
|
+
// This is a simplified approach that works for most cases
|
|
161
|
+
const afterMatch = content.substring(startIndex);
|
|
162
|
+
const body = extractBlockBody(afterMatch);
|
|
163
|
+
blocks.push({
|
|
164
|
+
description,
|
|
165
|
+
lineNumber,
|
|
166
|
+
body,
|
|
167
|
+
style,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
return blocks;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Check if a position in the content is inside a non-code context:
|
|
174
|
+
* string literal (single, double, or template), comment (line or block),
|
|
175
|
+
* or a template expression's string context.
|
|
176
|
+
*
|
|
177
|
+
* Handles nested template literals: `` `outer ${`inner`} still outer` ``
|
|
178
|
+
* by tracking template expression depth via a stack.
|
|
179
|
+
*/
|
|
180
|
+
function isInsideString(content, position) {
|
|
181
|
+
let inString = false;
|
|
182
|
+
let stringChar = "";
|
|
183
|
+
let escaped = false;
|
|
184
|
+
// Stack tracks brace depth inside template expressions.
|
|
185
|
+
// When we encounter `${`, we push 0. Nested `{` increments top.
|
|
186
|
+
// `}` at depth 0 pops the stack and re-enters the template literal.
|
|
187
|
+
const templateExprStack = [];
|
|
188
|
+
for (let i = 0; i < position && i < content.length; i++) {
|
|
189
|
+
const char = content[i];
|
|
190
|
+
if (escaped) {
|
|
191
|
+
escaped = false;
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
if (char === "\\") {
|
|
195
|
+
escaped = true;
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
// Inside a template literal — handle ${...} expressions
|
|
199
|
+
if (inString && stringChar === "`") {
|
|
200
|
+
if (char === "$" && i + 1 < content.length && content[i + 1] === "{") {
|
|
201
|
+
// Enter template expression — temporarily leave string context
|
|
202
|
+
templateExprStack.push(0);
|
|
203
|
+
inString = false;
|
|
204
|
+
i++; // skip the `{`
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
if (char === "`") {
|
|
208
|
+
inString = false;
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
// Inside a non-template string
|
|
214
|
+
if (inString) {
|
|
215
|
+
if (char === stringChar) {
|
|
216
|
+
inString = false;
|
|
217
|
+
}
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
// Not in any string — check if we're inside a template expression
|
|
221
|
+
if (templateExprStack.length > 0) {
|
|
222
|
+
if (char === "{") {
|
|
223
|
+
templateExprStack[templateExprStack.length - 1]++;
|
|
224
|
+
}
|
|
225
|
+
else if (char === "}") {
|
|
226
|
+
if (templateExprStack[templateExprStack.length - 1] === 0) {
|
|
227
|
+
// Closing the template expression — re-enter the template literal
|
|
228
|
+
templateExprStack.pop();
|
|
229
|
+
inString = true;
|
|
230
|
+
stringChar = "`";
|
|
231
|
+
}
|
|
232
|
+
else {
|
|
233
|
+
templateExprStack[templateExprStack.length - 1]--;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
else if (char === "`" || char === '"' || char === "'") {
|
|
237
|
+
inString = true;
|
|
238
|
+
stringChar = char;
|
|
239
|
+
}
|
|
240
|
+
else if (char === "/" &&
|
|
241
|
+
i + 1 < content.length &&
|
|
242
|
+
content[i + 1] === "/") {
|
|
243
|
+
// Line comment — if position falls within it, return true
|
|
244
|
+
const eol = content.indexOf("\n", i);
|
|
245
|
+
const commentEnd = eol === -1 ? content.length : eol;
|
|
246
|
+
if (position <= commentEnd)
|
|
247
|
+
return true;
|
|
248
|
+
i = commentEnd;
|
|
249
|
+
}
|
|
250
|
+
else if (char === "/" &&
|
|
251
|
+
i + 1 < content.length &&
|
|
252
|
+
content[i + 1] === "*") {
|
|
253
|
+
// Block comment — if position falls within it, return true
|
|
254
|
+
const end = content.indexOf("*/", i + 2);
|
|
255
|
+
const commentEnd = end === -1 ? content.length : end + 1;
|
|
256
|
+
if (position <= commentEnd)
|
|
257
|
+
return true;
|
|
258
|
+
i = commentEnd;
|
|
259
|
+
}
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
// Top-level code
|
|
263
|
+
if (char === "`" || char === '"' || char === "'") {
|
|
264
|
+
inString = true;
|
|
265
|
+
stringChar = char;
|
|
266
|
+
}
|
|
267
|
+
else if (char === "/" &&
|
|
268
|
+
i + 1 < content.length &&
|
|
269
|
+
content[i + 1] === "/") {
|
|
270
|
+
// Line comment — if position falls within it, return true
|
|
271
|
+
const eol = content.indexOf("\n", i);
|
|
272
|
+
const commentEnd = eol === -1 ? content.length : eol;
|
|
273
|
+
if (position <= commentEnd)
|
|
274
|
+
return true;
|
|
275
|
+
i = commentEnd;
|
|
276
|
+
}
|
|
277
|
+
else if (char === "/" &&
|
|
278
|
+
i + 1 < content.length &&
|
|
279
|
+
content[i + 1] === "*") {
|
|
280
|
+
// Block comment — if position falls within it, return true
|
|
281
|
+
const end = content.indexOf("*/", i + 2);
|
|
282
|
+
const commentEnd = end === -1 ? content.length : end + 1;
|
|
283
|
+
if (position <= commentEnd)
|
|
284
|
+
return true;
|
|
285
|
+
i = commentEnd;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return inString || templateExprStack.length > 0;
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Extract the body of a function block (content between { and matching })
|
|
292
|
+
*/
|
|
293
|
+
function extractBlockBody(content) {
|
|
294
|
+
// Find the first opening brace
|
|
295
|
+
const firstBrace = content.indexOf("{");
|
|
296
|
+
if (firstBrace === -1) {
|
|
297
|
+
return "";
|
|
298
|
+
}
|
|
299
|
+
let depth = 0;
|
|
300
|
+
let inString = false;
|
|
301
|
+
let stringChar = "";
|
|
302
|
+
let escaped = false;
|
|
303
|
+
for (let i = firstBrace; i < content.length; i++) {
|
|
304
|
+
const char = content[i];
|
|
305
|
+
if (escaped) {
|
|
306
|
+
escaped = false;
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
if (char === "\\") {
|
|
310
|
+
escaped = true;
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
if (!inString && (char === '"' || char === "'" || char === "`")) {
|
|
314
|
+
inString = true;
|
|
315
|
+
stringChar = char;
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
if (inString && char === stringChar) {
|
|
319
|
+
inString = false;
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
if (!inString) {
|
|
323
|
+
if (char === "{") {
|
|
324
|
+
depth++;
|
|
325
|
+
}
|
|
326
|
+
else if (char === "}") {
|
|
327
|
+
depth--;
|
|
328
|
+
if (depth === 0) {
|
|
329
|
+
return content.substring(firstBrace, i + 1);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
// If we didn't find a matching brace, return everything after the first brace
|
|
335
|
+
return content.substring(firstBrace);
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* Escape special regex characters in a string for safe use in `new RegExp()`.
|
|
339
|
+
*/
|
|
340
|
+
function escapeRegex(str) {
|
|
341
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* Check if a test block contains calls to any of the imported production functions
|
|
345
|
+
*/
|
|
346
|
+
export function testBlockCallsProductionCode(body, importedFunctions) {
|
|
347
|
+
if (importedFunctions.length === 0) {
|
|
348
|
+
return false;
|
|
349
|
+
}
|
|
350
|
+
for (const fn of importedFunctions) {
|
|
351
|
+
// Check for any reference to the imported name bounded by non-identifier chars.
|
|
352
|
+
// Uses [\w$] to match JS identifier characters (letters, digits, _, $).
|
|
353
|
+
// This catches direct calls (fn()), method calls (ns.method()),
|
|
354
|
+
// callback references (arr.map(fn)), and assignments (const x = fn).
|
|
355
|
+
const escaped = escapeRegex(fn.name);
|
|
356
|
+
const referencePattern = new RegExp(`(?<![\\w$])${escaped}(?![\\w$])`);
|
|
357
|
+
if (referencePattern.test(body)) {
|
|
358
|
+
return true;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return false;
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* Analyze a single test file for tautological tests
|
|
365
|
+
*/
|
|
366
|
+
export function analyzeTestFile(content, filePath) {
|
|
367
|
+
try {
|
|
368
|
+
const importedFunctions = extractImports(content);
|
|
369
|
+
const testBlocks = extractTestBlocks(content);
|
|
370
|
+
const analyzedBlocks = testBlocks.map((block) => ({
|
|
371
|
+
description: block.description,
|
|
372
|
+
lineNumber: block.lineNumber,
|
|
373
|
+
style: block.style,
|
|
374
|
+
isTautological: !testBlockCallsProductionCode(block.body, importedFunctions),
|
|
375
|
+
}));
|
|
376
|
+
const tautologicalCount = analyzedBlocks.filter((b) => b.isTautological).length;
|
|
377
|
+
const totalTests = analyzedBlocks.length;
|
|
378
|
+
const tautologicalPercentage = totalTests > 0 ? (tautologicalCount / totalTests) * 100 : 0;
|
|
379
|
+
return {
|
|
380
|
+
filePath,
|
|
381
|
+
totalTests,
|
|
382
|
+
tautologicalCount,
|
|
383
|
+
tautologicalPercentage,
|
|
384
|
+
testBlocks: analyzedBlocks,
|
|
385
|
+
importedFunctions,
|
|
386
|
+
parseSuccess: true,
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
catch (error) {
|
|
390
|
+
return {
|
|
391
|
+
filePath,
|
|
392
|
+
totalTests: 0,
|
|
393
|
+
tautologicalCount: 0,
|
|
394
|
+
tautologicalPercentage: 0,
|
|
395
|
+
testBlocks: [],
|
|
396
|
+
importedFunctions: [],
|
|
397
|
+
parseSuccess: false,
|
|
398
|
+
parseError: error instanceof Error ? error.message : "Unknown parse error",
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Detect tautological tests across multiple files
|
|
404
|
+
*/
|
|
405
|
+
export function detectTautologicalTests(files) {
|
|
406
|
+
const fileResults = files.map((file) => analyzeTestFile(file.content, file.path));
|
|
407
|
+
const totalFiles = fileResults.length;
|
|
408
|
+
const totalTests = fileResults.reduce((sum, r) => sum + r.totalTests, 0);
|
|
409
|
+
const totalTautological = fileResults.reduce((sum, r) => sum + r.tautologicalCount, 0);
|
|
410
|
+
const overallPercentage = totalTests > 0 ? (totalTautological / totalTests) * 100 : 0;
|
|
411
|
+
return {
|
|
412
|
+
fileResults,
|
|
413
|
+
summary: {
|
|
414
|
+
totalFiles,
|
|
415
|
+
totalTests,
|
|
416
|
+
totalTautological,
|
|
417
|
+
overallPercentage,
|
|
418
|
+
exceedsBlockingThreshold: overallPercentage > 50,
|
|
419
|
+
},
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Format tautology results as markdown for QA output
|
|
424
|
+
*/
|
|
425
|
+
export function formatTautologyResults(results) {
|
|
426
|
+
const lines = [];
|
|
427
|
+
lines.push("### Test Quality Review");
|
|
428
|
+
lines.push("");
|
|
429
|
+
// Summary table
|
|
430
|
+
lines.push("| Category | Status | Notes |");
|
|
431
|
+
lines.push("|----------|--------|-------|");
|
|
432
|
+
if (results.summary.totalTests === 0) {
|
|
433
|
+
lines.push("| Tautology Check | ⏭️ SKIP | No test blocks found |");
|
|
434
|
+
return lines.join("\n");
|
|
435
|
+
}
|
|
436
|
+
const status = results.summary.exceedsBlockingThreshold
|
|
437
|
+
? "❌ FAIL"
|
|
438
|
+
: results.summary.totalTautological > 0
|
|
439
|
+
? "⚠️ WARN"
|
|
440
|
+
: "✅ OK";
|
|
441
|
+
const notes = results.summary.totalTautological > 0
|
|
442
|
+
? `${results.summary.totalTautological} tautological test blocks found (${results.summary.overallPercentage.toFixed(1)}%)`
|
|
443
|
+
: "All tests call production code";
|
|
444
|
+
lines.push(`| Tautology Check | ${status} | ${notes} |`);
|
|
445
|
+
lines.push("");
|
|
446
|
+
// List tautological tests if any found
|
|
447
|
+
if (results.summary.totalTautological > 0) {
|
|
448
|
+
lines.push("**Tautological Tests Found:**");
|
|
449
|
+
lines.push("");
|
|
450
|
+
for (const fileResult of results.fileResults) {
|
|
451
|
+
const tautologicalBlocks = fileResult.testBlocks.filter((b) => b.isTautological);
|
|
452
|
+
for (const block of tautologicalBlocks) {
|
|
453
|
+
lines.push(`- \`${fileResult.filePath}:${block.lineNumber}\` - \`${block.style}("${block.description}")\` - No production function calls`);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
lines.push("");
|
|
457
|
+
}
|
|
458
|
+
// Verdict impact
|
|
459
|
+
if (results.summary.exceedsBlockingThreshold) {
|
|
460
|
+
lines.push("**Verdict Impact:** >50% tautological tests — blocks `READY_FOR_MERGE`");
|
|
461
|
+
lines.push("");
|
|
462
|
+
}
|
|
463
|
+
// Parse errors if any
|
|
464
|
+
const parseErrors = results.fileResults.filter((r) => !r.parseSuccess);
|
|
465
|
+
if (parseErrors.length > 0) {
|
|
466
|
+
lines.push("**Parse Warnings:**");
|
|
467
|
+
for (const error of parseErrors) {
|
|
468
|
+
lines.push(`- \`${error.filePath}\`: ${error.parseError}`);
|
|
469
|
+
}
|
|
470
|
+
lines.push("");
|
|
471
|
+
}
|
|
472
|
+
return lines.join("\n");
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* Determine verdict impact based on tautology results
|
|
476
|
+
*/
|
|
477
|
+
export function getTautologyVerdictImpact(results) {
|
|
478
|
+
if (results.summary.totalTests === 0) {
|
|
479
|
+
return "none";
|
|
480
|
+
}
|
|
481
|
+
if (results.summary.exceedsBlockingThreshold) {
|
|
482
|
+
return "blocking";
|
|
483
|
+
}
|
|
484
|
+
if (results.summary.totalTautological > 0) {
|
|
485
|
+
return "warning";
|
|
486
|
+
}
|
|
487
|
+
return "none";
|
|
488
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git diff utilities for pipeline observability (AC-1, AC-3, AC-4)
|
|
3
|
+
*
|
|
4
|
+
* Provides efficient git diff statistics for phase logging.
|
|
5
|
+
* Uses single git commands where possible to avoid redundant operations.
|
|
6
|
+
*/
|
|
7
|
+
import type { FileDiffStat } from "./run-log-schema.js";
|
|
8
|
+
/**
|
|
9
|
+
* Result from getGitDiffStats (AC-4)
|
|
10
|
+
*/
|
|
11
|
+
export interface GitDiffStatsResult {
|
|
12
|
+
/** List of modified file paths (AC-1) */
|
|
13
|
+
filesModified: string[];
|
|
14
|
+
/** Per-file diff statistics (AC-3) */
|
|
15
|
+
fileDiffStats: FileDiffStat[];
|
|
16
|
+
/** Total lines added across all files */
|
|
17
|
+
totalAdditions: number;
|
|
18
|
+
/** Total lines deleted across all files */
|
|
19
|
+
totalDeletions: number;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Get git commit SHA for a worktree (AC-2)
|
|
23
|
+
*
|
|
24
|
+
* @param worktreePath - Path to the git worktree
|
|
25
|
+
* @returns The current HEAD commit SHA, or undefined on error
|
|
26
|
+
*/
|
|
27
|
+
export declare function getCommitHash(worktreePath: string): string | undefined;
|
|
28
|
+
/**
|
|
29
|
+
* Get git diff statistics for a worktree (AC-1, AC-3, AC-4)
|
|
30
|
+
*
|
|
31
|
+
* Efficiently captures both filesModified and fileDiffStats using
|
|
32
|
+
* minimal git commands. Uses main...HEAD comparison by default.
|
|
33
|
+
*
|
|
34
|
+
* @param worktreePath - Path to the git worktree
|
|
35
|
+
* @param baseBranch - Branch to compare against (default: "main")
|
|
36
|
+
* @returns GitDiffStatsResult with files, stats, and totals
|
|
37
|
+
*/
|
|
38
|
+
export declare function getGitDiffStats(worktreePath: string, baseBranch?: string): GitDiffStatsResult;
|
|
39
|
+
export type { FileDiffStat };
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git diff utilities for pipeline observability (AC-1, AC-3, AC-4)
|
|
3
|
+
*
|
|
4
|
+
* Provides efficient git diff statistics for phase logging.
|
|
5
|
+
* Uses single git commands where possible to avoid redundant operations.
|
|
6
|
+
*/
|
|
7
|
+
import { spawnSync } from "child_process";
|
|
8
|
+
/**
|
|
9
|
+
* Parse git diff --numstat output into additions/deletions per file
|
|
10
|
+
*
|
|
11
|
+
* Format: <additions>\t<deletions>\t<filepath>
|
|
12
|
+
* Binary files show: -\t-\t<filepath>
|
|
13
|
+
*/
|
|
14
|
+
function parseNumstat(output) {
|
|
15
|
+
const result = new Map();
|
|
16
|
+
if (!output.trim()) {
|
|
17
|
+
return result;
|
|
18
|
+
}
|
|
19
|
+
const lines = output.trim().split("\n");
|
|
20
|
+
for (const line of lines) {
|
|
21
|
+
if (!line.trim())
|
|
22
|
+
continue;
|
|
23
|
+
const parts = line.split("\t");
|
|
24
|
+
if (parts.length < 3)
|
|
25
|
+
continue;
|
|
26
|
+
const [addStr, delStr, ...pathParts] = parts;
|
|
27
|
+
const filePath = pathParts.join("\t"); // Handle filenames with tabs
|
|
28
|
+
// Binary files show "-" for additions/deletions
|
|
29
|
+
const additions = addStr === "-" ? 0 : parseInt(addStr, 10);
|
|
30
|
+
const deletions = delStr === "-" ? 0 : parseInt(delStr, 10);
|
|
31
|
+
if (!isNaN(additions) && !isNaN(deletions)) {
|
|
32
|
+
result.set(filePath, { additions, deletions });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return result;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Parse git diff --name-status output into file statuses
|
|
39
|
+
*
|
|
40
|
+
* Format: <status>\t<filepath> (or <status>\t<oldpath>\t<newpath> for renames)
|
|
41
|
+
* Status codes: A=added, M=modified, D=deleted, R=renamed, C=copied, T=type-changed
|
|
42
|
+
*/
|
|
43
|
+
function parseNameStatus(output) {
|
|
44
|
+
const result = new Map();
|
|
45
|
+
if (!output.trim()) {
|
|
46
|
+
return result;
|
|
47
|
+
}
|
|
48
|
+
const lines = output.trim().split("\n");
|
|
49
|
+
for (const line of lines) {
|
|
50
|
+
if (!line.trim())
|
|
51
|
+
continue;
|
|
52
|
+
const parts = line.split("\t");
|
|
53
|
+
if (parts.length < 2)
|
|
54
|
+
continue;
|
|
55
|
+
const statusCode = parts[0];
|
|
56
|
+
// For renames (R100), use the new filename (last part)
|
|
57
|
+
const filePath = parts[parts.length - 1];
|
|
58
|
+
let status;
|
|
59
|
+
if (statusCode.startsWith("A")) {
|
|
60
|
+
status = "added";
|
|
61
|
+
}
|
|
62
|
+
else if (statusCode.startsWith("D")) {
|
|
63
|
+
status = "deleted";
|
|
64
|
+
}
|
|
65
|
+
else if (statusCode.startsWith("R")) {
|
|
66
|
+
status = "renamed";
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
// M, C, T, or anything else -> modified
|
|
70
|
+
status = "modified";
|
|
71
|
+
}
|
|
72
|
+
result.set(filePath, status);
|
|
73
|
+
}
|
|
74
|
+
return result;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Get git commit SHA for a worktree (AC-2)
|
|
78
|
+
*
|
|
79
|
+
* @param worktreePath - Path to the git worktree
|
|
80
|
+
* @returns The current HEAD commit SHA, or undefined on error
|
|
81
|
+
*/
|
|
82
|
+
export function getCommitHash(worktreePath) {
|
|
83
|
+
const result = spawnSync("git", ["-C", worktreePath, "rev-parse", "HEAD"], {
|
|
84
|
+
stdio: "pipe",
|
|
85
|
+
encoding: "utf-8",
|
|
86
|
+
});
|
|
87
|
+
if (result.status !== 0) {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
return result.stdout.trim();
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Get git diff statistics for a worktree (AC-1, AC-3, AC-4)
|
|
94
|
+
*
|
|
95
|
+
* Efficiently captures both filesModified and fileDiffStats using
|
|
96
|
+
* minimal git commands. Uses main...HEAD comparison by default.
|
|
97
|
+
*
|
|
98
|
+
* @param worktreePath - Path to the git worktree
|
|
99
|
+
* @param baseBranch - Branch to compare against (default: "main")
|
|
100
|
+
* @returns GitDiffStatsResult with files, stats, and totals
|
|
101
|
+
*/
|
|
102
|
+
export function getGitDiffStats(worktreePath, baseBranch = "main") {
|
|
103
|
+
const diffRef = `${baseBranch}...HEAD`;
|
|
104
|
+
// Get numstat for additions/deletions
|
|
105
|
+
const numstatResult = spawnSync("git", ["-C", worktreePath, "diff", "--numstat", diffRef], { stdio: "pipe", encoding: "utf-8" });
|
|
106
|
+
// Get name-status for file status (added/modified/deleted/renamed)
|
|
107
|
+
const nameStatusResult = spawnSync("git", ["-C", worktreePath, "diff", "--name-status", diffRef], { stdio: "pipe", encoding: "utf-8" });
|
|
108
|
+
// Handle git command failures gracefully
|
|
109
|
+
if (numstatResult.status !== 0 || nameStatusResult.status !== 0) {
|
|
110
|
+
return {
|
|
111
|
+
filesModified: [],
|
|
112
|
+
fileDiffStats: [],
|
|
113
|
+
totalAdditions: 0,
|
|
114
|
+
totalDeletions: 0,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
const numstatMap = parseNumstat(numstatResult.stdout);
|
|
118
|
+
const statusMap = parseNameStatus(nameStatusResult.stdout);
|
|
119
|
+
// Combine into fileDiffStats array
|
|
120
|
+
const fileDiffStats = [];
|
|
121
|
+
let totalAdditions = 0;
|
|
122
|
+
let totalDeletions = 0;
|
|
123
|
+
for (const [path, stats] of numstatMap) {
|
|
124
|
+
const status = statusMap.get(path) ?? "modified";
|
|
125
|
+
fileDiffStats.push({
|
|
126
|
+
path,
|
|
127
|
+
additions: stats.additions,
|
|
128
|
+
deletions: stats.deletions,
|
|
129
|
+
status,
|
|
130
|
+
});
|
|
131
|
+
totalAdditions += stats.additions;
|
|
132
|
+
totalDeletions += stats.deletions;
|
|
133
|
+
}
|
|
134
|
+
// filesModified is just the paths
|
|
135
|
+
const filesModified = fileDiffStats.map((f) => f.path);
|
|
136
|
+
return {
|
|
137
|
+
filesModified,
|
|
138
|
+
fileDiffStats,
|
|
139
|
+
totalAdditions,
|
|
140
|
+
totalDeletions,
|
|
141
|
+
};
|
|
142
|
+
}
|