ado-sync 0.1.64 → 0.1.67

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 (63) hide show
  1. package/README.md +20 -15
  2. package/dist/__tests__/regressions.test.js +1011 -1
  3. package/dist/__tests__/regressions.test.js.map +1 -1
  4. package/dist/ai/generate-spec.d.ts +1 -1
  5. package/dist/ai/generate-spec.js +23 -0
  6. package/dist/ai/generate-spec.js.map +1 -1
  7. package/dist/ai/summarizer.d.ts +3 -2
  8. package/dist/ai/summarizer.js +50 -1
  9. package/dist/ai/summarizer.js.map +1 -1
  10. package/dist/azure/test-cases.d.ts +11 -1
  11. package/dist/azure/test-cases.js +286 -43
  12. package/dist/azure/test-cases.js.map +1 -1
  13. package/dist/cli.js +91 -14
  14. package/dist/cli.js.map +1 -1
  15. package/dist/config.js +74 -1
  16. package/dist/config.js.map +1 -1
  17. package/dist/id-markers.d.ts +1 -0
  18. package/dist/id-markers.js +13 -0
  19. package/dist/id-markers.js.map +1 -1
  20. package/dist/mcp-server.js +1 -1
  21. package/dist/mcp-server.js.map +1 -1
  22. package/dist/sync/cache.d.ts +2 -0
  23. package/dist/sync/cache.js.map +1 -1
  24. package/dist/sync/engine.d.ts +12 -1
  25. package/dist/sync/engine.js +210 -41
  26. package/dist/sync/engine.js.map +1 -1
  27. package/dist/types.d.ts +56 -4
  28. package/llms.txt +12 -11
  29. package/package.json +8 -1
  30. package/docs/advanced.md +0 -988
  31. package/docs/agent-setup.md +0 -204
  32. package/docs/capability-roadmap.md +0 -280
  33. package/docs/cli.md +0 -609
  34. package/docs/configuration.md +0 -322
  35. package/docs/examples/csharp-mstest-local-llm.yaml +0 -35
  36. package/docs/examples/csharp-mstest.yaml +0 -21
  37. package/docs/examples/csharp-nunit.yaml +0 -21
  38. package/docs/examples/csharp-specflow.yaml +0 -16
  39. package/docs/examples/cypress.yaml +0 -21
  40. package/docs/examples/detox-react-native.yaml +0 -21
  41. package/docs/examples/espresso-android.yaml +0 -21
  42. package/docs/examples/flutter-dart.yaml +0 -21
  43. package/docs/examples/java-junit.yaml +0 -21
  44. package/docs/examples/java-testng.yaml +0 -21
  45. package/docs/examples/js-jasmine-wdio.yaml +0 -21
  46. package/docs/examples/js-jest.yaml +0 -21
  47. package/docs/examples/playwright-js.yaml +0 -21
  48. package/docs/examples/playwright-ts.yaml +0 -21
  49. package/docs/examples/puppeteer.yaml +0 -21
  50. package/docs/examples/python-pytest.yaml +0 -21
  51. package/docs/examples/robot-framework.yaml +0 -19
  52. package/docs/examples/testcafe.yaml +0 -21
  53. package/docs/examples/xcuitest-ios.yaml +0 -21
  54. package/docs/mcp-server.md +0 -312
  55. package/docs/publish-test-results.md +0 -939
  56. package/docs/spec-formats.md +0 -1357
  57. package/docs/troubleshooting.md +0 -101
  58. package/docs/vscode-extension.md +0 -139
  59. package/docs/work-item-links.md +0 -115
  60. package/docs/workflows.md +0 -457
  61. package/mkdocs.yml +0 -40
  62. package/requirements-docs.txt +0 -4
  63. package/scripts/build_site.sh +0 -6
@@ -41,6 +41,7 @@ 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;
@@ -313,6 +314,36 @@ async function parseLocalFiles(filePaths, config, tagsFilter) {
313
314
  }
314
315
  return { tests: results, failures };
315
316
  }
