ado-sync 0.1.65 → 0.1.68

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.
Files changed (67) hide show
  1. package/README.md +15 -15
  2. package/dist/__tests__/regressions.test.js +1133 -1
  3. package/dist/__tests__/regressions.test.js.map +1 -1
  4. package/dist/ai/summarizer.d.ts +2 -1
  5. package/dist/ai/summarizer.js +6 -1
  6. package/dist/ai/summarizer.js.map +1 -1
  7. package/dist/azure/test-cases.d.ts +11 -1
  8. package/dist/azure/test-cases.js +286 -43
  9. package/dist/azure/test-cases.js.map +1 -1
  10. package/dist/cli-diagnostics.d.ts +66 -0
  11. package/dist/cli-diagnostics.js +75 -0
  12. package/dist/cli-diagnostics.js.map +1 -0
  13. package/dist/cli.js +335 -23
  14. package/dist/cli.js.map +1 -1
  15. package/dist/config.js +194 -9
  16. package/dist/config.js.map +1 -1
  17. package/dist/extensions.d.ts +8 -0
  18. package/dist/extensions.js +86 -0
  19. package/dist/extensions.js.map +1 -0
  20. package/dist/id-markers.d.ts +1 -0
  21. package/dist/id-markers.js +13 -0
  22. package/dist/id-markers.js.map +1 -1
  23. package/dist/sync/cache.d.ts +2 -0
  24. package/dist/sync/cache.js.map +1 -1
  25. package/dist/sync/engine.d.ts +29 -2
  26. package/dist/sync/engine.js +270 -41
  27. package/dist/sync/engine.js.map +1 -1
  28. package/dist/sync/publish-results.d.ts +25 -0
  29. package/dist/sync/publish-results.js +81 -2
  30. package/dist/sync/publish-results.js.map +1 -1
  31. package/dist/types.d.ts +98 -2
  32. package/llms.txt +11 -11
  33. package/package.json +9 -1
  34. package/docs/advanced.md +0 -989
  35. package/docs/agent-setup.md +0 -204
  36. package/docs/capability-roadmap.md +0 -280
  37. package/docs/cli.md +0 -614
  38. package/docs/configuration.md +0 -322
  39. package/docs/examples/csharp-mstest-local-llm.yaml +0 -35
  40. package/docs/examples/csharp-mstest.yaml +0 -21
  41. package/docs/examples/csharp-nunit.yaml +0 -21
  42. package/docs/examples/csharp-specflow.yaml +0 -16
  43. package/docs/examples/cypress.yaml +0 -21
  44. package/docs/examples/detox-react-native.yaml +0 -21
  45. package/docs/examples/espresso-android.yaml +0 -21
  46. package/docs/examples/flutter-dart.yaml +0 -21
  47. package/docs/examples/java-junit.yaml +0 -21
  48. package/docs/examples/java-testng.yaml +0 -21
  49. package/docs/examples/js-jasmine-wdio.yaml +0 -21
  50. package/docs/examples/js-jest.yaml +0 -21
  51. package/docs/examples/playwright-js.yaml +0 -21
  52. package/docs/examples/playwright-ts.yaml +0 -21
  53. package/docs/examples/puppeteer.yaml +0 -21
  54. package/docs/examples/python-pytest.yaml +0 -21
  55. package/docs/examples/robot-framework.yaml +0 -19
  56. package/docs/examples/testcafe.yaml +0 -21
  57. package/docs/examples/xcuitest-ios.yaml +0 -21
  58. package/docs/mcp-server.md +0 -312
  59. package/docs/publish-test-results.md +0 -947
  60. package/docs/spec-formats.md +0 -1357
  61. package/docs/troubleshooting.md +0 -101
  62. package/docs/vscode-extension.md +0 -139
  63. package/docs/work-item-links.md +0 -115
  64. package/docs/workflows.md +0 -457
  65. package/mkdocs.yml +0 -40
  66. package/requirements-docs.txt +0 -4
  67. package/scripts/build_site.sh +0 -6
@@ -41,14 +41,19 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
41
41
  Object.defineProperty(exports, "__esModule", { value: true });
42
42
  exports.failOnParseErrors = failOnParseErrors;
43
43
  exports.buildPushDiff = buildPushDiff;
44
+ exports.validatePushModeOptions = validatePushModeOptions;
44
45
  exports.push = push;
45
46
  exports.pull = pull;
46
47
  exports.status = status;
48
+ exports.evaluateClassificationRules = evaluateClassificationRules;
49
+ exports.parseDuration = parseDuration;
50
+ exports.enforceStalenessPolicy = enforceStalenessPolicy;
47
51
  exports.detectStaleTestCases = detectStaleTestCases;
