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/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: "LEXICAL",
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: "LEXICAL",
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: 1,
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: 1,
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.7",
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.7";
17638
+ const CLI_VERSION = "0.5.9";
17004
17639
  function parseArgs(argv, onError) {
17005
17640
  const args = {
17006
17641
  target: ".",