317
+ function validatePushModeOptions(opts) {
318
+ const enabledModes = [
319
+ opts.createOnly && '--create-only',
320
+ opts.linkOnly && '--link-only',
321
+ opts.updateOnly && '--update-only',
322
+ ].filter(Boolean);
323
+ if (enabledModes.length > 1) {
324
+ throw new Error(`Only one push mode can be used at a time: ${enabledModes.join(', ')}`);
325
+ }
326
+ }
327
+ function filterDiscoveredFiles(filePaths, sourceFiles) {
328
+ if (!sourceFiles?.length)
329
+ return filePaths;
330
+ const requested = new Set(sourceFiles.map((filePath) => path.normalize(path.resolve(filePath))));
331
+ return filePaths.filter((filePath) => requested.has(path.normalize(filePath)));
332
+ }
333
+ function formatSuitePreview(pathKey) {
334
+ if (!pathKey)
335
+ return undefined;
336
+ return pathKey.split('/').join(' / ');
337
+ }
338
+ function mapRemoteTestsByTitle(testCases) {
339
+ const byTitle = new Map();
340
+ for (const testCase of testCases) {
341
+ const matches = byTitle.get(testCase.title) ?? [];
342
+ matches.push(testCase);
343
+ byTitle.set(testCase.title, matches);
344
+ }
345
+ return byTitle;
346
+ }
316
347
  // ─── Multi-plan helpers ───────────────────────────────────────────────────────