48
52
  exports.coverageReport = coverageReport;
49
53
  const tag_expressions_1 = __importDefault(require("@cucumber/tag-expressions"));
50
54
  const fs = __importStar(require("fs"));
51
55
  const glob_1 = require("glob");
56
+ const minimatch_1 = require("minimatch");
52
57
  const os = __importStar(require("os"));
53
58
  const path = __importStar(require("path"));
54
59
  const summarizer_1 = require("../ai/summarizer");
@@ -313,6 +318,45 @@ async function parseLocalFiles(filePaths, config, tagsFilter) {
313
318
  }
314
319
  return { tests: results, failures };
315
320
  }
321
+ function validatePushModeOptions(opts) {
322
+ const enabledModes = [
323
+ opts.createOnly && '--create-only',
324
+ opts.linkOnly && '--link-only',
325
+ opts.updateOnly && '--update-only',
326
+ ].filter(Boolean);
327
+ if (enabledModes.length > 1) {
328
+ throw new Error(`Only one push mode can be used at a time: ${enabledModes.join(', ')}`);
329
+ }
330
+ }
331
+ function filterDiscoveredFiles(filePaths, sourceFiles, includePatterns, configDir) {
332
+ let filtered = filePaths;
333
+ if (sourceFiles?.length) {
334
+ const requested = new Set(sourceFiles.map((filePath) => path.normalize(path.resolve(filePath))));
335
+ filtered = filtered.filter((filePath) => requested.has(path.normalize(filePath)));
336
+ }
337
+ if (includePatterns?.length && configDir) {
338
+ const resolvedDir = path.resolve(configDir);
339
+ filtered = filtered.filter((filePath) => {
340
+ const relative = path.relative(resolvedDir, filePath);
341
+ return includePatterns.some((pattern) => (0, minimatch_1.minimatch)(relative, pattern, { dot: true }));
342
+ });
343
+ }
344
+ return filtered;
345
+ }
346
+ function formatSuitePreview(pathKey) {
347
+ if (!pathKey)
348
+ return undefined;
349
+ return pathKey.split('/').join(' / ');
350
+ }
351
+ function mapRemoteTestsByTitle(testCases) {
352
+ const byTitle = new Map();
353
+ for (const testCase of testCases) {
354
+ const matches = byTitle.get(testCase.title) ?? [];
355
+ matches.push(testCase);
356
+ byTitle.set(testCase.title, matches);
357
+ }
358
+ return byTitle;
359
+ }
316
360
  // ─── Multi-plan helpers ───────────────────────────────────────────────────────
