mjolnir-qa 0.5.7 → 0.5.9
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/CHANGELOG.md +15 -0
- package/dist/cli.d.mts +2 -2
- package/dist/cli.mjs +644 -9
- package/dist/mcp/stdio.d.mts +1 -0
- package/dist/mcp/stdio.mjs +18983 -0
- package/package.json +3 -2
package/dist/cli.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import { delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "no
|
|
|
6
6
|
import process$1 from "node:process";
|
|
7
7
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
8
8
|
import * as ts$1 from "ts-morph";
|
|
9
|
-
import { Project, ts } from "ts-morph";
|
|
9
|
+
import { Project, SyntaxKind, ts } from "ts-morph";
|
|
10
10
|
import { Language, Parser } from "web-tree-sitter";
|
|
11
11
|
import { parse } from "yaml";
|
|
12
12
|
import { createHash, randomBytes } from "node:crypto";
|
|
@@ -7245,11 +7245,587 @@ function matchBrace$1(text, open) {
|
|
|
7245
7245
|
return -1;
|
|
7246
7246
|
}
|
|
7247
7247
|
//#endregion
|
|
7248
|
+
//#region src/engine/qa-model.ts
|
|
7249
|
+
/**
|
|
7250
|
+
* Extract the semantic model for one parsed file. Resolves undefined
|
|
7251
|
+
* when the language has no extractor or the parse stage produced no
|
|
7252
|
+
* tree (the astQuery discipline: undefined = no model, never an empty
|
|
7253
|
+
* claim). Python needs no AST — regex boundaries over the text.
|
|
7254
|
+
*/
|
|
7255
|
+
function extractQaModel(file) {
|
|
7256
|
+
if (file.path.endsWith(".java")) return extractJavaModel(file);
|
|
7257
|
+
if (file.path.endsWith(".cs")) return extractCSharpModel(file);
|
|
7258
|
+
if (file.path.endsWith(".py")) return extractPythonModel(file);
|
|
7259
|
+
if (/\.[cm]?[jt]sx?$/.test(file.path)) return extractTsModel(file, parseTsFile(file));
|
|
7260
|
+
}
|
|
7261
|
+
function pos(text, index) {
|
|
7262
|
+
return {
|
|
7263
|
+
index,
|
|
7264
|
+
...nodeLineCol(text, index)
|
|
7265
|
+
};
|
|
7266
|
+
}
|
|
7267
|
+
/** JUnit 4/5, TestNG setup/teardown annotation simple names. */
|
|
7268
|
+
const JAVA_HOOK_ANNOTATIONS = /* @__PURE__ */ new Set([
|
|
7269
|
+
"BeforeEach",
|
|
7270
|
+
"AfterEach",
|
|
7271
|
+
"BeforeAll",
|
|
7272
|
+
"AfterAll",
|
|
7273
|
+
"Before",
|
|
7274
|
+
"After",
|
|
7275
|
+
"BeforeClass",
|
|
7276
|
+
"AfterClass",
|
|
7277
|
+
"BeforeMethod",
|
|
7278
|
+
"AfterMethod",
|
|
7279
|
+
"BeforeSuite",
|
|
7280
|
+
"AfterSuite"
|
|
7281
|
+
]);
|
|
7282
|
+
/** Retry annotation simple names (JV retry-masking family: @RetryingTest). */
|
|
7283
|
+
const JAVA_RETRY_ANNOTATIONS = /* @__PURE__ */ new Set(["RetryingTest"]);
|
|
7284
|
+
/** All annotation simple names on a Java modifiers node. */
|
|
7285
|
+
function javaAnnotationNames(modifiers) {
|
|
7286
|
+
const names = [];
|
|
7287
|
+
for (const child of modifiers.children) {
|
|
7288
|
+
if (child?.type !== "marker_annotation" && child?.type !== "annotation") continue;
|
|
7289
|
+
const nameNode = child.childForFieldName("name");
|
|
7290
|
+
if (!nameNode) continue;
|
|
7291
|
+
let last;
|
|
7292
|
+
for (const part of nameNode.children) if (part?.type === "identifier") last = part.text;
|
|
7293
|
+
names.push(last ?? nameNode.text);
|
|
7294
|
+
}
|
|
7295
|
+
return names;
|
|
7296
|
+
}
|
|
7297
|
+
function extractJavaModel(file) {
|
|
7298
|
+
const tree = getTreeSitterTree(file.ast);
|
|
7299
|
+
if (!tree) return void 0;
|
|
7300
|
+
const text = file.text;
|
|
7301
|
+
const nodes = [];
|
|
7302
|
+
for (const test of javaTestMethods(tree)) nodes.push({
|
|
7303
|
+
concept: "test",
|
|
7304
|
+
name: test.name,
|
|
7305
|
+
start: pos(text, test.annotation.startIndex),
|
|
7306
|
+
end: pos(text, test.body.endIndex),
|
|
7307
|
+
truncated: test.body.hasError
|
|
7308
|
+
});
|
|
7309
|
+
for (const decl of tree.rootNode.descendantsOfType("method_declaration")) {
|
|
7310
|
+
const modifiers = decl.childForFieldName("modifiers") ?? decl.children[0];
|
|
7311
|
+
if (!modifiers || modifiers.type !== "modifiers") continue;
|
|
7312
|
+
const body = decl.childForFieldName("body");
|
|
7313
|
+
if (!body) continue;
|
|
7314
|
+
const nameNode = decl.childForFieldName("name");
|
|
7315
|
+
if (!nameNode) continue;
|
|
7316
|
+
for (const annotation of javaAnnotationNames(modifiers)) {
|
|
7317
|
+
if (JAVA_HOOK_ANNOTATIONS.has(annotation)) {
|
|
7318
|
+
let concept = "setup";
|
|
7319
|
+
if (annotation.startsWith("After")) concept = "teardown";
|
|
7320
|
+
nodes.push({
|
|
7321
|
+
concept,
|
|
7322
|
+
name: nameNode.text,
|
|
7323
|
+
start: pos(text, decl.startIndex),
|
|
7324
|
+
end: pos(text, body.endIndex),
|
|
7325
|
+
truncated: body.hasError
|
|
7326
|
+
});
|
|
7327
|
+
break;
|
|
7328
|
+
}
|
|
7329
|
+
if (JAVA_RETRY_ANNOTATIONS.has(annotation)) {
|
|
7330
|
+
nodes.push({
|
|
7331
|
+
concept: "retry",
|
|
7332
|
+
name: nameNode.text,
|
|
7333
|
+
start: pos(text, decl.startIndex),
|
|
7334
|
+
end: pos(text, body.endIndex),
|
|
7335
|
+
truncated: body.hasError
|
|
7336
|
+
});
|
|
7337
|
+
break;
|
|
7338
|
+
}
|
|
7339
|
+
}
|
|
7340
|
+
}
|
|
7341
|
+
nodes.push(...javaCSharpCallNodes(tree, text, JAVA_CALLEE_CONCEPTS));
|
|
7342
|
+
return {
|
|
7343
|
+
language: "java",
|
|
7344
|
+
nodes
|
|
7345
|
+
};
|
|
7346
|
+
}
|
|
7347
|
+
/** MSTest/NUnit setup/teardown attribute simple names. */
|
|
7348
|
+
const CS_HOOK_ATTRIBUTES = /* @__PURE__ */ new Set([
|
|
7349
|
+
"TestInitialize",
|
|
7350
|
+
"TestCleanup",
|
|
7351
|
+
"SetUp",
|
|
7352
|
+
"TearDown",
|
|
7353
|
+
"OneTimeSetUp",
|
|
7354
|
+
"OneTimeTearDown",
|
|
7355
|
+
"ClassInitialize",
|
|
7356
|
+
"ClassCleanup",
|
|
7357
|
+
"AssemblyInitialize",
|
|
7358
|
+
"AssemblyCleanup",
|
|
7359
|
+
"FixtureSetUp",
|
|
7360
|
+
"FixtureTearDown"
|
|
7361
|
+
]);
|
|
7362
|
+
/** Retry attribute simple names (CS retry-masking family: [Retry(n)]). */
|
|
7363
|
+
const CS_RETRY_ATTRIBUTES = /* @__PURE__ */ new Set([
|
|
7364
|
+
"Retry",
|
|
7365
|
+
"RetryFact",
|
|
7366
|
+
"RetryTheory"
|
|
7367
|
+
]);
|
|
7368
|
+
function extractCSharpModel(file) {
|
|
7369
|
+
const tree = getTreeSitterTree(file.ast);
|
|
7370
|
+
if (!tree) return void 0;
|
|
7371
|
+
const text = file.text;
|
|
7372
|
+
const nodes = [];
|
|
7373
|
+
for (const test of csharpTestMethods(tree)) nodes.push({
|
|
7374
|
+
concept: "test",
|
|
7375
|
+
name: test.name,
|
|
7376
|
+
start: pos(text, test.attribute.startIndex),
|
|
7377
|
+
end: pos(text, test.body.endIndex),
|
|
7378
|
+
truncated: test.body.hasError
|
|
7379
|
+
});
|
|
7380
|
+
for (const decl of tree.rootNode.descendantsOfType("method_declaration")) {
|
|
7381
|
+
const body = decl.childForFieldName("body");
|
|
7382
|
+
if (!body) continue;
|
|
7383
|
+
const nameNode = decl.childForFieldName("name");
|
|
7384
|
+
if (!nameNode) continue;
|
|
7385
|
+
let attr;
|
|
7386
|
+
for (const child of decl.children) {
|
|
7387
|
+
if (child?.type !== "attribute_list") continue;
|
|
7388
|
+
for (const grand of child.children) {
|
|
7389
|
+
if (grand?.type !== "attribute") continue;
|
|
7390
|
+
const attrName = grand.childForFieldName("name");
|
|
7391
|
+
if (!attrName) continue;
|
|
7392
|
+
let last;
|
|
7393
|
+
for (const part of attrName.children) if (part?.type === "identifier") last = part.text;
|
|
7394
|
+
const simple = last ?? attrName.text;
|
|
7395
|
+
if (CS_HOOK_ATTRIBUTES.has(simple) || CS_RETRY_ATTRIBUTES.has(simple)) {
|
|
7396
|
+
attr = simple;
|
|
7397
|
+
break;
|
|
7398
|
+
}
|
|
7399
|
+
}
|
|
7400
|
+
if (attr) break;
|
|
7401
|
+
}
|
|
7402
|
+
if (!attr) continue;
|
|
7403
|
+
let concept;
|
|
7404
|
+
if (attr.includes("TearDown") || attr.includes("Cleanup")) concept = "teardown";
|
|
7405
|
+
else if (CS_RETRY_ATTRIBUTES.has(attr)) concept = "retry";
|
|
7406
|
+
else concept = "setup";
|
|
7407
|
+
nodes.push({
|
|
7408
|
+
concept,
|
|
7409
|
+
name: nameNode.text,
|
|
7410
|
+
start: pos(text, decl.startIndex),
|
|
7411
|
+
end: pos(text, body.endIndex),
|
|
7412
|
+
truncated: body.hasError
|
|
7413
|
+
});
|
|
7414
|
+
}
|
|
7415
|
+
nodes.push(...javaCSharpCallNodes(tree, text, CS_CALLEE_CONCEPTS));
|
|
7416
|
+
for (const throwStmt of tree.rootNode.descendantsOfType("throw_statement")) {
|
|
7417
|
+
const creation = throwStmt.namedChildren.find((c) => c?.type === "object_creation_expression");
|
|
7418
|
+
if (!creation) continue;
|
|
7419
|
+
const typeNode = creation.childForFieldName("type");
|
|
7420
|
+
if (!typeNode) continue;
|
|
7421
|
+
const typeName = typeNode.text;
|
|
7422
|
+
if (/assert/i.test(typeName) && /exception/i.test(typeName)) nodes.push({
|
|
7423
|
+
concept: "assertion",
|
|
7424
|
+
name: typeName,
|
|
7425
|
+
callee: typeName,
|
|
7426
|
+
start: pos(text, creation.startIndex),
|
|
7427
|
+
end: pos(text, creation.endIndex),
|
|
7428
|
+
text: creation.text,
|
|
7429
|
+
ancestors: ancestorCallNames(creation)
|
|
7430
|
+
});
|
|
7431
|
+
}
|
|
7432
|
+
return {
|
|
7433
|
+
language: "csharp",
|
|
7434
|
+
nodes
|
|
7435
|
+
};
|
|
7436
|
+
}
|
|
7437
|
+
/**
|
|
7438
|
+
* Java callee vocabulary — copied from the rules that measured it:
|
|
7439
|
+
* assert-prefix/bare fail/verify + helper idiom (QA-JV-103's oracle),
|
|
7440
|
+
* waitFor* (QA-JV-103 isThrowingWait + QA-JV-105), Thread.sleep
|
|
7441
|
+
* (QA-JV-102/hard-sleep family), navigate/goto (hardcoded-url JV),
|
|
7442
|
+
* querySelector/locator (shared-page/brittle-selectors JV), route/
|
|
7443
|
+
* exposeFunction/setRoute (blanket-route JV + QA-CS-102's environment
|
|
7444
|
+
* delegates), mock/spy (QA-JV fixture practice: Mockito mock/spy).
|
|
7445
|
+
*/
|
|
7446
|
+
const JAVA_CALLEE_CONCEPTS = {
|
|
7447
|
+
exact: {
|
|
7448
|
+
fail: "assertion",
|
|
7449
|
+
verify: "assertion",
|
|
7450
|
+
navigate: "navigation",
|
|
7451
|
+
goto: "navigation",
|
|
7452
|
+
querySelector: "locator",
|
|
7453
|
+
querySelectorAll: "locator",
|
|
7454
|
+
locator: "locator",
|
|
7455
|
+
frameLocator: "locator",
|
|
7456
|
+
route: "network-interaction",
|
|
7457
|
+
setRoute: "network-interaction",
|
|
7458
|
+
exposeFunction: "interaction",
|
|
7459
|
+
mock: "mock",
|
|
7460
|
+
spy: "mock"
|
|
7461
|
+
},
|
|
7462
|
+
receiverQualified: { sleep: { Thread: "wait" } },
|
|
7463
|
+
prefixes: [
|
|
7464
|
+
{
|
|
7465
|
+
test: (n) => /^assert[A-Z]/.test(n),
|
|
7466
|
+
concept: "assertion"
|
|
7467
|
+
},
|
|
7468
|
+
{
|
|
7469
|
+
test: (n) => isHelperIdiom(n) && /^[vca]/.test(n),
|
|
7470
|
+
concept: "assertion"
|
|
7471
|
+
},
|
|
7472
|
+
{
|
|
7473
|
+
test: (n) => n.startsWith("waitFor"),
|
|
7474
|
+
concept: "wait"
|
|
7475
|
+
},
|
|
7476
|
+
{
|
|
7477
|
+
test: (n) => /^(?:click|fill|tap|hover|dblclick|press|check|selectOption|type|focus)$/.test(n),
|
|
7478
|
+
concept: "action"
|
|
7479
|
+
}
|
|
7480
|
+
]
|
|
7481
|
+
};
|
|
7482
|
+
/**
|
|
7483
|
+
* C# callee vocabulary — copied from the rules that measured it:
|
|
7484
|
+
* Assert receiver + Shouldly + Expect + Verify + helper idiom +
|
|
7485
|
+
* WaitFor*Async≠WaitForTimeoutAsync (QA-CS-103), Thread/Task Sleep/Delay
|
|
7486
|
+
* (QA-CS-102), GotoAsync (hardcoded-url CS), RouteAsync/SetRoute/
|
|
7487
|
+
* RouteFromHARAsync/ExposeFunctionAsync/AddLocatorHandlerAsync
|
|
7488
|
+
* (QA-CS-102's ENVIRONMENT_DELEGATE_APIS), ClickAsync/FillAsync…,
|
|
7489
|
+
* Locator/FrameLocator/GetBy*.
|
|
7490
|
+
*/
|
|
7491
|
+
const CS_CALLEE_CONCEPTS = {
|
|
7492
|
+
exact: {
|
|
7493
|
+
expect: "assertion",
|
|
7494
|
+
Expect: "assertion",
|
|
7495
|
+
GotoAsync: "navigation",
|
|
7496
|
+
GoToAsync: "navigation",
|
|
7497
|
+
RouteAsync: "network-interaction",
|
|
7498
|
+
RouteFromHARAsync: "network-interaction",
|
|
7499
|
+
RouteFromHAR: "network-interaction",
|
|
7500
|
+
SetRoute: "network-interaction",
|
|
7501
|
+
SetRouteHandler: "network-interaction",
|
|
7502
|
+
UnrouteAllAsync: "network-interaction",
|
|
7503
|
+
AddLocatorHandlerAsync: "interaction",
|
|
7504
|
+
ExposeFunctionAsync: "interaction",
|
|
7505
|
+
ExposeFunction: "interaction",
|
|
7506
|
+
ExposeBindingAsync: "interaction",
|
|
7507
|
+
RunAxe: "assertion",
|
|
7508
|
+
Locator: "locator",
|
|
7509
|
+
FrameLocator: "locator",
|
|
7510
|
+
GetByTestId: "locator",
|
|
7511
|
+
GetByRole: "locator",
|
|
7512
|
+
GetByText: "locator",
|
|
7513
|
+
GetByLabel: "locator",
|
|
7514
|
+
GetByPlaceholder: "locator",
|
|
7515
|
+
GetByAltText: "locator",
|
|
7516
|
+
GetByTitle: "locator",
|
|
7517
|
+
QuerySelector: "locator",
|
|
7518
|
+
QuerySelectorAll: "locator"
|
|
7519
|
+
},
|
|
7520
|
+
receiverQualified: {
|
|
7521
|
+
Sleep: { Thread: "wait" },
|
|
7522
|
+
Delay: { Task: "wait" }
|
|
7523
|
+
},
|
|
7524
|
+
prefixes: [
|
|
7525
|
+
{
|
|
7526
|
+
test: (n) => n === "Should" || /^Should[A-Z]/.test(n),
|
|
7527
|
+
concept: "assertion"
|
|
7528
|
+
},
|
|
7529
|
+
{
|
|
7530
|
+
test: isHelperIdiom,
|
|
7531
|
+
concept: "assertion"
|
|
7532
|
+
},
|
|
7533
|
+
{
|
|
7534
|
+
test: (n) => n.startsWith("WaitFor") && n.endsWith("Async") && n !== "WaitForTimeoutAsync",
|
|
7535
|
+
concept: "wait"
|
|
7536
|
+
},
|
|
7537
|
+
{
|
|
7538
|
+
test: (n) => n === "WaitForTimeoutAsync",
|
|
7539
|
+
concept: "wait"
|
|
7540
|
+
},
|
|
7541
|
+
{
|
|
7542
|
+
test: (n) => /^(?:ClickAsync|FillAsync|TapAsync|HoverAsync|DblClickAsync|PressAsync|CheckAsync|UncheckAsync|SelectOptionAsync|FocusAsync|TypeAsync)$/.test(n),
|
|
7543
|
+
concept: "action"
|
|
7544
|
+
}
|
|
7545
|
+
]
|
|
7546
|
+
};
|
|
7547
|
+
/**
|
|
7548
|
+
* Classify every invocation in the tree against a table, carrying
|
|
7549
|
+
* callee/receiver/text/ancestors. Ancestors are the enclosing invocation
|
|
7550
|
+
* callee names up to the method boundary (outer→inner) — the
|
|
7551
|
+
* firstAncestorCallNamed generalization, so containment decisions
|
|
7552
|
+
* (route delegates, WhenAny races) are expressible over the model.
|
|
7553
|
+
*/
|
|
7554
|
+
function javaCSharpCallNodes(tree, text, table) {
|
|
7555
|
+
const nodes = [];
|
|
7556
|
+
for (const call of invocationsWithin(tree.rootNode)) {
|
|
7557
|
+
const callee = callName(call);
|
|
7558
|
+
if (callee === void 0) continue;
|
|
7559
|
+
const receiver = receiverText(call);
|
|
7560
|
+
let concept = table.exact[callee];
|
|
7561
|
+
if (concept === void 0 && receiver !== void 0) concept = table.receiverQualified[callee]?.[receiver];
|
|
7562
|
+
if (concept === void 0) {
|
|
7563
|
+
for (const p of table.prefixes) if (p.test(callee)) {
|
|
7564
|
+
concept = p.concept;
|
|
7565
|
+
break;
|
|
7566
|
+
}
|
|
7567
|
+
}
|
|
7568
|
+
if (concept === void 0) continue;
|
|
7569
|
+
const node = {
|
|
7570
|
+
concept,
|
|
7571
|
+
name: callee,
|
|
7572
|
+
callee,
|
|
7573
|
+
start: pos(text, call.startIndex),
|
|
7574
|
+
end: pos(text, call.endIndex),
|
|
7575
|
+
text: call.text,
|
|
7576
|
+
ancestors: ancestorCallNames(call)
|
|
7577
|
+
};
|
|
7578
|
+
if (receiver !== void 0) node.receiver = receiver;
|
|
7579
|
+
nodes.push(node);
|
|
7580
|
+
}
|
|
7581
|
+
return nodes;
|
|
7582
|
+
}
|
|
7583
|
+
/** Enclosing invocation callee names, outer→inner, to the method boundary. */
|
|
7584
|
+
function ancestorCallNames(node) {
|
|
7585
|
+
const names = [];
|
|
7586
|
+
let current = node.parent;
|
|
7587
|
+
while (current) {
|
|
7588
|
+
if (current.type === "method_declaration" || current.type === "constructor_declaration") break;
|
|
7589
|
+
if (isInvocation(current)) {
|
|
7590
|
+
const name = callName(current);
|
|
7591
|
+
if (name !== void 0) names.unshift(name);
|
|
7592
|
+
}
|
|
7593
|
+
current = current.parent;
|
|
7594
|
+
}
|
|
7595
|
+
return names;
|
|
7596
|
+
}
|
|
7597
|
+
/**
|
|
7598
|
+
* TS callee vocabulary — copied from the rules that measured it:
|
|
7599
|
+
* expect/assert (qa-pw-002, qa-test-003), waitFor + goto/route families
|
|
7600
|
+
* (qa-pw-101/102/103/118/123/142), evaluate/exposeFunction (qa-pw-005),
|
|
7601
|
+
* click/fill and the locator family (qa-pw-004/104/112/113/114/145),
|
|
7602
|
+
* jest/vi mock+retryTimes (qa-test-006, qa-tqual-001), delay/sleep/pause
|
|
7603
|
+
* helper idioms (qa-test-004), describe.serial (qa-pw-117).
|
|
7604
|
+
*/
|
|
7605
|
+
const TS_CALLEE_CONCEPTS = {
|
|
7606
|
+
exact: {
|
|
7607
|
+
expect: "assertion",
|
|
7608
|
+
assert: "assertion",
|
|
7609
|
+
goto: "navigation",
|
|
7610
|
+
route: "network-interaction",
|
|
7611
|
+
unroute: "network-interaction",
|
|
7612
|
+
routeFromHAR: "network-interaction",
|
|
7613
|
+
evaluate: "interaction",
|
|
7614
|
+
evaluateHandle: "interaction",
|
|
7615
|
+
exposeFunction: "interaction",
|
|
7616
|
+
addInitScript: "interaction",
|
|
7617
|
+
addLocatorHandler: "interaction",
|
|
7618
|
+
locator: "locator",
|
|
7619
|
+
frameLocator: "locator",
|
|
7620
|
+
getByTestId: "locator",
|
|
7621
|
+
getByRole: "locator",
|
|
7622
|
+
getByText: "locator",
|
|
7623
|
+
getByLabel: "locator",
|
|
7624
|
+
getByPlaceholder: "locator",
|
|
7625
|
+
getByAltText: "locator",
|
|
7626
|
+
getByTitle: "locator",
|
|
7627
|
+
$: "locator",
|
|
7628
|
+
$$: "locator"
|
|
7629
|
+
},
|
|
7630
|
+
receiverQualified: {},
|
|
7631
|
+
prefixes: [
|
|
7632
|
+
{
|
|
7633
|
+
test: (n) => n.startsWith("waitFor"),
|
|
7634
|
+
concept: "wait"
|
|
7635
|
+
},
|
|
7636
|
+
{
|
|
7637
|
+
test: (n) => /^(?:delay|sleep|pause|wait|timeout)$/.test(n),
|
|
7638
|
+
concept: "wait"
|
|
7639
|
+
},
|
|
7640
|
+
{
|
|
7641
|
+
test: (n) => /^(?:click|dblclick|fill|hover|tap|check|uncheck|selectOption|press|type|focus|dragTo)$/.test(n),
|
|
7642
|
+
concept: "action"
|
|
7643
|
+
},
|
|
7644
|
+
{
|
|
7645
|
+
test: (n) => n === "retryTimes",
|
|
7646
|
+
concept: "retry"
|
|
7647
|
+
},
|
|
7648
|
+
{
|
|
7649
|
+
test: (n) => n === "mock" || n === "fn" || n === "spyOn",
|
|
7650
|
+
concept: "mock"
|
|
7651
|
+
}
|
|
7652
|
+
]
|
|
7653
|
+
};
|
|
7654
|
+
const TS_TEST_CALLEE_RE = /^(?:it|test)(?:\.\w+)*$/;
|
|
7655
|
+
const TS_HOOK_CONCEPTS = {
|
|
7656
|
+
beforeEach: "setup",
|
|
7657
|
+
beforeAll: "setup",
|
|
7658
|
+
afterEach: "teardown",
|
|
7659
|
+
afterAll: "teardown"
|
|
7660
|
+
};
|
|
7661
|
+
function calleeChainText(expr) {
|
|
7662
|
+
return expr.getText().replace(/\s+/g, "");
|
|
7663
|
+
}
|
|
7664
|
+
function extractTsModel(file, sf) {
|
|
7665
|
+
const text = file.text;
|
|
7666
|
+
const nodes = [];
|
|
7667
|
+
for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
7668
|
+
const chain = calleeChainText(call.getExpression());
|
|
7669
|
+
const parts = chain.split(".");
|
|
7670
|
+
const base = parts[parts.length - 1];
|
|
7671
|
+
const start = pos(text, call.getStart());
|
|
7672
|
+
const end = pos(text, call.getEnd());
|
|
7673
|
+
if (TS_TEST_CALLEE_RE.test(chain)) {
|
|
7674
|
+
const callback = call.getArguments().find((a) => a.getKindName().includes("Function"));
|
|
7675
|
+
if (callback) {
|
|
7676
|
+
nodes.push({
|
|
7677
|
+
concept: "test",
|
|
7678
|
+
name: chain,
|
|
7679
|
+
start,
|
|
7680
|
+
end: pos(text, callback.getEnd())
|
|
7681
|
+
});
|
|
7682
|
+
continue;
|
|
7683
|
+
}
|
|
7684
|
+
}
|
|
7685
|
+
const hook = TS_HOOK_CONCEPTS[base];
|
|
7686
|
+
if (hook && /(?:^|\.)(?:beforeEach|beforeAll|afterEach|afterAll)$/.test(chain)) {
|
|
7687
|
+
const callback = call.getArguments().find((a) => a.getKindName().includes("Function"));
|
|
7688
|
+
const node = {
|
|
7689
|
+
concept: hook,
|
|
7690
|
+
name: chain,
|
|
7691
|
+
start
|
|
7692
|
+
};
|
|
7693
|
+
if (callback) node.end = pos(text, callback.getEnd());
|
|
7694
|
+
nodes.push(node);
|
|
7695
|
+
continue;
|
|
7696
|
+
}
|
|
7697
|
+
if (/^describe(?:\.\w+)*\.serial$/.test(chain) || /\.describe\.serial/.test(chain)) {
|
|
7698
|
+
nodes.push({
|
|
7699
|
+
concept: "lifecycle",
|
|
7700
|
+
name: chain,
|
|
7701
|
+
start,
|
|
7702
|
+
end
|
|
7703
|
+
});
|
|
7704
|
+
continue;
|
|
7705
|
+
}
|
|
7706
|
+
const receiverParts = chain.split(".");
|
|
7707
|
+
const name = receiverParts[receiverParts.length - 1];
|
|
7708
|
+
let receiver;
|
|
7709
|
+
if (receiverParts.length > 1) receiver = receiverParts.slice(0, -1).join(".");
|
|
7710
|
+
let concept = TS_CALLEE_CONCEPTS.exact[name];
|
|
7711
|
+
if (concept === void 0) {
|
|
7712
|
+
for (const p of TS_CALLEE_CONCEPTS.prefixes) if (p.test(name)) {
|
|
7713
|
+
concept = p.concept;
|
|
7714
|
+
break;
|
|
7715
|
+
}
|
|
7716
|
+
}
|
|
7717
|
+
if (concept === void 0) continue;
|
|
7718
|
+
const node = {
|
|
7719
|
+
concept,
|
|
7720
|
+
name,
|
|
7721
|
+
callee: name,
|
|
7722
|
+
start,
|
|
7723
|
+
end,
|
|
7724
|
+
text: call.getText(),
|
|
7725
|
+
awaited: isAwaitedTsCall(call)
|
|
7726
|
+
};
|
|
7727
|
+
if (receiver !== void 0) node.receiver = receiver;
|
|
7728
|
+
nodes.push(node);
|
|
7729
|
+
}
|
|
7730
|
+
let language = "javascript";
|
|
7731
|
+
if (/\.[cm]?ts$/.test(file.path) || /\.tsx$/.test(file.path)) language = "typescript";
|
|
7732
|
+
return {
|
|
7733
|
+
language,
|
|
7734
|
+
nodes
|
|
7735
|
+
};
|
|
7736
|
+
}
|
|
7737
|
+
/**
|
|
7738
|
+
* Awaitedness (qa-pw-002's consumption oracle: an AwaitExpression or
|
|
7739
|
+
* ReturnStatement ancestor consumes the promise). Carried as an extra
|
|
7740
|
+
* field on TS call nodes — the model stays grammar-agnostic, consumers
|
|
7741
|
+
* narrow.
|
|
7742
|
+
*/
|
|
7743
|
+
function isAwaitedTsCall(call) {
|
|
7744
|
+
let parent = call.getParent();
|
|
7745
|
+
while (parent) {
|
|
7746
|
+
const kind = parent.getKindName();
|
|
7747
|
+
if (kind === "AwaitExpression" || kind === "ReturnStatement") return true;
|
|
7748
|
+
if (kind === "ArrowFunction" || kind === "FunctionDeclaration" || kind === "FunctionExpression") return false;
|
|
7749
|
+
parent = parent.getParent();
|
|
7750
|
+
}
|
|
7751
|
+
return false;
|
|
7752
|
+
}
|
|
7753
|
+
/**
|
|
7754
|
+
* Python vocabularies copied from the rules that measured them:
|
|
7755
|
+
* def test_ (qa-py-003), @pytest.fixture (qa-py-011), time.sleep/
|
|
7756
|
+
* wait_for_timeout (qa-py-005/qa-py-102/qa-py-103), assert/self.assert*
|
|
7757
|
+
* (qa-py-003's oracle vocabulary).
|
|
7758
|
+
*/
|
|
7759
|
+
const PY_TEST_RE = /^([ \t]*)(?:async\s+)?def\s+(test_\w+)\s*\([^)]*\)\s*:/gm;
|
|
7760
|
+
const PY_FIXTURE_RE = /@pytest\.fixture\b/g;
|
|
7761
|
+
const PY_WAIT_RE = /\btime\.sleep\s*\(|\.wait_for_timeout\s*\(/g;
|
|
7762
|
+
const PY_ASSERT_RE = /^([ \t]*)assert\b/gm;
|
|
7763
|
+
const PY_SELF_ASSERT_RE = /self\.assert[A-Z]\w*\s*\(/g;
|
|
7764
|
+
function extractPythonModel(file) {
|
|
7765
|
+
const text = file.text;
|
|
7766
|
+
const nodes = [];
|
|
7767
|
+
const push = (concept, m, name) => {
|
|
7768
|
+
const node = {
|
|
7769
|
+
concept,
|
|
7770
|
+
start: {
|
|
7771
|
+
index: m.index,
|
|
7772
|
+
line: lineAt(text, m.index),
|
|
7773
|
+
column: colAt(text, m.index)
|
|
7774
|
+
},
|
|
7775
|
+
end: {
|
|
7776
|
+
index: m.index + m[0].length,
|
|
7777
|
+
line: lineAt(text, m.index + m[0].length),
|
|
7778
|
+
column: colAt(text, m.index + m[0].length)
|
|
7779
|
+
},
|
|
7780
|
+
text: m[0]
|
|
7781
|
+
};
|
|
7782
|
+
if (name !== void 0) node.name = name;
|
|
7783
|
+
nodes.push(node);
|
|
7784
|
+
};
|
|
7785
|
+
for (const re of [
|
|
7786
|
+
PY_TEST_RE,
|
|
7787
|
+
PY_FIXTURE_RE,
|
|
7788
|
+
PY_WAIT_RE,
|
|
7789
|
+
PY_ASSERT_RE,
|
|
7790
|
+
PY_SELF_ASSERT_RE
|
|
7791
|
+
]) {
|
|
7792
|
+
re.lastIndex = 0;
|
|
7793
|
+
let m;
|
|
7794
|
+
while ((m = re.exec(text)) !== null) {
|
|
7795
|
+
let concept = "assertion";
|
|
7796
|
+
if (re === PY_TEST_RE) concept = "test";
|
|
7797
|
+
else if (re === PY_FIXTURE_RE) concept = "fixture";
|
|
7798
|
+
else if (re === PY_WAIT_RE) concept = "wait";
|
|
7799
|
+
push(concept, m, re === PY_TEST_RE ? m[2] : void 0);
|
|
7800
|
+
}
|
|
7801
|
+
}
|
|
7802
|
+
return {
|
|
7803
|
+
language: "python",
|
|
7804
|
+
nodes
|
|
7805
|
+
};
|
|
7806
|
+
}
|
|
7807
|
+
//#endregion
|
|
7248
7808
|
//#region src/rules/java/qa-jv-105-wait-for-timeout.ts
|
|
7249
7809
|
/**
|
|
7250
7810
|
* QA-JV-105 — waitForTimeout / hardcoded navigation waits.
|
|
7251
7811
|
* Severity: warning · Confidence: high · deterministic-defect
|
|
7252
7812
|
* Playwright-Java's page.waitForTimeout is a hard sleep.
|
|
7813
|
+
*
|
|
7814
|
+
* Lane A migration dossier (blueprint §10, hard-sleep JV/CS family —
|
|
7815
|
+
* the plan's suggested first family):
|
|
7816
|
+
* 1. BASELINE (rev 1, LEXICAL): regex over codeText; FP 0.10 (n=20).
|
|
7817
|
+
* 2. IMPLEMENTATION (rev 2, QA_MODEL): a finding requires a real
|
|
7818
|
+
* method_invocation node with callee `waitForTimeout` — string/
|
|
7819
|
+
* comment/declaration containment structurally cannot fire.
|
|
7820
|
+
* 3. RE-MEASUREMENT: sidecar regenerated at rev 2 in this change.
|
|
7821
|
+
* 4. FN check: parity tests over committed must-fire fixtures prove
|
|
7822
|
+
* finding-identical results; must-not-fire fixtures pin precision.
|
|
7823
|
+
* 5. PERFORMANCE: model extraction walks the existing parse tree —
|
|
7824
|
+
* no additional parse.
|
|
7825
|
+
* 6. ADR: QA-model adopted (grammar precision > lexical simplicity;
|
|
7826
|
+
* the tree is already available at rule time). Legacy lexical
|
|
7827
|
+
* fallback retained ONLY for AST-less contexts (parse declined /
|
|
7828
|
+
* unit tests).
|
|
7253
7829
|
*/
|
|
7254
7830
|
const jvWaitForTimeout = defineRule({
|
|
7255
7831
|
id: "QA-JV-105",
|
|
@@ -7269,12 +7845,34 @@ const jvWaitForTimeout = defineRule({
|
|
|
7269
7845
|
],
|
|
7270
7846
|
falsePositiveRisk: "low",
|
|
7271
7847
|
autofix: false,
|
|
7272
|
-
detectionStrategy: "
|
|
7848
|
+
detectionStrategy: "QA_MODEL",
|
|
7273
7849
|
introduced: "0.3.8",
|
|
7850
|
+
detectorRevision: 2,
|
|
7274
7851
|
run(ctx) {
|
|
7275
|
-
const text = ctx.codeText ?? ctx.text;
|
|
7276
7852
|
const findings = [];
|
|
7277
7853
|
if (!ctx.path.endsWith(".java")) return findings;
|
|
7854
|
+
const tree = getTreeSitterTree(ctx.ast);
|
|
7855
|
+
if (tree) {
|
|
7856
|
+
const model = extractQaModel({
|
|
7857
|
+
path: ctx.path,
|
|
7858
|
+
text: ctx.text,
|
|
7859
|
+
ast: tree
|
|
7860
|
+
});
|
|
7861
|
+
for (const node of model.nodes) if (node.concept === "wait" && node.callee === "waitForTimeout") findings.push({
|
|
7862
|
+
severity: "warning",
|
|
7863
|
+
confidence: "high",
|
|
7864
|
+
findingType: "deterministic-defect",
|
|
7865
|
+
qaImpact: "FLAKY-RISK",
|
|
7866
|
+
file: ctx.path,
|
|
7867
|
+
line: node.start.line,
|
|
7868
|
+
column: node.start.column,
|
|
7869
|
+
message: "`waitForTimeout()` hard sleep.",
|
|
7870
|
+
why: "Fixed waits encode hope, not synchronization — too short flakes under load, too long slows every run.",
|
|
7871
|
+
fix: "Use `page.locator(...).waitFor()` or `assertThat(locator).isVisible()` with auto-waiting."
|
|
7872
|
+
});
|
|
7873
|
+
return findings;
|
|
7874
|
+
}
|
|
7875
|
+
const text = ctx.codeText ?? ctx.text;
|
|
7278
7876
|
const re = /\.waitForTimeout\s*\(/g;
|
|
7279
7877
|
let m;
|
|
7280
7878
|
while ((m = re.exec(text)) !== null) findings.push({
|
|
@@ -7551,6 +8149,21 @@ function matchBrace(text, open) {
|
|
|
7551
8149
|
* PascalCase Async-suffixed .NET API (verified in
|
|
7552
8150
|
* docs/JAVA-CSHARP-IDIOM-MAPPING.md — .NET Playwright is async-only,
|
|
7553
8151
|
* there is no sync WaitForTimeout).
|
|
8152
|
+
*
|
|
8153
|
+
* Lane A migration dossier (blueprint §10, hard-sleep JV/CS family):
|
|
8154
|
+
* 1. BASELINE (rev 1, LEXICAL): regex over codeText; FP 0.25 (n=16) —
|
|
8155
|
+
* adjudicated FP classes were prose mentions in comments/strings.
|
|
8156
|
+
* 2. IMPLEMENTATION (rev 2, QA_MODEL): a finding requires a real
|
|
8157
|
+
* invocation_expression node with callee `WaitForTimeoutAsync` —
|
|
8158
|
+
* declarations, comments and strings are invisible to the grammar.
|
|
8159
|
+
* 3. RE-MEASUREMENT: sidecar regenerated at rev 2 in this change.
|
|
8160
|
+
* 4. FN check: parity tests over committed must-fire fixtures prove
|
|
8161
|
+
* finding-identical results; must-not-fire fixtures pin precision.
|
|
8162
|
+
* 5. PERFORMANCE: model extraction walks the existing parse tree —
|
|
8163
|
+
* no additional parse.
|
|
8164
|
+
* 6. ADR: QA-model adopted (grammar precision > lexical simplicity).
|
|
8165
|
+
* Legacy lexical fallback retained ONLY for AST-less contexts
|
|
8166
|
+
* (parse declined / unit tests).
|
|
7554
8167
|
*/
|
|
7555
8168
|
const csWaitForTimeout = defineRule({
|
|
7556
8169
|
id: "QA-CS-105",
|
|
@@ -7570,13 +8183,35 @@ const csWaitForTimeout = defineRule({
|
|
|
7570
8183
|
],
|
|
7571
8184
|
falsePositiveRisk: "low",
|
|
7572
8185
|
autofix: false,
|
|
7573
|
-
detectionStrategy: "
|
|
8186
|
+
detectionStrategy: "QA_MODEL",
|
|
7574
8187
|
introduced: "0.4.0",
|
|
7575
8188
|
tier: "extended",
|
|
8189
|
+
detectorRevision: 2,
|
|
7576
8190
|
run(ctx) {
|
|
7577
|
-
const text = ctx.codeText ?? ctx.text;
|
|
7578
8191
|
const findings = [];
|
|
7579
8192
|
if (!ctx.path.endsWith(".cs")) return findings;
|
|
8193
|
+
const tree = getTreeSitterTree(ctx.ast);
|
|
8194
|
+
if (tree) {
|
|
8195
|
+
const model = extractQaModel({
|
|
8196
|
+
path: ctx.path,
|
|
8197
|
+
text: ctx.text,
|
|
8198
|
+
ast: tree
|
|
8199
|
+
});
|
|
8200
|
+
for (const node of model.nodes) if (node.concept === "wait" && node.callee === "WaitForTimeoutAsync") findings.push({
|
|
8201
|
+
severity: "warning",
|
|
8202
|
+
confidence: "high",
|
|
8203
|
+
findingType: "deterministic-defect",
|
|
8204
|
+
qaImpact: "FLAKY-RISK",
|
|
8205
|
+
file: ctx.path,
|
|
8206
|
+
line: node.start.line,
|
|
8207
|
+
column: node.start.column,
|
|
8208
|
+
message: "`WaitForTimeoutAsync()` hard sleep.",
|
|
8209
|
+
why: "Fixed waits encode hope, not synchronization — too short flakes under load, too long slows every run.",
|
|
8210
|
+
fix: "Use `await Assertions.Expect(locator).ToBeVisibleAsync()` or `locator.WaitForAsync()` with auto-waiting."
|
|
8211
|
+
});
|
|
8212
|
+
return findings;
|
|
8213
|
+
}
|
|
8214
|
+
const text = ctx.codeText ?? ctx.text;
|
|
7580
8215
|
const re = /\.WaitForTimeoutAsync\s*\(/g;
|
|
7581
8216
|
let m;
|
|
7582
8217
|
while ((m = re.exec(text)) !== null) findings.push({
|
|
@@ -8869,7 +9504,7 @@ const MEASURED_FP = {
|
|
|
8869
9504
|
"QA-CS-105": {
|
|
8870
9505
|
fpRate: .25,
|
|
8871
9506
|
n: 16,
|
|
8872
|
-
detectorRevision:
|
|
9507
|
+
detectorRevision: 2,
|
|
8873
9508
|
ciLow: .1018,
|
|
8874
9509
|
ciHigh: .495
|
|
8875
9510
|
},
|
|
@@ -8932,7 +9567,7 @@ const MEASURED_FP = {
|
|
|
8932
9567
|
"QA-JV-105": {
|
|
8933
9568
|
fpRate: .1,
|
|
8934
9569
|
n: 20,
|
|
8935
|
-
detectorRevision:
|
|
9570
|
+
detectorRevision: 2,
|
|
8936
9571
|
ciLow: .0279,
|
|
8937
9572
|
ciHigh: .301
|
|
8938
9573
|
},
|
|
@@ -12575,7 +13210,7 @@ function renderSarif(result, repoRootUri) {
|
|
|
12575
13210
|
tool: { driver: {
|
|
12576
13211
|
name: "Mjölnir",
|
|
12577
13212
|
informationUri: "https://github.com/Sergey-Bar/Mjolnir",
|
|
12578
|
-
version: "0.5.
|
|
13213
|
+
version: "0.5.9",
|
|
12579
13214
|
rules: [...rules.values()].map((r) => {
|
|
12580
13215
|
const meta = RULES.find((x) => x.id === r.id);
|
|
12581
13216
|
return {
|
|
@@ -17000,7 +17635,7 @@ const { runScan, buildUniversalRules, fallbackWorkspace, pathMatchesGlob, isVali
|
|
|
17000
17635
|
* `scripts/sync-sarif-version.cjs` on release and guarded by
|
|
17001
17636
|
* `tests/version-consistency.spec.ts` locally.
|
|
17002
17637
|
*/
|
|
17003
|
-
const CLI_VERSION = "0.5.
|
|
17638
|
+
const CLI_VERSION = "0.5.9";
|
|
17004
17639
|
function parseArgs(argv, onError) {
|
|
17005
17640
|
const args = {
|
|
17006
17641
|
target: ".",
|