universal-dev-standards 6.14.0-beta.4 → 6.14.0-beta.5

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 (41) hide show
  1. package/bin/uds.js +7 -1
  2. package/bundled/ai/standards/full-coverage-testing.ai.yaml +46 -5
  3. package/bundled/core/full-coverage-testing.md +57 -3
  4. package/bundled/locales/zh-CN/CHANGELOG.md +29 -2
  5. package/bundled/locales/zh-CN/README.md +1 -1
  6. package/bundled/locales/zh-CN/SECURITY.md +1 -1
  7. package/bundled/locales/zh-CN/core/full-coverage-testing.md +61 -7
  8. package/bundled/locales/zh-CN/docs/CHEATSHEET.md +3 -1
  9. package/bundled/locales/zh-CN/docs/FEATURE-REFERENCE.md +8 -5
  10. package/bundled/locales/zh-TW/CHANGELOG.md +29 -2
  11. package/bundled/locales/zh-TW/README.md +1 -1
  12. package/bundled/locales/zh-TW/SECURITY.md +1 -1
  13. package/bundled/locales/zh-TW/core/full-coverage-testing.md +61 -7
  14. package/bundled/locales/zh-TW/docs/CHEATSHEET.md +3 -1
  15. package/bundled/locales/zh-TW/docs/FEATURE-REFERENCE.md +8 -5
  16. package/bundled/templates/gates/check-anti-fake-tests.mjs +991 -0
  17. package/bundled/templates/gates/check-stubs.mjs +644 -0
  18. package/package.json +2 -2
  19. package/src/commands/audit.js +11 -0
  20. package/src/commands/check.js +124 -24
  21. package/src/commands/init.js +16 -0
  22. package/src/commands/update.js +180 -19
  23. package/src/core/install-records.js +2 -1
  24. package/src/i18n/messages.js +50 -9
  25. package/src/reconciler/backup-manager.js +418 -82
  26. package/src/reconciler/index.js +27 -5
  27. package/src/reconciler/install-roots.js +90 -0
  28. package/src/reconciler/plan-executor.js +23 -2
  29. package/src/uninstallers/hook-uninstaller.js +2 -1
  30. package/src/utils/command-hash-ownership.js +103 -0
  31. package/src/utils/copier.js +21 -1
  32. package/src/utils/gate-scripts.js +141 -0
  33. package/src/utils/health-scorer.js +10 -7
  34. package/src/utils/skill-hash-ownership.js +64 -0
  35. package/src/utils/skills-installer.js +12 -1
  36. package/src/utils/test-change-check.js +160 -0
  37. package/src/utils/test-policy.js +214 -0
  38. package/src/utils/update-summary.js +29 -0
  39. package/standards-registry.json +7 -7
  40. package/bundled/extensions/languages/php/fat-free-patterns.md +0 -915
  41. package/bundled/extensions/languages/php/php-style.md +0 -693
