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
@@ -5,65 +5,155 @@
5
5
  * Backup structure:
6
6
  * .uds-backup-<ISO timestamp>/
7
7
  * ├── backup-manifest.json # Backup metadata + rollback instructions
8
- * ├── .standards/ # Backed up standard files
8
+ * ├── .standards/ # Backed up standard files (and manifest.json)
9
9
  * ├── CLAUDE.md # Backed up integration files
10
10
  * └── .claude/skills/... # Backed up skill files
11
11
  *
12
- * Only files that will be modified/deleted are backed up (minimal footprint).
13
- * Keeps the 5 most recent backups; auto-cleans older ones.
12
+ * Only the paths a step will touch are backed up (minimal footprint). Keeps the 5 most recent
13
+ * backups; auto-cleans older ones.
14
+ *
15
+ * What a backup has to be able to undo (XSPEC-454 R1). A rollback that restores "the files that were
16
+ * overwritten" and nothing else leaves a state that neither version describes:
17
+ * - files the step CREATED (a new skill folder) have no earlier copy, so the backup has to name them
18
+ * (`createdFiles`, filled in by `finalizeBackup` once the step is done) or nothing can remove them;
19
+ * - `.standards/manifest.json` records hashes of everything else, so restoring files without it
20
+ * makes `uds check` call every restored file modified;
21
+ * - a directory has to be restored by walking it, not with `copyFileSync` (which throws on one);
22
+ * - `uds update --skills` / `--commands` write outside the reconciler, so they take a backup of
23
+ * their own (`createStepBackup`), and consecutive steps are chained: each backup records the hash
24
+ * of the manifest before and after, and `rollback` walks back through every backup whose "before"
25
+ * is the previous backup's "after" — i.e. through one unbroken series of UDS updates.
14
26
  */
15
27
 
16
28
  import {
17
29
  existsSync,
18
30
  mkdirSync,
19
- copyFileSync,
20
31
  cpSync,
21
- statSync,
32
+ lstatSync,
33
+ readlinkSync,
22
34
  readFileSync,
23
35
  writeFileSync,
24
36
  readdirSync,
25
- rmSync
37
+ rmSync,
38
+ rmdirSync
26
39
  } from 'fs';
27
- import { join, dirname } from 'path';
40
+ import { createHash } from 'crypto';
41
+ import { join, dirname, isAbsolute, sep } from 'path';
28
42
 
29
43
  const MAX_BACKUPS = 5;
30
44
  const BACKUP_PREFIX = '.uds-backup-';
45
+ const MANIFEST_REL = '.standards/manifest.json';
46
+ const FORMAT_VERSION = 2;
31
47
  let _backupCounter = 0;
32
48
 
49
+ const toPosix = (p) => p.split(sep).join('/');
50
+
51
+ /** SHA-256 of a file's bytes, or null when it cannot be read. */
52
+ function hashFileBytes(path) {
53
+ try {
54
+ return createHash('sha256').update(readFileSync(path)).digest('hex');
55
+ } catch {
56
+ return null;
57
+ }
58
+ }
59
+
33
60
  /**
34
- * Create a backup for the files that will be affected by a reconciliation plan.
35
- *
36
- * @param {string} projectPath - Project root
37
- * @param {import('./diff-engine.js').ReconciliationPlan} plan - The reconciliation plan
38
- * @returns {{ backupId: string, backupDir: string, backedUp: string[], errors: string[] }}
61
+ * What a leaf IS, for comparing a restored leaf with its backup: the bytes of a file, the target of a
62
+ * symlink. (A symlink is copied as a link, never followed — a skills folder can hold links to
63
+ * directories, and following one would copy somebody else's tree into the backup.)
39
64
  */
