universal-dev-standards 6.13.0-beta.2 → 6.13.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.
@@ -0,0 +1,282 @@
1
+ /**
2
+ * Git pre-commit hook wiring — shared by `uds init` (writer, see
3
+ * setupHuskyHook in ../commands/init.js) and `uds check` (read-only
4
+ * detector, see checkPreCommitHookWiring in ../commands/check.js).
5
+ *
6
+ * Origin: `uds init` writes `.husky/pre-commit`, but never made git actually
7
+ * run it. It relied on husky's own bootstrap (`npm install` triggering the
8
+ * `prepare` script, which husky uses to set `core.hooksPath`), which only
9
+ * fires the NEXT time `npm install` runs — never, if the adopter's
10
+ * `node_modules` already existed. Verified 2026-09-26 against three real
11
+ * adopters (asiaostrich-telemetry-server, asiaostrich-telemetry-client,
12
+ * machine-setup): all three have `.husky/pre-commit` calling `npx uds check`,
13
+ * none has `core.hooksPath` set, and the hook has never once run.
14
+ *
15
+ * Fix: set `core.hooksPath` directly ourselves. This is exactly what husky's
16
+ * own `index.js` does internally (verified against husky ^9.1.7's source:
17
+ * `git config core.hooksPath ${dir}/_`, then a shim under `_/` that execs the
18
+ * real script one directory up) — we do the equivalent for `.husky` directly
19
+ * so the hook is live immediately after `uds init`, independent of whether
20
+ * husky is installed or `npm install` ever runs again.
21
+ */
22
+ import { existsSync, readFileSync } from 'fs';
23
+ import { execSync } from 'child_process';
24
+ import { join, dirname, basename, isAbsolute } from 'path';
25
+
26
+ /** Read `core.hooksPath` from LOCAL git config only (never global/system) — the
27
+ * scope we write to, and the only one that could conflict with what we set.
28
+ * @returns {string|null} the configured value, or null if unset
29
+ */
30
+ export function getLocalHooksPathConfig(projectPath) {
31
+ try {
32
+ const out = execSync('git config --local --get core.hooksPath', {
33
+ cwd: projectPath,
34
+ encoding: 'utf-8',
35
+ stdio: ['pipe', 'pipe', 'pipe']
36
+ }).trim();
37
+ return out || null;
38
+ } catch {
39
+ // `git config --get` exits 1 when the key is unset — that is not an error.
40
+ return null;
41
+ }
42
+ }
43
+
44
+ /**
45
+ * The hooks directory git will ACTUALLY use right now, honoring `core.hooksPath`
46
+ * (local, global or system — whichever git resolves) and falling back to
47
+ * `.git/hooks` when unset. This is `git rev-parse --git-path hooks`, chosen
48
+ * over reading `core.hooksPath` ourselves because it matches what git itself
49
+ * resolves, including the worktree/`.git`-is-a-file case.
50
+ * @returns {string|null} absolute path, or null if it could not be determined
51
+ * (e.g. not a git repository, or git is not on PATH) — callers must treat
52
+ * that as "unknown", never as "unwired".
53
+ */
54
+ export function getEffectiveHooksDir(projectPath) {
55
+ try {
56
+ const out = execSync('git rev-parse --git-path hooks', {
57
+ cwd: projectPath,
58
+ encoding: 'utf-8',
59
+ stdio: ['pipe', 'pipe', 'pipe']
60
+ }).trim();
61
+ if (!out) return null;
62
+ return isAbsolute(out) ? out : join(projectPath, out);
63
+ } catch {
64
+ return null;
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Point git's hook directory at `targetRelDir` (e.g. `.husky`) so a
70
+ * pre-commit hook UDS writes there actually runs — without depending on
71
+ * husky (or any npm install) ever executing.
72
+ *
73
+ * Never overrides:
74
+ * - an adopter's own `core.hooksPath` pointed anywhere else — we do not
75
+ * know what they use it for, and clobbering it could break hooks for
76
+ * every event, not just pre-commit.
77
+ * - an existing native `.git/hooks/pre-commit`, when `core.hooksPath` is
78
+ * unset (git's default is `.git/hooks`) — setting `core.hooksPath` in
79
+ * that case would silently stop git from ever running that file again.
80
+ *
81
+ * @param {string} projectPath
82
+ * @param {string} targetRelDir - relative to projectPath, e.g. '.husky'
83
+ * @returns {{wired: boolean, reason?: string, hint?: string}}
84
+ */
85
+ export function wireGitHooksPath(projectPath, targetRelDir) {
86
+ const targetAbs = join(projectPath, targetRelDir);
87
+ const configured = getLocalHooksPathConfig(projectPath);
88
+
89
+ if (configured) {
90
+ const configuredAbs = isAbsolute(configured) ? configured : join(projectPath, configured);
91
+ if (configuredAbs === targetAbs) {
92
+ return { wired: true };
93
+ }
94
+ return {
95
+ wired: false,
96
+ 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
+ };
99
+ }
100
+
101
+ // Unset → git's default is `.git/hooks`. An existing hook there is the
102
+ // adopter's own (or from a tool that writes it directly, not via
103
+ // core.hooksPath); switching hooksPath now would silently stop git from
104
+ // ever running it again.
105
+ const nativeHookPath = join(projectPath, '.git', 'hooks', 'pre-commit');
106
+ if (existsSync(nativeHookPath)) {
107
+ return {
108
+ wired: false,
109
+ 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
+ };
112
+ }
113
+
114
+ try {
115
+ execSync(`git config --local core.hooksPath ${targetRelDir}`, {
116
+ cwd: projectPath,
117
+ stdio: ['pipe', 'pipe', 'pipe']
118
+ });
119
+ return { wired: true };
120
+ } catch (e) {
121
+ return {
122
+ wired: false,
123
+ reason: `could not set git core.hooksPath (${e.message})`,
124
+ hint: `Run manually: git config --local core.hooksPath ${targetRelDir}`
125
+ };
126
+ }
127
+ }
128
+
129
+ /** Does `filePath` contain the marker UDS writes into a hook it manages? */
130
+ function hasUdsMarker(filePath) {
131
+ try {
132
+ return readFileSync(filePath, 'utf-8').includes('uds check');
133
+ } catch {
134
+ return false;
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Does `content` begin with a shebang line (`#!...`)?
140
+ *
141
+ * Why this matters on Windows and nowhere else: POSIX git, on ENOEXEC (a
142
+ * script with no shebang), silently retries the exec via `/bin/sh` — that
143
+ * fallback is why a husky v9 hook with no shebang has always worked on macOS
144
+ * and Linux. Git for Windows has no such fallback: without a shebang line it
145
+ * cannot resolve an interpreter at all, and fails EVERY commit with
146
+ * `error: cannot spawn <hookfile>: No such file or directory` — the exact
147
+ * message measured 2026-09-27 in CI (windows-latest) for both a husky hook
148
+ * (which has never carried a shebang) and a `.git/hooks/pre-commit` test
149
+ * fixture that also happened to lack one; a sibling fixture carrying
150
+ * `#!/bin/sh` executed correctly. The message names the hook file, not the
151
+ * missing interpreter, which is why this was first mistaken for a wiring
152
+ * problem rather than a content problem.
153
+ */
154
+ export function hasShebang(content) {
155
+ return typeof content === 'string' && content.startsWith('#!');
156
+ }
157
+
158
+ /**
159
+ * Prepend `#!/bin/sh` when `content` has no shebang; a no-op otherwise. Never
160
+ * touches an existing shebang (an adopter may already declare bash or another
161
+ * interpreter) and never rewrites any other line — pairs with
162
+ * stripLegacyHuskyShLine below, which makes the same promise for the v8
163
+ * sourcing line.
164
+ * @returns {{content: string, added: boolean}}
165
+ */
166
+ export function ensureShebang(content) {
167
+ if (hasShebang(content)) return { content, added: false };
168
+ return { content: `#!/bin/sh\n${content}`, added: true };
169
+ }
170
+
171
+ /**
172
+ * husky v8's `_/husky.sh` sourcing line — the exact shape husky itself
173
+ * generated before v9 (`. "$(dirname -- "$0")/_/husky.sh"`, with minor
174
+ * `dirname` argument variations). The directory it sources (`.husky/_/`)
175
+ * only exists after husky's OWN bootstrap has actually run; once git
176
+ * executes the hook file directly — which `wireGitHooksPath` above makes it
177
+ * do, by pointing `core.hooksPath` straight at `.husky` instead of at
178
+ * husky's shim — a hook still carrying this line fails on EVERY commit with
179
+ * "No such file or directory", worse than the original defect. Verified
180
+ * against a real adopter's exact legacy template after following the
181
+ * hooksPath-only advice this module used to give (2026-09-27).
182
+ *
183
+ * Shared by the detector (checkPreCommitHookWiring, below) and the writer
184
+ * (setupHuskyHook's rewrite step, init.js) so they can never disagree on
185
+ * what counts as this line.
186
+ */
187
+ export const LEGACY_HUSKY_SH_LINE = /^\s*(\.|source)\s+.*_\/husky\.sh["']?\s*$/;
188
+
189
+ /** Does `content` contain husky v8's `_/husky.sh` sourcing line? */
190
+ export function hasLegacyHuskyShLine(content) {
191
+ return content.split('\n').some((line) => LEGACY_HUSKY_SH_LINE.test(line));
192
+ }
193
+
194
+ /**
195
+ * Remove husky v8's `_/husky.sh` sourcing line from `content`, if present.
196
+ * Everything else — the adopter's own commands, an existing `uds check`
197
+ * line, a shebang — is left exactly as it was, in its original order.
198
+ * @returns {{content: string, removed: boolean}}
199
+ */
200
+ export function stripLegacyHuskyShLine(content) {
201
+ const lines = content.split('\n');
202
+ const kept = lines.filter((line) => !LEGACY_HUSKY_SH_LINE.test(line));
203
+ return { content: kept.join('\n'), removed: kept.length !== lines.length };
204
+ }
205
+
206
+ /**
207
+ * Read-only detector for `uds check`: a UDS-managed pre-commit hook file
208
+ * exists, but is it actually on git's execution path?
209
+ *
210
+ * Handles three wiring shapes as "wired":
211
+ * 1. Direct — `core.hooksPath` points straight at the UDS-managed file's
212
+ * directory (what `wireGitHooksPath` above sets up).
213
+ * 2. husky's own bootstrap — `core.hooksPath` points at `<dir>/_` (a shim
214
+ * directory husky itself creates via `npx husky`/the `prepare` script);
215
+ * the shim at `<dir>/_/pre-commit` forwards to `<dir>/pre-commit`, the
216
+ * UDS-managed file, one directory up.
217
+ * 3. Non-Node native install — `.git/hooks/pre-commit` IS the UDS-managed
218
+ * file, and `core.hooksPath` is unset (git's default).
219
+ *
220
+ * Also reports `missingShebang`, independent of `wired`: a hook file that
221
+ * IS on git's execution path can still fail every commit on Windows if it
222
+ * has no `#!` line (see hasShebang above) — a portability defect, not a
223
+ * wiring defect, so it is surfaced even when `wired: true`.
224
+ *
225
+ * @param {string} projectPath
226
+ * @returns {{relevant: boolean, wired?: boolean, hookFile?: string,
227
+ * configuredHooksPath?: string|null, effectiveHooksDir?: string|null,
228
+ * legacyV8?: boolean, missingShebang?: boolean}}
229
+ * `relevant: false` means there is nothing UDS-managed to report on (no
230
+ * hook file, or hooks-dir could not be determined — never guess "unwired"
231
+ * from a failed lookup).
232
+ */
233
+ export function checkPreCommitHookWiring(projectPath) {
234
+ if (!existsSync(join(projectPath, '.git'))) return { relevant: false };
235
+
236
+ const huskyHookPath = join(projectPath, '.husky', 'pre-commit');
237
+ const nativeHookPath = join(projectPath, '.git', 'hooks', 'pre-commit');
238
+ const hasHuskyHook = existsSync(huskyHookPath) && hasUdsMarker(huskyHookPath);
239
+ const hasNativeHook = existsSync(nativeHookPath) && hasUdsMarker(nativeHookPath);
240
+
241
+ if (!hasHuskyHook && !hasNativeHook) return { relevant: false };
242
+
243
+ const effectiveHooksDir = getEffectiveHooksDir(projectPath);
244
+ if (!effectiveHooksDir) return { relevant: false }; // can't determine — do not guess
245
+
246
+ // The UDS-managed source file — not the resolved effectiveFile below, which
247
+ // for husky's shim shape (case 2) is a forwarding script we do not own —
248
+ // is the one whose first line actually decides whether Windows can spawn it.
249
+ const hookFile = hasHuskyHook ? '.husky/pre-commit' : '.git/hooks/pre-commit';
250
+ const managedPath = hasHuskyHook ? huskyHookPath : nativeHookPath;
251
+ const missingShebang = (() => {
252
+ try { return !hasShebang(readFileSync(managedPath, 'utf-8')); } catch { return false; }
253
+ })();
254
+
255
+ const effectiveFile = join(effectiveHooksDir, 'pre-commit');
256
+ let wired = false;
257
+ if (existsSync(effectiveFile)) {
258
+ if (hasUdsMarker(effectiveFile)) {
259
+ wired = true;
260
+ } else if (basename(effectiveHooksDir) === '_') {
261
+ // husky-style shim dir: the real script lives one directory up.
262
+ const delegated = join(dirname(effectiveHooksDir), 'pre-commit');
263
+ wired = existsSync(delegated) && hasUdsMarker(delegated);
264
+ }
265
+ }
266
+
267
+ if (wired) return { relevant: true, wired: true, hookFile, missingShebang };
268
+
269
+ const legacyV8 = hasHuskyHook && (() => {
270
+ try { return hasLegacyHuskyShLine(readFileSync(huskyHookPath, 'utf-8')); } catch { return false; }
271
+ })();
272
+
273
+ return {
274
+ relevant: true,
275
+ wired: false,
276
+ hookFile,
277
+ configuredHooksPath: getLocalHooksPathConfig(projectPath),
278
+ effectiveHooksDir,
279
+ legacyV8,
280
+ missingShebang
281
+ };
282
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
- "version": "6.13.0-beta.2",
3
+ "version": "6.13.0-beta.5",
4
4
  "lastUpdated": "2026-05-13",
5
5
  "description": "Standards registry for universal-dev-standards with integrated skills and AI-optimized formats",
6
6
  "formats": {
@@ -58,14 +58,14 @@
58
58
  "standards": {
59
59
  "name": "universal-dev-standards",
60
60
  "url": "https://github.com/AsiaOstrich/universal-dev-standards",
61
- "version": "6.13.0-beta.2"
61
+ "version": "6.13.0-beta.5"
62
62
  },
63
63
  "skills": {
64
64
  "name": "universal-dev-standards",
65
65
  "url": "https://github.com/AsiaOstrich/universal-dev-standards",
66
66
  "localPath": "skills",
67
67
  "rawUrl": "https://raw.githubusercontent.com/AsiaOstrich/universal-dev-standards/main/skills",
68
- "version": "6.13.0-beta.2",
68
+ "version": "6.13.0-beta.5",
69
69
  "note": "Skills are now included in the main repository under skills/"
70
70
  }
71
71
  },
@@ -2295,7 +2295,7 @@
2295
2295
  "id": "license-compliance",
2296
2296
  "name": "License Compliance Standards",
2297
2297
  "nameZh": "授權合規標準",
2298
- "version": "6.13.0-beta.2",
2298
+ "version": "6.13.0-beta.5",
2299
2299
  "source": {
2300
2300
  "human": "core/license-compliance.md",
2301
2301
  "ai": "ai/standards/license-compliance.ai.yaml"
@@ -2307,7 +2307,7 @@
2307
2307
  "id": "verification-oracle",
2308
2308
  "name": "Verification Oracle Standards",
2309
2309
  "nameZh": "驗證 Oracle 標準",
2310
- "version": "6.13.0-beta.2",
2310
+ "version": "6.13.0-beta.5",
2311
2311
  "source": {
2312
2312
  "human": "core/verification-oracle.md",
2313
2313
  "ai": "ai/standards/verification-oracle.ai.yaml"
@@ -2319,7 +2319,7 @@
2319
2319
  "id": "model-provenance",
2320
2320
  "name": "Model Provenance Policy Standards",
2321
2321
  "nameZh": "模型來源政策標準",
2322
- "version": "6.13.0-beta.2",
2322
+ "version": "6.13.0-beta.5",
2323
2323
  "source": {
2324
2324
  "human": "core/model-provenance.md",
2325
2325
  "ai": "ai/standards/model-provenance.ai.yaml"
@@ -2331,7 +2331,7 @@
2331
2331
  "id": "resource-cost-boundary",
2332
2332
  "name": "Resource / Cost Boundary Declaration Standards",
2333
2333
  "nameZh": "資源/成本邊界宣告標準",
2334
- "version": "6.13.0-beta.2",
2334
+ "version": "6.13.0-beta.5",
2335
2335
  "source": {
2336
2336
  "human": "core/resource-cost-boundary.md",
2337
2337
  "ai": "ai/standards/resource-cost-boundary.ai.yaml"