317
361
  /**
318
362
  * Build an effective config for a single plan entry in testPlans[] mode.
@@ -330,6 +374,7 @@ function configForPlanEntry(base, entry) {
330
374
  testPlan: {
331
375
  id: entry.id,
332
376
  suiteId: entry.suiteId ?? base.testPlan.suiteId,
377
+ hierarchy: entry.hierarchy ?? base.testPlan.hierarchy,
333
378
  suiteMapping: entry.suiteMapping ?? base.testPlan.suiteMapping,
334
379
  suiteRouting: entry.suiteRouting ?? base.testPlan.suiteRouting,
335
380
  },
@@ -375,6 +420,7 @@ async function resolveTargetSuiteFromRouting(client, config, test, suiteCache) {
375
420
  }
376
421
  // ─── Push ─────────────────────────────────────────────────────────────────────
377
422
  async function push(config, configDir, opts = {}) {
423
+ validatePushModeOptions(opts);
378
424
  // Multi-plan: run AI summarisation across all plans first so progress totals
379
425
  // are correct, then delegate each plan's sync loop (with AI already applied).
380
426
  if (config.testPlans?.length) {
@@ -383,7 +429,7 @@ async function push(config, configDir, opts = {}) {
383
429
  const parseFailures = [];
384
430
  for (const entry of config.testPlans) {
385
431
  const entryConfig = configForPlanEntry(config, entry);
386
- const files = await discoverFiles(entryConfig.local.include, entryConfig.local.exclude, configDir);
432
+ const files = filterDiscoveredFiles(await discoverFiles(entryConfig.local.include, entryConfig.local.exclude, configDir), opts.sourceFiles, opts.includePatterns, configDir);
387
433
  const parsed = await parseLocalFiles(files, entryConfig, opts.tags);
388
434
  parseFailures.push(...parsed.failures);
389
435
  const tests = parsed.tests;
@@ -430,7 +476,7 @@ async function pushSingle(config, configDir, opts) {
430
476
  let resolvedTests = opts._preloadedTests ?? [];
431
477
  let parseFailures = [];
432
478
  if (!opts._preloadedTests) {
433
- const files = await discoverFiles(config.local.include, config.local.exclude, configDir);
479
+ const files = filterDiscoveredFiles(await discoverFiles(config.local.include, config.local.exclude, configDir), opts.sourceFiles, opts.includePatterns, configDir);
434
480
  const parsed = await parseLocalFiles(files, config, opts.tags);
435
481
  resolvedTests = parsed.tests;
436
482
  parseFailures = parsed.failures;
@@ -515,7 +561,7 @@ async function pushSingle(config, configDir, opts) {
515
561
  const titleField = config.sync?.titleField ?? 'System.Title';
516
562
  const conflictAction = config.sync?.conflictAction ?? 'overwrite';
517
563
  const disableLocal = config.sync?.disableLocalChanges ?? false;
518
- const byFolder = config.testPlan.suiteMapping === 'byFolder' || config.testPlan.suiteMapping === 'byFile';
564
+ const byFolder = (0, test_cases_1.usesGeneratedSuiteHierarchy)(config);
519
565
  // Load local cache for conflict detection and skip optimisation
520
566
  const cache = (0, cache_1.loadCache)(configDir);
521
567
  // G: seed in-memory suite cache from persisted _suites to avoid redundant API calls
@@ -535,18 +581,24 @@ async function pushSingle(config, configDir, opts) {
535
581
  const markAutomated = config.sync?.markAutomated ?? false;
536
582
  const recoveredIds = new Set(); // "filePath:line" keys
537
583
  let preloadedRemoteTcs;
584
+ let remoteByTitle;
538
585
  const unlinkedWithAtName = resolvedTests.filter(t => !t.azureId && t.automatedTestName);
539
- if (markAutomated && unlinkedWithAtName.length > 0) {
586
+ const shouldPreloadRemoteTcs = (markAutomated && unlinkedWithAtName.length > 0)
587
+ || (opts.linkOnly && resolvedTests.some((test) => !test.azureId));
588
+ if (shouldPreloadRemoteTcs) {
540
589
  try {
541
590
  preloadedRemoteTcs = await (0, test_cases_1.getTestCasesInSuite)(client, config);
542
- const byAtName = new Map(preloadedRemoteTcs
543
- .filter(tc => tc.automatedTestName)
544
- .map(tc => [tc.automatedTestName, tc]));
545
- for (const test of unlinkedWithAtName) {
546
- const match = byAtName.get(test.automatedTestName);
547
- if (match) {
548
- test.azureId = match.id;
549
- recoveredIds.add(`${test.filePath}:${test.line}`);
591
+ remoteByTitle = mapRemoteTestsByTitle(preloadedRemoteTcs);
592
+ if (markAutomated && unlinkedWithAtName.length > 0) {
593
+ const byAtName = new Map(preloadedRemoteTcs
594
+ .filter(tc => tc.automatedTestName)
595
+ .map(tc => [tc.automatedTestName, tc]));
596
+ for (const test of unlinkedWithAtName) {
597
+ const match = byAtName.get(test.automatedTestName);
598
+ if (match) {
599
+ test.azureId = match.id;
600
+ recoveredIds.add(`${test.filePath}:${test.line}`);
601
+ }
550
602
  }
551
603
  }
552
604
  }
@@ -594,7 +646,19 @@ async function pushSingle(config, configDir, opts) {
594
646
  const pushWorkers = Array.from({ length: Math.min(PUSH_CONCURRENCY, pushQueue.length) }, async () => {
595
647
  while (pushQueue.length > 0) {
596
648
  const test = pushQueue.shift();
649
+ const currentHierarchyKey = byFolder ? (0, test_cases_1.getGeneratedSuitePathKeyForTest)(config, test, configDir) : undefined;
650
+ const targetSuitePath = formatSuitePreview(currentHierarchyKey);
597
651
  if (test.azureId) {
652
+ if (opts.createOnly || opts.linkOnly) {
653
+ reportProgress({
654
+ action: 'skipped',
655
+ filePath: test.filePath,
656
+ title: test.title,
657
+ azureId: test.azureId,
658
+ detail: opts.createOnly ? 'skipped by create-only mode' : 'already linked; skipped by link-only mode',
659
+ });
660
+ continue;
661
+ }
598
662
  try {
599
663
  const cached = cache[test.azureId];
600
664
  // Use pre-fetched TC from cache; fall back to live fetch on miss
@@ -602,11 +666,21 @@ async function pushSingle(config, configDir, opts) {
602
666
  ? remoteTcCache.get(test.azureId)
603
667
  : await (0, test_cases_1.getTestCase)(client, test.azureId, titleField);
604
668
  if (!remote) {
669
+ if (opts.updateOnly) {
670
+ reportProgress({
671
+ action: 'skipped',
672
+ filePath: test.filePath,
673
+ title: test.title,
674
+ azureId: test.azureId,
675
+ detail: 'remote test case missing; skipped by update-only mode',
676
+ });
677
+ continue;
678
+ }
605
679
  // TC was deleted from Azure — re-create it and write back the new ID.
606
680
  let newId;
607
681
  if (!opts.dryRun) {
608
682
  const suiteIdOverride = byFolder
609
- ? await (0, test_cases_1.getOrCreateSuiteForFile)(client, config, test.filePath, configDir, suiteCache)
683
+ ? await (0, test_cases_1.getOrCreateGeneratedSuiteForTest)(client, config, test, configDir, suiteCache)
610
684
  : await resolveTargetSuiteFromRouting(client, config, test, suiteCache);
611
685
  newId = await (0, test_cases_1.createTestCase)(client, test, config, suiteIdOverride, configDir);
612
686
  createdIds.add(newId);
@@ -616,7 +690,7 @@ async function pushSingle(config, configDir, opts) {
616
690
  await (0, test_cases_1.addTestCaseToConditionSuites)(client, config, newId, test, conditionSuiteCache);
617
691
  const created = await (0, test_cases_1.getTestCase)(client, newId, titleField);
618
692
  if (created)
619
- updateCacheEntry(cache, test, created);
693
+ updateCacheEntry(cache, test, created, currentHierarchyKey);
620
694
  }
621
695
  reportProgress({ action: 'created', filePath: test.filePath, title: test.title, azureId: newId });
622
696
  continue;
@@ -625,23 +699,44 @@ async function pushSingle(config, configDir, opts) {
625
699
  // Normalise local to @param@ before comparing so outlines don't report
626
700
  // stepsChanged on every push after the first successful sync.
627
701
  const { changedFields, diffDetail } = buildPushDiff(test, remote, config, cached);
628
- if (changedFields.length === 0) {
702
+ const previousHierarchyKey = cached?.suitePathKey ?? (cached?.filePath
703
+ ? (0, test_cases_1.getGeneratedSuitePathKey)(config, cached.filePath, configDir)
704
+ : undefined);
705
+ const suiteChanged = byFolder &&
706
+ previousHierarchyKey !== undefined &&
707
+ currentHierarchyKey !== undefined &&
708
+ previousHierarchyKey !== currentHierarchyKey;
709
+ const previousSuitePath = formatSuitePreview(previousHierarchyKey);
710
+ const effectiveChangedFields = suiteChanged ? [...changedFields, 'suite'] : changedFields;
711
+ const effectiveDiffDetail = suiteChanged
712
+ ? [
713
+ ...diffDetail,
714
+ {
715
+ field: 'suite',
716
+ remote: previousHierarchyKey || '(plan root)',
717
+ local: currentHierarchyKey || '(plan root)',
718
+ },
719
+ ]
720
+ : diffDetail;
721
+ if (effectiveChangedFields.length === 0) {
629
722
  // Update cache entry even on skip (changedDate may differ due to other fields)
630
- updateCacheEntry(cache, test, remote);
631
- reportProgress({ action: 'skipped', filePath: test.filePath, title: test.title, azureId: test.azureId });
723
+ updateCacheEntry(cache, test, remote, currentHierarchyKey);
724
+ reportProgress({ action: 'skipped', filePath: test.filePath, title: test.title, azureId: test.azureId, detail: 'no changes' });
632
725
  continue;
633
726
  }
634
727
  // Conflict detection: remote was changed since last push AND local also differs
635
- if (cached && remote.changedDate && remote.changedDate !== cached.changedDate) {
728
+ if (changedFields.length > 0 && cached && remote.changedDate && remote.changedDate !== cached.changedDate) {
636
729
  const relFile = path.relative(configDir, test.filePath);
637
730
  const conflict = {
638
731
  action: 'conflict',
639
732
  filePath: test.filePath,
640
733
  title: test.title,
641
734
  azureId: test.azureId,
642
- changedFields,
643
- diffDetail,
644
- detail: `${relFile}:${test.line} — changed fields: ${changedFields.join(', ')}`,
735
+ targetSuitePath,
736
+ previousSuitePath,
737
+ changedFields: effectiveChangedFields,
738
+ diffDetail: effectiveDiffDetail,
739
+ detail: `${relFile}:${test.line} — changed fields: ${effectiveChangedFields.join(', ')}`,
645
740
  };
646
741
  if (conflictAction === 'skip') {
647
742
  reportProgress(conflict);
@@ -655,24 +750,39 @@ async function pushSingle(config, configDir, opts) {
655
750
  // 'overwrite' — fall through to update
656
751
  }
657
752
  if (!opts.dryRun) {
658
- await (0, test_cases_1.updateTestCase)(client, test.azureId, test, config, configDir);
753
+ if (changedFields.length > 0) {
754
+ await (0, test_cases_1.updateTestCase)(client, test.azureId, test, config, configDir);
755
+ }
659
756
  // Ensure the TC is in the configured suite (it may not be if the suite was
660
757
  // changed in config, or if the TC was imported with an ID but never pushed before).
661
758
  const updateSuiteId = byFolder
662
- ? await (0, test_cases_1.getOrCreateSuiteForFile)(client, config, test.filePath, configDir, suiteCache)
759
+ ? await (0, test_cases_1.getOrCreateGeneratedSuiteForTest)(client, config, test, configDir, suiteCache)
663
760
  : await resolveTargetSuiteFromRouting(client, config, test, suiteCache) ?? config.testPlan.suiteId;
664
761
  if (updateSuiteId) {
665
762
  await (0, test_cases_1.addTestCaseToSuite)(client, config, test.azureId, updateSuiteId);
763
+ if (suiteChanged && previousHierarchyKey) {
764
+ const previousSuiteId = cached?.suitePathKey
765
+ ? await (0, test_cases_1.resolveExistingSuiteForPathKey)(client, config, previousHierarchyKey, suiteCache)
766
+ : cached?.filePath
767
+ ? await (0, test_cases_1.resolveExistingSuiteForFile)(client, config, cached.filePath, configDir, suiteCache)
768
+ : undefined;
769
+ if (previousSuiteId && previousSuiteId !== updateSuiteId) {
770
+ await (0, test_cases_1.removeTestCaseFromSuite)(client, config, test.azureId, previousSuiteId);
771
+ if ((0, test_cases_1.shouldCleanupEmptyGeneratedSuites)(config)) {
772
+ await (0, test_cases_1.pruneEmptyGeneratedSuitesForPathKey)(client, config, previousHierarchyKey, suiteCache);
773
+ }
774
+ }
775
+ }
666
776
  }
667
777
  else {
668
778
  await (0, test_cases_1.addTestCaseToRootSuite)(client, config, test.azureId);
669
779
  }
670
780
  await (0, test_cases_1.addTestCaseToConditionSuites)(client, config, test.azureId, test, conditionSuiteCache);
671
- const updated = await (0, test_cases_1.getTestCase)(client, test.azureId, titleField);
781
+ const updated = changedFields.length > 0 ? await (0, test_cases_1.getTestCase)(client, test.azureId, titleField) : remote;
672
782
  if (updated)
673
- updateCacheEntry(cache, test, updated);
783
+ updateCacheEntry(cache, test, updated, currentHierarchyKey);
674
784
  }
675
- reportProgress({ action: 'updated', filePath: test.filePath, title: test.title, azureId: test.azureId, changedFields, diffDetail });
785
+ reportProgress({ action: 'updated', filePath: test.filePath, title: test.title, azureId: test.azureId, targetSuitePath, previousSuitePath, changedFields: effectiveChangedFields, diffDetail: effectiveDiffDetail });
676
786
  }
677
787
  catch (err) {
678
788
  const msg = err instanceof Error ? err.message : String(err);
@@ -680,11 +790,51 @@ async function pushSingle(config, configDir, opts) {
680
790
  }
681
791
  }
682
792
  else {
793
+ if (opts.updateOnly) {
794
+ reportProgress({
795
+ action: 'skipped',
796
+ filePath: test.filePath,
797
+ title: test.title,
798
+ detail: 'unlinked local spec; skipped by update-only mode',
799
+ });
800
+ continue;
801
+ }
802
+ if (opts.linkOnly) {
803
+ try {
804
+ const canonicalTitle = (0, test_cases_1.buildAzureSyncContent)(test, config.sync?.format).title;
805
+ const titleMatches = remoteByTitle?.get(canonicalTitle) ?? [];
806
+ if (titleMatches.length !== 1) {
807
+ const detail = titleMatches.length === 0
808
+ ? 'no unique title match found in current remote scope'
809
+ : `multiple title matches found in current remote scope (${titleMatches.length})`;
810
+ reportProgress({ action: 'skipped', filePath: test.filePath, title: test.title, detail });
811
+ continue;
812
+ }
813
+ const matched = titleMatches[0];
814
+ test.azureId = matched.id;
815
+ recoveredIds.add(`${test.filePath}:${test.line}`);
816
+ if (!opts.dryRun && !disableLocal) {
817
+ pendingWritebacks.push({ test, newId: matched.id });
818
+ }
819
+ const detail = opts.dryRun
820
+ ? 'would link to existing test case by exact title match'
821
+ : disableLocal
822
+ ? 'matched existing test case by exact title match (local writeback skipped)'
823
+ : 'linked to existing test case by exact title match';
824
+ reportProgress({ action: 'linked', filePath: test.filePath, title: test.title, azureId: matched.id, detail });
825
+ continue;
826
+ }
827
+ catch (err) {
828
+ const msg = err instanceof Error ? err.message : String(err);
829
+ reportProgress({ action: 'error', filePath: test.filePath, title: test.title, detail: msg });
830
+ continue;
831
+ }
832
+ }
683
833
  try {
684
834
  let newId;
685
835
  if (!opts.dryRun) {
686
836
  const suiteIdOverride = byFolder
687
- ? await (0, test_cases_1.getOrCreateSuiteForFile)(client, config, test.filePath, configDir, suiteCache)
837
+ ? await (0, test_cases_1.getOrCreateGeneratedSuiteForTest)(client, config, test, configDir, suiteCache)
688
838
  : await resolveTargetSuiteFromRouting(client, config, test, suiteCache);
689
839
  newId = await (0, test_cases_1.createTestCase)(client, test, config, suiteIdOverride, configDir);
690
840
  createdIds.add(newId);
@@ -695,9 +845,9 @@ async function pushSingle(config, configDir, opts) {
695
845
  // Fetch back to get changedDate for cache
696
846
  const created = await (0, test_cases_1.getTestCase)(client, newId, titleField);
697
847
  if (created)
698
- updateCacheEntry(cache, test, created);
848
+ updateCacheEntry(cache, test, created, currentHierarchyKey);
699
849
  }
700
- reportProgress({ action: 'created', filePath: test.filePath, title: test.title, azureId: newId });
850
+ reportProgress({ action: 'created', filePath: test.filePath, title: test.title, azureId: newId, targetSuitePath });
701
851
  }
702
852
  catch (err) {
703
853
  const msg = err instanceof Error ? err.message : String(err);
@@ -754,17 +904,17 @@ async function pushSingle(config, configDir, opts) {
754
904
  }
755
905
  }
756
906
  // Removed TC detection is only safe on full-scope runs.
757
- // Tag-filtered or condition-filtered pushes intentionally operate on a subset.
758
- const shouldDetectRemoved = !opts.tags && !config.local.condition;
907
+ // Tag-filtered, source-file-filtered, create-only, or condition-filtered pushes intentionally operate on a subset.
908
+ const shouldDetectRemoved = !opts.tags && !opts.sourceFiles?.length && !opts.createOnly && !opts.linkOnly && !opts.updateOnly && !config.local.condition;
909
+ const localIds = new Set([
910
+ ...resolvedTests.map((t) => t.azureId).filter(Boolean),
911
+ ...createdIds,
912
+ ]);
759
913
  if (shouldDetectRemoved) {
760
914
  try {
761
915
  // Reuse the pre-loaded remote TCs if we already fetched them for automatedTestName
762
916
  // matching, otherwise fetch now. This avoids a redundant round-trip.
763
917
  const remoteTcs = preloadedRemoteTcs ?? await (0, test_cases_1.getTestCasesInSuite)(client, config);
764
- const localIds = new Set([
765
- ...resolvedTests.map((t) => t.azureId).filter(Boolean),
766
- ...createdIds,
767
- ]);
768
918
  for (const remote of remoteTcs) {
769
919
  if (!localIds.has(remote.id)) {
770
920
  if (!opts.dryRun) {
@@ -782,6 +932,22 @@ async function pushSingle(config, configDir, opts) {
782
932
  }
783
933
  catch { /* best-effort: don't fail the whole push */ }