@@ -345,6 +345,17 @@ async function handleReport(auditResult, options, msg) {
345
345
  const isDryRun = options.dryRun || false;
346
346
  const forceGh = options.gh || false;
347
347
 
348
+ // XSPEC-454 R3: submitting means talking to GitHub (the `gh` CLI, or a browser/clipboard
349
+ // fallback). Offline mode must not do that, and must say so — not fail, not skip silently.
350
+ // A dry run only prints the report, so it still works offline and is offered as the way out.
351
+ if (options.offline && !isDryRun) {
352
+ console.log();
353
+ console.log(chalk.yellow(msg.offlineNoSubmit || 'Offline mode: the report is not submitted.'));
354
+ console.log(chalk.gray(msg.offlineDryRunHint || ' Use --dry-run to preview it, or run again without --offline to submit.'));
355
+ console.log();
356
+ return;
357
+ }
358
+
348
359
  // Gather findings for selection
349
360
  const findings = [];
350
361
 
@@ -4,7 +4,7 @@ import { createSpinner } from '../utils/spinner.js';
4
4
  import { existsSync, readFileSync } from 'fs';
5
5
  import { join, basename } from 'path';
6
6
  import { execSync } from 'child_process';
7
- import { readManifest, writeManifest, isInitialized, copyStandard, copyIntegration } from '../utils/copier.js';
7
+ import { readManifest, writeManifest, isInitialized, copyStandard, copyIntegration, readPackagedSource } from '../utils/copier.js';
8
8
  import {
9
9
  getAllStandards,
10
10
  getRepositoryInfo, isShippedFilename, resolveStandardFilename, resolveStandardSourcePath } from '../utils/registry.js';
@@ -16,7 +16,7 @@ import {
16
16
  computeIntegrationBlockHash,
17
17
  pruneIntegrationFileHashes
18
18
  } from '../utils/hasher.js';
19
- import { downloadFromGitHub, getMarketplaceSkillsInfo } from '../utils/github.js';
19
+ import { getMarketplaceSkillsInfo } from '../utils/github.js';
20
20
  import {
21
21
  getInstalledSkillsInfoForAgent,
22
22
  getInstalledCommandsForAgent
@@ -46,6 +46,10 @@ import { guardAgainstSelfAdoption } from '../utils/detect-self-adoption.js';
46
46
  import { lintAll as lintI18nAll, partitionFindings as partitionI18nFindings } from '../lint/i18n.js';
47
47
  import { resolveIntegrationFile } from '../core/constants.js';
48
48
  import { checkPreCommitHookWiring } from '../utils/git-hooks.js';
49
+ import { runTestChangeCheck } from '../utils/test-change-check.js';
50
+ import { runTestQualityGates } from '../utils/gate-scripts.js';
51
+ import { pruneForeignSkillHashes, skillIssuesOf } from '../utils/skill-hash-ownership.js';
52
+ import { pruneForeignCommandHashes, projectCommandHashes, commandIssuesOf } from '../utils/command-hash-ownership.js';
49
53
 
50
54
  /**
51
55
  * Display the summary of file integrity status
@@ -413,11 +417,35 @@ export async function checkCommand(options = {}) {
413
417
 
414
418
  // === Enhanced Integrity Checks (v3.3.0+) ===
415
419
 
416
- // Check Skills integrity if skillHashes exist
417
- checkSkillsIntegrity(manifest, projectPath, msg);
418
-
419
- // Check Commands integrity if commandHashes exist
420
- checkCommandsIntegrity(manifest, projectPath, msg);
420
+ // Check Skills integrity if skillHashes exist.
421
+ //
422
+ // XSPEC-454 R2, in this order and no other. Step 1: forget the records that were never UDS's to
423
+ // keep (the adopter's own skills; `agents/`, `workflows/`, `_shared/` folders an old CLI copied
424
+ // in and a later update removed). In memory only — `check` is read-only — and `uds update`
425
+ // drops them from the file. Step 2: what is left is a record of a file UDS installed, so a
426
+ // missing or changed one is a real finding and counts toward the verdict below. Doing step 2
427
+ // alone would turn every existing project's `check` (and the pre-commit hook that runs it)
428
+ // red the day it upgrades, for files nobody can restore.
429
+ const skillRecordsBefore = Object.keys(manifest.skillHashes || {}).length;
430
+ pruneForeignSkillHashes(manifest);
431
+ const ignoredSkillRecords = skillRecordsBefore - Object.keys(manifest.skillHashes || {}).length;
432
+ const skillsIntegrity = checkSkillsIntegrity(manifest, projectPath, msg, ignoredSkillRecords);
433
+ const skillIssues = skillIssuesOf(skillsIntegrity);
434
+
435
+ // Check Commands integrity if commandHashes exist.
436
+ //
437
+ // XSPEC-454 R2, the Commands half, same order as above: first set aside the records this project's
438
+ // check cannot vouch for (commands UDS does not ship; commands installed at user level, which live
439
+ // outside the project), then let what is left decide the verdict.
440
+ const commandRecordsBefore = Object.keys(manifest.commandHashes || {}).length;
441
+ pruneForeignCommandHashes(manifest);
442
+ const ignoredCommandRecords = commandRecordsBefore - Object.keys(manifest.commandHashes || {}).length;
443
+ const projectCommands = projectCommandHashes(manifest);
444
+ const commandsIntegrity = checkCommandsIntegrity(
445
+ { ...manifest, commandHashes: projectCommands.hashes }, projectPath, msg,
446
+ { foreign: ignoredCommandRecords, elsewhere: projectCommands.ignored.length }
447
+ );
448
+ const commandIssues = commandIssuesOf(commandsIntegrity);
421
449
 
422
450
  // XSPEC adopter-report Q3: neither of the two checks above (content-hash
423
451
  // integrity) says anything about an installed Skills/Commands version
@@ -465,6 +493,13 @@ export async function checkCommand(options = {}) {
465
493
  await showDiff(projectPath, manifest, fileStatus.modified);
466
494
  return;
467
495
  }
496
+ if (options.diff) {
497
+ // Nothing to diff. Say what "nothing" is measured against rather than printing no line at all —
498
+ // silence reads the same as "the diff step did not run".
499
+ printDiffBaseline(manifest, msg);
500
+ console.log(chalk.green(msg.diffNone));
501
+ console.log();
502
+ }
468
503
 
469
504
  // Interactive mode (default when issues detected, only in TTY)
470
505
  const hasIssues = fileStatus.modified.length > 0 ||
@@ -513,6 +548,14 @@ export async function checkCommand(options = {}) {
513
548
  // pre-commit 檢查檔存在,但 git 實際不會執行它(只報告,不寫入 —— 見 checkPreCommitWiring 下方註解)
514
549
  checkPreCommitWiring(projectPath, msg);
515
550
 
551
+ // XSPEC-444 R2 + R5: this commit changes code but no test; fake tests; empty shells.
552
+ // Warnings by default — a commit is blocked only when the project sets "mode": "block" in
553
+ // .standards/test-policy.json. Both are read-only here (nothing is written to the project).
554
+ // XSPEC-454 R2 (same class): both report whether they blocked; in "block" mode they set the exit code
555
+ // themselves, but the run still ended "compliant" because the verdict below never read what they said.
556
+ const testChange = runTestChangeCheck(projectPath);
557
+ const testGates = runTestQualityGates(projectPath);
558
+
516
559
  // Workflow status
517
560
  displayWorkflowStatus(projectPath);
518
561
 
@@ -520,8 +563,14 @@ export async function checkCommand(options = {}) {
520
563
  // XSPEC-418 R1: integration block problems (UDS markers removed, block
521
564
  // modified, or the tracked file missing) now feed the verdict — they used to
522
565
  // be checked and printed above, then silently dropped here.
566
+ // XSPEC-454 R2: the Skills integrity result used to be thrown away (the call above was a bare
567
+ // statement), so a deleted or edited skill file printed ✗ and still ended in "compliant", exit 0.
523
568
  const allGood = fileStatus.missing.length === 0 &&
524
569
  fileStatus.modified.length === 0 &&
570
+ skillIssues.length === 0 &&
571
+ commandIssues.length === 0 &&
572
+ !testChange.blocked &&
573
+ !testGates.blocked &&
525
574
  integrationBlockStatus.modified.length === 0 &&
526
575
  integrationBlockStatus.missing.length === 0 &&
527
576
  integrationBlockStatus.noMarkers.length === 0 &&
@@ -530,6 +579,12 @@ export async function checkCommand(options = {}) {
530
579
  console.log(chalk.green(msg.projectCompliant));
531
580
  } else {
532
581
  console.log(chalk.yellow(msg.issuesDetected));
582
+ if (commandIssues.length > 0) {
583
+ console.log(chalk.gray(msg.commandsIntegrityFix || ' Command files listed above are missing or were changed: run `uds update --apply --commands` to reinstall them.'));
584
+ }
585
+ if (skillIssues.length > 0) {
586
+ console.log(chalk.gray(msg.skillsIntegrityFix || ' Skill files listed above are missing or were changed: run `uds update --apply --skills` to reinstall them.'));
587
+ }
533
588
  // Set non-zero exit code in CI mode so pipelines detect failures
534
589
  if (options.ci) {
535
590
  process.exitCode = 1;
@@ -592,6 +647,7 @@ async function interactiveMode(projectPath, manifest, fileStatus, msg) {
592
647
 
593
648
  switch (action) {
594
649
  case 'view': {
650
+ printDiffBaseline(manifest, msg);
595
651
  await showSingleFileDiff(projectPath, manifest, issue.file, msg);
596
652
  // After viewing, ask again
597
653
  const followUp = await select({
@@ -649,8 +705,33 @@ async function interactiveMode(projectPath, manifest, fileStatus, msg) {
649
705
  }
650
706
  }
651
707
 
708
+ /**
709
+ * Tell the reader what the diff is measured against (XSPEC-453 R1).
710
+ *
711
+ * `uds check --diff` answers "what did I change in what UDS gave me". The original is the copy inside
712
+ * the installed UDS package, so this says that — and where to look for what UDS itself changed since.
713
+ * When the project's standards were installed from a different version than the package now installed,
714
+ * a file UDS changed in between shows up as a difference, so that gap is stated rather than left to be
715
+ * discovered.
716
+ */
717
+ function printDiffBaseline(manifest, msg) {
718
+ const packageVersion = getRepositoryInfo().standards.version;
719
+ console.log(chalk.gray(msg.diffBaseline.replace('{version}', packageVersion)));
720
+ const installedVersion = manifest?.upstream?.version;
721
+ if (installedVersion && installedVersion !== packageVersion) {
722
+ console.log(chalk.yellow(
723
+ msg.diffBaselineVersionDiffers.replace('{installed}', installedVersion).replace('{version}', packageVersion)
724
+ ));
725
+ }
726
+ }
727
+
652
728
  /**
653
729
  * Show diff for a single file
730
+ *
731
+ * The original is read from the installed UDS package, never downloaded (XSPEC-453 R1).
732
+ *
733
+ * @returns {Promise<'shown'|'no-original'|'unreadable'>} What happened; `'no-original'` means the
734
+ * installed package has no original for this file (reported by name, not skipped silently).
654
735
  */
655
736
  async function showSingleFileDiff(projectPath, manifest, relativePath, msg) {
656
737
  const { readFileSync } = await import('fs');
@@ -662,31 +743,31 @@ async function showSingleFileDiff(projectPath, manifest, relativePath, msg) {
662
743
  currentContent = readFileSync(fullPath, 'utf-8');
663
744
  } catch {
664
745
  console.log(chalk.red(msg.couldNotReadFile));
665
- return;
746
+ return 'unreadable';
666
747
  }
667
748
 
668
- // Get original content from GitHub
749
+ // Get original content from the installed package
669
750
  const sourcePath = getSourcePathFromRelative(manifest, relativePath);
670
751
  if (!sourcePath) {
671
752
  console.log(chalk.red(msg.couldNotDetermineSource2));
672
- return;
753
+ return 'unreadable';
673
754
  }
674
755
 
675
- console.log(chalk.gray(msg.fetchingOriginal));
676
756
  let originalContent;
677
757
  try {
678
- originalContent = await downloadFromGitHub(sourcePath);
758
+ originalContent = readPackagedSource(sourcePath);
679
759
  } catch (error) {
680
- if (error.message.includes('429')) {
681
- console.log(chalk.red(msg.rateLimited || 'GitHub API rate limit exceeded. Please wait a few minutes and try again.'));
682
- } else {
683
- console.log(chalk.red(`${msg.couldNotFetchOriginal} (${error.message})`));
684
- }
685
- return;
760
+ console.log(chalk.red(`${msg.couldNotReadOriginal.replace('{file}', relativePath)} (${error.message})`));
761
+ return 'unreadable';
686
762
  }
687
- if (!originalContent) {
688
- console.log(chalk.red(msg.couldNotFetchOriginal));
689
- return;
763
+ if (originalContent === null) {
764
+ console.log(chalk.red(
765
+ msg.noOriginalInPackage
766
+ .replace('{file}', relativePath)
767
+ .replace('{source}', sourcePath)
768
+ .replace('{version}', getRepositoryInfo().standards.version)
769
+ ));
770
+ return 'no-original';
690
771
  }
691
772
 
692
773
  // Simple diff display
@@ -721,6 +802,7 @@ async function showSingleFileDiff(projectPath, manifest, relativePath, msg) {
721
802
  }
722
803
  }
723
804
  console.log();
805
+ return 'shown';
724
806
  }
725
807
 
726
808
  /**
@@ -728,11 +810,17 @@ async function showSingleFileDiff(projectPath, manifest, relativePath, msg) {
728
810
  */
729
811
  async function showDiff(projectPath, manifest, modifiedFiles) {
730
812
  const msg = t().commands.check;
813
+ printDiffBaseline(manifest, msg);
814
+ let missingOriginals = 0;
731
815
  for (const file of modifiedFiles) {
732
816
  console.log(chalk.cyan(`\n${msg.diffFor.replace('{file}', file)}`));
733
817
  console.log(chalk.gray('─'.repeat(50)));
734
- await showSingleFileDiff(projectPath, manifest, file, msg);
818
+ const outcome = await showSingleFileDiff(projectPath, manifest, file, msg);
819
+ if (outcome === 'no-original') missingOriginals++;
735
820
  }
821
+ // A package that lacks the original of a file it should have shipped is a defect worth a non-zero
822
+ // exit: scripts must not read "no diff shown" as "no difference".
823
+ if (missingOriginals > 0) process.exitCode = 1;
736
824
  }
737
825
 
738
826
  /**
@@ -1916,7 +2004,7 @@ function checkSkillsCommandsVersionStaleness(manifest, projectPath, msg) { // es
1916
2004
  * @param {Object} msg - Localized messages
1917
2005
  * @returns {Object} Status { unchanged: [], modified: [], missing: [] }
1918
2006
  */
1919
- function checkSkillsIntegrity(manifest, projectPath, msg) {
2007
+ function checkSkillsIntegrity(manifest, projectPath, msg, ignoredRecords = 0) {
1920
2008
  const skillHashes = manifest.skillHashes;
1921
2009
 
1922
2010
  // Skip if no skill hashes tracked
@@ -1925,6 +2013,10 @@ function checkSkillsIntegrity(manifest, projectPath, msg) {
1925
2013
  }
1926
2014
 
1927
2015
  console.log(chalk.cyan(msg.skillsIntegrityCheck || 'Skills File Integrity'));
2016
+ if (ignoredRecords > 0) {
2017
+ console.log(chalk.gray((msg.skillsStaleRecordsIgnored || ' {count} skill record(s) ignored: they describe files UDS did not install.')
2018
+ .replace('{count}', ignoredRecords)));
2019
+ }
1928
2020
 
1929
2021
  const status = { unchanged: [], modified: [], missing: [], tracked: true };
1930
2022
 
@@ -1987,7 +2079,7 @@ function checkSkillsIntegrity(manifest, projectPath, msg) {
1987
2079
  * @param {Object} msg - Localized messages
1988
2080
  * @returns {Object} Status { unchanged: [], modified: [], missing: [] }
1989
2081
  */
1990
- export function checkCommandsIntegrity(manifest, projectPath, msg) {
2082
+ export function checkCommandsIntegrity(manifest, projectPath, msg, ignored = {}) {
1991
2083
  const commandHashes = manifest.commandHashes;
1992
2084
 
1993
2085
  // Skip if no command hashes tracked
@@ -1996,6 +2088,14 @@ export function checkCommandsIntegrity(manifest, projectPath, msg) {
1996
2088
  }
1997
2089
 
1998
2090
  console.log(chalk.cyan(msg.commandsIntegrityCheck || 'Commands File Integrity'));
2091
+ if (ignored.foreign > 0) {
2092
+ console.log(chalk.gray((msg.commandsStaleRecordsIgnored || ' {count} command record(s) ignored: they describe commands UDS does not ship.')
2093
+ .replace('{count}', ignored.foreign)));
2094
+ }
2095
+ if (ignored.elsewhere > 0) {
2096
+ console.log(chalk.gray((msg.commandsElsewhereIgnored || ' {count} command record(s) ignored: those commands are installed at user level, shared by every project.')
2097
+ .replace('{count}', ignored.elsewhere)));
2098
+ }
1999
2099
 
2000
2100
  const status = { unchanged: [], modified: [], missing: [], untracked: [], tracked: true };
2001
2101
 
@@ -32,6 +32,7 @@ import { withFileTransaction } from '../utils/transaction.js';
32
32
  import { newRecorder, mkdirTracked, recordFile, persistRecorder, RECORD_KINDS } from '../core/install-records.js';
33
33
  import { wireGitHooksPath, getLocalHooksPathConfig, stripLegacyHuskyShLine, ensureShebang, hookRunsUdsCheck, buildPreCommitBlock } from '../utils/git-hooks.js';
34
34
  import { migrateLegacyHuskyHook } from '../utils/legacy-hook-migration.js';
35
+ import { installGateScripts } from '../utils/gate-scripts.js';
35
36
 
36
37
  /**
37
38
  * Init command - initialize standards in current project
@@ -346,6 +347,21 @@ export async function initCommand(options) {
346
347
  for (const created of combinedResults.createdIntegrationFiles ?? []) {
347
348
  recordFile(installRecorder, projectPath, created, RECORD_KINDS.INTEGRATION_FILE);
348
349
  }
350
+
351
+ // 4.8. Ship the fake-test and stub scanners the full-coverage-testing standard asks for
352
+ // (XSPEC-444 R5). They are written whether or not git is present — the hook step below
353
+ // needs a repository, the scanners do not — and recorded so `uds uninstall` removes
354
+ // exactly these files. A file that already exists is the adopter's and is left alone.
355
+ const gateResult = installGateScripts(projectPath, installRecorder);
356
+ for (const rel of gateResult.written) {
357
+ console.log(chalk.green(` ✓ ${rel} (fake-test / stub scanner — \`uds check\` runs it and warns; edit it freely)`));
358
+ }
359
+ for (const rel of gateResult.kept) {
360
+ console.log(chalk.gray(` · ${rel} already exists — kept as is`));
361
+ }
362
+ for (const file of gateResult.missingTemplate) {
363
+ console.log(chalk.yellow(` ⚠ scripts/${file} not installed — the template is missing from this UDS package`));
364
+ }
349
365
  persistRecorder(projectPath, installRecorder);
350
366
 
351
367
  // 5. Setup Pre-commit Hook
@@ -25,6 +25,9 @@ import {
25
25
  getToolFromPath
26
26
  } from '../utils/reference-sync.js';
27
27
  import { checkForUpdates } from '../utils/npm-registry.js';
28
+ import { pruneForeignSkillHashes } from '../utils/skill-hash-ownership.js';
29
+ import { pruneForeignCommandHashes } from '../utils/command-hash-ownership.js';
30
+ import { commandsUpdatedMessage } from '../utils/update-summary.js';
28
31
  import { t, setLanguage, isLanguageExplicitlySet } from '../i18n/messages.js';
29
32
  import { config } from '../utils/config-manager.js';
30
33
  import {
@@ -47,7 +50,8 @@ import {
47
50
  import { getMarketplaceSkillsInfo } from '../utils/github.js';
48
51
  import { detectAITools } from '../utils/detector.js';
49
52
  import { HOOK_CAPABLE_TOOLS, resolveHookTools, installMissingHooks } from '../installers/hooks-installer.js';
50
- import { persistRecorder, mergeRecorderInto } from '../core/install-records.js';
53
+ import { persistRecorder, mergeRecorderInto, newRecorder } from '../core/install-records.js';
54
+ import { installGateScripts, gateStandardInstalled, GATE_SCRIPTS } from '../utils/gate-scripts.js';
51
55
  import { migrateLegacyHuskyHook } from '../utils/legacy-hook-migration.js';
52
56
  import {
53
57
  promptSkillsInstallLocation,
@@ -58,8 +62,12 @@ import {
58
62
  plan as reconcilerPlan,
59
63
  rollbackLast,
60
64
  formatPlan,
61
- listBackups
65
+ listBackups,
66
+ createStepBackup,
67
+ finalizeBackup,
68
+ cleanupBackups
62
69
  } from '../reconciler/index.js';
70
+ import { stepWritePaths } from '../reconciler/install-roots.js';
63
71
  import { restoreSingleFile } from './check.js';
64
72
  import { guardAgainstSelfAdoption } from '../utils/detect-self-adoption.js';
65
73
  import { resolveIntegrationFile, SUPPORTED_AI_TOOLS, getToolFormat } from '../core/constants.js';
@@ -619,6 +627,10 @@ export async function updateCommand(options) {
619
627
  // definition — telling them to run `uds update --prune` and then returning
620
628
  // before anything is examined is how the complaint became unclearable.
621
629
  const retiredOnLatest = pruneRetiredHashes(manifest, projectPath);
630
+ // XSPEC-454 R2: skill records UDS never owned go the same way (an up-to-date adopter is exactly the
631
+ // one who has nothing else to trigger the correction).
632
+ const foreignSkillsOnLatest = pruneForeignSkillHashes(manifest);
633
+ const foreignCommandsOnLatest = pruneForeignCommandHashes(manifest);
622
634
  if (retiredOnLatest.length > 0) {
623
635
  console.log();
624
636
  console.log(chalk.gray(
@@ -627,6 +639,20 @@ export async function updateCommand(options) {
627
639
  for (const path of retiredOnLatest) {
628
640
  console.log(chalk.gray(` - ${path}`));
629
641
  }
642
+ }
643
+ if (foreignSkillsOnLatest.length > 0) {
644
+ console.log();
645
+ console.log(chalk.gray(
646
+ ` ${(msg.droppedForeignSkillHashes || 'Dropped {count} skill record(s) that do not describe files UDS installed.').replace('{count}', foreignSkillsOnLatest.length)}`
647
+ ));
648
+ }
649
+ if (foreignCommandsOnLatest.length > 0) {
650
+ console.log();
651
+ console.log(chalk.gray(
652
+ ` ${(msg.droppedForeignCommandHashes || 'Dropped {count} command record(s) for commands UDS does not ship.').replace('{count}', foreignCommandsOnLatest.length)}`
653
+ ));
654
+ }
655
+ if (retiredOnLatest.length > 0 || foreignSkillsOnLatest.length > 0 || foreignCommandsOnLatest.length > 0) {
630
656
  writeManifest(manifest, projectPath);
631
657
  }
632
658
 
@@ -1139,7 +1165,7 @@ export async function updateCommand(options) {
1139
1165
  manifest.options.coverage_model = manifest.options.coverage_model || 'full-coverage';
1140
1166
  console.log();
1141
1167
  console.log(chalk.yellow(msg.testParadigmMigrated || '⚠ Testing paradigm migrated: pyramid thresholds (UT≥80%/IT≥70%) → behavior-completeness full coverage (XSPEC-178)'));
1142
- console.log(chalk.cyan(msg.testParadigmNote || ' full-coverage-testing.ai.yaml installed. Review scripts/check-stubs.sh and scripts/check-anti-fake-tests.sh in your project.'));
1168
+ console.log(chalk.cyan(msg.testParadigmNote || ' full-coverage-testing.ai.yaml installed. Review scripts/check-stubs.mjs and scripts/check-anti-fake-tests.mjs in your project (`uds update` offers to write them).'));
1143
1169
  }
1144
1170
 
1145
1171
  // Update manifest
@@ -1657,6 +1683,9 @@ export async function updateCommand(options) {
1657
1683
  // 錯誤訊息單一出口閘門:問過才寫,而且從不覆寫。
1658
1684
  await offerErrorExitGate(projectPath, options);
1659
1685
 
1686
+ // 假測試與空殼掃描(XSPEC-444 R5):同樣問過才寫、從不覆寫。
1687
+ await offerTestQualityGates(projectPath, options);
1688
+
1660
1689
  // Exit explicitly to prevent hanging.
1661
1690
  // T11: a partial update exits non-zero so CI / scripts can detect the failure.
1662
1691
  process.exit(updateIncomplete ? 1 : 0);
@@ -1706,6 +1735,47 @@ async function offerErrorExitGate(projectPath, options) {
1706
1735
  console.log();
1707
1736
  }
1708
1737
 
1738
+ /**
1739
+ * Offer the fake-test and stub scanners to an EXISTING installation.
1740
+ * // implements XSPEC-444 R5
1741
+ *
1742
+ * `uds init` writes them for a new project. A project initialised before that has the
1743
+ * full-coverage-testing standard — which asks for these two scripts — but never received them.
1744
+ * Writing files into a repository nobody asked us to touch is a trust problem, so this shows
1745
+ * what is about to be written, asks (default: no), and never overwrites a file that exists.
1746
+ */
1747
+ async function offerTestQualityGates(projectPath, options) {
1748
+ if (!gateStandardInstalled(projectPath)) return;
1749
+ const wanted = GATE_SCRIPTS.filter(({ file }) => !existsSync(join(projectPath, 'scripts', file)));
1750
+ if (wanted.length === 0) return;
1751
+ const available = wanted.filter(({ file }) => existsSync(join(getRepoRoot(), 'templates', 'gates', file)));
1752
+ if (available.length === 0) return; // templates not shipped in this package — stay silent, do not pretend
1753
+
1754
+ console.log(chalk.bold(' Fake-test and stub scanners'));
1755
+ console.log(chalk.gray(' The full-coverage-testing standard asks for these scripts; this project does not have them yet:'));
1756
+ for (const { file, what } of available) console.log(chalk.gray(` - scripts/${file} (finds ${what})`));
1757
+ console.log(chalk.gray(' Pure Node, no dependencies. `uds check` runs them and prints what they find as a WARNING;'));
1758
+ console.log(chalk.gray(' nothing is blocked unless you set "mode": "block" in .standards/test-policy.json.'));
1759
+ console.log();
1760
+
1761
+ const ok = await confirmOrFail({
1762
+ message: 'Write these scripts into scripts/ ?',
1763
+ defaultValue: false, // silence is not consent
1764
+ options
1765
+ });
1766
+ if (!ok) {
1767
+ console.log(chalk.gray(' Skipped. `uds check` will mention it again, but never writes them on its own.'));
1768
+ console.log();
1769
+ return;
1770
+ }
1771
+ const recorder = newRecorder();
1772
+ const result = installGateScripts(projectPath, recorder);
1773
+ persistRecorder(projectPath, recorder);
1774
+ for (const rel of result.written) console.log(chalk.green(` ✓ ${rel}`));
1775
+ for (const rel of result.kept) console.log(chalk.gray(` · ${rel} already exists — kept as is`));
1776
+ console.log();
1777
+ }
1778
+
1709
1779
  /**
1710
1780
  * Switch the claude-code integration's target file between CLAUDE.md and
1711
1781
  * CLAUDE.local.md for an EXISTING installation, without a full reinstall.
@@ -2800,6 +2870,16 @@ async function updateSkillsOnly(projectPath, manifest, options) {
2800
2870
  return;
2801
2871
  }
2802
2872
 
2873
+ // XSPEC-454 R1: this step writes outside the reconciler, so it takes its own rollback point — the skill
2874
+ // folders UDS ships, in each project-level skills folder (new ones are created, old ones rewritten, and
2875
+ // `.manifest.json` replaced), and nothing else: the adopter's own skills are not UDS's to roll back.
2876
+ // No backup, no write: an install that cannot be undone must not start.
2877
+ const skillsBackup = takeStepBackup(projectPath, 'skills', fileBasedInstallations, 'skills');
2878
+ if (!skillsBackup.ok) {
2879
+ process.exit(1);
2880
+ return;
2881
+ }
2882
+
2803
2883
  const spinner = createSpinner(msg.installingSkills || 'Installing Skills...').start();
2804
2884
 
2805
2885
  const skillsLocaleForUpdate = resolveLocale(manifest, projectPath, options);
@@ -2845,6 +2925,9 @@ async function updateSkillsOnly(projectPath, manifest, options) {
2845
2925
  if (!manifest.skillHashes) manifest.skillHashes = {};
2846
2926
  Object.assign(manifest.skillHashes, result.allFileHashes);
2847
2927
  }
2928
+ // XSPEC-454 R2: and forget the records that were never UDS's (the manifest this merges into
2929
+ // may come from an installer that hashed the whole skills folder).
2930
+ pruneForeignSkillHashes(manifest);
2848
2931
 
2849
2932
  // 🔴 Re-read before writing. `manifest` was loaded at the top of the update
2850
2933
  // command, BEFORE the reconciler ran; the reconciler writes its own copy to
@@ -2866,6 +2949,7 @@ async function updateSkillsOnly(projectPath, manifest, options) {
2866
2949
  freshForSkills.skills = manifest.skills;
2867
2950
  freshForSkills.skillHashes = manifest.skillHashes;
2868
2951
  writeManifest(freshForSkills, projectPath);
2952
+ finishStepBackup(projectPath, skillsBackup);
2869
2953
 
2870
2954
  console.log();
2871
2955
  process.exit(0);
@@ -2924,6 +3008,13 @@ async function updateCommandsOnly(projectPath, manifest, options) {
2924
3008
  }
2925
3009
  console.log();
2926
3010
 
3011
+ // XSPEC-454 R1: same as the Skills step — a rollback point first, and no write without one.
3012
+ const commandsBackup = takeStepBackup(projectPath, 'commands', commandsInstallations, 'commands');
3013
+ if (!commandsBackup.ok) {
3014
+ process.exit(1);
3015
+ return;
3016
+ }
3017
+
2927
3018
  const spinner = createSpinner(msg.installingCommands || 'Installing commands...').start();
2928
3019
 
2929
3020
  const commandsLocale = resolveLocale(manifest, projectPath, options);
@@ -2944,9 +3035,8 @@ async function updateCommandsOnly(projectPath, manifest, options) {
2944
3035
  }).join(', ');
2945
3036
 
2946
3037
  if (result.totalErrors === 0) {
2947
- spinner.succeed((msg.commandsUpdated || 'Updated {count} commands: {locations}')
2948
- .replace('{count}', result.totalInstalled)
2949
- .replace('{locations}', locations));
3038
+ // XSPEC-454 R4: tools and commands are different numbers; see commandsUpdatedMessage.
3039
+ spinner.succeed(commandsUpdatedMessage(msg, commandsInstallations, result, locations));
2950
3040
  } else {
2951
3041
  spinner.warn((msg.commandsUpdatedWithErrors || 'Updated commands with {errors} errors')
2952
3042
  .replace('{errors}', result.totalErrors));
@@ -2971,6 +3061,8 @@ async function updateCommandsOnly(projectPath, manifest, options) {
2971
3061
  if (!manifest.commandHashes) manifest.commandHashes = {};
2972
3062
  replaceCommandHashesForUpdatedAgents(manifest.commandHashes, result.allFileHashes);
2973
3063
  }
3064
+ // XSPEC-454 R2: and forget command records for commands UDS does not ship.
3065
+ pruneForeignCommandHashes(manifest);
2974
3066
 
2975
3067
  // 🔴 Re-read before writing. `manifest` was loaded at the top of the update
2976
3068
  // command, BEFORE the reconciler ran; the reconciler writes its own copy to
@@ -2994,6 +3086,7 @@ async function updateCommandsOnly(projectPath, manifest, options) {
2994
3086
  freshForCommands.commands = manifest.commands;
2995
3087
  freshForCommands.commandHashes = manifest.commandHashes;
2996
3088
  writeManifest(freshForCommands, projectPath);
3089
+ finishStepBackup(projectPath, commandsBackup);
2997
3090
 
2998
3091
  console.log();
2999
3092
  process.exit(0);
@@ -3386,7 +3479,47 @@ async function promptNewFeatureInstallation(missingSkills, outdatedSkills, missi
3386
3479
  // ─── DSR (Declarative State Reconciliation) Handlers ─────────────
3387
3480
 
3388
3481
  /**
3389
- * Handle --rollback: restore from the most recent backup.
3482
+ * Take the rollback point for a Skills/Commands step (XSPEC-454 R1).
3483
+ * Prints what it did; `ok: false` means the caller must not write anything.
3484
+ */
3485
+ function takeStepBackup(projectPath, label, installations, kind) {
3486
+ const { paths, outside } = stepWritePaths(projectPath, installations, kind);
3487
+ const backup = createStepBackup(projectPath, { label, paths, notBackedUp: outside });
3488
+ if (backup.errors.length > 0) {
3489
+ console.log(chalk.red(`Could not take a backup before updating ${label}; nothing was changed.`));
3490
+ for (const err of backup.errors) console.log(chalk.red(` ${err}`));
3491
+ console.log();
3492
+ return { ok: false };
3493
+ }
3494
+ cleanupBackupsQuietly(projectPath);
3495
+ return { ok: true, backupId: backup.backupId };
3496
+ }
3497
+
3498
+ /** The step is over (its last manifest write is done): record what it created, and say how to undo it. */
3499
+ function finishStepBackup(projectPath, backup) {
3500
+ finalizeBackup(projectPath, backup.backupId);
3501
+ console.log(chalk.gray(` Backup: ${backup.backupId}`));
3502
+ console.log(chalk.gray(' Use `uds update --rollback` to undo.'));
3503
+ }
3504
+
3505
+ function cleanupBackupsQuietly(projectPath) {
3506
+ try {
3507
+ cleanupBackups(projectPath);
3508
+ } catch {
3509
+ // an old backup that cannot be removed is not a reason to stop the update
3510
+ }
3511
+ }
3512
+
3513
+ /** Print up to `limit` paths, then how many more. */
3514
+ function printPaths(prefix, paths, limit, colour) {
3515
+ for (const p of paths.slice(0, limit)) console.log(colour(` ${prefix} ${p}`));
3516
+ if (paths.length > limit) console.log(colour(` ${prefix} … and ${paths.length - limit} more`));
3517
+ }
3518
+
3519
+ /**
3520
+ * Handle --rollback: undo the newest backup and every earlier one from the same unbroken series of UDS
3521
+ * updates, then say, item by item, what was restored, what was removed, and what was NOT put back.
3522
+ * It never ends in "successful" while any of those lists is not empty (XSPEC-454 R1).
3390
3523
  */
3391
3524
  async function handleRollback(projectPath) {
3392
3525
  const backups = listBackups(projectPath);
@@ -3397,22 +3530,46 @@ async function handleRollback(projectPath) {
3397
3530
  }
3398
3531
 
3399
3532
  const latest = backups[0];
3400
- console.log(chalk.cyan(`Rolling back to: ${latest.backupId}`));
3533
+ console.log(chalk.cyan(`Rolling back from: ${latest.backupId}`));
3401
3534
  console.log(chalk.gray(` Created: ${latest.createdAt}`));
3402
- console.log(chalk.gray(` Actions: ${latest.actionCount}`));
3403
3535
  console.log();
3404
3536
 
3405
3537
  const result = rollbackLast(projectPath);
3406
- if (result.success) {
3407
- console.log(chalk.green(`Rollback successful. Restored ${result.restored.length} files.`));
3408
- for (const file of result.restored) {
3409
- console.log(chalk.gray(` ← ${file}`));
3410
- }
3411
- } else {
3412
- console.log(chalk.red('Rollback failed:'));
3413
- for (const err of result.errors) {
3414
- console.log(chalk.red(` ${err}`));
3415
- }
3538
+
3539
+ for (const step of result.steps || []) {
3540
+ console.log(chalk.cyan(`${step.backupId}${step.label ? ` (${step.label})` : ''}`));
3541
+ console.log(chalk.gray(` Restored ${step.restored.length} file(s), removed ${step.removed.length} file(s) the update had created.`));
3542
+ printPaths('←', step.restored, 12, chalk.gray);
3543
+ printPaths('✕', step.removed, 12, chalk.gray);
3544
+ }
3545
+ if (!result.steps) {
3546
+ // a rollbackLast that predates the step report
3547
+ printPaths('←', result.restored || [], 12, chalk.gray);
3548
+ }
3549
+ console.log();
3550
+
3551
+ if ((result.steps || []).length > 1) {
3552
+ console.log(chalk.gray(` ${result.steps.length} consecutive updates were undone together (each one started from where the previous one ended).`));
3553
+ }
3554
+ if (result.olderBackups > 0) {
3555
+ console.log(chalk.gray(` ${result.olderBackups} older backup(s) are not part of that series (something else changed the project in between) and were left alone.`));
3556
+ }
3557
+
3558
+ if (result.errors.length > 0) {
3559
+ process.exitCode = 1;
3560
+ console.log(chalk.red('Rollback did NOT complete. The project may be in a mixed state:'));
3561
+ printPaths('✗', result.errors, 30, chalk.red);
3562
+ console.log(chalk.yellow(' What to do: fix the cause above (permissions, a locked file) and run `uds update --rollback` again —'));
3563
+ console.log(chalk.yellow(' it is safe to repeat. The backups are still in .uds-backup-* if you need to copy files back by hand.'));
3564
+ }
3565
+ if ((result.notRestored || []).length > 0) {
3566
+ console.log(chalk.yellow('Not restored:'));
3567
+ printPaths('!', result.notRestored, 30, chalk.yellow);
3568
+ }
3569
+ if (result.errors.length === 0 && (result.notRestored || []).length === 0) {
3570
+ console.log(chalk.green(`Rollback successful. Restored ${result.restored.length} file(s), removed ${(result.removed || []).length}.`));
3571
+ } else if (result.errors.length === 0) {
3572
+ console.log(chalk.yellow('Rollback finished for everything inside this project, except the items listed above.'));
3416
3573
  }
3417
3574
  console.log();
3418
3575
  }
@@ -3480,6 +3637,10 @@ async function handleReconcile(projectPath, options, { force }) {
3480
3637
  const planResult = await reconcilerPlan(projectPath, { force });
3481
3638
 
3482
3639
  if (planResult.plan.actions.length === 0) {
3640
+ // XSPEC-454 R2: nothing to apply, but the manifest may still list skill files UDS never installed
3641
+ // (they only ever get corrected by a write, and an up-to-date project has none coming).
3642
+ const current = readManifest(projectPath);
3643
+ if (current && (pruneForeignSkillHashes(current).length + pruneForeignCommandHashes(current).length) > 0) writeManifest(current, projectPath);
3483
3644
  console.log(chalk.green('Everything is up to date. No changes needed.'));
3484
3645
  console.log();
3485
3646
  return;