universal-dev-standards 6.14.0-beta.2 → 6.14.0-beta.3

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.
@@ -1,5 +1,7 @@
1
- import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'fs';
1
+ import { existsSync, readFileSync, writeFileSync, unlinkSync, readdirSync, rmdirSync } from 'fs';
2
2
  import { join, basename } from 'path';
3
+ import { proveUnchanged, RECORD_KINDS, RECORDS_KEY, isRealDirectory } from '../core/install-records.js';
4
+ import { stripUdsHookBlock } from '../utils/git-hooks.js';
3
5
  import {
4
6
  collectHookConfigs, standardsSourceDir, hooksSourceDir,
5
7
  CODEX_HOOK_SCRIPT, GEMINI_HOOK_SCRIPT, AGY_HOOK_SCRIPT,
@@ -88,7 +90,7 @@ function applyHooksMap(config, nextMap) {
88
90
  * difference is which file and which key they look at.
89
91
  */
90
92
  function uninstallHookConfigFile({ configPath, label, dryRun, deleteEmptyFile }) {
91
- const result = { removed: [], skipped: [], errors: [] };
93
+ const result = { removed: [], skipped: [], errors: [], deletedPaths: [] };
92
94
  if (!existsSync(configPath)) return result; // nothing installed here — nothing to report
93
95
 
94
96
  let config;
@@ -106,18 +108,21 @@ function uninstallHookConfigFile({ configPath, label, dryRun, deleteEmptyFile })
106
108
  }
107
109
 
108
110
  const entryLabel = `${label} (${removedCount} UDS hook ${removedCount === 1 ? 'entry' : 'entries'})`;
111
+ const updatedConfig = applyHooksMap(config, nextMap);
112
+ const willDeleteFile = deleteEmptyFile && Object.keys(updatedConfig).length === 0;
113
+ // Decided BEFORE the dry-run return: the preview must say the file goes away,
114
+ // and the folder-cleanup step needs to know the folder will be empty.
109
115
  if (dryRun) {
110
- result.removed.push(entryLabel);
116
+ result.removed.push(willDeleteFile ? `${entryLabel}, file removed — created by UDS, now empty` : entryLabel);
117
+ if (willDeleteFile) result.deletedPaths.push(label);
111
118
  return result;
112
119
  }
113
120
 
114
- const updatedConfig = applyHooksMap(config, nextMap);
115
- const isNowEmpty = Object.keys(updatedConfig).length === 0;
116
-
117
121
  try {
118
- if (deleteEmptyFile && isNowEmpty) {
122
+ if (willDeleteFile) {
119
123
  unlinkSync(configPath);
120
124
  result.removed.push(`${entryLabel}, file removed — created by UDS, now empty`);
125
+ result.deletedPaths.push(label);
121
126
  } else {
122
127
  writeFileSync(configPath, JSON.stringify(updatedConfig, null, 2) + '\n');
123
128
  result.removed.push(entryLabel);
@@ -186,7 +191,7 @@ export function uninstallAgyHooks(projectPath, options = {}) {
186
191
  const configPath = join(projectPath, '.agents', 'hooks.json');
187
192
  const label = '.agents/hooks.json';
188
193
  const dryRun = options.dryRun || false;
189
- const result = { removed: [], skipped: [], errors: [] };
194
+ const result = { removed: [], skipped: [], errors: [], deletedPaths: [] };
190
195
  if (!existsSync(configPath)) return result;
191
196
 
192
197
  let config;
@@ -242,14 +247,17 @@ export function uninstallAgyHooks(projectPath, options = {}) {
242
247
  }
243
248
 
244
249
  const entryLabel = `${label} (${removedCount} UDS hook ${removedCount === 1 ? 'entry' : 'entries'})`;
250
+ const willDeleteFile = Object.keys(next).length === 0;
245
251
  if (dryRun) {
246
- result.removed.push(entryLabel);
252
+ result.removed.push(willDeleteFile ? `${entryLabel}, file removed — created by UDS, now empty` : entryLabel);
253
+ if (willDeleteFile) result.deletedPaths.push(label);
247
254
  return result;
248
255
  }
249
256
  try {
250
- if (Object.keys(next).length === 0) {
257
+ if (willDeleteFile) {
251
258
  unlinkSync(configPath);
252
259
  result.removed.push(`${entryLabel}, file removed — created by UDS, now empty`);
260
+ result.deletedPaths.push(label);
253
261
  } else {
254
262
  writeFileSync(configPath, JSON.stringify(next, null, 2) + '\n');
255
263
  result.removed.push(entryLabel);
@@ -260,18 +268,113 @@ export function uninstallAgyHooks(projectPath, options = {}) {
260
268
  return result;
261
269
  }
262
270
 
271
+ // ─────────────────────────────────────────────────────────────────────────────
272
+ // Proof of authorship — "only delete what UDS can prove it wrote, unchanged".
273
+ //
274
+ // A record in `manifest.installedArtifacts` (core/install-records.js) says UDS
275
+ // wrote a file; a matching hash says nobody has changed it since. Both are
276
+ // required to delete a whole file. Anything else is KEPT, and the reason is
277
+ // printed, because "not provably ours" is the honest state of a file from an
278
+ // older UDS (no record) or one an adopter has edited (hash differs) — and
279
+ // guessing "probably ours" is how an adopter's own hook script gets deleted.
280
+ // ─────────────────────────────────────────────────────────────────────────────
281
+
282
+ /** Relative paths (forward slashes) of every file under `dir`, or [] if it is not there. */
283
+ function listFiles(dir, rel = '') {
284
+ if (!dir || !existsSync(dir)) return [];
285
+ const out = [];
286
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
287
+ const r = rel ? `${rel}/${e.name}` : e.name;
288
+ if (e.isDirectory()) out.push(...listFiles(join(dir, e.name), r));
289
+ else out.push(r);
290
+ }
291
+ return out;
292
+ }
293
+
294
+ /**
295
+ * Remove the hook scripts UDS copied into `scripts/hooks/`.
296
+ *
297
+ * `scripts/hooks/` is a directory UDS scaffolds inside the adopter's project, so
298
+ * an adopter's own scripts can sit beside ours (a test in hook-uninstaller.test.js
299
+ * places one there and it must survive). Only files that have an install record
300
+ * AND still match it are deleted.
301
+ */
302
+ export function uninstallHookScripts(projectPath, manifest, { dryRun = false, blockedBy = null } = {}) {
303
+ const result = { removed: [], skipped: [], errors: [], deletedPaths: [] };
304
+ const files = manifest?.[RECORDS_KEY]?.files || {};
305
+ const recorded = Object.entries(files)
306
+ .filter(([, rec]) => rec && rec.kind === RECORD_KINDS.HOOK_SCRIPT)
307
+ .map(([rel]) => rel)
308
+ .sort();
309
+
310
+ for (const rel of recorded) {
311
+ const abs = join(projectPath, rel);
312
+ if (!existsSync(abs)) continue; // already gone — nothing to remove, nothing to report
313
+ if (blockedBy) {
314
+ result.skipped.push(`${rel} (kept: ${blockedBy})`);
315
+ continue;
316
+ }
317
+ const proof = proveUnchanged(manifest, projectPath, rel);
318
+ if (proof.state !== 'proven') {
319
+ result.skipped.push(`${rel} (kept: ${proof.why})`);
320
+ continue;
321
+ }
322
+ try {
323
+ if (!dryRun) unlinkSync(abs);
324
+ result.removed.push(`${rel} (deleted — installed by UDS, ${proof.why})`);
325
+ result.deletedPaths.push(rel);
326
+ } catch (error) {
327
+ result.errors.push(`${rel} — ${error.message}`);
328
+ }
329
+ }
330
+
331
+ // Files that carry the name of a script UDS ships but have no record. Say so once,
332
+ // so a leftover `scripts/hooks/` is an explained decision and not a silent gap.
333
+ const recordedSet = new Set(recorded);
334
+ const shipped = listFiles(hooksSourceDir());
335
+ const unrecorded = shipped
336
+ .map((f) => `scripts/hooks/${f}`)
337
+ .filter((rel) => existsSync(join(projectPath, rel)) && !recordedSet.has(rel));
338
+ if (unrecorded.length > 0) {
339
+ result.skipped.push(
340
+ `scripts/hooks/ (kept: ${unrecorded.length} file${unrecorded.length === 1 ? '' : 's'} carry the name of a UDS hook script, ` +
341
+ 'but the manifest has no record that UDS wrote them — installed by an older UDS, or by hand. ' +
342
+ 'Delete them yourself if you no longer want them)'
343
+ );
344
+ }
345
+ return result;
346
+ }
347
+
348
+ // Line-level fallback, for the one-line form older UDS wrote and for a block whose
349
+ // end marker an adopter removed. The current block is removed whole, by its markers
350
+ // (stripUdsHookBlock) — its inner lines are not individually matchable.
351
+ const UDS_PRECOMMIT_LINE = /uds\s+check|universal-dev-standards\s+check|checkin-standards|^#\s*UDS Standard Check\s*$/;
352
+ const NATIVE_UDS_LINE = /uds\s+check|checkin-standards|UDS pre-commit hook/;
353
+
354
+ /** True when nothing is left but a shebang and blank lines. */
355
+ function nothingButShebang(lines) {
356
+ return lines.every((l) => !l.trim() || /^#!/.test(l.trim()));
357
+ }
358
+
263
359
  /**
264
360
  * Remove UDS-related lines from .husky/pre-commit, the native
265
361
  * .git/hooks/pre-commit fallback, and the enforcement-hook entries UDS wrote
266
362
  * into .claude/settings.json, .codex/hooks.json, .gemini/settings.json and
267
- * .agents/hooks.json.
363
+ * .agents/hooks.json; and the hook scripts copied into scripts/hooks/.
364
+ *
365
+ * Whole-file deletion (a hook file, a script) needs proof — see proveUnchanged.
366
+ * A hook file that is not provably wholly UDS's still has its UDS lines removed
367
+ * (each is a line UDS wrote, and the rest of the file is untouched) and the
368
+ * remainder is reported as kept, with the reason.
369
+ *
268
370
  * @param {string} projectPath - Project root path
269
- * @param {Object} options - { dryRun: boolean }
270
- * @returns {Object} { removed: string[], skipped: string[], errors: string[] }
371
+ * @param {Object} options - { dryRun: boolean, manifest: object|null }
372
+ * `manifest` must be read BEFORE `.standards/` is removed; it carries the install records.
373
+ * @returns {Object} { removed: string[], skipped: string[], errors: string[], deletedPaths: string[] }
271
374
  */
272
375
  export function uninstallHook(projectPath, options = {}) {
273
- const { dryRun = false } = options;
274
- const result = { removed: [], skipped: [], errors: [] };
376
+ const { dryRun = false, manifest = null } = options;
377
+ const result = { removed: [], skipped: [], errors: [], deletedPaths: [] };
275
378
  const hookPath = join(projectPath, '.husky', 'pre-commit');
276
379
 
277
380
  if (!existsSync(hookPath)) {
@@ -279,12 +382,23 @@ export function uninstallHook(projectPath, options = {}) {
279
382
  } else {
280
383
  try {
281
384
  const content = readFileSync(hookPath, 'utf-8');
385
+ const proof = proveUnchanged(manifest, projectPath, '.husky/pre-commit');
282
386
  const lines = content.split('\n');
283
- const udsPattern = /uds\s+check|checkin-standards/;
284
- const filteredLines = lines.filter(line => !udsPattern.test(line));
387
+ const filteredLines = stripUdsHookBlock(content).content.split('\n')
388
+ .filter(line => !UDS_PRECOMMIT_LINE.test(line));
285
389
 
286
- if (filteredLines.length === lines.length) {
390
+ if (proof.state === 'proven') {
391
+ // UDS created this file and nobody has touched it: nothing of the adopter's is in it.
392
+ if (!dryRun) unlinkSync(hookPath);
393
+ result.removed.push(`.husky/pre-commit (deleted — created by UDS, ${proof.why})`);
394
+ result.deletedPaths.push('.husky/pre-commit');
395
+ } else if (filteredLines.length === lines.length) {
287
396
  result.skipped.push('.husky/pre-commit (no UDS lines found)');
397
+ } else if (nothingButShebang(filteredLines)) {
398
+ // Only UDS's lines (and a shebang) were in it — removing them leaves nothing worth keeping.
399
+ if (!dryRun) unlinkSync(hookPath);
400
+ result.removed.push('.husky/pre-commit (UDS check lines; file removed — nothing else was in it)');
401
+ result.deletedPaths.push('.husky/pre-commit');
288
402
  } else if (dryRun) {
289
403
  result.removed.push('.husky/pre-commit (UDS check lines)');
290
404
  } else {
@@ -301,25 +415,30 @@ export function uninstallHook(projectPath, options = {}) {
301
415
  if (existsSync(nativeHookPath)) {
302
416
  try {
303
417
  const content = readFileSync(nativeHookPath, 'utf-8');
304
- const udsPattern = /uds\s+check|checkin-standards|UDS pre-commit hook/;
418
+ const proof = proveUnchanged(manifest, projectPath, '.git/hooks/pre-commit');
305
419
 
306
- if (!udsPattern.test(content)) {
420
+ if (proof.state === 'proven') {
421
+ // The whole script is what `uds init` wrote — including the parts no line
422
+ // pattern would ever match (its "Auto-generated by uds init" header, the
423
+ // linter fallbacks, the closing echo). Deleting only the matching lines
424
+ // used to leave that body behind, still executable, still claiming
425
+ // "Pre-commit checks passed".
426
+ if (!dryRun) unlinkSync(nativeHookPath);
427
+ result.removed.push(`.git/hooks/pre-commit (UDS native hook, file removed — ${proof.why})`);
428
+ result.deletedPaths.push('.git/hooks/pre-commit');
429
+ } else if (!NATIVE_UDS_LINE.test(content)) {
307
430
  result.skipped.push('.git/hooks/pre-commit (no UDS lines found)');
308
- } else if (dryRun) {
309
- result.removed.push('.git/hooks/pre-commit (UDS native hook)');
310
431
  } else {
311
- // Remove only UDS-related lines, keep other hook content
312
432
  const lines = content.split('\n');
313
- const filtered = lines.filter(line => !udsPattern.test(line));
314
-
315
- // If only shebang remains, remove the file entirely; otherwise rewrite
316
- const nonEmpty = filtered.filter(l => l.trim() && l.trim() !== '#!/bin/sh');
317
- if (nonEmpty.length === 0) {
318
- unlinkSync(nativeHookPath);
433
+ const filtered = lines.filter(line => !NATIVE_UDS_LINE.test(line));
434
+ if (nothingButShebang(filtered)) {
435
+ if (!dryRun) unlinkSync(nativeHookPath);
319
436
  result.removed.push('.git/hooks/pre-commit (UDS native hook, file removed)');
437
+ result.deletedPaths.push('.git/hooks/pre-commit');
320
438
  } else {
321
- writeFileSync(nativeHookPath, filtered.join('\n'), 'utf-8');
439
+ if (!dryRun) writeFileSync(nativeHookPath, filtered.join('\n'), 'utf-8');
322
440
  result.removed.push('.git/hooks/pre-commit (UDS lines removed)');
441
+ result.skipped.push(`.git/hooks/pre-commit (kept: the rest of the script — ${proof.why})`);
323
442
  }
324
443
  }
325
444
  } catch (error) {
@@ -329,16 +448,81 @@ export function uninstallHook(projectPath, options = {}) {
329
448
 
330
449
  // Enforcement hooks written by installHooks()/installCodexHooks()/installGeminiHooks()
331
450
  // — the gap this function used to have entirely (see module doc comment above).
332
- for (const sub of [
451
+ const configResults = [
333
452
  uninstallClaudeCodeHooks(projectPath, { dryRun }),
334
453
  uninstallCodexHooks(projectPath, { dryRun }),
335
454
  uninstallGeminiHooks(projectPath, { dryRun }),
336
455
  uninstallAgyHooks(projectPath, { dryRun }),
337
- ]) {
456
+ ];
457
+ for (const sub of configResults) {
338
458
  result.removed.push(...sub.removed);
339
459
  result.skipped.push(...sub.skipped);
340
460
  result.errors.push(...sub.errors);
461
+ result.deletedPaths.push(...sub.deletedPaths);
341
462
  }
342
463
 
464
+ // The scripts those entries ran. If a config file could not be cleaned (it still
465
+ // names a script), deleting the script would turn a working hook into a failing one.
466
+ const configFailed = configResults.some((r) => r.errors.length > 0);
467
+ const scripts = uninstallHookScripts(projectPath, manifest, {
468
+ dryRun,
469
+ blockedBy: configFailed ? 'a hook config file could not be cleaned (see the error above) and still runs this script' : null
470
+ });
471
+ result.removed.push(...scripts.removed);
472
+ result.skipped.push(...scripts.skipped);
473
+ result.errors.push(...scripts.errors);
474
+ result.deletedPaths.push(...scripts.deletedPaths);
475
+
476
+ return result;
477
+ }
478
+
479
+ /**
480
+ * Remove the folders UDS had to create for its files (`.codex/`, `scripts/hooks/`, ...),
481
+ * once they are empty.
482
+ *
483
+ * A folder is removed only if (a) the manifest recorded that UDS created it and
484
+ * (b) nothing is left in it. An empty `.codex/` that the adopter made themselves is
485
+ * indistinguishable by looking; the record is the only thing that tells them apart.
486
+ * A folder that still holds anything — `.agents/rules/style.md` — is kept and
487
+ * reported, so the adopter sees why it stayed.
488
+ *
489
+ * In a dry run nothing has been deleted yet, so `plannedDeletions` (relative paths
490
+ * the run WOULD delete) stands in for the missing deletions when judging "empty".
491
+ *
492
+ * @param {string} projectPath
493
+ * @param {Object|null} manifest - read before `.standards/` was removed
494
+ * @param {{ dryRun?: boolean, plannedDeletions?: string[] }} [options]
495
+ * @returns {{ removed: string[], skipped: string[], errors: string[], deletedPaths: string[] }}
496
+ */
497
+ export function pruneCreatedDirs(projectPath, manifest, { dryRun = false, plannedDeletions = [] } = {}) {
498
+ const result = { removed: [], skipped: [], errors: [], deletedPaths: [] };
499
+ const dirs = manifest?.[RECORDS_KEY]?.createdDirs || [];
500
+ const gone = new Set(plannedDeletions.map((p) => p.replace(/\\/g, '/')));
501
+ // Deepest first: `scripts/hooks` must go before `scripts` can be seen as empty.
502
+ const ordered = [...new Set(dirs)]
503
+ .filter((d) => d && !d.startsWith('..') && !d.startsWith('/'))
504
+ .sort((a, b) => b.split('/').length - a.split('/').length);
505
+
506
+ for (const rel of ordered) {
507
+ const abs = join(projectPath, rel);
508
+ if (!existsSync(abs)) continue;
509
+ if (!isRealDirectory(abs)) {
510
+ result.skipped.push(`${rel}/ (kept: not a plain directory)`);
511
+ continue;
512
+ }
513
+ try {
514
+ const left = readdirSync(abs).filter((name) => !gone.has(`${rel}/${name}`));
515
+ if (left.length > 0) {
516
+ result.skipped.push(`${rel}/ (kept: created by UDS but not empty — still holds ${left.slice(0, 3).join(', ')}${left.length > 3 ? ', ...' : ''}, which UDS did not write)`);
517
+ continue;
518
+ }
519
+ if (!dryRun) rmdirSync(abs);
520
+ gone.add(rel);
521
+ result.removed.push(`${rel}/ (empty folder created by UDS, removed)`);
522
+ result.deletedPaths.push(rel);
523
+ } catch (error) {
524
+ result.errors.push(`${rel}/ — ${error.message}`);
525
+ }
526
+ }
343
527
  return result;
344
528
  }
@@ -3,6 +3,7 @@ import { join } from 'path';
3
3
  import { extractMarkedContent } from '../utils/integration-generator.js';
4
4
  import { SUPPORTED_AI_TOOLS } from '../core/constants.js';
5
5
  import { resolveIntegrationFile } from '../core/constants.js';
6
+ import { proveUnchanged } from '../core/install-records.js';
6
7
 
7
8
  /**
8
9
  * Get the format for a given integration file name
@@ -29,7 +30,7 @@ function getFormatForFile(fileName) {
29
30
  */
30
31
  export async function uninstallIntegrations(projectPath, manifest, options = {}) {
31
32
  const { dryRun = false, interactive = false, promptFn = null } = options;
32
- const result = { removed: [], skipped: [], errors: [] };
33
+ const result = { removed: [], skipped: [], errors: [], deletedPaths: [] };
33
34
 
34
35
  const integrations = manifest?.integrations || [];
35
36
  if (integrations.length === 0) {
@@ -64,11 +65,27 @@ export async function uninstallIntegrations(projectPath, manifest, options = {})
64
65
  const userContent = (parts.before.trim() + parts.after.trim()).trim();
65
66
  const hasUserContent = userContent.length > 0;
66
67
 
68
+ // Text outside the UDS block is not automatically the adopter's. A file UDS
69
+ // creates from nothing carries generated text there — AGENTS.md's
70
+ // "> Auto-generated by ..." header and sections, CLAUDE.md's leading guidance —
71
+ // and treating all of it as "user content" left an orphaned file pointing at
72
+ // the `.standards/` this very command removes. It is only "not the adopter's"
73
+ // if UDS recorded a hash of it when it created the file and the text still
74
+ // matches; without that proof it stays, and the reason is printed.
75
+ const proof = hasUserContent ? proveUnchanged(manifest, projectPath, fileName) : null;
76
+ const outsideIsUdsOwn = hasUserContent && proof.state === 'proven';
77
+ const keptNote = () => `${fileName} (kept: the text outside the UDS block — ${proof.why})`;
78
+
67
79
  if (dryRun) {
68
- if (hasUserContent) {
69
- result.removed.push(`${fileName} (remove UDS block, keep user content)`);
70
- } else {
80
+ if (!hasUserContent) {
71
81
  result.removed.push(`${fileName} (delete file)`);
82
+ result.deletedPaths.push(fileName);
83
+ } else if (outsideIsUdsOwn) {
84
+ result.removed.push(`${fileName} (delete file — everything outside the UDS block was generated by UDS)`);
85
+ result.deletedPaths.push(fileName);
86
+ } else {
87
+ result.removed.push(`${fileName} (remove UDS block, keep user content)`);
88
+ result.skipped.push(keptNote());
72
89
  }
73
90
  continue;
74
91
  }
@@ -77,26 +94,39 @@ export async function uninstallIntegrations(projectPath, manifest, options = {})
77
94
  // 100% UDS-generated → delete entire file
78
95
  unlinkSync(filePath);
79
96
  result.removed.push(`${fileName} (deleted)`);
97
+ result.deletedPaths.push(fileName);
98
+ } else if (outsideIsUdsOwn) {
99
+ // UDS created this file and its non-block text is still exactly what UDS
100
+ // wrote: there is no adopter content to preserve, so no question to ask.
101
+ unlinkSync(filePath);
102
+ result.removed.push(`${fileName} (deleted — everything outside the UDS block was generated by UDS and is unchanged)`);
103
+ result.deletedPaths.push(fileName);
80
104
  } else if (interactive && promptFn) {
81
105
  // Ask user what to do
82
106
  const action = await promptFn(fileName, hasUserContent);
83
107
  if (action === 'delete-file') {
84
108
  unlinkSync(filePath);
85
109
  result.removed.push(`${fileName} (deleted)`);
110
+ result.deletedPaths.push(fileName);
86
111
  } else if (action === 'remove-block') {
87
112
  const cleaned = (parts.before + parts.after).trim() + '\n';
88
113
  writeFileSync(filePath, cleaned, 'utf-8');
89
114
  result.removed.push(`${fileName} (UDS block removed)`);
115
+ result.skipped.push(keptNote());
90
116
  } else {
91
117
  result.skipped.push(`${fileName} (user skipped)`);
92
118
  }
93
119
  } else {
94
- // Non-interactive (--yes): remove UDS block only, preserve user content
120
+ // Non-interactive (--yes): remove UDS block only, preserve everything else
95
121
  const cleaned = (parts.before + parts.after).trim() + '\n';
96
122
  writeFileSync(filePath, cleaned, 'utf-8');
97
123
  result.removed.push(`${fileName} (UDS block removed)`);
124
+ result.skipped.push(keptNote());
98
125
  }
99
126
  } catch (error) {
127
+ // A closed prompt is the user stopping the run, not a failure of this file:
128
+ // let the command report it (and exit non-zero) instead of retrying the next file.
129
+ if (error?.name === 'ExitPromptError') throw error;
100
130
  result.errors.push(`${fileName} — ${error.message}`);
101
131
  }
102
132
  }
@@ -11,6 +11,7 @@
11
11
  * adopters (asiaostrich-telemetry-server, asiaostrich-telemetry-client,
12
12
  * machine-setup): all three have `.husky/pre-commit` calling `npx uds check`,
13
13
  * none has `core.hooksPath` set, and the hook has never once run.
14
+ * (That `npx uds check` form was itself replaced — see buildPreCommitBlock.)
14
15
  *
15
16
  * Fix: set `core.hooksPath` directly ourselves. This is exactly what husky's
16
17
  * own `index.js` does internally (verified against husky ^9.1.7's source:
@@ -94,7 +95,7 @@ export function wireGitHooksPath(projectPath, targetRelDir) {
94
95
  return {
95
96
  wired: false,
96
97
  reason: `git core.hooksPath is already set to "${configured}"`,
97
- hint: `UDS will not override an existing core.hooksPath. Confirm the hook script there also runs \`npx uds check\`, or switch it yourself: git config --local core.hooksPath ${targetRelDir}`
98
+ hint: `UDS will not override an existing core.hooksPath. Confirm the hook script there also runs \`universal-dev-standards check\` (the installed CLI — not the short name "uds", which on the npm registry is an unrelated package), or switch it yourself: git config --local core.hooksPath ${targetRelDir}`
98
99
  };
99
100
  }
100
101
 
@@ -107,7 +108,7 @@ export function wireGitHooksPath(projectPath, targetRelDir) {
107
108
  return {
108
109
  wired: false,
109
110
  reason: '.git/hooks/pre-commit already exists',
110
- hint: 'UDS will not overwrite or bypass an existing .git/hooks/pre-commit. Add "npx uds check" to it manually, or remove it and re-run `uds init`.'
111
+ hint: 'UDS will not overwrite or bypass an existing .git/hooks/pre-commit. Add a "universal-dev-standards check" line to it manually (it needs the UDS CLI installed in this project or on PATH), or remove it and re-run `uds init`.'
111
112
  };
112
113
  }
113
114
 
@@ -126,10 +127,129 @@ export function wireGitHooksPath(projectPath, targetRelDir) {
126
127
  }
127
128
  }
128
129
 
129
- /** Does `filePath` contain the marker UDS writes into a hook it manages? */
130
+ // ─────────────────────────────────────────────────────────────────────────────
131
+ // What UDS writes into `.husky/pre-commit`.
132
+ //
133
+ // 🔴 This used to be the single line `npx uds check`. `npx` resolves a bare
134
+ // command name from node_modules/.bin and PATH first, and only when neither has
135
+ // it does it go to the npm registry — and the registry package named `uds` is
136
+ // NOT this project (it is an unrelated package by another maintainer, no
137
+ // `bin`, last published 2022). So a clone with no UDS installed had a commit
138
+ // hook that asked npm for a stranger's package by name. It failed today only
139
+ // because that package has no executable; the day its owner publishes one with a
140
+ // `uds` bin, every adopter's every commit runs the stranger's code.
141
+ //
142
+ // Measured 2026-09-30 against a local fake registry that logs each request
143
+ // (npm 10.9.9, 11.20.0, 12.1.0 — identical): `npx --no-install uds` STILL fetches
144
+ // the package metadata for `uds` (GET /uds), so `--no-install` is not a fix; and
145
+ // `npx --no-install --package=universal-dev-standards uds` does not find a
146
+ // GLOBAL install at all (it goes to the registry for our own name and fails).
147
+ // Neither is usable, so the hook does not involve npm at all: it looks in this
148
+ // project's node_modules/.bin, then on PATH, and it looks for the bin named
149
+ // after the full package name — `universal-dev-standards` is a name only this
150
+ // project can publish, `uds` is a name anyone's package can also claim.
151
+ // ─────────────────────────────────────────────────────────────────────────────
152
+
153
+ /** First line of the block UDS writes (also the marker older UDS versions wrote). */
154
+ export const UDS_HOOK_MARKER = '# UDS Standard Check';
155
+ /** Last line of the block — lets uninstall remove the whole block, not guess line by line. */
156
+ export const UDS_HOOK_END_MARKER = '# End UDS Standard Check';
157
+ /** The bin every UDS release has shipped since the rename; named after the package on purpose. */
158
+ export const UDS_BIN_NAME = 'universal-dev-standards';
159
+ /** A hook line that runs the UDS check — the old short form or the current full-name form. */
160
+ export const UDS_CHECK_COMMAND_RE = /\b(?:uds|universal-dev-standards)\s+check\b/;
161
+
162
+ /**
163
+ * Any place a hook script runs the bare name `uds` through a package runner
164
+ * (npx, bunx, pnpm dlx, yarn dlx, npm exec ...). Broader than the two exact lines UDS
165
+ * ever wrote (see legacy-hook-migration.js): this finds lines to TELL the
166
+ * adopter about, not lines UDS may change.
167
+ */
168
+ export const BARE_UDS_RUNNER_RE = /\b(?:npx|bunx|pnpx|pnpm\s+dlx|yarn\s+dlx|npm\s+exec|npm\s+x)\s+(?:-\S+\s+)*uds\b/;
169
+
170
+ /** Does this hook script still run the bare name `uds` through a package runner? */
171
+ export function hasBareUdsRunner(content) {
172
+ return typeof content === 'string' && content.split('\n').some((l) => BARE_UDS_RUNNER_RE.test(l));
173
+ }
174
+
175
+ /** Does hook script `content` run the UDS check, in either the old or the current form? */
176
+ export function hookRunsUdsCheck(content) {
177
+ return typeof content === 'string' && UDS_CHECK_COMMAND_RE.test(content);
178
+ }
179
+
180
+ /**
181
+ * The block `uds init` appends to `.husky/pre-commit` (and `uds update` swaps in
182
+ * for the old one-liner). POSIX sh; runs in git-bash on Windows.
183
+ *
184
+ * - The subshell keeps the PATH change from leaking into the adopter's own
185
+ * commands in the same hook file.
186
+ * - `|| exit $?` makes both outcomes stop the commit even when the adopter has
187
+ * put more commands after this block. Without it the "not installed" branch
188
+ * would be a silent skip of the check, which is what this must never be.
189
+ * - Not installed: say what is missing and how to fix it, exit non-zero. It
190
+ * neither skips the check nor downloads anything.
191
+ *
192
+ * @param {{args?: string}} [opts] extra arguments to keep on the check command
193
+ * (e.g. `--standard checkin-standards`, which older UDS versions wrote).
194
+ * @returns {string} the block, LF line endings, ending with a newline
195
+ */
196
+ export function buildPreCommitBlock({ args = '' } = {}) {
197
+ const cmd = args ? `${UDS_BIN_NAME} check ${args}` : `${UDS_BIN_NAME} check`;
198
+ return [
199
+ UDS_HOOK_MARKER,
200
+ '# Runs the UDS CLI installed in this project (node_modules/.bin) or on PATH.',
201
+ '# It never asks npm to fetch anything: the short name "uds" on the npm registry',
202
+ '# belongs to an unrelated package, and npx would download and run it.',
203
+ '(',
204
+ ' PATH="$PWD/node_modules/.bin:$PATH"',
205
+ ` if command -v ${UDS_BIN_NAME} >/dev/null 2>&1; then`,
206
+ ` ${cmd}`,
207
+ ' exit $?',
208
+ ' fi',
209
+ ` echo "[UDS] Pre-commit check cannot run: the UDS CLI (${UDS_BIN_NAME}) is not installed." >&2`,
210
+ ` echo "[UDS] Install it: npm install --save-dev ${UDS_BIN_NAME} (or: npm install -g ${UDS_BIN_NAME})" >&2`,
211
+ ' echo "[UDS] This commit is blocked until it is installed, or until you remove this block from .husky/pre-commit." >&2',
212
+ ' exit 1',
213
+ ') || exit $?',
214
+ UDS_HOOK_END_MARKER,
215
+ ''
216
+ ].join('\n');
217
+ }
218
+
219
+ /**
220
+ * Remove the block buildPreCommitBlock() writes — from the marker line through
221
+ * the end-marker line, inclusive — and nothing else. A block whose end marker is
222
+ * gone (the adopter cut into it) is left alone: what remains of it is no longer
223
+ * provably UDS's, and the caller's line-level fallback deals with the lines it can name.
224
+ * @returns {{content: string, removed: boolean}}
225
+ */
226
+ export function stripUdsHookBlock(content) {
227
+ const lines = content.split('\n');
228
+ const out = [];
229
+ let removed = false;
230
+ for (let i = 0; i < lines.length; i++) {
231
+ if (lines[i].trim() === UDS_HOOK_MARKER) {
232
+ let end = -1;
233
+ for (let j = i + 1; j < lines.length; j++) {
234
+ if (lines[j].trim() === UDS_HOOK_END_MARKER) { end = j; break; }
235
+ // Another marker before an end marker means this block is not well-formed.
236
+ if (lines[j].trim() === UDS_HOOK_MARKER) break;
237
+ }
238
+ if (end !== -1) {
239
+ i = end;
240
+ removed = true;
241
+ continue;
242
+ }
243
+ }
244
+ out.push(lines[i]);
245
+ }
246
+ return { content: out.join('\n'), removed };
247
+ }
248
+
249
+ /** Does `filePath` contain a hook line UDS manages (the old one-liner or the current block)? */
130
250
  function hasUdsMarker(filePath) {
131
251
  try {
132
- return readFileSync(filePath, 'utf-8').includes('uds check');
252
+ return hookRunsUdsCheck(readFileSync(filePath, 'utf-8'));
133
253
  } catch {
134
254
  return false;
135
255
  }
@@ -225,7 +345,7 @@ export function stripLegacyHuskyShLine(content) {
225
345
  * @param {string} projectPath
226
346
  * @returns {{relevant: boolean, wired?: boolean, hookFile?: string,
227
347
  * configuredHooksPath?: string|null, effectiveHooksDir?: string|null,
228
- * legacyV8?: boolean, missingShebang?: boolean}}
348
+ * legacyV8?: boolean, missingShebang?: boolean, legacyBareUds?: boolean}}
229
349
  * `relevant: false` means there is nothing UDS-managed to report on (no
230
350
  * hook file, or hooks-dir could not be determined — never guess "unwired"
231
351
  * from a failed lookup).
@@ -252,6 +372,13 @@ export function checkPreCommitHookWiring(projectPath) {
252
372
  try { return !hasShebang(readFileSync(managedPath, 'utf-8')); } catch { return false; }
253
373
  })();
254
374
 
375
+ // Independent of wiring, like missingShebang: the file can be wired and still ask
376
+ // npm for the bare name `uds` (see buildPreCommitBlock). Only the husky file is
377
+ // examined — that is the one UDS wrote the old one-liner into.
378
+ const legacyBareUds = hasHuskyHook && (() => {
379
+ try { return hasBareUdsRunner(readFileSync(huskyHookPath, 'utf-8')); } catch { return false; }
380
+ })();
381
+
255
382
  const effectiveFile = join(effectiveHooksDir, 'pre-commit');
256
383
  let wired = false;
257
384
  if (existsSync(effectiveFile)) {
@@ -264,7 +391,7 @@ export function checkPreCommitHookWiring(projectPath) {
264
391
  }
265
392
  }
266
393
 
267
- if (wired) return { relevant: true, wired: true, hookFile, missingShebang };
394
+ if (wired) return { relevant: true, wired: true, hookFile, missingShebang, legacyBareUds };
268
395
 
269
396
  const legacyV8 = hasHuskyHook && (() => {
270
397
  try { return hasLegacyHuskyShLine(readFileSync(huskyHookPath, 'utf-8')); } catch { return false; }
@@ -277,6 +404,7 @@ export function checkPreCommitHookWiring(projectPath) {
277
404
  configuredHooksPath: getLocalHooksPathConfig(projectPath),
278
405
  effectiveHooksDir,
279
406
  legacyV8,
280
- missingShebang
407
+ missingShebang,
408
+ legacyBareUds
281
409
  };
282
410
  }