784
934
  }
935
+ if (!opts.dryRun && (0, test_cases_1.shouldCleanupEmptyGeneratedSuites)(config) && shouldDetectRemoved) {
936
+ const staleHierarchyKeys = [...new Set(Object.entries(cache)
937
+ .filter(([cacheKey]) => cacheKey !== '_suites')
938
+ .map(([cacheKey, entry]) => ({ azureId: Number(cacheKey), entry }))
939
+ .filter(({ azureId, entry }) => Number.isFinite(azureId) && !localIds.has(azureId) && !!entry)
940
+ .map(({ entry }) => entry.suitePathKey ?? (0, test_cases_1.getGeneratedSuitePathKey)(config, entry.filePath, configDir))
941
+ .filter((pathKey) => !!pathKey))];
942
+ for (const staleHierarchyKey of staleHierarchyKeys) {
943
+ try {
944
+ await (0, test_cases_1.pruneEmptyGeneratedSuitesForPathKey)(client, config, staleHierarchyKey, suiteCache);
945
+ }
946
+ catch {
947
+ // Best-effort cleanup; do not fail the push when stale branch pruning cannot complete.
948
+ }
949
+ }
950
+ }
785
951
  if (!opts.dryRun) {
786
952
  // G: persist suite name→id map so the next push avoids redundant API traversals
787
953
  if (suiteCache.size > 0) {
@@ -803,7 +969,7 @@ async function pull(config, configDir, opts = {}) {
803
969
  return pullSingle(config, configDir, opts);
804
970
  }
805
971
  async function pullSingle(config, configDir, opts) {
806
- const files = await discoverFiles(config.local.include, config.local.exclude, configDir);
972
+ const files = filterDiscoveredFiles(await discoverFiles(config.local.include, config.local.exclude, configDir), opts.sourceFiles, opts.includePatterns, configDir);
807
973
  const parsed = await parseLocalFiles(files, config, opts.tags);
808
974
  failOnParseErrors('pull', parsed.failures);
809
975
  const tests = parsed.tests;
@@ -841,7 +1007,7 @@ async function pullSingle(config, configDir, opts) {
841
1007
  const stepsChanged = remoteStepsText !== localStepsText;
842
1008
  const descriptionChanged = (remote.description ?? '') !== (test.description ?? '');
843
1009
  if (!titleChanged && !stepsChanged && !descriptionChanged) {
844
- reportProgress({ action: 'skipped', filePath: test.filePath, title: test.title, azureId: test.azureId });
1010
+ reportProgress({ action: 'skipped', filePath: test.filePath, title: test.title, azureId: test.azureId, detail: 'no changes' });
845
1011
  continue;
846
1012
  }
847
1013
  if (!opts.dryRun) {
@@ -904,6 +1070,14 @@ async function pullSingle(config, configDir, opts) {
904
1070
  detail: `Pull-create is not supported for local.type "${config.local.type}".`,
905
1071
  });
906
1072
  }
1073
+ else if (opts.sourceFiles?.length) {
1074
+ results.push({
1075
+ action: 'error',
1076
+ filePath: '',
1077
+ title: '',
1078
+ detail: 'Pull-create is not supported together with --source-file because partial runs cannot infer which unlinked remote tests to materialize.',
1079
+ });
1080
+ }
907
1081
  else {
908
1082
  try {
909
1083
  const remoteTcs = await (0, test_cases_1.getTestCasesInSuite)(client, config);
@@ -939,10 +1113,18 @@ async function pullSingle(config, configDir, opts) {
939
1113
  }
940
1114
  // ─── Status ───────────────────────────────────────────────────────────────────
941
1115
  async function status(config, configDir, opts = {}) {
942
- return push(config, configDir, { dryRun: true, tags: opts.tags, onProgress: opts.onProgress, onAiProgress: opts.onAiProgress, aiSummary: opts.aiSummary });
1116
+ return push(config, configDir, {
1117
+ dryRun: true,
1118
+ tags: opts.tags,
1119
+ sourceFiles: opts.sourceFiles,
1120
+ includePatterns: opts.includePatterns,
1121
+ onProgress: opts.onProgress,
1122
+ onAiProgress: opts.onAiProgress,
1123
+ aiSummary: opts.aiSummary,
1124
+ });
943
1125
  }
944
1126
  // ─── Cache helpers ────────────────────────────────────────────────────────────
945
- function updateCacheEntry(cache, test, remote) {
1127
+ function updateCacheEntry(cache, test, remote, suitePathKey) {
946
1128
  if (!remote.changedDate)
947
1129
  return;
948
1130
  cache[remote.id] = {
@@ -955,6 +1137,7 @@ function updateCacheEntry(cache, test, remote) {
955
1137
  remoteDescriptionHash: (0, cache_1.hashString)(remote.description),
956
1138
  changedDate: remote.changedDate,
957
1139
  filePath: test.filePath,
1140
+ suitePathKey,
958
1141
  };
959
1142
  }
960
1143
  // ─── Apply remote changes to local file ───────────────────────────────────────
@@ -1173,6 +1356,52 @@ function buildPullMarkdownContent(tc, tagPrefix) {
1173
1356
  }
1174
1357
  return lines.join('\n');
1175
1358
  }
1359
+ function evaluateClassificationRules(rules, testTags, testPath) {
1360
+ for (const rule of rules) {
1361
+ if (rule.match.default) {
1362
+ return { classification: rule.classification, matchedRule: rule, destinations: rule.destinations };
1363
+ }
1364
+ if (rule.match.tags?.length) {
1365
+ const normalizedTags = testTags.map((t) => t.startsWith('@') ? t : `@${t}`);
1366
+ const matchTags = rule.match.tags.map((t) => t.startsWith('@') ? t : `@${t}`);
1367
+ if (matchTags.some((mt) => normalizedTags.includes(mt))) {
1368
+ return { classification: rule.classification, matchedRule: rule, destinations: rule.destinations };
1369
+ }
1370
+ }
1371
+ if (rule.match.path) {
1372
+ if ((0, minimatch_1.minimatch)(testPath, rule.match.path, { dot: true })) {
1373
+ return { classification: rule.classification, matchedRule: rule, destinations: rule.destinations };
1374
+ }
1375
+ }
1376
+ }
1377
+ return undefined;
1378
+ }
1379
+ // ─── Staleness policy helpers ────────────────────────────────────────────────
1380
+ function parseDuration(duration) {
1381
+ const match = duration.match(/^(\d+)\s*(d|h|m|w)$/i);
1382
+ if (!match)
1383
+ throw new Error(`Invalid duration format: "${duration}". Expected format: 30d, 24h, 7w, etc.`);
1384
+ const value = parseInt(match[1], 10);
1385
+ const unit = match[2].toLowerCase();
1386
+ const ms = { m: 60_000, h: 3_600_000, d: 86_400_000, w: 604_800_000 };
1387
+ return value * ms[unit];
1388
+ }
1389
+ function enforceStalenessPolicy(staleCases, policy) {
1390
+ if (!policy)
1391
+ return { prunable: staleCases, exceeded: false };
1392
+ let prunable = staleCases;
1393
+ if (policy.maxAge) {
1394
+ const maxAgeMs = parseDuration(policy.maxAge);
1395
+ const cutoff = Date.now() - maxAgeMs;
1396
+ prunable = prunable.filter((tc) => {
1397
+ if (!tc.lastSyncDate)
1398
+ return true;
1399
+ return new Date(tc.lastSyncDate).getTime() < cutoff;
1400
+ });
1401
+ }
1402
+ const exceeded = policy.maxPruneCount !== undefined && prunable.length > policy.maxPruneCount;
1403
+ return { prunable, exceeded };
1404
+ }
1176
1405
  /**
1177
1406
  * Detect Azure DevOps Test Cases that have no corresponding local spec.
1178
1407
  * A TC is "stale" when it exists in the plan suite but no local file references it
@@ -1180,7 +1409,7 @@ function buildPullMarkdownContent(tc, tagPrefix) {
1180
1409
  * without running push, or when TCs are created directly in Azure without a local spec.
1181
1410
  */
1182
1411
  async function detectStaleTestCases(config, configDir, opts = {}) {
1183
- const files = await discoverFiles(config.local.include, config.local.exclude, configDir);
1412
+ const files = filterDiscoveredFiles(await discoverFiles(config.local.include, config.local.exclude, configDir), opts.sourceFiles, opts.includePatterns, configDir);
1184
1413
  const parsed = await parseLocalFiles(files, config, opts.tags);
1185
1414
  failOnParseErrors('stale test case detection', parsed.failures);
1186
1415
  const tests = parsed.tests;
@@ -1211,7 +1440,7 @@ async function detectStaleTestCases(config, configDir, opts = {}) {
1211
1440
  * 2. Story coverage — % of referenced User Stories that have at least one linked spec
1212
1441
  */
1213
1442
  async function coverageReport(config, configDir, opts = {}) {
1214
- const files = await discoverFiles(config.local.include, config.local.exclude, configDir);
1443
+ const files = filterDiscoveredFiles(await discoverFiles(config.local.include, config.local.exclude, configDir), opts.sourceFiles, opts.includePatterns, configDir);
1215
1444
  const parsed = await parseLocalFiles(files, config, opts.tags);
1216
1445
  failOnParseErrors('coverage report', parsed.failures);
1217
1446
  const tests = parsed.tests;