317
348
  /**
318
349
  * Build an effective config for a single plan entry in testPlans[] mode.
@@ -330,6 +361,7 @@ function configForPlanEntry(base, entry) {
330
361
  testPlan: {
331
362
  id: entry.id,
332
363
  suiteId: entry.suiteId ?? base.testPlan.suiteId,
364
+ hierarchy: entry.hierarchy ?? base.testPlan.hierarchy,
333
365
  suiteMapping: entry.suiteMapping ?? base.testPlan.suiteMapping,
334
366
  suiteRouting: entry.suiteRouting ?? base.testPlan.suiteRouting,
335
367
  },
@@ -375,6 +407,7 @@ async function resolveTargetSuiteFromRouting(client, config, test, suiteCache) {
375
407
  }
376
408
  // ─── Push ─────────────────────────────────────────────────────────────────────
377
409
  async function push(config, configDir, opts = {}) {
410
+ validatePushModeOptions(opts);
378
411
  // Multi-plan: run AI summarisation across all plans first so progress totals
379
412
  // are correct, then delegate each plan's sync loop (with AI already applied).
380
413
  if (config.testPlans?.length) {
@@ -383,7 +416,7 @@ async function push(config, configDir, opts = {}) {
383
416
  const parseFailures = [];
384
417
  for (const entry of config.testPlans) {
385
418
  const entryConfig = configForPlanEntry(config, entry);
386
- const files = await discoverFiles(entryConfig.local.include, entryConfig.local.exclude, configDir);
419
+ const files = filterDiscoveredFiles(await discoverFiles(entryConfig.local.include, entryConfig.local.exclude, configDir), opts.sourceFiles);
387
420
  const parsed = await parseLocalFiles(files, entryConfig, opts.tags);
388
421
  parseFailures.push(...parsed.failures);
389
422
  const tests = parsed.tests;
@@ -430,7 +463,7 @@ async function pushSingle(config, configDir, opts) {
430
463
  let resolvedTests = opts._preloadedTests ?? [];
431
464
  let parseFailures = [];
432
465
  if (!opts._preloadedTests) {
433
- const files = await discoverFiles(config.local.include, config.local.exclude, configDir);
466
+ const files = filterDiscoveredFiles(await discoverFiles(config.local.include, config.local.exclude, configDir), opts.sourceFiles);
434
467
  const parsed = await parseLocalFiles(files, config, opts.tags);
435
468
  resolvedTests = parsed.tests;
436
469
  parseFailures = parsed.failures;
@@ -515,7 +548,7 @@ async function pushSingle(config, configDir, opts) {
515
548
  const titleField = config.sync?.titleField ?? 'System.Title';
516
549
  const conflictAction = config.sync?.conflictAction ?? 'overwrite';
517
550
  const disableLocal = config.sync?.disableLocalChanges ?? false;
518
- const byFolder = config.testPlan.suiteMapping === 'byFolder' || config.testPlan.suiteMapping === 'byFile';
551
+ const byFolder = (0, test_cases_1.usesGeneratedSuiteHierarchy)(config);
519
552
  // Load local cache for conflict detection and skip optimisation
520
553
  const cache = (0, cache_1.loadCache)(configDir);
521
554
  // G: seed in-memory suite cache from persisted _suites to avoid redundant API calls
@@ -535,18 +568,24 @@ async function pushSingle(config, configDir, opts) {
535
568
  const markAutomated = config.sync?.markAutomated ?? false;
536
569
  const recoveredIds = new Set(); // "filePath:line" keys
537
570
  let preloadedRemoteTcs;
571
+ let remoteByTitle;
538
572
  const unlinkedWithAtName = resolvedTests.filter(t => !t.azureId && t.automatedTestName);
539
- if (markAutomated && unlinkedWithAtName.length > 0) {
573
+ const shouldPreloadRemoteTcs = (markAutomated && unlinkedWithAtName.length > 0)
574
+ || (opts.linkOnly && resolvedTests.some((test) => !test.azureId));
575
+ if (shouldPreloadRemoteTcs) {
540
576
  try {
541
577
  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}`);
578
+ remoteByTitle = mapRemoteTestsByTitle(preloadedRemoteTcs);
579
+ if (markAutomated && unlinkedWithAtName.length > 0) {
580
+ const byAtName = new Map(preloadedRemoteTcs
581
+ .filter(tc => tc.automatedTestName)
582
+ .map(tc => [tc.automatedTestName, tc]));
583
+ for (const test of unlinkedWithAtName) {
584
+ const match = byAtName.get(test.automatedTestName);
585
+ if (match) {
586
+ test.azureId = match.id;
587
+ recoveredIds.add(`${test.filePath}:${test.line}`);
588
+ }
550
589
  }
551
590
  }
552
591
  }
@@ -594,7 +633,19 @@ async function pushSingle(config, configDir, opts) {
594
633
  const pushWorkers = Array.from({ length: Math.min(PUSH_CONCURRENCY, pushQueue.length) }, async () => {
595
634
  while (pushQueue.length > 0) {
596
635
  const test = pushQueue.shift();
636
+ const currentHierarchyKey = byFolder ? (0, test_cases_1.getGeneratedSuitePathKeyForTest)(config, test, configDir) : undefined;
637
+ const targetSuitePath = formatSuitePreview(currentHierarchyKey);
597
638
  if (test.azureId) {
639
+ if (opts.createOnly || opts.linkOnly) {
640
+ reportProgress({
641
+ action: 'skipped',
642
+ filePath: test.filePath,
643
+ title: test.title,
644
+ azureId: test.azureId,
645
+ detail: opts.createOnly ? 'skipped by create-only mode' : 'already linked; skipped by link-only mode',
646
+ });
647
+ continue;
648
+ }
598
649
  try {
599
650
  const cached = cache[test.azureId];
600
651
  // Use pre-fetched TC from cache; fall back to live fetch on miss
@@ -602,11 +653,21 @@ async function pushSingle(config, configDir, opts) {
602
653
  ? remoteTcCache.get(test.azureId)
603
654
  : await (0, test_cases_1.getTestCase)(client, test.azureId, titleField);
604
655
  if (!remote) {
656
+ if (opts.updateOnly) {
657
+ reportProgress({
658
+ action: 'skipped',
659
+ filePath: test.filePath,
660
+ title: test.title,
661
+ azureId: test.azureId,
662
+ detail: 'remote test case missing; skipped by update-only mode',
663
+ });
664
+ continue;
665
+ }
605
666
  // TC was deleted from Azure — re-create it and write back the new ID.
606
667
  let newId;
607
668
  if (!opts.dryRun) {
608
669
  const suiteIdOverride = byFolder
609
- ? await (0, test_cases_1.getOrCreateSuiteForFile)(client, config, test.filePath, configDir, suiteCache)
670
+ ? await (0, test_cases_1.getOrCreateGeneratedSuiteForTest)(client, config, test, configDir, suiteCache)
610
671
  : await resolveTargetSuiteFromRouting(client, config, test, suiteCache);
611
672
  newId = await (0, test_cases_1.createTestCase)(client, test, config, suiteIdOverride, configDir);
612
673
  createdIds.add(newId);
@@ -616,7 +677,7 @@ async function pushSingle(config, configDir, opts) {
616
677
  await (0, test_cases_1.addTestCaseToConditionSuites)(client, config, newId, test, conditionSuiteCache);
617
678
  const created = await (0, test_cases_1.getTestCase)(client, newId, titleField);
618
679
  if (created)
619
- updateCacheEntry(cache, test, created);
680
+ updateCacheEntry(cache, test, created, currentHierarchyKey);
620
681
  }
621
682
  reportProgress({ action: 'created', filePath: test.filePath, title: test.title, azureId: newId });
622
683
  continue;
@@ -625,23 +686,44 @@ async function pushSingle(config, configDir, opts) {
625
686
  // Normalise local to @param@ before comparing so outlines don't report
626
687
  // stepsChanged on every push after the first successful sync.
627
688
  const { changedFields, diffDetail } = buildPushDiff(test, remote, config, cached);
628
- if (changedFields.length === 0) {
689
+ const previousHierarchyKey = cached?.suitePathKey ?? (cached?.filePath
690
+ ? (0, test_cases_1.getGeneratedSuitePathKey)(config, cached.filePath, configDir)
691
+ : undefined);
692
+ const suiteChanged = byFolder &&
693
+ previousHierarchyKey !== undefined &&
694
+ currentHierarchyKey !== undefined &&
695
+ previousHierarchyKey !== currentHierarchyKey;
696
+ const previousSuitePath = formatSuitePreview(previousHierarchyKey);
697
+ const effectiveChangedFields = suiteChanged ? [...changedFields, 'suite'] : changedFields;
698
+ const effectiveDiffDetail = suiteChanged
699
+ ? [
700
+ ...diffDetail,
701
+ {
702
+ field: 'suite',
703
+ remote: previousHierarchyKey || '(plan root)',
704
+ local: currentHierarchyKey || '(plan root)',
705
+ },
706
+ ]
707
+ : diffDetail;
708
+ if (effectiveChangedFields.length === 0) {
629
709
  // 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 });
710
+ updateCacheEntry(cache, test, remote, currentHierarchyKey);
711
+ reportProgress({ action: 'skipped', filePath: test.filePath, title: test.title, azureId: test.azureId, detail: 'no changes' });
632
712
  continue;
633
713
  }
634
714
  // Conflict detection: remote was changed since last push AND local also differs
635
- if (cached && remote.changedDate && remote.changedDate !== cached.changedDate) {
715
+ if (changedFields.length > 0 && cached && remote.changedDate && remote.changedDate !== cached.changedDate) {
636
716
  const relFile = path.relative(configDir, test.filePath);
637
717
  const conflict = {
638
718
  action: 'conflict',
639
719
  filePath: test.filePath,
640
720
  title: test.title,
641
721
  azureId: test.azureId,
642
- changedFields,
643
- diffDetail,
644
- detail: `${relFile}:${test.line} — changed fields: ${changedFields.join(', ')}`,
722
+ targetSuitePath,
723
+ previousSuitePath,
724
+ changedFields: effectiveChangedFields,
725
+ diffDetail: effectiveDiffDetail,
726
+ detail: `${relFile}:${test.line} — changed fields: ${effectiveChangedFields.join(', ')}`,
645
727
  };
646
728
  if (conflictAction === 'skip') {
647
729
  reportProgress(conflict);
@@ -655,24 +737,39 @@ async function pushSingle(config, configDir, opts) {
655
737
  // 'overwrite' — fall through to update
656
738
  }
657
739
  if (!opts.dryRun) {
658
- await (0, test_cases_1.updateTestCase)(client, test.azureId, test, config, configDir);
740
+ if (changedFields.length > 0) {
741
+ await (0, test_cases_1.updateTestCase)(client, test.azureId, test, config, configDir);
742
+ }
659
743
  // Ensure the TC is in the configured suite (it may not be if the suite was
660
744
  // changed in config, or if the TC was imported with an ID but never pushed before).
661
745
  const updateSuiteId = byFolder
662
- ? await (0, test_cases_1.getOrCreateSuiteForFile)(client, config, test.filePath, configDir, suiteCache)
746
+ ? await (0, test_cases_1.getOrCreateGeneratedSuiteForTest)(client, config, test, configDir, suiteCache)
663
747
  : await resolveTargetSuiteFromRouting(client, config, test, suiteCache) ?? config.testPlan.suiteId;
664
748
  if (updateSuiteId) {
665
749
  await (0, test_cases_1.addTestCaseToSuite)(client, config, test.azureId, updateSuiteId);
750
+ if (suiteChanged && previousHierarchyKey) {
751
+ const previousSuiteId = cached?.suitePathKey
752
+ ? await (0, test_cases_1.resolveExistingSuiteForPathKey)(client, config, previousHierarchyKey, suiteCache)
753
+ : cached?.filePath
754
+ ? await (0, test_cases_1.resolveExistingSuiteForFile)(client, config, cached.filePath, configDir, suiteCache)
755
+ : undefined;
756
+ if (previousSuiteId && previousSuiteId !== updateSuiteId) {
757
+ await (0, test_cases_1.removeTestCaseFromSuite)(client, config, test.azureId, previousSuiteId);
758
+ if ((0, test_cases_1.shouldCleanupEmptyGeneratedSuites)(config)) {
759
+ await (0, test_cases_1.pruneEmptyGeneratedSuitesForPathKey)(client, config, previousHierarchyKey, suiteCache);
760
+ }
761
+ }
762
+ }
666
763
  }
667
764
  else {
668
765
  await (0, test_cases_1.addTestCaseToRootSuite)(client, config, test.azureId);
669
766
  }
670
767
  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);
768
+ const updated = changedFields.length > 0 ? await (0, test_cases_1.getTestCase)(client, test.azureId, titleField) : remote;
672
769
  if (updated)
673
- updateCacheEntry(cache, test, updated);
770
+ updateCacheEntry(cache, test, updated, currentHierarchyKey);
674
771
  }
675
- reportProgress({ action: 'updated', filePath: test.filePath, title: test.title, azureId: test.azureId, changedFields, diffDetail });
772
+ reportProgress({ action: 'updated', filePath: test.filePath, title: test.title, azureId: test.azureId, targetSuitePath, previousSuitePath, changedFields: effectiveChangedFields, diffDetail: effectiveDiffDetail });
676
773
  }
677
774
  catch (err) {
678
775
  const msg = err instanceof Error ? err.message : String(err);
@@ -680,11 +777,51 @@ async function pushSingle(config, configDir, opts) {
680
777
  }
681
778
  }
682
779
  else {
780
+ if (opts.updateOnly) {
781
+ reportProgress({
782
+ action: 'skipped',
783
+ filePath: test.filePath,
784
+ title: test.title,
785
+ detail: 'unlinked local spec; skipped by update-only mode',
786
+ });
787
+ continue;
788
+ }
789
+ if (opts.linkOnly) {
790
+ try {
791
+ const canonicalTitle = (0, test_cases_1.buildAzureSyncContent)(test, config.sync?.format).title;
792
+ const titleMatches = remoteByTitle?.get(canonicalTitle) ?? [];
793
+ if (titleMatches.length !== 1) {
794
+ const detail = titleMatches.length === 0
795
+ ? 'no unique title match found in current remote scope'
796
+ : `multiple title matches found in current remote scope (${titleMatches.length})`;
797
+ reportProgress({ action: 'skipped', filePath: test.filePath, title: test.title, detail });
798
+ continue;
799
+ }
800
+ const matched = titleMatches[0];
801
+ test.azureId = matched.id;
802
+ recoveredIds.add(`${test.filePath}:${test.line}`);
803
+ if (!opts.dryRun && !disableLocal) {
804
+ pendingWritebacks.push({ test, newId: matched.id });
805
+ }
806
+ const detail = opts.dryRun
807
+ ? 'would link to existing test case by exact title match'
808
+ : disableLocal
809
+ ? 'matched existing test case by exact title match (local writeback skipped)'
810
+ : 'linked to existing test case by exact title match';
811
+ reportProgress({ action: 'linked', filePath: test.filePath, title: test.title, azureId: matched.id, detail });
812
+ continue;
813
+ }
814
+ catch (err) {
815
+ const msg = err instanceof Error ? err.message : String(err);
816
+ reportProgress({ action: 'error', filePath: test.filePath, title: test.title, detail: msg });
817
+ continue;
818
+ }
819
+ }
683
820
  try {
684
821
  let newId;
685
822
  if (!opts.dryRun) {
686
823
  const suiteIdOverride = byFolder
687
- ? await (0, test_cases_1.getOrCreateSuiteForFile)(client, config, test.filePath, configDir, suiteCache)
824
+ ? await (0, test_cases_1.getOrCreateGeneratedSuiteForTest)(client, config, test, configDir, suiteCache)
688
825
  : await resolveTargetSuiteFromRouting(client, config, test, suiteCache);
689
826
  newId = await (0, test_cases_1.createTestCase)(client, test, config, suiteIdOverride, configDir);
690
827
  createdIds.add(newId);
@@ -695,9 +832,9 @@ async function pushSingle(config, configDir, opts) {
695
832
  // Fetch back to get changedDate for cache
696
833
  const created = await (0, test_cases_1.getTestCase)(client, newId, titleField);
697
834
  if (created)
698
- updateCacheEntry(cache, test, created);
835
+ updateCacheEntry(cache, test, created, currentHierarchyKey);
699
836
  }
700
- reportProgress({ action: 'created', filePath: test.filePath, title: test.title, azureId: newId });
837
+ reportProgress({ action: 'created', filePath: test.filePath, title: test.title, azureId: newId, targetSuitePath });
701
838
  }
702
839
  catch (err) {
703
840
  const msg = err instanceof Error ? err.message : String(err);
@@ -754,17 +891,17 @@ async function pushSingle(config, configDir, opts) {
754
891
  }
755
892
  }
756
893
  // 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;
894
+ // Tag-filtered, source-file-filtered, create-only, or condition-filtered pushes intentionally operate on a subset.
895
+ const shouldDetectRemoved = !opts.tags && !opts.sourceFiles?.length && !opts.createOnly && !opts.linkOnly && !opts.updateOnly && !config.local.condition;
896
+ const localIds = new Set([
897
+ ...resolvedTests.map((t) => t.azureId).filter(Boolean),
898
+ ...createdIds,
899
+ ]);
759
900
  if (shouldDetectRemoved) {
760
901
  try {
761
902
  // Reuse the pre-loaded remote TCs if we already fetched them for automatedTestName
762
903
  // matching, otherwise fetch now. This avoids a redundant round-trip.
763
904
  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
905
  for (const remote of remoteTcs) {
769
906
  if (!localIds.has(remote.id)) {
770
907
  if (!opts.dryRun) {
@@ -782,6 +919,22 @@ async function pushSingle(config, configDir, opts) {
782
919
  }
783
920
  catch { /* best-effort: don't fail the whole push */ }