40
- export function createBackup(projectPath, plan) {
65
+ function leafSignature(path) {
66
+ try {
67
+ if (lstatSync(path).isSymbolicLink()) return `link:${readlinkSync(path)}`;
68
+ } catch {
69
+ return null;
70
+ }
71
+ return hashFileBytes(path);
72
+ }
73
+
74
+ /** Copy one leaf (file or symlink), overwriting a file or link at the destination — never a directory. */
75
+ function copyLeaf(src, dst) {
76
+ try {
77
+ if (lstatSync(dst).isSymbolicLink()) rmSync(dst, { force: true });
78
+ } catch {
79
+ // dst does not exist
80
+ }
81
+ cpSync(src, dst, { force: true, verbatimSymlinks: true });
82
+ }
83
+
84
+ /** Every file under `rel` (project-relative, posix). A file lists itself; a missing path lists nothing. */
85
+ function listFiles(root, rel) {
86
+ const abs = join(root, rel);
87
+ let st;
88
+ try {
89
+ st = lstatSync(abs);
90
+ } catch {
91
+ return [];
92
+ }
93
+ if (!st.isDirectory()) return [rel];
94
+ const out = [];
95
+ let entries;
96
+ try {
97
+ entries = readdirSync(abs, { withFileTypes: true });
98
+ } catch {
99
+ return [];
100
+ }
101
+ for (const e of entries) out.push(...listFiles(root, `${rel}/${e.name}`));
102
+ return out;
103
+ }
104
+
105
+ /** Every directory under (and including) `rel` that exists now. */
106
+ function listDirs(root, rel) {
107
+ const abs = join(root, rel);
108
+ let st;
109
+ try {
110
+ st = lstatSync(abs);
111
+ } catch {
112
+ return [];
113
+ }
114
+ if (!st.isDirectory()) return [];
115
+ const out = [rel];
116
+ for (const e of readdirSync(abs, { withFileTypes: true })) {
117
+ if (e.isDirectory()) out.push(...listDirs(root, `${rel}/${e.name}`));
118
+ }
119
+ return out;
120
+ }
121
+
122
+ /** Ancestor directories of a project-relative path, nearest last, project root excluded. */
123
+ function ancestorsOf(rel) {
124
+ const parts = rel.split('/');
125
+ const out = [];
126
+ for (let i = 1; i < parts.length; i++) out.push(parts.slice(0, i).join('/'));
127
+ return out;
128
+ }
129
+
130
+ function newBackupId() {
41
131
  const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
42
132
  const counter = String(++_backupCounter).padStart(4, '0');
43
- const backupId = `${BACKUP_PREFIX}${timestamp}-${counter}`;
133
+ return `${BACKUP_PREFIX}${timestamp}-${counter}`;
134
+ }
135
+
136
+ /**
137
+ * Snapshot `paths` into a new backup directory.
138
+ *
139
+ * @param {string} projectPath
140
+ * @param {Object} spec
141
+ * @param {string} spec.label - What step this backs up ('reconcile', 'skills', 'commands')
142
+ * @param {string[]} spec.mustBackUp - Paths whose absence/copy failure counts against `coverage`
143
+ * @param {string[]} spec.alsoWatch - Extra project-relative paths: backed up when present, recorded as absent otherwise
144
+ * @param {Object} spec.plan - Plan summary stored in the backup manifest (optional)
145
+ * @param {Array<{path: string, reason: string}>} spec.notBackedUp - Things outside the project this step also writes
146
+ */
147
+ function snapshot(projectPath, spec) {
148
+ const backupId = newBackupId();
44
149
  const backupDir = join(projectPath, backupId);
45
150
  const backedUp = [];
46
151
  const errors = [];
47
152
 
48
- // Only backup files that will be updated or deleted
49
- const filesToBackup = plan.actions
50
- .filter(a => a.type === 'update' || a.type === 'delete' || a.type === 'migrate_block')
51
- .map(a => a.path);
52
-
53
- if (filesToBackup.length === 0) {
54
- return { backupId, backupDir, backedUp, errors };
55
- }
56
-
57
- // Create backup directory
58
153
  try {
59
154
  mkdirSync(backupDir, { recursive: true });
60
155
  } catch (err) {
61
- return {
62
- backupId,
63
- backupDir,
64
- backedUp,
65
- errors: [`Failed to create backup directory: ${err.message}`]
66
- };
156
+ return { backupId, backupDir, backedUp, errors: [`Failed to create backup directory: ${err.message}`] };
67
157
  }
68
158
 
69
159
  // Make the backup invisible to git, from inside itself.
@@ -88,6 +178,23 @@ export function createBackup(projectPath, plan) {
88
178
  errors.push(`Failed to write backup .gitignore: ${err.message}`);
89
179
  }
90
180
 
181
+ const notBackedUp = [...(spec.notBackedUp || [])];
182
+ const mustBackUp = [];
183
+ for (const p of spec.mustBackUp || []) {
184
+ if (isAbsolute(p)) {
185
+ // `join(projectPath, '/abs/path')` would silently point somewhere else entirely. A path outside
186
+ // the project (a user-level skills folder) is shared by every project; it is reported, not copied.
187
+ notBackedUp.push({ path: p, reason: 'outside this project (user-level); shared by every project, so it is not backed up or rolled back' });
188
+ } else {
189
+ mustBackUp.push(toPosix(p));
190
+ }
191
+ }
192
+ const extra = (spec.alsoWatch || []).map(toPosix);
193
+
194
+ // The manifest first: every other record in the project is a hash that only means something next to it.
195
+ const watched = [...new Set([...mustBackUp, ...extra, MANIFEST_REL])];
196
+ const preexistingDirs = new Set();
197
+
91
198
  // Backup each entry.
92
199
  //
93
200
  // Skills are DIRECTORIES (`.claude/skills/<name>`), and `copyFileSync` on a
@@ -97,17 +204,25 @@ export function createBackup(projectPath, plan) {
97
204
  // recorded 74 files for a plan of 129 actions, with **0 of the 55 skill
98
205
  // directories** among them — a rollback point that did not cover the largest
99
206
  // part of what was about to be overwritten. (XSPEC-382 R6)
100
- for (const relativePath of filesToBackup) {
207
+ for (const relativePath of watched) {
208
+ for (const a of ancestorsOf(relativePath)) {
209
+ if (existsSync(join(projectPath, a))) preexistingDirs.add(a);
210
+ }
101
211
  const sourcePath = join(projectPath, relativePath);
102
212
  if (!existsSync(sourcePath)) continue;
103
213
 
104
214
  const targetPath = join(backupDir, relativePath);
105
215
  try {
106
216
  mkdirSync(dirname(targetPath), { recursive: true });
107
- if (statSync(sourcePath).isDirectory()) {
108
- cpSync(sourcePath, targetPath, { recursive: true });
217
+ if (lstatSync(sourcePath).isDirectory()) {
218
+ for (const d of listDirs(projectPath, relativePath)) preexistingDirs.add(d);
219
+ mkdirSync(targetPath, { recursive: true });
220
+ for (const f of listFiles(projectPath, relativePath)) {
221
+ mkdirSync(dirname(join(backupDir, f)), { recursive: true });
222
+ copyLeaf(join(projectPath, f), join(backupDir, f));
223
+ }
109
224
  } else {
110
- copyFileSync(sourcePath, targetPath);
225
+ copyLeaf(sourcePath, targetPath);
111
226
  }
112
227
  backedUp.push(relativePath);
113
228
  } catch (err) {
@@ -115,20 +230,12 @@ export function createBackup(projectPath, plan) {
115
230
  }
116
231
  }
117
232
 
118
- // Write backup manifest
119
233
  const backupManifest = {
234
+ format: FORMAT_VERSION,
120
235
  backupId,
236
+ label: spec.label || 'update',
121
237
  createdAt: new Date().toISOString(),
122
- plan: {
123
- summary: plan.summary,
124
- actionCount: plan.actions.length,
125
- actions: plan.actions.map(a => ({
126
- type: a.type,
127
- category: a.category,
128
- path: a.path,
129
- reason: a.reason
130
- }))
131
- },
238
+ plan: spec.plan || { summary: null, actionCount: 0, actions: [] },
132
239
  backedUpFiles: backedUp,
133
240
  // What could NOT be backed up, and the resulting gap.
134
241
  //
@@ -139,18 +246,24 @@ export function createBackup(projectPath, plan) {
139
246
  // does not contain. (XSPEC-382 R6)
140
247
  failedToBackUp: errors.slice(),
141
248
  coverage: {
142
- planned: filesToBackup.length,
143
- backedUp: backedUp.length,
144
- failed: filesToBackup.length - backedUp.length
249
+ planned: mustBackUp.length,
250
+ backedUp: mustBackUp.filter((p) => backedUp.includes(p)).length,
251
+ failed: mustBackUp.length - mustBackUp.filter((p) => backedUp.includes(p)).length
145
252
  },
253
+ // XSPEC-454 R1: the parts a "copy what is about to change" backup cannot know on its own.
254
+ watched,
255
+ preexistingDirs: [...preexistingDirs].sort(),
256
+ createdFiles: [],
257
+ createdDirs: [],
258
+ notBackedUp,
259
+ preManifestHash: hashFileBytes(join(projectPath, MANIFEST_REL)),
260
+ postManifestHash: null,
261
+ finalized: false,
146
262
  projectPath
147
263
  };
148
264
 
149
265
  try {
150
- writeFileSync(
151
- join(backupDir, 'backup-manifest.json'),
152
- JSON.stringify(backupManifest, null, 2)
153
- );
266
+ writeFileSync(join(backupDir, 'backup-manifest.json'), JSON.stringify(backupManifest, null, 2));
154
267
  } catch (err) {
155
268
  errors.push(`Failed to write backup manifest: ${err.message}`);
156
269
  }
@@ -159,66 +272,278 @@ export function createBackup(projectPath, plan) {
159
272
  }
160
273
 
161
274
  /**
162
- * Rollback to a specific backup.
275
+ * Create a backup for the files that will be affected by a reconciliation plan.
163
276
  *
164
277
  * @param {string} projectPath - Project root
165
- * @param {string} [backupId] - Specific backup ID (defaults to most recent)
166
- * @returns {{ success: boolean, restored: string[], errors: string[] }}
278
+ * @param {import('./diff-engine.js').ReconciliationPlan} plan - The reconciliation plan
279
+ * @param {Object} [options]
280
+ * @param {string[]} [options.alsoWatch] - Extra project-relative paths the step writes outside its plan
281
+ * (the skills/commands bookkeeping files the installers rewrite)
282
+ * @returns {{ backupId: string, backupDir: string, backedUp: string[], errors: string[] }}
167
283
  */
168
- export function rollback(projectPath, backupId = null) {
169
- const restored = [];
170
- const errors = [];
284
+ export function createBackup(projectPath, plan, options = {}) {
285
+ // Only backup files that will be updated or deleted
286
+ const mustBackUp = plan.actions
287
+ .filter(a => a.type === 'update' || a.type === 'delete' || a.type === 'migrate_block')
288
+ .map(a => a.path);
289
+ // A `create` has no earlier copy to save, but it must be watched: the backup is what lets a rollback
290
+ // know that file did not exist before.
291
+ const created = plan.actions.filter(a => a.type === 'create').map(a => a.path).filter((p) => !isAbsolute(p));
171
292
 
172
- // Find backup
173
- if (!backupId) {
174
- const backups = listBackups(projectPath);
175
- if (backups.length === 0) {
176
- return { success: false, restored, errors: ['No backups found'] };
293
+ return snapshot(projectPath, {
294
+ label: 'reconcile',
295
+ mustBackUp,
296
+ alsoWatch: [...created, ...(options.alsoWatch || [])],
297
+ plan: {
298
+ summary: plan.summary,
299
+ actionCount: plan.actions.length,
300
+ actions: plan.actions.map(a => ({
301
+ type: a.type,
302
+ category: a.category,
303
+ path: a.path,
304
+ reason: a.reason
305
+ }))
306
+ }
307
+ });
308
+ }
309
+
310
+ /**
311
+ * Backup for a step that writes outside the reconciler: `uds update --skills`, `--commands`.
312
+ *
313
+ * @param {string} projectPath
314
+ * @param {Object} spec
315
+ * @param {string} spec.label - 'skills' | 'commands'
316
+ * @param {string[]} spec.paths - Project-relative directories the step writes into
317
+ * @param {Array<{path: string, reason: string}>} [spec.notBackedUp] - Targets outside the project
318
+ */
319
+ export function createStepBackup(projectPath, spec) {
320
+ return snapshot(projectPath, {
321
+ label: spec.label,
322
+ mustBackUp: [],
323
+ alsoWatch: spec.paths || [],
324
+ notBackedUp: spec.notBackedUp || [],
325
+ plan: { summary: null, actionCount: 0, actions: [] }
326
+ });
327
+ }
328
+
329
+ function readBackupManifest(projectPath, backupId) {
330
+ const manifestPath = join(projectPath, backupId, 'backup-manifest.json');
331
+ return JSON.parse(readFileSync(manifestPath, 'utf-8'));
332
+ }
333
+
334
+ /**
335
+ * Record what the step did, once it is done: the files it created, and the manifest it left behind.
336
+ *
337
+ * Call after the step's last write to `.standards/manifest.json`. A later write that is not followed by
338
+ * another call leaves `postManifestHash` stale, which only ever breaks the chain (the rollback then
339
+ * undoes this one step and says so) — it can never make a rollback restore the wrong thing.
340
+ * Never throws: a backup that cannot be finalized is still a backup.
341
+ *
342
+ * @param {string} projectPath
343
+ * @param {string|null} backupId
344
+ * @returns {{ createdFiles: string[], errors: string[] }}
345
+ */
346
+ export function finalizeBackup(projectPath, backupId) {
347
+ const errors = [];
348
+ if (!backupId) return { createdFiles: [], errors };
349
+ try {
350
+ const backupDir = join(projectPath, backupId);
351
+ const bm = readBackupManifest(projectPath, backupId);
352
+ const created = new Set();
353
+ for (const rel of bm.watched || []) {
354
+ const before = new Set(listFiles(backupDir, rel));
355
+ for (const f of listFiles(projectPath, rel)) {
356
+ if (!before.has(f)) created.add(f);
357
+ }
358
+ }
359
+ const pre = new Set(bm.preexistingDirs || []);
360
+ const createdDirs = new Set();
361
+ for (const f of created) {
362
+ for (const a of ancestorsOf(f)) if (!pre.has(a)) createdDirs.add(a);
177
363
  }
178
- backupId = backups[0].backupId; // Most recent
364
+ bm.createdFiles = [...created].sort();
365
+ bm.createdDirs = [...createdDirs].sort((a, b) => b.length - a.length);
366
+ bm.postManifestHash = hashFileBytes(join(projectPath, MANIFEST_REL));
367
+ bm.finalized = true;
368
+ bm.finalizedAt = new Date().toISOString();
369
+ writeFileSync(join(backupDir, 'backup-manifest.json'), JSON.stringify(bm, null, 2));
370
+ return { createdFiles: bm.createdFiles, errors };
371
+ } catch (err) {
372
+ errors.push(`Failed to finalize ${backupId}: ${err.message}`);
373
+ return { createdFiles: [], errors };
179
374
  }
375
+ }
376
+
377
+ /** Undo one backup. Never throws. */
378
+ function restoreOne(projectPath, backupId) {
379
+ const restored = [];
380
+ const removed = [];
381
+ const errors = [];
382
+ const notRestored = [];
180
383
 
181
384
  const backupDir = join(projectPath, backupId);
182
385
  if (!existsSync(backupDir)) {
183
- return { success: false, restored, errors: [`Backup not found: ${backupId}`] };
386
+ return { restored, removed, errors: [`Backup not found: ${backupId}`], notRestored, label: null };
184
387
  }
185
388
 
186
389
  // Read backup manifest
187
- const manifestPath = join(backupDir, 'backup-manifest.json');
188
- let backupManifest;
390
+ let bm;
189
391
  try {
190
- backupManifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
392
+ bm = readBackupManifest(projectPath, backupId);
191
393
  } catch (err) {
192
- return { success: false, restored, errors: [`Failed to read backup manifest: ${err.message}`] };
394
+ return { restored, removed, errors: [`Failed to read backup manifest: ${err.message}`], notRestored, label: null };
193
395
  }
194
396
 
195
- // Restore each backed-up file
196
- for (const relativePath of backupManifest.backedUpFiles) {
197
- const sourcePath = join(backupDir, relativePath);
198
- const targetPath = join(projectPath, relativePath);
397
+ // 1. Remove what the step created. This comes first so that a path that is a file in the backup and was
398
+ // replaced by a directory since (or the reverse) is clear before it is restored.
399
+ for (const rel of bm.createdFiles || []) {
400
+ const abs = join(projectPath, rel);
401
+ try {
402
+ if (existsSync(abs)) {
403
+ rmSync(abs, { force: true });
404
+ removed.push(rel);
405
+ }
406
+ } catch (err) {
407
+ errors.push(`Failed to remove ${rel}: ${err.message}`);
408
+ }
409
+ }
199
410
 
411
+ // 2. Restore each backed-up entry. A directory is restored file by file: `copyFileSync` throws on one.
412
+ for (const relativePath of bm.backedUpFiles || []) {
413
+ const sourcePath = join(backupDir, relativePath);
200
414
  if (!existsSync(sourcePath)) {
201
415
  errors.push(`Backup file missing: ${relativePath}`);
202
416
  continue;
203
417
  }
204
-
205
418
  try {
206
- mkdirSync(dirname(targetPath), { recursive: true });
207
- copyFileSync(sourcePath, targetPath);
208
- restored.push(relativePath);
419
+ if (lstatSync(sourcePath).isDirectory()) {
420
+ mkdirSync(join(projectPath, relativePath), { recursive: true });
421
+ for (const f of listFiles(backupDir, relativePath)) {
422
+ mkdirSync(dirname(join(projectPath, f)), { recursive: true });
423
+ copyLeaf(join(backupDir, f), join(projectPath, f));
424
+ restored.push(f);
425
+ }
426
+ } else {
427
+ mkdirSync(dirname(join(projectPath, relativePath)), { recursive: true });
428
+ copyLeaf(sourcePath, join(projectPath, relativePath));
429
+ restored.push(relativePath);
430
+ }
209
431
  } catch (err) {
210
432
  errors.push(`Failed to restore ${relativePath}: ${err.message}`);
211
433
  }
212
434
  }
213
435
 
214
- return { success: errors.length === 0, restored, errors };
436
+ // 3. Directories the step created, now empty. Only empty ones: rmdir refuses anything else, which is
437
+ // what keeps a file the user added afterwards from being swept away.
438
+ for (const rel of bm.createdDirs || []) {
439
+ try {
440
+ rmdirSync(join(projectPath, rel));
441
+ } catch {
442
+ // not empty, or already gone — leave it
443
+ }
444
+ }
445
+
446
+ // 4. Read back. "Restored" is a claim; this is what makes it a fact.
447
+ for (const rel of new Set(restored)) {
448
+ const a = leafSignature(join(projectPath, rel));
449
+ const b = leafSignature(join(backupDir, rel));
450
+ if (a === null || a !== b) errors.push(`Restored file differs from the backup: ${rel}`);
451
+ }
452
+ for (const rel of removed) {
453
+ if (existsSync(join(projectPath, rel))) errors.push(`Created file is still there after rollback: ${rel}`);
454
+ }
455
+
456
+ for (const item of bm.notBackedUp || []) {
457
+ notRestored.push(`${item.path} — ${item.reason}`);
458
+ }
459
+ if (bm.format !== FORMAT_VERSION) {
460
+ notRestored.push(
461
+ `${backupId} was made by an older UDS: files that update created, and .standards/manifest.json, ` +
462
+ 'were not recorded and are not restored from it.'
463
+ );
464
+ } else if (!bm.finalized) {
465
+ notRestored.push(
466
+ `${backupId} was never finalized (the update that made it did not finish): files it created are not known, so none were removed.`
467
+ );
468
+ }
469
+ if ((bm.failedToBackUp || []).length > 0) {
470
+ notRestored.push(`${backupId} is incomplete: ${bm.failedToBackUp.join('; ')}`);
471
+ }
472
+
473
+ return { restored, removed, errors, notRestored, label: bm.label || null };
474
+ }
475
+
476
+ /**
477
+ * Rollback.
478
+ *
479
+ * With no `backupId`, undoes the newest backup AND every earlier one that belongs to the same unbroken
480
+ * series of UDS updates (see the file header) — so `update --apply`, `--apply --skills`, `--apply --commands`
481
+ * followed by one `--rollback` returns to where the first of them started. With an explicit `backupId`,
482
+ * undoes only that backup.
483
+ *
484
+ * @param {string} projectPath - Project root
485
+ * @param {string} [backupId] - Specific backup ID (defaults to the most recent series)
486
+ * @returns {{ success: boolean, restored: string[], removed: string[], errors: string[],
487
+ * notRestored: string[], steps: Array<Object>, olderBackups: number }}
488
+ */
489
+ export function rollback(projectPath, backupId = null) {
490
+ const restored = [];
491
+ const removed = [];
492
+ const errors = [];
493
+ const notRestored = [];
494
+ const steps = [];
495
+
496
+ const all = listBackups(projectPath);
497
+ let chain;
498
+ if (backupId) {
499
+ if (!existsSync(join(projectPath, backupId))) {
500
+ return { success: false, restored, removed, errors: [`Backup not found: ${backupId}`], notRestored, steps, olderBackups: 0 };
501
+ }
502
+ chain = [backupId];
503
+ } else {
504
+ if (all.length === 0) {
505
+ return { success: false, restored, removed, errors: ['No backups found'], notRestored, steps, olderBackups: 0 };
506
+ }
507
+ chain = [all[0].backupId];
508
+ // Walk back while "the manifest before this step" is "the manifest the previous step left".
509
+ for (let i = 1; i < all.length; i++) {
510
+ const newer = all[i - 1];
511
+ const older = all[i];
512
+ if (newer.preManifestHash && older.postManifestHash && newer.preManifestHash === older.postManifestHash) {
513
+ chain.push(older.backupId);
514
+ } else {
515
+ break;
516
+ }
517
+ }
518
+ }
519
+
520
+ for (const id of chain) {
521
+ const one = restoreOne(projectPath, id);
522
+ steps.push({ backupId: id, label: one.label, restored: one.restored, removed: one.removed, errors: one.errors, notRestored: one.notRestored });
523
+ restored.push(...one.restored);
524
+ removed.push(...one.removed);
525
+ errors.push(...one.errors);
526
+ notRestored.push(...one.notRestored);
527
+ }
528
+
529
+ const olderBackups = backupId ? 0 : all.length - chain.length;
530
+ return {
531
+ success: errors.length === 0,
532
+ restored: [...new Set(restored)],
533
+ removed: [...new Set(removed)],
534
+ errors,
535
+ notRestored,
536
+ steps,
537
+ olderBackups
538
+ };
215
539
  }
216
540
 
217
541
  /**
218
542
  * List all backups for a project, sorted by creation time (newest first).
219
543
  *
220
544
  * @param {string} projectPath
221
- * @returns {Array<{ backupId: string, createdAt: string, actionCount: number }>}
545
+ * @returns {Array<{ backupId: string, createdAt: string, actionCount: number, label: string|null,
546
+ * preManifestHash: string|null, postManifestHash: string|null }>}
222
547
  */
223
548
  export function listBackups(projectPath) {
224
549
  try {
@@ -231,12 +556,22 @@ export function listBackups(projectPath) {
231
556
  const backupDir = join(projectPath, entry.name);
232
557
  const manifestPath = join(backupDir, 'backup-manifest.json');
233
558
 
234
- let info = { backupId: entry.name, createdAt: '', actionCount: 0 };
559
+ const info = {
560
+ backupId: entry.name,
561
+ createdAt: '',
562
+ actionCount: 0,
563
+ label: null,
564
+ preManifestHash: null,
565
+ postManifestHash: null
566
+ };
235
567
  if (existsSync(manifestPath)) {
236
568
  try {
237
569
  const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
238
570
  info.createdAt = manifest.createdAt || '';
239
571
  info.actionCount = manifest.plan?.actionCount || 0;
572
+ info.label = manifest.label || null;
573
+ info.preManifestHash = manifest.preManifestHash || null;
574
+ info.postManifestHash = manifest.postManifestHash || null;
240
575
  } catch {
241
576
  // Use defaults
242
577
  }
@@ -245,8 +580,9 @@ export function listBackups(projectPath) {
245
580
  backups.push(info);
246
581
  }
247
582
 
248
- // Sort newest first
249
- backups.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
583
+ // Sort newest first. The id carries a per-process counter after the timestamp, so ties on
584
+ // `createdAt` (two steps in the same millisecond) still order by creation.
585
+ backups.sort((a, b) => b.createdAt.localeCompare(a.createdAt) || b.backupId.localeCompare(a.backupId));
250
586
  return backups;
251
587
  } catch {
252
588
  return [];