784
921
  }
922
+ if (!opts.dryRun && (0, test_cases_1.shouldCleanupEmptyGeneratedSuites)(config) && shouldDetectRemoved) {
923
+ const staleHierarchyKeys = [...new Set(Object.entries(cache)
924
+ .filter(([cacheKey]) => cacheKey !== '_suites')
925
+ .map(([cacheKey, entry]) => ({ azureId: Number(cacheKey), entry }))
926
+ .filter(({ azureId, entry }) => Number.isFinite(azureId) && !localIds.has(azureId) && !!entry)
927
+ .map(({ entry }) => entry.suitePathKey ?? (0, test_cases_1.getGeneratedSuitePathKey)(config, entry.filePath, configDir))
928
+ .filter((pathKey) => !!pathKey))];
929
+ for (const staleHierarchyKey of staleHierarchyKeys) {
930
+ try {
931
+ await (0, test_cases_1.pruneEmptyGeneratedSuitesForPathKey)(client, config, staleHierarchyKey, suiteCache);
932
+ }
933
+ catch {
934
+ // Best-effort cleanup; do not fail the push when stale branch pruning cannot complete.
935
+ }
936
+ }
937
+ }
785
938
  if (!opts.dryRun) {
786
939
  // G: persist suite name→id map so the next push avoids redundant API traversals
787
940
  if (suiteCache.size > 0) {
@@ -803,7 +956,7 @@ async function pull(config, configDir, opts = {}) {
803
956
  return pullSingle(config, configDir, opts);
804
957
  }
805
958
  async function pullSingle(config, configDir, opts) {
806
- const files = await discoverFiles(config.local.include, config.local.exclude, configDir);
959
+ const files = filterDiscoveredFiles(await discoverFiles(config.local.include, config.local.exclude, configDir), opts.sourceFiles);
807
960
  const parsed = await parseLocalFiles(files, config, opts.tags);
808
961
  failOnParseErrors('pull', parsed.failures);
809
962
  const tests = parsed.tests;
@@ -841,7 +994,7 @@ async function pullSingle(config, configDir, opts) {
841
994
  const stepsChanged = remoteStepsText !== localStepsText;
842
995
  const descriptionChanged = (remote.description ?? '') !== (test.description ?? '');
843
996
  if (!titleChanged && !stepsChanged && !descriptionChanged) {
844
- reportProgress({ action: 'skipped', filePath: test.filePath, title: test.title, azureId: test.azureId });
997
+ reportProgress({ action: 'skipped', filePath: test.filePath, title: test.title, azureId: test.azureId, detail: 'no changes' });
845
998
  continue;
846
999
  }
847
1000
  if (!opts.dryRun) {
@@ -904,6 +1057,14 @@ async function pullSingle(config, configDir, opts) {
904
1057
  detail: `Pull-create is not supported for local.type "${config.local.type}".`,
905
1058
  });
906
1059
  }
1060
+ else if (opts.sourceFiles?.length) {
1061
+ results.push({
1062
+ action: 'error',
1063
+ filePath: '',
1064
+ title: '',
1065
+ detail: 'Pull-create is not supported together with --source-file because partial runs cannot infer which unlinked remote tests to materialize.',
1066
+ });
1067
+ }
907
1068
  else {
908
1069
  try {
909
1070
  const remoteTcs = await (0, test_cases_1.getTestCasesInSuite)(client, config);
@@ -939,10 +1100,17 @@ async function pullSingle(config, configDir, opts) {
939
1100
  }
940
1101
  // ─── Status ───────────────────────────────────────────────────────────────────
941
1102
  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 });
1103
+ return push(config, configDir, {
1104
+ dryRun: true,
1105
+ tags: opts.tags,
1106
+ sourceFiles: opts.sourceFiles,
1107
+ onProgress: opts.onProgress,
1108
+ onAiProgress: opts.onAiProgress,
1109
+ aiSummary: opts.aiSummary,
1110
+ });
943
1111
  }
944
1112
  // ─── Cache helpers ────────────────────────────────────────────────────────────
945
- function updateCacheEntry(cache, test, remote) {
1113
+ function updateCacheEntry(cache, test, remote, suitePathKey) {
946
1114
  if (!remote.changedDate)
947
1115
  return;
948
1116
  cache[remote.id] = {
@@ -955,6 +1123,7 @@ function updateCacheEntry(cache, test, remote) {
955
1123
  remoteDescriptionHash: (0, cache_1.hashString)(remote.description),
956
1124
  changedDate: remote.changedDate,
957
1125
  filePath: test.filePath,
1126
+ suitePathKey,
958
1127
  };
959
1128
  }
960
1129
  // ─── Apply remote changes to local file ───────────────────────────────────────
@@ -1180,7 +1349,7 @@ function buildPullMarkdownContent(tc, tagPrefix) {
1180
1349
  * without running push, or when TCs are created directly in Azure without a local spec.
1181
1350
  */
1182
1351
  async function detectStaleTestCases(config, configDir, opts = {}) {
1183
- const files = await discoverFiles(config.local.include, config.local.exclude, configDir);
1352
+ const files = filterDiscoveredFiles(await discoverFiles(config.local.include, config.local.exclude, configDir), opts.sourceFiles);
1184
1353
  const parsed = await parseLocalFiles(files, config, opts.tags);
1185
1354
  failOnParseErrors('stale test case detection', parsed.failures);
1186
1355
  const tests = parsed.tests;
@@ -1211,7 +1380,7 @@ async function detectStaleTestCases(config, configDir, opts = {}) {
1211
1380
  * 2. Story coverage — % of referenced User Stories that have at least one linked spec
1212
1381
  */
1213
1382
  async function coverageReport(config, configDir, opts = {}) {
1214
- const files = await discoverFiles(config.local.include, config.local.exclude, configDir);
1383
+ const files = filterDiscoveredFiles(await discoverFiles(config.local.include, config.local.exclude, configDir), opts.sourceFiles);
1215
1384
  const parsed = await parseLocalFiles(files, config, opts.tags);
1216
1385
  failOnParseErrors('coverage report', parsed.failures);
1217
1386
  const tests = parsed.tests;