@boyingliu01/xp-gate 0.19.3 → 0.20.0
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.
- package/adapters/gate-4.sh +83 -18
- package/adapters/gate-8.sh +49 -5
- package/adapters/iac.sh +1 -1
- package/adapters/typescript.sh +15 -60
- package/bin/xp-gate.js +15 -16
- package/gate-10.sh +35 -2
- package/gate-4.sh +110 -21
- package/gate-8.sh +49 -5
- package/hooks/gate-10.sh +35 -2
- package/hooks/gate-4.sh +110 -21
- package/hooks/gate-8.sh +49 -5
- package/hooks/lib/jscpd-run.sh +63 -0
- package/hooks/lib/sprint-gate-report.sh +58 -0
- package/hooks/lib/test-failure.sh +152 -0
- package/hooks/lib/typecheck.sh +141 -0
- package/hooks/lib/validate-code-walkthrough.cjs +43 -5
- package/hooks/post-merge +100 -0
- package/hooks/pre-commit +389 -162
- package/hooks/pre-push +75 -19
- package/lib/__tests__/bootstrap.test.js +23 -41
- package/lib/__tests__/check-version.test.js +8 -1
- package/lib/__tests__/detect-deps.test.js +16 -0
- package/lib/__tests__/doctor-drift-round2.test.js +212 -0
- package/lib/__tests__/doctor-executed-surface.test.js +209 -0
- package/lib/__tests__/doctor-hook-drift.test.js +329 -0
- package/lib/__tests__/doctor-machine-mutation.test.js +116 -0
- package/lib/__tests__/doctor-module-drift.test.js +206 -0
- package/lib/__tests__/doctor-module-resolution-parity.test.js +314 -0
- package/lib/__tests__/doctor-path-equivalence.test.js +59 -0
- package/lib/__tests__/doctor-repair-boundary.test.js +52 -0
- package/lib/__tests__/doctor-tui-skill-freshness.test.js +105 -0
- package/lib/__tests__/doctor.test.js +155 -0
- package/lib/__tests__/gate-runner.test.js +19 -3
- package/lib/__tests__/helpers/lib-sandbox.cjs +55 -0
- package/lib/__tests__/init.test.js +44 -17
- package/lib/__tests__/install-skill-full-dir.test.js +151 -0
- package/lib/__tests__/install-skill.test.js +51 -132
- package/lib/__tests__/language-tools-detect.test.js +158 -0
- package/lib/__tests__/phase-transition.test.js +15 -1
- package/lib/__tests__/qoder-delphi-agents.test.js +285 -0
- package/lib/__tests__/shared-paths.test.js +20 -0
- package/lib/__tests__/shared-utils.test.js +69 -0
- package/lib/__tests__/skill-flags.test.js +48 -0
- package/lib/__tests__/ui-detector.test.ts +6 -2
- package/lib/__tests__/uninstall.test.js +20 -0
- package/lib/__tests__/update-hooks.test.js +12 -0
- package/lib/__tests__/update-skill.test.js +7 -4
- package/lib/detect-deps.js +3 -20
- package/lib/doctor-tui.js +22 -5
- package/lib/doctor.js +1178 -31
- package/lib/init.js +184 -33
- package/lib/install-cmd.js +7 -4
- package/lib/install-skill.js +38 -95
- package/lib/language-tools.js +83 -4
- package/lib/mattpocock-license-verdict.json +1 -1
- package/lib/shared-paths.js +5 -2
- package/lib/shared-utils.js +47 -3
- package/lib/uninstall.js +12 -0
- package/lib/update-hooks.js +4 -4
- package/lib/update-skill.js +6 -7
- package/mock-policy/AGENTS.md +4 -4
- package/mutation/AGENTS.md +4 -4
- package/mutation/__tests__/gate-m.test.ts +66 -14
- package/mutation/__tests__/mutmut-runner.test.ts +91 -42
- package/mutation/__tests__/test-filter-parity.test.ts +37 -0
- package/mutation/gate-m.ts +21 -8
- package/package.json +1 -1
- package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
- package/plugins/claude-code/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
- package/plugins/claude-code/skills/delphi-review/AGENTS.md +4 -4
- package/plugins/claude-code/skills/delphi-review/SKILL.md +98 -14
- package/plugins/claude-code/skills/delphi-review/references/code-walkthrough.md +36 -3
- package/plugins/claude-code/skills/delphi-review/references/requirements.md +6 -6
- package/plugins/claude-code/skills/sprint-flow/AGENTS.md +4 -4
- package/plugins/claude-code/skills/sprint-flow/references/phase-2-design.md +3 -3
- package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +4 -4
- package/plugins/dsh/README.md +169 -4
- package/plugins/dsh/dsh-overlay.patch.yml +67 -0
- package/plugins/dsh/package.json +1 -1
- package/plugins/dsh/scripts/prepack.cjs +24 -19
- package/plugins/dsh/setup-dsh.ps1 +160 -0
- package/plugins/dsh/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
- package/plugins/dsh/skills/delphi-review/AGENTS.md +4 -4
- package/plugins/dsh/skills/delphi-review/SKILL.md +98 -14
- package/plugins/dsh/skills/delphi-review/references/code-walkthrough.md +36 -3
- package/plugins/dsh/skills/delphi-review/references/requirements.md +6 -6
- package/plugins/dsh/skills/sprint-flow/AGENTS.md +4 -4
- package/plugins/dsh/skills/sprint-flow/references/phase-2-design.md +3 -3
- package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +4 -4
- package/plugins/dsh/src/command.test.ts +66 -2
- package/plugins/dsh/src/command.ts +36 -14
- package/plugins/dsh/src/contract.test.ts +31 -1
- package/plugins/dsh/src/delphi-config.test.ts +190 -0
- package/plugins/dsh/src/delphi-config.ts +223 -0
- package/plugins/dsh/src/delphi-run.test.ts +181 -0
- package/plugins/dsh/src/delphi-run.ts +129 -0
- package/plugins/dsh/src/delphi.test.ts +222 -0
- package/plugins/dsh/src/delphi.ts +247 -0
- package/plugins/dsh/src/dialect.test.ts +59 -0
- package/plugins/dsh/src/dialect.ts +40 -0
- package/plugins/dsh/src/index.ts +70 -4
- package/plugins/dsh/src/skills.test.ts +26 -19
- package/plugins/opencode/package.json +1 -1
- package/plugins/opencode/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
- package/plugins/opencode/skills/delphi-review/AGENTS.md +4 -4
- package/plugins/opencode/skills/delphi-review/SKILL.md +98 -14
- package/plugins/opencode/skills/delphi-review/references/code-walkthrough.md +36 -3
- package/plugins/opencode/skills/delphi-review/references/requirements.md +6 -6
- package/plugins/opencode/skills/sprint-flow/AGENTS.md +4 -4
- package/plugins/opencode/skills/sprint-flow/references/phase-2-design.md +3 -3
- package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +4 -4
- package/plugins/qoder/agents/delphi-architecture.md +1 -1
- package/plugins/qoder/agents/delphi-feasibility.md +1 -1
- package/plugins/qoder/agents/delphi-technical.md +1 -1
- package/plugins/qoder/plugin.json +1 -1
- package/plugins/qoder/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
- package/plugins/qoder/skills/delphi-review/AGENTS.md +4 -4
- package/plugins/qoder/skills/delphi-review/SKILL.md +98 -14
- package/plugins/qoder/skills/delphi-review/references/code-walkthrough.md +36 -3
- package/plugins/qoder/skills/delphi-review/references/requirements.md +6 -6
- package/plugins/qoder/skills/sprint-flow/AGENTS.md +4 -4
- package/plugins/qoder/skills/sprint-flow/references/phase-2-design.md +3 -3
- package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +4 -4
- package/principles/AGENTS.md +9 -8
- package/principles/__tests__/adapter-metrics.test.ts +292 -0
- package/principles/__tests__/analyzer.test.ts +29 -6
- package/principles/__tests__/baseline-prune-timeout.test.ts +71 -0
- package/principles/__tests__/baseline-prune.test.ts +338 -0
- package/principles/__tests__/boy-scout-init-baseline.test.ts +167 -0
- package/principles/__tests__/boy-scout-integration.test.ts +97 -6
- package/principles/__tests__/boy-scout.test.ts +253 -7
- package/principles/__tests__/config-enforcement.test.ts +469 -0
- package/principles/__tests__/config-git-timeout.test.ts +66 -0
- package/principles/__tests__/direct-execution.test.ts +187 -0
- package/principles/__tests__/index.test.ts +16 -3
- package/principles/__tests__/warnings-baseline-budgets.test.ts +74 -0
- package/principles/adapters/typescript.ts +613 -13
- package/principles/analyzer.ts +22 -0
- package/principles/baseline-prune.ts +217 -0
- package/principles/boy-scout.ts +481 -51
- package/principles/config.ts +362 -54
- package/principles/direct-execution.ts +60 -0
- package/principles/index.ts +40 -17
- package/principles/rules/__tests__/clean-code/many-exports.test.ts +16 -10
- package/principles/rules/clean-code/code-duplication.ts +8 -8
- package/principles/rules/clean-code/deep-nesting.ts +8 -8
- package/principles/rules/clean-code/god-class.ts +8 -8
- package/principles/rules/clean-code/large-file.ts +15 -11
- package/principles/rules/clean-code/long-function.ts +8 -8
- package/principles/rules/clean-code/magic-numbers.ts +10 -7
- package/principles/rules/clean-code/many-exports.ts +7 -7
- package/principles/rules/clean-code/missing-error-handling.ts +5 -5
- package/principles/rules/clean-code/too-many-params.ts +8 -8
- package/principles/rules/clean-code/unused-imports.ts +5 -5
- package/principles/rules/solid/dip.ts +18 -8
- package/principles/rules/solid/isp.ts +9 -8
- package/principles/rules/solid/lsp.ts +6 -6
- package/principles/rules/solid/ocp.ts +6 -5
- package/principles/rules/solid/srp.ts +7 -8
- package/principles/types.ts +15 -0
- package/scripts/__tests__/sync-package-content.test.js +28 -0
- package/scripts/delphi-external-review.cjs +193 -10
- package/scripts/sync-package-content.js +7 -4
- package/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
- package/skills/delphi-review/AGENTS.md +4 -4
- package/skills/delphi-review/SKILL.md +98 -14
- package/skills/delphi-review/references/code-walkthrough.md +36 -3
- package/skills/delphi-review/references/requirements.md +6 -6
- package/skills/sprint-flow/AGENTS.md +4 -4
- package/skills/sprint-flow/references/phase-2-design.md +3 -3
- package/skills/test-specification-alignment/AGENTS.md +4 -4
- package/lib/__tests__/download-skill.test.js +0 -281
- package/lib/download-skill.js +0 -96
package/lib/doctor.js
CHANGED
|
@@ -42,6 +42,7 @@ function getPackageVersion() {
|
|
|
42
42
|
const SIGNATURES = {
|
|
43
43
|
'pre-commit': 'OpenCode Quality Gates - Pre-Commit Hook',
|
|
44
44
|
'pre-push': 'Pre-push Hook — 8 gates:',
|
|
45
|
+
'post-merge': 'XP-Gate VERSION sync hook',
|
|
45
46
|
'adapter-common.sh': 'detect_project_lang()'
|
|
46
47
|
};
|
|
47
48
|
|
|
@@ -231,6 +232,7 @@ async function checkLocalHooks(checks) {
|
|
|
231
232
|
const hooksDir = path.join(gitDir, 'hooks');
|
|
232
233
|
issues += checkSingleHook(hooksDir, 'pre-commit', SIGNATURES['pre-commit'], 'Hooks', checks);
|
|
233
234
|
issues += checkSingleHook(hooksDir, 'pre-push', SIGNATURES['pre-push'], 'Hooks', checks);
|
|
235
|
+
issues += checkSingleHook(hooksDir, 'post-merge', SIGNATURES['post-merge'], 'Hooks', checks);
|
|
234
236
|
return issues;
|
|
235
237
|
}
|
|
236
238
|
|
|
@@ -238,12 +240,13 @@ async function checkGlobalHooks(checks) {
|
|
|
238
240
|
let issues = 0;
|
|
239
241
|
issues += checkSingleHook(GLOBAL_HOOKS_DIR, 'pre-commit', SIGNATURES['pre-commit'], 'Global hooks', checks);
|
|
240
242
|
issues += checkSingleHook(GLOBAL_HOOKS_DIR, 'pre-push', SIGNATURES['pre-push'], 'Global hooks', checks);
|
|
243
|
+
issues += checkSingleHook(GLOBAL_HOOKS_DIR, 'post-merge', SIGNATURES['post-merge'], 'Global hooks', checks);
|
|
241
244
|
|
|
242
245
|
const hooksPath = await getCurrentHooksPath();
|
|
243
246
|
if (hooksPath === null || hooksPath === '') {
|
|
244
247
|
checks.push({ name: 'Git core.hooksPath', status: 'FAIL', detail: 'Not set' });
|
|
245
248
|
issues++;
|
|
246
|
-
} else if (hooksPath
|
|
249
|
+
} else if (!pathsEquivalent(hooksPath, GLOBAL_HOOKS_DIR)) {
|
|
247
250
|
checks.push({ name: 'Git core.hooksPath', status: 'FAIL', detail: `Expected ${GLOBAL_HOOKS_DIR}, got ${hooksPath}` });
|
|
248
251
|
issues++;
|
|
249
252
|
} else {
|
|
@@ -252,6 +255,845 @@ async function checkGlobalHooks(checks) {
|
|
|
252
255
|
return issues;
|
|
253
256
|
}
|
|
254
257
|
|
|
258
|
+
/**
|
|
259
|
+
* Compare two paths that must denote the same directory.
|
|
260
|
+
*
|
|
261
|
+
* `git config` echoes core.hooksPath with forward slashes on Windows while
|
|
262
|
+
* path.join builds GLOBAL_HOOKS_DIR with backslashes, and drive letters come
|
|
263
|
+
* back lower-cased depending on who wrote them. A byte comparison therefore
|
|
264
|
+
* reported every correctly configured Windows machine as broken, and --fix
|
|
265
|
+
* could never clear it because the value it wrote read back differently (#496).
|
|
266
|
+
*/
|
|
267
|
+
function pathsEquivalent(a, b) {
|
|
268
|
+
if (typeof a !== 'string' || typeof b !== 'string') return false;
|
|
269
|
+
const normalize = (p) => {
|
|
270
|
+
let s = p.replace(/[\\/]+/g, '/');
|
|
271
|
+
if (s.length > 1) s = s.replace(/\/+$/, '');
|
|
272
|
+
return s.replace(/^([A-Za-z]):/, (m, drive) => `${drive.toUpperCase()}:`);
|
|
273
|
+
};
|
|
274
|
+
return normalize(a) === normalize(b);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Hook files eligible for repo-vs-installed drift comparison. Only hooks that
|
|
279
|
+
* exist on both sides are compared, so a repo without post-merge is not flagged.
|
|
280
|
+
* The lib/ shared libraries (sourced by pre-commit at runtime) are covered
|
|
281
|
+
* separately by diagnoseHookDrift/syncGlobalHooksFromRepo (#488).
|
|
282
|
+
*/
|
|
283
|
+
const DRIFT_COMPARED_HOOKS = ['pre-commit', 'pre-push', 'post-merge'];
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Compare the hooks that actually execute (the installed copy under
|
|
287
|
+
* core.hooksPath) against the repository's source-of-truth `githooks/` (#451).
|
|
288
|
+
*
|
|
289
|
+
* Editing `githooks/pre-commit` in a repo has no local effect when
|
|
290
|
+
* `core.hooksPath` points at an installed copy, and nothing used to report how
|
|
291
|
+
* far the two had diverged or in which direction. A stale installed copy makes
|
|
292
|
+
* the local gate *looser* than the repo (silent under-enforcement); a newer one
|
|
293
|
+
* makes it *stricter* (spurious local failures). Both need to be visible.
|
|
294
|
+
*
|
|
295
|
+
* Direction uses mtime, which is the only ordering signal available without a
|
|
296
|
+
* shared version marker. It is reported as a hint, not as proof of content order.
|
|
297
|
+
*
|
|
298
|
+
* @param {{repoRoot?: string, repoHooks?: string, installedHooks?: string}} [ctx]
|
|
299
|
+
* @returns {{checks: Array<{name: string, status: string, detail: string}>, issues: number}}
|
|
300
|
+
*/
|
|
301
|
+
function diagnoseHookDrift(ctx = {}) {
|
|
302
|
+
const checks = [];
|
|
303
|
+
let issues = 0;
|
|
304
|
+
|
|
305
|
+
// XP_GATE_REPO_ROOT lets a caller (or a test) name the canonical repo
|
|
306
|
+
// explicitly instead of inferring it from the process cwd.
|
|
307
|
+
const repoRoot = ctx.repoRoot || process.env.XP_GATE_REPO_ROOT || process.cwd();
|
|
308
|
+
const repoHooks = ctx.repoHooks || path.join(repoRoot, 'githooks');
|
|
309
|
+
const installedHooks = ctx.installedHooks || GLOBAL_HOOKS_DIR;
|
|
310
|
+
|
|
311
|
+
// Nothing to compare when the project carries no canonical *hook* source, or
|
|
312
|
+
// when no global copy exists at all. Both must stay completely silent.
|
|
313
|
+
//
|
|
314
|
+
// A `githooks/` directory holding only `adapters/` is NOT a hook source of
|
|
315
|
+
// truth -- consumer projects get that layout from `xp-gate init`, and the
|
|
316
|
+
// adapters there are unrelated to which hook file executes.
|
|
317
|
+
if (!hasCanonicalHooks(repoHooks) || !fs.existsSync(installedHooks)) {
|
|
318
|
+
return { checks, issues };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
for (const name of DRIFT_COMPARED_HOOKS) {
|
|
322
|
+
const repoFile = path.join(repoHooks, name);
|
|
323
|
+
const installedFile = path.join(installedHooks, name);
|
|
324
|
+
if (!fs.existsSync(repoFile) || !fs.existsSync(installedFile)) continue;
|
|
325
|
+
|
|
326
|
+
const label = `Hook drift: ${name}`;
|
|
327
|
+
let repoStat;
|
|
328
|
+
let installedStat;
|
|
329
|
+
try {
|
|
330
|
+
repoStat = fs.statSync(repoFile);
|
|
331
|
+
installedStat = fs.statSync(installedFile);
|
|
332
|
+
} catch {
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (filesAreIdentical(repoFile, installedFile)) {
|
|
337
|
+
checks.push({ name: label, status: 'PASS', detail: 'Installed copy matches githooks/' });
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const direction = installedStat.mtimeMs > repoStat.mtimeMs
|
|
342
|
+
? 'installed copy is newer than githooks/'
|
|
343
|
+
: 'githooks/ is newer than the installed copy (installed copy is older)';
|
|
344
|
+
checks.push({
|
|
345
|
+
name: label,
|
|
346
|
+
status: 'FAIL',
|
|
347
|
+
detail: `${direction} — local commits run githooks/${name} only after syncing`,
|
|
348
|
+
});
|
|
349
|
+
issues++;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// lib/ shared libraries: pre-commit sources lib/*.sh at runtime, so a missing
|
|
353
|
+
// or stale installed lib/ copy breaks the hook entirely after a sync (#488).
|
|
354
|
+
const repoLib = path.join(repoHooks, 'lib');
|
|
355
|
+
const installedLib = path.join(installedHooks, 'lib');
|
|
356
|
+
if (fs.existsSync(repoLib) && fs.statSync(repoLib).isDirectory()) {
|
|
357
|
+
let libEntries = [];
|
|
358
|
+
try {
|
|
359
|
+
libEntries = fs.readdirSync(repoLib, { withFileTypes: true });
|
|
360
|
+
} catch {
|
|
361
|
+
libEntries = [];
|
|
362
|
+
}
|
|
363
|
+
for (const entry of libEntries) {
|
|
364
|
+
if (!entry.isFile()) continue;
|
|
365
|
+
const rel = `lib/${entry.name}`;
|
|
366
|
+
const repoFile = path.join(repoLib, entry.name);
|
|
367
|
+
const installedFile = path.join(installedLib, entry.name);
|
|
368
|
+
const label = `Hook drift: ${rel}`;
|
|
369
|
+
if (!fs.existsSync(installedFile)) {
|
|
370
|
+
checks.push({
|
|
371
|
+
name: label,
|
|
372
|
+
status: 'FAIL',
|
|
373
|
+
detail: 'installed lib/ copy is missing (hooks source lib/ at runtime)',
|
|
374
|
+
});
|
|
375
|
+
issues++;
|
|
376
|
+
} else if (!filesAreIdentical(repoFile, installedFile)) {
|
|
377
|
+
checks.push({
|
|
378
|
+
name: label,
|
|
379
|
+
status: 'FAIL',
|
|
380
|
+
detail: 'installed lib/ copy differs from githooks/lib/',
|
|
381
|
+
});
|
|
382
|
+
issues++;
|
|
383
|
+
} else {
|
|
384
|
+
checks.push({ name: label, status: 'PASS', detail: 'Installed copy matches githooks/' });
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
return { checks, issues };
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** Compare two files by content length first, then by bytes. */
|
|
393
|
+
function filesAreIdentical(fileA, fileB) {
|
|
394
|
+
try {
|
|
395
|
+
const a = fs.readFileSync(fileA);
|
|
396
|
+
const b = fs.readFileSync(fileB);
|
|
397
|
+
return a.length === b.length && a.equals(b);
|
|
398
|
+
} catch {
|
|
399
|
+
return false;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Does this directory actually hold canonical hook source?
|
|
405
|
+
*
|
|
406
|
+
* Requires at least one of the compared hook files to exist. A directory with
|
|
407
|
+
* only `adapters/` (what `xp-gate init` writes into consumer projects) does not
|
|
408
|
+
* qualify: there is no hook there to be the source of truth.
|
|
409
|
+
*
|
|
410
|
+
* @param {string} repoHooks
|
|
411
|
+
* @returns {boolean}
|
|
412
|
+
*/
|
|
413
|
+
function hasCanonicalHooks(repoHooks) {
|
|
414
|
+
if (!fs.existsSync(repoHooks)) return false;
|
|
415
|
+
return DRIFT_COMPARED_HOOKS.some((name) => fs.existsSync(path.join(repoHooks, name)));
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Module names installed where the hook will look for them.
|
|
420
|
+
*
|
|
421
|
+
* pre-commit resolves an adapter flat first (`adapters/typescript.sh`) and only
|
|
422
|
+
* then nested (`adapters/adapters/typescript.sh`), so the file that executes is
|
|
423
|
+
* whichever of those two exists -- and only that one. Comparing the other copy
|
|
424
|
+
* would report drift in a file git never runs.
|
|
425
|
+
*
|
|
426
|
+
* @param {string} installedAdapterDir
|
|
427
|
+
* @returns {Array<{name: string, file: string}>}
|
|
428
|
+
*/
|
|
429
|
+
function listExecutedModules(installedAdapterDir) {
|
|
430
|
+
const found = [];
|
|
431
|
+
const seen = new Set();
|
|
432
|
+
|
|
433
|
+
let entries;
|
|
434
|
+
try {
|
|
435
|
+
entries = fs.readdirSync(installedAdapterDir, { withFileTypes: true });
|
|
436
|
+
} catch {
|
|
437
|
+
return found;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
for (const entry of entries) {
|
|
441
|
+
if (!entry.isFile() || !entry.name.endsWith('.sh')) continue;
|
|
442
|
+
const file = path.join(installedAdapterDir, entry.name);
|
|
443
|
+
if (seen.has(entry.name)) continue;
|
|
444
|
+
seen.add(entry.name);
|
|
445
|
+
found.push({ name: entry.name, file });
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Nested copies only count for names with no flat counterpart, because that
|
|
449
|
+
// is exactly the fallback resolve_adapter_path() takes.
|
|
450
|
+
const nestedDir = path.join(installedAdapterDir, 'adapters');
|
|
451
|
+
let nestedEntries = [];
|
|
452
|
+
try {
|
|
453
|
+
nestedEntries = fs.readdirSync(nestedDir, { withFileTypes: true });
|
|
454
|
+
} catch {
|
|
455
|
+
nestedEntries = [];
|
|
456
|
+
}
|
|
457
|
+
for (const entry of nestedEntries) {
|
|
458
|
+
if (!entry.isFile() || !entry.name.endsWith('.sh')) continue;
|
|
459
|
+
if (seen.has(entry.name)) continue;
|
|
460
|
+
seen.add(entry.name);
|
|
461
|
+
found.push({ name: entry.name, file: path.join(nestedDir, entry.name) });
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
return found;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Where the repository keeps the source of truth for one executed module.
|
|
469
|
+
*
|
|
470
|
+
* Mirrors the installer's own split: `adapter-common.sh` and `gate-*.sh` are
|
|
471
|
+
* copied from the githooks root, language adapters from `githooks/adapters/`.
|
|
472
|
+
*
|
|
473
|
+
* @param {string} repoHooks
|
|
474
|
+
* @param {string} name
|
|
475
|
+
* @returns {string|null}
|
|
476
|
+
*/
|
|
477
|
+
function repoModuleSource(repoHooks, name) {
|
|
478
|
+
const rootLevel = name === 'adapter-common.sh' || /^gate-.+\.sh$/.test(name);
|
|
479
|
+
const candidates = rootLevel
|
|
480
|
+
? [path.join(repoHooks, name)]
|
|
481
|
+
: [path.join(repoHooks, 'adapters', name), path.join(repoHooks, name)];
|
|
482
|
+
return candidates.find((candidate) => fs.existsSync(candidate)) || null;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Which lines each side carries that the other does not.
|
|
487
|
+
*
|
|
488
|
+
* Content equality alone cannot tell a plain stale copy from a bidirectional
|
|
489
|
+
* divergence, and the difference decides whether overwriting is safe: a stale
|
|
490
|
+
* copy is a subset of the repo, while a machine-hardened copy holds lines the
|
|
491
|
+
* repo has never had. Blank/indentation noise is folded away so a reformatted
|
|
492
|
+
* file does not read as divergence.
|
|
493
|
+
*
|
|
494
|
+
* @param {string} repoText
|
|
495
|
+
* @param {string} installedText
|
|
496
|
+
* @returns {{repoOnly: number, installedOnly: number}}
|
|
497
|
+
*/
|
|
498
|
+
function contentDelta(repoText, installedText) {
|
|
499
|
+
const lineSet = (text) => new Set(
|
|
500
|
+
text.split('\n').map((line) => line.trim()).filter((line) => line.length > 0)
|
|
501
|
+
);
|
|
502
|
+
const repo = lineSet(repoText);
|
|
503
|
+
const installed = lineSet(installedText);
|
|
504
|
+
let repoOnly = 0;
|
|
505
|
+
let installedOnly = 0;
|
|
506
|
+
for (const line of repo) if (!installed.has(line)) repoOnly++;
|
|
507
|
+
for (const line of installed) if (!repo.has(line)) installedOnly++;
|
|
508
|
+
return { repoOnly, installedOnly };
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Read two files as text, returning null when either side is unreadable.
|
|
513
|
+
*/
|
|
514
|
+
function readPair(repoFile, installedFile) {
|
|
515
|
+
try {
|
|
516
|
+
return { repoText: fs.readFileSync(repoFile, 'utf8'), installedText: fs.readFileSync(installedFile, 'utf8') };
|
|
517
|
+
} catch {
|
|
518
|
+
return null;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Compare the gate modules that actually execute against the repository's
|
|
524
|
+
* `githooks/` source (#495).
|
|
525
|
+
*
|
|
526
|
+
* `checkAdapters` only proves a gate script is *present*, and the hook drift
|
|
527
|
+
* check stops at the hook files plus `lib/`. The modules pre-commit sources for
|
|
528
|
+
* Gates 3/4/7/8/9/10 live in the global adapters directory, so a repo fix to
|
|
529
|
+
* `githooks/gate-8.sh` was invisible here and inert locally at the same time --
|
|
530
|
+
* which is why #493 stayed unfixed for so long.
|
|
531
|
+
*
|
|
532
|
+
* @param {{repoRoot?: string, repoHooks?: string, installedAdapters?: string}} [ctx]
|
|
533
|
+
* @returns {{checks: Array<{name: string, status: string, detail: string}>, issues: number}}
|
|
534
|
+
*/
|
|
535
|
+
function diagnoseModuleDrift(ctx = {}) {
|
|
536
|
+
const checks = [];
|
|
537
|
+
let issues = 0;
|
|
538
|
+
|
|
539
|
+
const repoRoot = ctx.repoRoot || process.env.XP_GATE_REPO_ROOT || process.cwd();
|
|
540
|
+
const repoHooks = ctx.repoHooks || path.join(repoRoot, 'githooks');
|
|
541
|
+
|
|
542
|
+
// Silent without a canonical repo source to compare against: consumer
|
|
543
|
+
// projects have no `githooks/` root of truth, only what was installed.
|
|
544
|
+
if (!hasCanonicalHooks(repoHooks)) {
|
|
545
|
+
return { checks, issues };
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
const dirs = resolveExecutedModuleDirs({
|
|
549
|
+
globalAdapterDir: ctx.globalAdapterDir || ctx.installedAdapters,
|
|
550
|
+
projectGithooks: ctx.projectGithooks || repoHooks,
|
|
551
|
+
scriptDir: ctx.scriptDir,
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
// Copies the hook never resolves to, occupying the lookup positions it does
|
|
555
|
+
// resolve through. They are not drift -- nothing executes them -- but they are
|
|
556
|
+
// how #500 reproduces: the next install order shift, or a move to project
|
|
557
|
+
// mode, promotes one silently. Report with the absolute path so the developer
|
|
558
|
+
// can judge; do not count, because no shipped command removes them (#495 R2).
|
|
559
|
+
const executedFor = new Map();
|
|
560
|
+
for (const name of repoModuleNames(repoHooks)) {
|
|
561
|
+
const source = repoModuleSource(repoHooks, name);
|
|
562
|
+
if (!source) continue;
|
|
563
|
+
const executed = executedModulePath(name, dirs);
|
|
564
|
+
executedFor.set(name, executed);
|
|
565
|
+
|
|
566
|
+
if (!executed) continue; // not installed at all: nothing executes, nothing to judge
|
|
567
|
+
if (pathsEquivalent(executed, source)) continue; // the repo's own copy runs
|
|
568
|
+
|
|
569
|
+
const label = `${tierFor(executed, dirs).prefix}: ${name}`;
|
|
570
|
+
if (filesAreIdentical(source, executed)) {
|
|
571
|
+
checks.push({ name: label, status: 'PASS', detail: 'Executed copy matches githooks/' });
|
|
572
|
+
continue;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const texts = readPair(source, executed);
|
|
576
|
+
if (!texts) {
|
|
577
|
+
checks.push({ name: label, status: 'FAIL', detail: 'executed copy could not be read for comparison' });
|
|
578
|
+
issues++;
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
const { repoOnly, installedOnly } = contentDelta(texts.repoText, texts.installedText);
|
|
582
|
+
// contentDelta folds whitespace and line endings away, so an all-zero delta
|
|
583
|
+
// means the two copies say the same thing differently. Calling that drift
|
|
584
|
+
// would report FAIL while claiming zero unique lines (#495 Round 2).
|
|
585
|
+
if (repoOnly === 0 && installedOnly === 0) {
|
|
586
|
+
checks.push({ name: label, status: 'PASS', detail: 'Same content as githooks/, differing only in line endings or indentation' });
|
|
587
|
+
continue;
|
|
588
|
+
}
|
|
589
|
+
const hint = TIER_HINTS[tierFor(executed, dirs).tier] || '';
|
|
590
|
+
checks.push({ name: label, status: 'FAIL', detail: `${moduleDriftDetail(repoOnly, installedOnly)}${hint}` });
|
|
591
|
+
issues++;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
for (const [name, file] of nonExecutedCopies(dirs, executedFor)) {
|
|
595
|
+
checks.push({
|
|
596
|
+
name: `Non-executed copy: ${name}`,
|
|
597
|
+
status: 'WARN',
|
|
598
|
+
detail: `${file} is not executed (the hook resolves this module elsewhere), so it is installer residue and NOT counted as drift; `
|
|
599
|
+
+ 'it shadows the tracked file the moment ADAPTER_DIR resolves to its tier (#500). Remove it by hand -- no doctor command deletes installed copies.',
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
return { checks, issues };
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Every `.sh` copy the repository knows about that the resolution did NOT pick.
|
|
608
|
+
*
|
|
609
|
+
* @param {ReturnType<typeof resolveExecutedModuleDirs>} dirs
|
|
610
|
+
* @param {Map<string, string|null>} executedFor
|
|
611
|
+
* @returns {Array<[string, string]>}
|
|
612
|
+
*/
|
|
613
|
+
function nonExecutedCopies(dirs, executedFor) {
|
|
614
|
+
const found = [];
|
|
615
|
+
const seenFiles = new Set();
|
|
616
|
+
const tiers = [
|
|
617
|
+
dirs.globalAdapterDir,
|
|
618
|
+
path.join(dirs.globalAdapterDir, 'adapters'),
|
|
619
|
+
dirs.adapterDir,
|
|
620
|
+
path.join(dirs.adapterDir, 'adapters'),
|
|
621
|
+
dirs.projectGithooks,
|
|
622
|
+
path.join(dirs.projectGithooks, 'adapters'),
|
|
623
|
+
dirs.scriptDir,
|
|
624
|
+
path.join(dirs.scriptDir, 'adapters'),
|
|
625
|
+
];
|
|
626
|
+
for (const dir of tiers) {
|
|
627
|
+
for (const mod of listExecutedModules(dir)) {
|
|
628
|
+
if (seenFiles.has(path.resolve(mod.file))) continue;
|
|
629
|
+
if (!executedFor.has(mod.name)) continue; // not a module the repo ships
|
|
630
|
+
const executed = executedFor.get(mod.name);
|
|
631
|
+
if (executed && pathsEquivalent(executed, mod.file)) continue;
|
|
632
|
+
if (pathsEquivalent(repoModuleSource(dirs.projectGithooks, mod.name) || '', mod.file)) continue;
|
|
633
|
+
seenFiles.add(path.resolve(mod.file));
|
|
634
|
+
found.push([mod.name, mod.file]);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
return found;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Describe one drifted module by the direction of its drift.
|
|
642
|
+
*
|
|
643
|
+
* Only a module the *repo* is ahead of is safe to overwrite; anything where the
|
|
644
|
+
* installed copy holds its own lines needs an explicit decision, because syncing
|
|
645
|
+
* would delete work that exists nowhere else.
|
|
646
|
+
*/
|
|
647
|
+
function moduleDriftDetail(repoOnly, installedOnly) {
|
|
648
|
+
if (repoOnly > 0 && installedOnly > 0) {
|
|
649
|
+
return `diverged both ways — githooks/ has ${repoOnly} line(s) the install lacks, `
|
|
650
|
+
+ `the install has ${installedOnly} line(s) githooks/ lacks; sync refuses without an explicit decision`;
|
|
651
|
+
}
|
|
652
|
+
if (repoOnly > 0) {
|
|
653
|
+
return `installed copy is stale (${repoOnly} line(s) in githooks/ absent from the install)`;
|
|
654
|
+
}
|
|
655
|
+
return `installed copy is newer than githooks/ (${installedOnly} line(s) absent from the repo)`;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* Copy the repository's executed gate modules over the installed ones (#495).
|
|
660
|
+
*
|
|
661
|
+
* Deliberately refuses to overwrite a module whose installed copy carries lines
|
|
662
|
+
* the repo does not. That is the machine-only-hardening case (Gate 8's
|
|
663
|
+
* detect-secrets fallback here), and a silent `--fix` destroying it would be
|
|
664
|
+
* worse than the drift it repairs.
|
|
665
|
+
*
|
|
666
|
+
* @param {{repoRoot?: string, repoHooks?: string, installedAdapters?: string, force?: boolean}} [ctx]
|
|
667
|
+
* @returns {{synced: string[], skipped: string[], refused: Array<{name: string, repoOnly: number, installedOnly: number}>, errors: string[], installedAdapters: string}}
|
|
668
|
+
*/
|
|
669
|
+
function syncModulesFromRepo(ctx = {}) {
|
|
670
|
+
const synced = [];
|
|
671
|
+
const skipped = [];
|
|
672
|
+
const refused = [];
|
|
673
|
+
const errors = [];
|
|
674
|
+
|
|
675
|
+
// The diagnosis and the repair must agree on which repository they are talking
|
|
676
|
+
// about, or `XP_GATE_REPO_ROOT doctor --sync-hooks` reports drift it never
|
|
677
|
+
// clears (#495 Round 2; the same silent-no-op class #488/#416 outlawed).
|
|
678
|
+
const repoRoot = ctx.repoRoot || process.env.XP_GATE_REPO_ROOT || process.cwd();
|
|
679
|
+
const repoHooks = ctx.repoHooks || path.join(repoRoot, 'githooks');
|
|
680
|
+
|
|
681
|
+
if (!hasCanonicalHooks(repoHooks)) {
|
|
682
|
+
errors.push(`no canonical githooks/ source at ${repoHooks} -- nothing was synced`);
|
|
683
|
+
return { synced, skipped, refused, errors, installedAdapters: ctx.installedAdapters || ctx.globalAdapterDir || GLOBAL_ADAPTERS_DIR, sourceMissing: true };
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// Same resolution the diagnosis used, so a FAIL the diagnosis can produce is a
|
|
687
|
+
// FAIL this repair can clear -- in whichever tier it lives (#495 Round 3; the
|
|
688
|
+
// Round-2 pair diagnosed two tiers and synced one, leaving a permanent exit 1).
|
|
689
|
+
const dirs = resolveExecutedModuleDirs({
|
|
690
|
+
globalAdapterDir: ctx.globalAdapterDir || ctx.installedAdapters,
|
|
691
|
+
projectGithooks: ctx.projectGithooks || repoHooks,
|
|
692
|
+
scriptDir: ctx.scriptDir,
|
|
693
|
+
});
|
|
694
|
+
const installedAdapters = dirs.globalAdapterDir;
|
|
695
|
+
|
|
696
|
+
for (const name of repoModuleNames(repoHooks)) {
|
|
697
|
+
const source = repoModuleSource(repoHooks, name);
|
|
698
|
+
if (!source) continue;
|
|
699
|
+
const target = executedModulePath(name, dirs);
|
|
700
|
+
if (!target) continue; // nothing executes this module; residue is not this command's business
|
|
701
|
+
if (pathsEquivalent(source, target)) {
|
|
702
|
+
skipped.push(name);
|
|
703
|
+
continue;
|
|
704
|
+
}
|
|
705
|
+
if (filesAreIdentical(source, target)) {
|
|
706
|
+
skipped.push(name);
|
|
707
|
+
continue;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
const decision = copyGuardDecision(source, target, !!ctx.force);
|
|
711
|
+
// Formatting-only: nothing to repair, and rewriting the file would churn it.
|
|
712
|
+
if (!decision.refuse && !decision.forced && decision.repoOnly === 0 && decision.installedOnly === 0) {
|
|
713
|
+
skipped.push(name);
|
|
714
|
+
continue;
|
|
715
|
+
}
|
|
716
|
+
if (decision.refuse) {
|
|
717
|
+
refused.push({ name, repoOnly: decision.repoOnly, installedOnly: decision.installedOnly, unknown: decision.unknown, target });
|
|
718
|
+
continue;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
try {
|
|
722
|
+
fs.copyFileSync(source, target);
|
|
723
|
+
fs.chmodSync(target, 0o755);
|
|
724
|
+
synced.push(name);
|
|
725
|
+
} catch (err) {
|
|
726
|
+
errors.push(`${name}: ${err.message}`);
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
return { synced, skipped, refused, errors, installedAdapters, dirs, sourceMissing: false };
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/**
|
|
734
|
+
* Resolve the directories pre-commit will source its gate modules from, using
|
|
735
|
+
* the same 3-tier order as the hook itself (#495). Without this, doctor's output
|
|
736
|
+
* cannot answer "which copy is running".
|
|
737
|
+
*
|
|
738
|
+
* This is the single resolution model the printout, the drift diagnosis and the
|
|
739
|
+
* repair all read from. Round 2 had a second tier list inside
|
|
740
|
+
* `diagnoseModuleDrift()` that walked the project directory unconditionally and
|
|
741
|
+
* labelled whatever it found "executed" -- but the hook only reaches
|
|
742
|
+
* `$PROJECT_GITHOOKS/adapters/<lang>.sh` (githooks/pre-commit:67-69), never a
|
|
743
|
+
* flat project copy, so that label asserted a direction that was false and
|
|
744
|
+
* produced a FAIL no repair path could clear (#495 Round 3).
|
|
745
|
+
*
|
|
746
|
+
* @param {{globalAdapterDir?: string, installedAdapters?: string, projectGithooks?: string, scriptDir?: string}} [ctx]
|
|
747
|
+
* @returns {{adapterDir: string, gateDir: string, globalAdapterDir: string, projectGithooks: string, scriptDir: string}}
|
|
748
|
+
*/
|
|
749
|
+
function resolveExecutedModuleDirs(ctx = {}) {
|
|
750
|
+
const globalAdapterDir = ctx.globalAdapterDir || ctx.installedAdapters || GLOBAL_ADAPTERS_DIR;
|
|
751
|
+
const projectGithooks = ctx.projectGithooks || defaultProjectGithooks();
|
|
752
|
+
const scriptDir = ctx.scriptDir || GLOBAL_HOOKS_DIR;
|
|
753
|
+
|
|
754
|
+
const has = (dir, file) => fs.existsSync(path.join(dir, file));
|
|
755
|
+
|
|
756
|
+
let adapterDir = scriptDir;
|
|
757
|
+
if (has(globalAdapterDir, 'adapter-common.sh')) {
|
|
758
|
+
adapterDir = globalAdapterDir;
|
|
759
|
+
} else if (has(projectGithooks, 'adapter-common.sh')) {
|
|
760
|
+
adapterDir = projectGithooks;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
let gateDir = adapterDir;
|
|
764
|
+
if (!has(adapterDir, 'gate-3.sh')) {
|
|
765
|
+
if (has(projectGithooks, 'gate-3.sh')) gateDir = projectGithooks;
|
|
766
|
+
else if (has(scriptDir, 'gate-3.sh')) gateDir = scriptDir;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
return { adapterDir, gateDir, globalAdapterDir, projectGithooks, scriptDir };
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* The repository's `githooks/` as the hook itself derives it -- `git rev-parse
|
|
774
|
+
* --show-toplevel` (githooks/pre-commit:31), not the current directory. Run
|
|
775
|
+
* from a subdirectory, a cwd-based answer finds no `githooks/` and the whole
|
|
776
|
+
* drift surface goes silent (#495 Round 3).
|
|
777
|
+
*/
|
|
778
|
+
function defaultProjectGithooks() {
|
|
779
|
+
if (process.env.XP_GATE_REPO_ROOT) {
|
|
780
|
+
return path.join(process.env.XP_GATE_REPO_ROOT, 'githooks');
|
|
781
|
+
}
|
|
782
|
+
try {
|
|
783
|
+
const toplevel = execSync('git rev-parse --show-toplevel', {
|
|
784
|
+
encoding: 'utf8',
|
|
785
|
+
timeout: EXEC_TIMEOUT_MS,
|
|
786
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
787
|
+
}).trim();
|
|
788
|
+
if (toplevel) return path.join(toplevel, 'githooks');
|
|
789
|
+
} catch {
|
|
790
|
+
// Not a git repository -- fall back to the directory we were called in.
|
|
791
|
+
}
|
|
792
|
+
return path.join(process.cwd(), 'githooks');
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/**
|
|
796
|
+
* Module names the repository carries as source of truth.
|
|
797
|
+
*
|
|
798
|
+
* Enumerated from the repo rather than from what happens to be installed: the
|
|
799
|
+
* question the surface answers is "which copy of the file I changed is running",
|
|
800
|
+
* and a name nobody installed has no executed copy to judge.
|
|
801
|
+
*
|
|
802
|
+
* @param {string} repoHooks
|
|
803
|
+
* @returns {string[]}
|
|
804
|
+
*/
|
|
805
|
+
function repoModuleNames(repoHooks) {
|
|
806
|
+
const names = new Set();
|
|
807
|
+
const collect = (dir) => {
|
|
808
|
+
let entries;
|
|
809
|
+
try {
|
|
810
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
811
|
+
} catch {
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
for (const entry of entries) {
|
|
815
|
+
if (entry.isFile() && entry.name.endsWith('.sh')) names.add(entry.name);
|
|
816
|
+
}
|
|
817
|
+
};
|
|
818
|
+
collect(repoHooks);
|
|
819
|
+
collect(path.join(repoHooks, 'adapters'));
|
|
820
|
+
return [...names].sort();
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
/**
|
|
824
|
+
* Gate modules live flat next to `adapter-common.sh`; language adapters are
|
|
825
|
+
* looked up flat first, then nested (githooks/pre-commit:54-77).
|
|
826
|
+
*/
|
|
827
|
+
function isGateModuleFile(name) {
|
|
828
|
+
return name === 'adapter-common.sh'
|
|
829
|
+
|| name === 'sprint-gate.sh'
|
|
830
|
+
|| /^gate-.+\.sh$/.test(name);
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/**
|
|
834
|
+
* Which file pre-commit will actually read for one module name (#495 Round 3).
|
|
835
|
+
*
|
|
836
|
+
* @param {string} name
|
|
837
|
+
* @param {ReturnType<typeof resolveExecutedModuleDirs>} dirs
|
|
838
|
+
* @returns {string|null} null when nothing executes (the module is not installed)
|
|
839
|
+
*/
|
|
840
|
+
function executedModulePath(name, dirs) {
|
|
841
|
+
// `adapter-common.sh` is sourced from ADAPTER_DIR (githooks/pre-commit:41)
|
|
842
|
+
// before GATE_DIR is ever resolved, so it does not follow the gate-module chain.
|
|
843
|
+
if (name === 'adapter-common.sh') {
|
|
844
|
+
const sourced = path.join(dirs.adapterDir, name);
|
|
845
|
+
return fs.existsSync(sourced) ? sourced : null;
|
|
846
|
+
}
|
|
847
|
+
const candidates = isGateModuleFile(name)
|
|
848
|
+
? [path.join(dirs.gateDir, name)]
|
|
849
|
+
: [
|
|
850
|
+
path.join(dirs.adapterDir, name),
|
|
851
|
+
path.join(dirs.adapterDir, 'adapters', name),
|
|
852
|
+
path.join(dirs.projectGithooks, 'adapters', name),
|
|
853
|
+
path.join(dirs.scriptDir, 'adapters', name),
|
|
854
|
+
];
|
|
855
|
+
return candidates.find((candidate) => fs.existsSync(candidate)) || null;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* Which tier an executed copy came from, so a FAIL says where it ran from.
|
|
860
|
+
*/
|
|
861
|
+
function tierFor(executedPath, dirs) {
|
|
862
|
+
const under = (dir) => {
|
|
863
|
+
const base = path.resolve(dir);
|
|
864
|
+
const file = path.resolve(executedPath, '..');
|
|
865
|
+
return pathsEquivalent(base, file) || file.startsWith(`${base}${path.sep}`) || file.startsWith(`${base}/`);
|
|
866
|
+
};
|
|
867
|
+
if (under(dirs.projectGithooks)) return { prefix: 'Module drift (project)', tier: 'project' };
|
|
868
|
+
if (under(dirs.scriptDir)) return { prefix: 'Module drift (script dir)', tier: 'script dir' };
|
|
869
|
+
return { prefix: 'Module drift', tier: 'global' };
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
const TIER_HINTS = {
|
|
873
|
+
project: ' -- executed from the project tier: the hook resolves ADAPTER_DIR to <repo>/githooks and reads a flat copy before the nested source (#500)',
|
|
874
|
+
'script dir': ' -- executed from the installed hooks dir: GATE_DIR fell through to $SCRIPT_DIR (githooks/pre-commit:84-85)',
|
|
875
|
+
};
|
|
876
|
+
|
|
877
|
+
/**
|
|
878
|
+
* Decide whether one copy may be overwritten (#495 Round 2/3).
|
|
879
|
+
*
|
|
880
|
+
* Refuses when the destination holds lines the source lacks (machine-only
|
|
881
|
+
* hardening) and ALSO when its content cannot be read: an unreadable
|
|
882
|
+
* destination is unknown content, and unknown content is exactly what the
|
|
883
|
+
* refusal exists to protect.
|
|
884
|
+
*
|
|
885
|
+
* @param {string} source
|
|
886
|
+
* @param {string} target
|
|
887
|
+
* @param {boolean} force
|
|
888
|
+
* @returns {{refuse: boolean, forced: boolean, unknown: boolean, repoOnly: number, installedOnly: number}}
|
|
889
|
+
*/
|
|
890
|
+
function copyGuardDecision(source, target, force) {
|
|
891
|
+
if (!fs.existsSync(target)) return { refuse: false, forced: false, unknown: false, repoOnly: 0, installedOnly: 0 };
|
|
892
|
+
if (force) return { refuse: false, forced: true, unknown: false, repoOnly: 0, installedOnly: 0 };
|
|
893
|
+
const texts = readPair(source, target);
|
|
894
|
+
if (!texts) return { refuse: true, forced: false, unknown: true, repoOnly: 0, installedOnly: 0 };
|
|
895
|
+
const { repoOnly, installedOnly } = contentDelta(texts.repoText, texts.installedText);
|
|
896
|
+
return { refuse: installedOnly > 0, forced: false, unknown: false, repoOnly, installedOnly };
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
/**
|
|
900
|
+
* Split a list of already-installed files into the ones a refresh may overwrite
|
|
901
|
+
* and the ones a human must judge first (#495 Round 3).
|
|
902
|
+
*
|
|
903
|
+
* `fixStaleHooks()` used to hand the whole list to a forced, backup-less
|
|
904
|
+
* `updateHooks()` call, which destroyed machine-only content inside the same
|
|
905
|
+
* command whose module path refuses to do it.
|
|
906
|
+
*
|
|
907
|
+
* @param {Array<{name: string, source: string, target: string}>} entries
|
|
908
|
+
* @returns {{safe: Array, diverged: Array, unknown: Array, hasBlocking: boolean}}
|
|
909
|
+
*/
|
|
910
|
+
function planStaleHookSync(entries) {
|
|
911
|
+
const safe = [];
|
|
912
|
+
const diverged = [];
|
|
913
|
+
const unknown = [];
|
|
914
|
+
for (const entry of entries) {
|
|
915
|
+
if (!fs.existsSync(entry.target)) {
|
|
916
|
+
safe.push(entry);
|
|
917
|
+
continue;
|
|
918
|
+
}
|
|
919
|
+
const texts = readPair(entry.source, entry.target);
|
|
920
|
+
if (!texts) {
|
|
921
|
+
unknown.push(entry);
|
|
922
|
+
continue;
|
|
923
|
+
}
|
|
924
|
+
const { repoOnly, installedOnly } = contentDelta(texts.repoText, texts.installedText);
|
|
925
|
+
if (repoOnly === 0 && installedOnly === 0) continue; // same content, different line endings
|
|
926
|
+
if (installedOnly > 0) diverged.push({ ...entry, repoOnly, installedOnly });
|
|
927
|
+
else safe.push({ ...entry, repoOnly, installedOnly: 0 });
|
|
928
|
+
}
|
|
929
|
+
return { safe, diverged, unknown, hasBlocking: diverged.length > 0 || unknown.length > 0 };
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
/**
|
|
933
|
+
* Print the resolved module directories so drift output is actionable (#495).
|
|
934
|
+
*/
|
|
935
|
+
function printEffectiveModules() {
|
|
936
|
+
const { adapterDir, gateDir } = resolveExecutedModuleDirs();
|
|
937
|
+
console.log('');
|
|
938
|
+
console.log('Effective gate modules (sourced by pre-commit):');
|
|
939
|
+
console.log('---------------------------------------------');
|
|
940
|
+
console.log(` ADAPTER_DIR: ${adapterDir}`);
|
|
941
|
+
console.log(` GATE_DIR: ${gateDir}`);
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
/**
|
|
945
|
+
* Print which hook files git will actually execute, and where they come from
|
|
946
|
+
* (#451 REQ-3). A developer who edits `githooks/pre-commit` and sees no change
|
|
947
|
+
* has no way to discover that a different copy is in charge; this makes the
|
|
948
|
+
* effective path and its origin explicit.
|
|
949
|
+
*/
|
|
950
|
+
function printEffectiveHooks() {
|
|
951
|
+
const projectHooksPath = path.join(process.cwd(), '.git', 'hooks');
|
|
952
|
+
|
|
953
|
+
let configured = null;
|
|
954
|
+
try {
|
|
955
|
+
configured = execSync('git config --get core.hooksPath', {
|
|
956
|
+
encoding: 'utf8',
|
|
957
|
+
timeout: EXEC_TIMEOUT_MS,
|
|
958
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
959
|
+
}).trim();
|
|
960
|
+
} catch {
|
|
961
|
+
// No explicit core.hooksPath -> git falls back to .git/hooks.
|
|
962
|
+
configured = '';
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
const effective = configured || projectHooksPath;
|
|
966
|
+
const source = configured
|
|
967
|
+
? 'core.hooksPath (global install)'
|
|
968
|
+
: '.git/hooks (project default)';
|
|
969
|
+
|
|
970
|
+
console.log('');
|
|
971
|
+
console.log('Effective hooks:');
|
|
972
|
+
console.log('----------------');
|
|
973
|
+
console.log(` ${effective}`);
|
|
974
|
+
console.log(` source: ${source}`);
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
/**
|
|
978
|
+
* Copy the repository's `githooks/` over the installed global hooks (#451 REQ-2).
|
|
979
|
+
*
|
|
980
|
+
* This is the one-command escape from "I edited githooks/ and nothing changed".
|
|
981
|
+
* Only hooks that exist in the repo are copied; unrelated files in the installed
|
|
982
|
+
* directory are left alone.
|
|
983
|
+
*
|
|
984
|
+
* @param {{repoRoot?: string, repoHooks?: string, installedHooks?: string}} [ctx]
|
|
985
|
+
* @returns {{synced: string[], skipped: string[], refused: Array<{name: string, repoOnly: number, installedOnly: number}>, errors: string[], installedHooks: string}}
|
|
986
|
+
*/
|
|
987
|
+
function syncGlobalHooksFromRepo(ctx = {}) {
|
|
988
|
+
const repoRoot = ctx.repoRoot || process.env.XP_GATE_REPO_ROOT || process.cwd();
|
|
989
|
+
const repoHooks = ctx.repoHooks || path.join(repoRoot, 'githooks');
|
|
990
|
+
const installedHooks = ctx.installedHooks || GLOBAL_HOOKS_DIR;
|
|
991
|
+
|
|
992
|
+
const synced = [];
|
|
993
|
+
const skipped = [];
|
|
994
|
+
const refused = [];
|
|
995
|
+
const errors = [];
|
|
996
|
+
|
|
997
|
+
if (!fs.existsSync(repoHooks)) {
|
|
998
|
+
errors.push(`no githooks/ directory at ${repoHooks}`);
|
|
999
|
+
return { synced, skipped, refused, errors, installedHooks };
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
if (!hasCanonicalHooks(repoHooks)) {
|
|
1003
|
+
errors.push(`no hook files in ${repoHooks} (only adapters?) -- nothing to sync from`);
|
|
1004
|
+
return { synced, skipped, refused, errors, installedHooks };
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
try {
|
|
1008
|
+
fs.mkdirSync(installedHooks, { recursive: true });
|
|
1009
|
+
} catch (err) {
|
|
1010
|
+
errors.push(`cannot create ${installedHooks}: ${err.message}`);
|
|
1011
|
+
return { synced, skipped, refused, errors, installedHooks };
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
for (const name of DRIFT_COMPARED_HOOKS) {
|
|
1015
|
+
const source = path.join(repoHooks, name);
|
|
1016
|
+
if (!fs.existsSync(source)) {
|
|
1017
|
+
skipped.push(name);
|
|
1018
|
+
continue;
|
|
1019
|
+
}
|
|
1020
|
+
const target = path.join(installedHooks, name);
|
|
1021
|
+
try {
|
|
1022
|
+
if (fs.existsSync(target) && filesAreIdentical(source, target)) {
|
|
1023
|
+
skipped.push(name);
|
|
1024
|
+
continue;
|
|
1025
|
+
}
|
|
1026
|
+
// copyFileSync preserves content; the exec bit is re-applied explicitly
|
|
1027
|
+
// because a copied hook that is not executable silently never runs.
|
|
1028
|
+
fs.copyFileSync(source, target);
|
|
1029
|
+
fs.chmodSync(target, 0o755);
|
|
1030
|
+
synced.push(name);
|
|
1031
|
+
} catch (err) {
|
|
1032
|
+
errors.push(`${name}: ${err.message}`);
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
// lib/ shared libraries: pre-commit sources lib/*.sh, so a synced hook file
|
|
1037
|
+
// without its libraries dies at the source line on the machine (#488).
|
|
1038
|
+
const repoLib = path.join(repoHooks, 'lib');
|
|
1039
|
+
const installedLib = path.join(installedHooks, 'lib');
|
|
1040
|
+
if (fs.existsSync(repoLib) && fs.statSync(repoLib).isDirectory()) {
|
|
1041
|
+
try {
|
|
1042
|
+
fs.mkdirSync(installedLib, { recursive: true });
|
|
1043
|
+
} catch (err) {
|
|
1044
|
+
errors.push(`cannot create ${installedLib}: ${err.message}`);
|
|
1045
|
+
return { synced, skipped, refused, errors, installedHooks };
|
|
1046
|
+
}
|
|
1047
|
+
let libEntries = [];
|
|
1048
|
+
try {
|
|
1049
|
+
libEntries = fs.readdirSync(repoLib, { withFileTypes: true });
|
|
1050
|
+
} catch (err) {
|
|
1051
|
+
errors.push(`cannot read ${repoLib}: ${err.message}`);
|
|
1052
|
+
return { synced, skipped, refused, errors, installedHooks };
|
|
1053
|
+
}
|
|
1054
|
+
for (const entry of libEntries) {
|
|
1055
|
+
if (!entry.isFile()) continue;
|
|
1056
|
+
const rel = `lib/${entry.name}`;
|
|
1057
|
+
const source = path.join(repoLib, entry.name);
|
|
1058
|
+
const target = path.join(installedLib, entry.name);
|
|
1059
|
+
try {
|
|
1060
|
+
if (fs.existsSync(target) && filesAreIdentical(source, target)) {
|
|
1061
|
+
skipped.push(rel);
|
|
1062
|
+
continue;
|
|
1063
|
+
}
|
|
1064
|
+
// A shared library can carry machine-only hardening just like a gate
|
|
1065
|
+
// module does, so it gets the same refusal instead of an unconditional
|
|
1066
|
+
// overwrite (#488 Round 2 -- the module guard and the lib guard must not
|
|
1067
|
+
// disagree about how destructive a sync is allowed to be). Unreadable
|
|
1068
|
+
// content refuses too: unknown is not the same as safe (#495 Round 3).
|
|
1069
|
+
if (fs.existsSync(target)) {
|
|
1070
|
+
const decision = copyGuardDecision(source, target, !!ctx.force);
|
|
1071
|
+
if (decision.refuse) {
|
|
1072
|
+
refused.push({
|
|
1073
|
+
name: rel,
|
|
1074
|
+
repoOnly: decision.repoOnly,
|
|
1075
|
+
installedOnly: decision.installedOnly,
|
|
1076
|
+
unknown: decision.unknown,
|
|
1077
|
+
});
|
|
1078
|
+
continue;
|
|
1079
|
+
}
|
|
1080
|
+
if (decision.forced === false && decision.repoOnly === 0 && decision.installedOnly === 0) {
|
|
1081
|
+
skipped.push(rel);
|
|
1082
|
+
continue;
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
fs.copyFileSync(source, target);
|
|
1086
|
+
fs.chmodSync(target, 0o755);
|
|
1087
|
+
synced.push(rel);
|
|
1088
|
+
} catch (err) {
|
|
1089
|
+
errors.push(`${rel}: ${err.message}`);
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
return { synced, skipped, refused, errors, installedHooks };
|
|
1095
|
+
}
|
|
1096
|
+
|
|
255
1097
|
/**
|
|
256
1098
|
* Check a single hook file exists and is an xp-gate file.
|
|
257
1099
|
*/
|
|
@@ -306,7 +1148,7 @@ async function checkAdapters(checks, mode, gitDirPromise) {
|
|
|
306
1148
|
});
|
|
307
1149
|
issues++;
|
|
308
1150
|
} else {
|
|
309
|
-
checks.push({ name: 'Gate scripts', status: 'PASS', detail: `${EXPECTED_GATE_SCRIPTS.length} gate script(s)` });
|
|
1151
|
+
checks.push({ name: 'Gate scripts', status: 'PASS', detail: `${EXPECTED_GATE_SCRIPTS.length} gate script(s) present (presence only; content is covered by the Module drift checks)` });
|
|
310
1152
|
}
|
|
311
1153
|
|
|
312
1154
|
return issues;
|
|
@@ -365,6 +1207,19 @@ async function diagnoseAsync() {
|
|
|
365
1207
|
const tuiPromise = diagnoseTuiRegistration(checks);
|
|
366
1208
|
const skillsPromise = diagnoseInstalledSkills(config, checks);
|
|
367
1209
|
|
|
1210
|
+
// Group E: repo-vs-installed hook drift (#451). Independent of config.mode:
|
|
1211
|
+
// the drift matters exactly because the installed copy, not githooks/, is
|
|
1212
|
+
// what executes. Detect if this is the xp-gate repo itself.
|
|
1213
|
+
const driftPromise = Promise.resolve().then(() => {
|
|
1214
|
+
const drift = diagnoseHookDrift();
|
|
1215
|
+
checks.push(...drift.checks);
|
|
1216
|
+
// The gate modules pre-commit sources at runtime drift independently of the
|
|
1217
|
+
// hook files and live in a different directory (#495).
|
|
1218
|
+
const moduleDrift = diagnoseModuleDrift();
|
|
1219
|
+
checks.push(...moduleDrift.checks);
|
|
1220
|
+
return drift.issues + moduleDrift.issues;
|
|
1221
|
+
});
|
|
1222
|
+
|
|
368
1223
|
// Wait for ALL parallel groups
|
|
369
1224
|
const results = await Promise.allSettled([
|
|
370
1225
|
hooksPromise,
|
|
@@ -373,6 +1228,7 @@ async function diagnoseAsync() {
|
|
|
373
1228
|
cliToolsPromise,
|
|
374
1229
|
tuiPromise,
|
|
375
1230
|
skillsPromise,
|
|
1231
|
+
driftPromise,
|
|
376
1232
|
]);
|
|
377
1233
|
|
|
378
1234
|
// Collect issue counts from settled promises
|
|
@@ -665,8 +1521,10 @@ function fixMissingHooks(mode, srcDir, hooksDir) {
|
|
|
665
1521
|
const label = mode === 'local' ? '' : 'global ';
|
|
666
1522
|
const preCommitLabel = `${label}pre-commit hook`;
|
|
667
1523
|
const prePushLabel = `${label}pre-push hook`;
|
|
1524
|
+
const postMergeLabel = `${label}post-merge hook`;
|
|
668
1525
|
fixed = restoreHook(path.join(srcDir, 'hooks', 'pre-commit'), path.join(hooksDir, 'pre-commit'), preCommitLabel) || fixed;
|
|
669
1526
|
fixed = restoreHook(path.join(srcDir, 'hooks', 'pre-push'), path.join(hooksDir, 'pre-push'), prePushLabel) || fixed;
|
|
1527
|
+
fixed = restoreHook(path.join(srcDir, 'hooks', 'post-merge'), path.join(hooksDir, 'post-merge'), postMergeLabel) || fixed;
|
|
670
1528
|
return fixed;
|
|
671
1529
|
}
|
|
672
1530
|
|
|
@@ -767,7 +1625,7 @@ function fixHooksByMode(config, srcDir) {
|
|
|
767
1625
|
function fixGlobalHooksPath(config) {
|
|
768
1626
|
if (config.mode !== 'global') return false;
|
|
769
1627
|
const hooksPath = getCurrentHooksPathSync();
|
|
770
|
-
if (hooksPath
|
|
1628
|
+
if (!pathsEquivalent(hooksPath, GLOBAL_HOOKS_DIR)) {
|
|
771
1629
|
return fixCoreHooksPath(GLOBAL_HOOKS_DIR);
|
|
772
1630
|
}
|
|
773
1631
|
return false;
|
|
@@ -783,7 +1641,7 @@ function getAdaptersDirByMode(config) {
|
|
|
783
1641
|
* Attempt to fix known issues.
|
|
784
1642
|
* Only operates when mode === 'active' (local or global).
|
|
785
1643
|
*/
|
|
786
|
-
function fixIssues(checks, config) {
|
|
1644
|
+
function fixIssues(checks, config, installToolsFlag = false) {
|
|
787
1645
|
console.log('');
|
|
788
1646
|
console.log('Attempting fixes...');
|
|
789
1647
|
console.log('-------------------');
|
|
@@ -797,7 +1655,15 @@ function fixIssues(checks, config) {
|
|
|
797
1655
|
fixed = fixMissingAdapters(config.mode, srcDir, getAdaptersDirByMode(config)) || fixed;
|
|
798
1656
|
fixed = fixMissingGateScripts(srcDir, getAdaptersDirByMode(config)) || fixed;
|
|
799
1657
|
fixed = fixStaleHooks(config) || fixed;
|
|
800
|
-
fixed =
|
|
1658
|
+
fixed = fixHookDriftFromRepo() || fixed;
|
|
1659
|
+
// Installing a gate CLI (gitleaks/semgrep/lizard) changes the machine the same
|
|
1660
|
+
// way a language toolchain does, so it goes behind the same opt-in: `xp-gate
|
|
1661
|
+
// install` ends by running `doctor --fix`, which made an unguarded call here a
|
|
1662
|
+
// post-install network write nobody asked for (#502 Round 2). Guidance below
|
|
1663
|
+
// stays ungated, because reporting is not a mutation.
|
|
1664
|
+
if (installToolsFlag) {
|
|
1665
|
+
fixed = fixMissingCliTools() || fixed;
|
|
1666
|
+
}
|
|
801
1667
|
fixed = fixTuiRegistration() || fixed;
|
|
802
1668
|
fixed = printCliToolGuidance() || fixed;
|
|
803
1669
|
|
|
@@ -806,16 +1672,43 @@ function fixIssues(checks, config) {
|
|
|
806
1672
|
}
|
|
807
1673
|
}
|
|
808
1674
|
|
|
1675
|
+
/**
|
|
1676
|
+
* Repair repo-vs-installed hook drift (#451).
|
|
1677
|
+
*
|
|
1678
|
+
* Only acts when the current project carries its own `githooks/` -- i.e. the
|
|
1679
|
+
* xp-gate repo (or a fork) rather than a consumer project, where githooks/ is
|
|
1680
|
+
* absent and the installed copy is the only source of truth.
|
|
1681
|
+
*
|
|
1682
|
+
* @returns {boolean} whether anything was synced
|
|
1683
|
+
*/
|
|
1684
|
+
function fixHookDriftFromRepo() {
|
|
1685
|
+
const repoHooks = path.join(process.env.XP_GATE_REPO_ROOT || process.cwd(), 'githooks');
|
|
1686
|
+
if (!fs.existsSync(repoHooks)) return false;
|
|
1687
|
+
|
|
1688
|
+
const { synced, refused, errors } = syncGlobalHooksFromRepo();
|
|
1689
|
+
if (errors.length > 0) {
|
|
1690
|
+
for (const err of errors) console.log(` ✗ ${err}`);
|
|
1691
|
+
return false;
|
|
1692
|
+
}
|
|
1693
|
+
for (const r of refused) {
|
|
1694
|
+
console.log(` ⚠️ ${r.name} NOT synced — install has ${r.installedOnly} unique line(s); judge it, then use --sync-hooks --force, which overwrites with no backup, so keep a copy of the installed file first`);
|
|
1695
|
+
}
|
|
1696
|
+
if (synced.length === 0) return false;
|
|
1697
|
+
|
|
1698
|
+
console.log(` ✓ Synced ${synced.join(', ')} from githooks/ to ${GLOBAL_HOOKS_DIR}`);
|
|
1699
|
+
return true;
|
|
1700
|
+
}
|
|
1701
|
+
|
|
809
1702
|
/**
|
|
810
1703
|
* Sync hooks/adapters from the installed package to the project/global dir.
|
|
811
1704
|
* Catches cases where xp-gate was upgraded (npm update) but hooks are stale.
|
|
812
|
-
*
|
|
1705
|
+
* Refreshes only the files a content judgement has called safe to overwrite.
|
|
813
1706
|
*
|
|
814
1707
|
* @returns {boolean} Whether any hooks were updated
|
|
815
1708
|
*/
|
|
816
1709
|
function fixStaleHooks(config) {
|
|
817
1710
|
try {
|
|
818
|
-
const {
|
|
1711
|
+
const { detectLocalModifications, getPackageRoot } = require('./update-hooks.js');
|
|
819
1712
|
const isGlobal = config.mode === 'global';
|
|
820
1713
|
const srcDir = getPackageRoot();
|
|
821
1714
|
|
|
@@ -835,14 +1728,80 @@ function fixStaleHooks(config) {
|
|
|
835
1728
|
const modified = detectLocalModifications(srcDir, hooksDestDir, adaptersDestDir);
|
|
836
1729
|
if (modified.length === 0) return false;
|
|
837
1730
|
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
1731
|
+
// Round 2 (architecture) caught the second overwrite standard inside this one
|
|
1732
|
+
// command: it handed every stale label to a forced updateHooks(), destroying
|
|
1733
|
+
// machine-only content the module path of the same release refuses to clobber.
|
|
1734
|
+
// `copyAdapters` writes flat AND nested, so both destinations are judged before
|
|
1735
|
+
// anything is written, and the refresh then copies file by file instead of
|
|
1736
|
+
// running a blanket force -- a backup-less blanket force deletes work, while the
|
|
1737
|
+
// backed-up alternative litters the user's work tree with .bak (#495 Round 3).
|
|
1738
|
+
const plan = planStaleHookSync(
|
|
1739
|
+
modified.flatMap((label) => staleSyncEntries(label, srcDir, hooksDestDir, adaptersDestDir)),
|
|
1740
|
+
);
|
|
1741
|
+
|
|
1742
|
+
if (plan.hasBlocking) {
|
|
1743
|
+
console.log(` ⚠️ ${plan.diverged.length + plan.unknown.length} hook/adapter file(s) NOT refreshed —`);
|
|
1744
|
+
console.log(' the installed copy carries content the package does not, and overwriting');
|
|
1745
|
+
console.log(' it would delete work that exists nowhere else:');
|
|
1746
|
+
for (const d of plan.diverged) {
|
|
1747
|
+
console.log(` - ${d.name}: install has ${d.installedOnly} unique line(s), package has ${d.repoOnly}`);
|
|
1748
|
+
}
|
|
1749
|
+
for (const u of plan.unknown) {
|
|
1750
|
+
console.log(` - ${u.name}: installed copy could not be read`);
|
|
1751
|
+
}
|
|
1752
|
+
console.log(' Judge them (fold the hardening into the package, or move the file aside),');
|
|
1753
|
+
console.log(' then re-run --fix.');
|
|
1754
|
+
console.log(' There is no automatic backup of an overwritten hook/adapter file and nothing');
|
|
1755
|
+
console.log(' in this tool can undo one — judge against a copy you kept, not against a rollback.');
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
if (plan.safe.length === 0) return plan.hasBlocking;
|
|
1759
|
+
|
|
1760
|
+
console.log(` Refreshing ${plan.safe.length} outdated hook/adapter file(s)...`);
|
|
1761
|
+
let refreshed = 0;
|
|
1762
|
+
for (const entry of plan.safe) {
|
|
1763
|
+
if (!fs.existsSync(entry.source)) continue;
|
|
1764
|
+
try {
|
|
1765
|
+
fs.mkdirSync(path.dirname(entry.target), { recursive: true });
|
|
1766
|
+
fs.copyFileSync(entry.source, entry.target);
|
|
1767
|
+
fs.chmodSync(entry.target, 0o755);
|
|
1768
|
+
refreshed++;
|
|
1769
|
+
} catch (err) {
|
|
1770
|
+
console.log(` ✗ Could not refresh ${entry.name}: ${err.message}`);
|
|
1771
|
+
}
|
|
1772
|
+
}
|
|
1773
|
+
return refreshed > 0;
|
|
841
1774
|
} catch {
|
|
842
1775
|
return false;
|
|
843
1776
|
}
|
|
844
1777
|
}
|
|
845
1778
|
|
|
1779
|
+
/**
|
|
1780
|
+
* Where one `detectLocalModifications()` label lives on both sides.
|
|
1781
|
+
*
|
|
1782
|
+
* `adapters/<f>.sh` is listed once but written twice by `copyAdapters` (flat and
|
|
1783
|
+
* nested), so a machine edit to either copy has to block the refresh. The flat
|
|
1784
|
+
* copy is only judged where it already exists -- creating one here would plant
|
|
1785
|
+
* the very installer residue #500 is about (#495 Round 3).
|
|
1786
|
+
*/
|
|
1787
|
+
function staleSyncEntries(label, srcDir, hooksDestDir, adaptersDestDir) {
|
|
1788
|
+
if (['pre-commit', 'pre-push', 'post-merge', 'sprint-gate.sh'].includes(label)) {
|
|
1789
|
+
const hooksSource = label === 'sprint-gate.sh'
|
|
1790
|
+
? path.join(srcDir, label)
|
|
1791
|
+
: path.join(srcDir, 'hooks', label);
|
|
1792
|
+
return [{ name: label, source: hooksSource, target: path.join(hooksDestDir, label) }];
|
|
1793
|
+
}
|
|
1794
|
+
if (label.startsWith('adapters/')) {
|
|
1795
|
+
const file = label.slice('adapters/'.length);
|
|
1796
|
+
const source = path.join(srcDir, 'adapters', file);
|
|
1797
|
+
const entries = [{ name: label, source, target: path.join(adaptersDestDir, 'adapters', file) }];
|
|
1798
|
+
const flat = path.join(adaptersDestDir, file);
|
|
1799
|
+
if (fs.existsSync(flat)) entries.push({ name: `${label} (flat)`, source, target: flat });
|
|
1800
|
+
return entries;
|
|
1801
|
+
}
|
|
1802
|
+
return [{ name: label, source: path.join(srcDir, label), target: path.join(adaptersDestDir, label) }];
|
|
1803
|
+
}
|
|
1804
|
+
|
|
846
1805
|
/**
|
|
847
1806
|
* Auto-install missing CLI tools via bootstrap.
|
|
848
1807
|
* @returns {boolean} Whether any tools were installed
|
|
@@ -1005,55 +1964,210 @@ function diagnoseLanguageTools() {
|
|
|
1005
1964
|
}
|
|
1006
1965
|
}
|
|
1007
1966
|
|
|
1008
|
-
|
|
1967
|
+
// #488: an unrecognised flag used to be silently ignored — the npm release
|
|
1968
|
+
// that predates --sync-hooks made "doctor --sync-hooks" a silent no-op and the
|
|
1969
|
+
// user believed a sync had happened. Fail loudly instead.
|
|
1970
|
+
const KNOWN_FLAGS = ['--fix', '--sync-hooks', '--force', '--install-tools', '--json', '--format'];
|
|
1971
|
+
|
|
1972
|
+
/**
|
|
1973
|
+
* The accepted flag surface of `doctor`, as a pure function.
|
|
1974
|
+
*
|
|
1975
|
+
* The reason this is not inline in `doctor()`: this contract is about which
|
|
1976
|
+
* combinations are accepted, and answering that by running the real command
|
|
1977
|
+
* would exercise the repair surface (`--sync-hooks` copies repo files over the
|
|
1978
|
+
* installed ones). A contract that can only be tested by mutating the machine
|
|
1979
|
+
* is a contract nobody tests.
|
|
1980
|
+
*
|
|
1981
|
+
* @returns {{errors: string[], fixMode: boolean, syncHooks: boolean, force: boolean, installToolsFlag: boolean, jsonMode: boolean}}
|
|
1982
|
+
*/
|
|
1983
|
+
function doctorFlagContract(args) {
|
|
1984
|
+
const errors = [];
|
|
1985
|
+
// `--format json` carries a value; without this the value itself would be
|
|
1986
|
+
// reported as an unknown flag and the documented machine-readable mode could
|
|
1987
|
+
// never be reached (#304 promised it, nothing ever implemented it).
|
|
1988
|
+
const formatValue = args[args.indexOf('--format') + 1] || null;
|
|
1989
|
+
const flagTokens = args.filter((a, i) => !(i > 0 && args[i - 1] === '--format'));
|
|
1990
|
+
const unknownFlags = flagTokens.filter(a => !KNOWN_FLAGS.includes(a));
|
|
1991
|
+
if (unknownFlags.length > 0) {
|
|
1992
|
+
errors.push(`Unknown flag(s) for doctor: ${unknownFlags.join(', ')}`);
|
|
1993
|
+
errors.push(`Supported flags: ${KNOWN_FLAGS.join(', ')}`);
|
|
1994
|
+
}
|
|
1009
1995
|
const fixMode = args.includes('--fix');
|
|
1996
|
+
const syncHooks = args.includes('--sync-hooks');
|
|
1997
|
+
// #495: overwriting a module whose installed copy holds lines the repo lacks
|
|
1998
|
+
// destroys machine-only hardening, so it needs this explicit opt-in.
|
|
1999
|
+
const force = args.includes('--force');
|
|
2000
|
+
// Installing a toolchain changes the machine, not the repository, so it is
|
|
2001
|
+
// never a side effect of --fix (#502). --fix reports the missing tools.
|
|
2002
|
+
const installToolsFlag = args.includes('--install-tools');
|
|
2003
|
+
const jsonMode = args.includes('--json') || (args.includes('--format') && formatValue === 'json');
|
|
2004
|
+
if (args.includes('--format') && !['json', 'text'].includes(formatValue)) {
|
|
2005
|
+
errors.push(`Unknown --format value: ${formatValue || '(none)'} — expected json or text`);
|
|
2006
|
+
}
|
|
2007
|
+
if (jsonMode && (fixMode || syncHooks)) {
|
|
2008
|
+
// A JSON report is a read; mixing it with the repair surface would put
|
|
2009
|
+
// progress prose into the stream a script is parsing.
|
|
2010
|
+
errors.push('--json/--format json cannot be combined with --fix or --sync-hooks');
|
|
2011
|
+
}
|
|
2012
|
+
// Round 4 (feasibility) caught the original #488 defect in a new place:
|
|
2013
|
+
// `--force` is read only by the sync path, so `doctor --force` and
|
|
2014
|
+
// `doctor --fix --force` were accepted, printed nothing and changed nothing.
|
|
2015
|
+
if (force && !syncHooks) {
|
|
2016
|
+
errors.push('--force is only read by --sync-hooks, where it overrides the divergence refusal and overwrites without a backup');
|
|
2017
|
+
errors.push('Pass --sync-hooks --force, or drop --force');
|
|
2018
|
+
}
|
|
2019
|
+
// Same class: --install-tools installs only inside the --fix repair surface.
|
|
2020
|
+
if (installToolsFlag && !fixMode) {
|
|
2021
|
+
errors.push('--install-tools is only read by --fix, and it is the only flag that installs toolchains onto this machine');
|
|
2022
|
+
errors.push('Pass --fix --install-tools, or drop --install-tools');
|
|
2023
|
+
}
|
|
2024
|
+
return { errors, fixMode, syncHooks, force, installToolsFlag, jsonMode };
|
|
2025
|
+
}
|
|
2026
|
+
|
|
2027
|
+
async function doctor(args) {
|
|
2028
|
+
const { errors, fixMode, syncHooks, force, installToolsFlag, jsonMode } = doctorFlagContract(args);
|
|
2029
|
+
if (errors.length > 0) {
|
|
2030
|
+
for (const error of errors) console.error(error);
|
|
2031
|
+
return 1;
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
if (!jsonMode) {
|
|
2035
|
+
console.log('XP-Gate Doctor');
|
|
2036
|
+
console.log('==============');
|
|
2037
|
+
}
|
|
1010
2038
|
|
|
1011
|
-
|
|
1012
|
-
|
|
2039
|
+
// §4.13 (#451 REQ-3): state which hook files actually execute, so "I edited
|
|
2040
|
+
// githooks/ but nothing changed" is answerable without reading git config.
|
|
2041
|
+
if (!jsonMode) printEffectiveHooks();
|
|
2042
|
+
// #495: the same for the gate modules the hook sources at runtime.
|
|
2043
|
+
if (!jsonMode) printEffectiveModules();
|
|
1013
2044
|
|
|
1014
2045
|
const config = getConfig();
|
|
1015
2046
|
|
|
1016
2047
|
// §4.8: mode === "uninstalled" → print "xp-gate is not installed"
|
|
1017
2048
|
if (isUninstalledMode(config)) {
|
|
2049
|
+
if (jsonMode) {
|
|
2050
|
+
console.log(JSON.stringify(formatDoctorJson([{ name: 'Install', status: 'SKIP', detail: 'xp-gate is not installed' }], 0)));
|
|
2051
|
+
return 0;
|
|
2052
|
+
}
|
|
1018
2053
|
console.log('xp-gate is not installed.');
|
|
1019
2054
|
console.log('Run xp-gate init to install.');
|
|
1020
2055
|
return 0;
|
|
1021
2056
|
}
|
|
1022
2057
|
|
|
2058
|
+
// §4.13 (#451 REQ-2): explicit one-command sync from the repo's githooks/.
|
|
2059
|
+
if (syncHooks) {
|
|
2060
|
+
const { synced, skipped, refused, errors, installedHooks } = syncGlobalHooksFromRepo({ force });
|
|
2061
|
+
if (errors.length > 0) {
|
|
2062
|
+
for (const err of errors) console.log(` ✗ ${err}`);
|
|
2063
|
+
return 1;
|
|
2064
|
+
}
|
|
2065
|
+
if (synced.length > 0) {
|
|
2066
|
+
console.log(` ✓ Synced ${synced.join(', ')} from githooks/ to ${installedHooks}`);
|
|
2067
|
+
console.log(' The updated hooks take effect on the next commit.');
|
|
2068
|
+
} else {
|
|
2069
|
+
console.log(` ✓ Global hooks already match githooks/ (${skipped.join(', ') || 'nothing to sync'})`);
|
|
2070
|
+
}
|
|
2071
|
+
if (refused.length > 0) {
|
|
2072
|
+
console.log('');
|
|
2073
|
+
console.log(` ⚠️ ${refused.length} shared library file(s) NOT synced — the installed copy`);
|
|
2074
|
+
console.log(' carries content githooks/ does not, so overwriting would delete work:');
|
|
2075
|
+
for (const r of refused) {
|
|
2076
|
+
console.log(` - ${r.name}: install has ${r.installedOnly} unique line(s), githooks/ has ${r.repoOnly}`);
|
|
2077
|
+
}
|
|
2078
|
+
console.log(' Review them, then either fold the hardening into githooks/ or re-run --sync-hooks --force.');
|
|
2079
|
+
console.log(' Forcing overwrites the installed file with no backup: copy it aside first.');
|
|
2080
|
+
}
|
|
2081
|
+
|
|
2082
|
+
// #495: the gate modules are what the hook sources for Gates 3/4/7/8/9/10,
|
|
2083
|
+
// so syncing only the hook files left the executed surface untouched.
|
|
2084
|
+
const moduleSync = syncModulesFromRepo({ force });
|
|
2085
|
+
if (moduleSync.errors.length > 0) {
|
|
2086
|
+
for (const err of moduleSync.errors) console.log(` ✗ ${err}`);
|
|
2087
|
+
return 1;
|
|
2088
|
+
}
|
|
2089
|
+
if (moduleSync.synced.length > 0) {
|
|
2090
|
+
console.log(` ✓ Synced ${moduleSync.synced.length} gate module(s) to ${moduleSync.installedAdapters}`);
|
|
2091
|
+
console.log(` ${moduleSync.synced.join(', ')}`);
|
|
2092
|
+
}
|
|
2093
|
+
if (moduleSync.refused.length > 0) {
|
|
2094
|
+
console.log('');
|
|
2095
|
+
console.log(` ⚠️ ${moduleSync.refused.length} module(s) NOT synced — the installed copy carries`);
|
|
2096
|
+
console.log(' content the repo does not, so overwriting would delete work (#495):');
|
|
2097
|
+
for (const r of moduleSync.refused) {
|
|
2098
|
+
console.log(` - ${r.name}: install has ${r.installedOnly} unique line(s), githooks/ has ${r.repoOnly}`);
|
|
2099
|
+
}
|
|
2100
|
+
console.log(' Review them, then either fold the hardening into githooks/ or re-run --sync-hooks --force.');
|
|
2101
|
+
console.log(' Forcing overwrites the installed file with no backup: copy it aside first.');
|
|
2102
|
+
}
|
|
2103
|
+
}
|
|
2104
|
+
|
|
1023
2105
|
// §4.13: --fix only when mode === "active"
|
|
1024
2106
|
if (fixMode && isActiveMode(config)) {
|
|
1025
|
-
fixIssues(null, config);
|
|
2107
|
+
fixIssues(null, config, installToolsFlag);
|
|
1026
2108
|
}
|
|
1027
2109
|
|
|
1028
2110
|
// Global timeout wrapper — ensures doctor completes within GLOBAL_DIAGNOSIS_TIMEOUT_MS
|
|
1029
2111
|
// even if network/subprocess checks stack up (fix #348: 43s on Windows).
|
|
1030
2112
|
const diagnosisPromise = diagnoseAsync();
|
|
2113
|
+
let diagnosisTimer = null;
|
|
1031
2114
|
const timeoutPromise = new Promise((resolve) => {
|
|
1032
|
-
setTimeout(() => resolve({ checks: [{ name: 'Diagnosis', status: 'WARN', detail: `Timed out after ${GLOBAL_DIAGNOSIS_TIMEOUT_MS / 1000}s — some checks incomplete` }], issues: 0, timedOut: true }), GLOBAL_DIAGNOSIS_TIMEOUT_MS);
|
|
2115
|
+
diagnosisTimer = setTimeout(() => resolve({ checks: [{ name: 'Diagnosis', status: 'WARN', detail: `Timed out after ${GLOBAL_DIAGNOSIS_TIMEOUT_MS / 1000}s — some checks incomplete` }], issues: 0, timedOut: true }), GLOBAL_DIAGNOSIS_TIMEOUT_MS);
|
|
1033
2116
|
});
|
|
1034
|
-
const { checks, issues: diagnosedIssues } = await Promise.race([
|
|
2117
|
+
const { checks, issues: diagnosedIssues, timedOut } = await Promise.race([
|
|
1035
2118
|
diagnosisPromise.then(r => ({ ...r, timedOut: false })),
|
|
1036
2119
|
timeoutPromise,
|
|
1037
2120
|
]);
|
|
2121
|
+
clearTimeout(diagnosisTimer);
|
|
1038
2122
|
let issues = diagnosedIssues;
|
|
1039
2123
|
|
|
1040
|
-
|
|
2124
|
+
// A timeout discards every check that had not reported yet, including the
|
|
2125
|
+
// drift surface, so the raced result must never be allowed to read as clean
|
|
2126
|
+
// (#495 Round 2 -- this is the "stale modules, doctor green" outcome itself).
|
|
2127
|
+
if (timedOut) {
|
|
2128
|
+
issues = Math.max(issues, 1);
|
|
2129
|
+
const hookDrift = diagnoseHookDrift();
|
|
2130
|
+
const moduleDrift = diagnoseModuleDrift();
|
|
2131
|
+
checks.push(...hookDrift.checks, ...moduleDrift.checks);
|
|
2132
|
+
issues += hookDrift.issues + moduleDrift.issues;
|
|
2133
|
+
}
|
|
1041
2134
|
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
2135
|
+
let langIssues = 0;
|
|
2136
|
+
// Upgrade + plugin + language-tool status, kept as one callable because a
|
|
2137
|
+
// --format json run still has to execute all of it (#488).
|
|
2138
|
+
const collectTrailingChecks = async () => {
|
|
2139
|
+
try {
|
|
2140
|
+
const upgradePromise = diagnoseUpgrade();
|
|
2141
|
+
const upgradeTimeout = new Promise((resolve) => setTimeout(() => resolve(), 3000));
|
|
2142
|
+
await Promise.race([upgradePromise, upgradeTimeout]);
|
|
2143
|
+
} catch { /* non-blocking */ }
|
|
2144
|
+
const trailing = diagnoseOpenCodePlugin(checks);
|
|
2145
|
+
langIssues = diagnoseLanguageTools();
|
|
2146
|
+
return trailing + langIssues;
|
|
2147
|
+
};
|
|
2148
|
+
|
|
2149
|
+
if (jsonMode) {
|
|
2150
|
+
// Machine-readable output means EXACTLY one JSON document on stdout, so the
|
|
2151
|
+
// human report and the trailing checks' chatter are silenced while their
|
|
2152
|
+
// results are still collected into `checks`.
|
|
2153
|
+
const say = console.log;
|
|
2154
|
+
console.log = () => {};
|
|
2155
|
+
try {
|
|
2156
|
+
issues += await collectTrailingChecks();
|
|
2157
|
+
} finally {
|
|
2158
|
+
console.log = say;
|
|
2159
|
+
}
|
|
2160
|
+
console.log(JSON.stringify(formatDoctorJson(checks, issues)));
|
|
2161
|
+
return issues > 0 ? 1 : 0;
|
|
2162
|
+
}
|
|
1048
2163
|
|
|
1049
|
-
|
|
2164
|
+
printReport(checks);
|
|
1050
2165
|
|
|
1051
|
-
|
|
1052
|
-
const langIssues = diagnoseLanguageTools();
|
|
1053
|
-
issues += langIssues;
|
|
2166
|
+
issues += await collectTrailingChecks();
|
|
1054
2167
|
|
|
1055
|
-
// In --fix mode, auto-install missing language tools
|
|
1056
|
-
|
|
2168
|
+
// In --fix mode, auto-install missing language tools — only on explicit
|
|
2169
|
+
// --install-tools (#502: a diagnosis command must not run package managers).
|
|
2170
|
+
if (fixMode && installToolsFlag && langIssues > 0) {
|
|
1057
2171
|
try {
|
|
1058
2172
|
const { detectProjectLanguages, getToolsForLanguages, installTool, generateProjectConfig, LANGUAGE_REGISTRY } = require('./language-tools.js');
|
|
1059
2173
|
const projectRoot = process.cwd();
|
|
@@ -1080,6 +2194,11 @@ async function doctor(args) {
|
|
|
1080
2194
|
} catch { /* non-blocking */ }
|
|
1081
2195
|
}
|
|
1082
2196
|
|
|
2197
|
+
if (fixMode && !installToolsFlag && langIssues > 0) {
|
|
2198
|
+
console.log(`\n ${langIssues} language tool(s) are missing. --fix did not install them —`);
|
|
2199
|
+
console.log(' installing a toolchain changes the machine, so opt in with --install-tools (#502).');
|
|
2200
|
+
}
|
|
2201
|
+
|
|
1083
2202
|
if (issues === 0) {
|
|
1084
2203
|
console.log('\n✓ All checks passed');
|
|
1085
2204
|
return 0;
|
|
@@ -1091,10 +2210,24 @@ async function doctor(args) {
|
|
|
1091
2210
|
if (fixMode && isActiveMode(config)) {
|
|
1092
2211
|
console.log('\nRe-running diagnosis after fix...');
|
|
1093
2212
|
const postDiagPromise = diagnoseAsync();
|
|
2213
|
+
let postTimer = null;
|
|
1094
2214
|
const postTimeout = new Promise((resolve) => {
|
|
1095
|
-
setTimeout(() => resolve({
|
|
2215
|
+
postTimer = setTimeout(() => resolve({
|
|
2216
|
+
checks: [{ name: 'Post-fix diagnosis', status: 'WARN', detail: `Timed out after ${GLOBAL_DIAGNOSIS_TIMEOUT_MS / 1000}s — the fix result is unconfirmed, not clean` }],
|
|
2217
|
+
issues: 1,
|
|
2218
|
+
timedOut: true,
|
|
2219
|
+
}), GLOBAL_DIAGNOSIS_TIMEOUT_MS);
|
|
1096
2220
|
});
|
|
1097
|
-
const { checks: postChecks } = await Promise.race([
|
|
2221
|
+
const { checks: postChecks, timedOut: postTimedOut } = await Promise.race([
|
|
2222
|
+
postDiagPromise.then(r => ({ ...r, timedOut: false })),
|
|
2223
|
+
postTimeout,
|
|
2224
|
+
]);
|
|
2225
|
+
clearTimeout(postTimer);
|
|
2226
|
+
// Symmetry with the pre-fix race above: a timeout here discards the drift
|
|
2227
|
+
// surface too, so the post-fix report must not read as "all clear".
|
|
2228
|
+
if (postTimedOut) {
|
|
2229
|
+
postChecks.push(...diagnoseHookDrift().checks, ...diagnoseModuleDrift().checks);
|
|
2230
|
+
}
|
|
1098
2231
|
printReport(postChecks);
|
|
1099
2232
|
}
|
|
1100
2233
|
|
|
@@ -1125,4 +2258,18 @@ module.exports = {
|
|
|
1125
2258
|
ensureTuiRegistration,
|
|
1126
2259
|
readTuiJson,
|
|
1127
2260
|
formatDoctorJson,
|
|
2261
|
+
diagnoseHookDrift,
|
|
2262
|
+
syncGlobalHooksFromRepo,
|
|
2263
|
+
diagnoseModuleDrift,
|
|
2264
|
+
syncModulesFromRepo,
|
|
2265
|
+
resolveExecutedModuleDirs,
|
|
2266
|
+
executedModulePath,
|
|
2267
|
+
repoModuleNames,
|
|
2268
|
+
copyGuardDecision,
|
|
2269
|
+
planStaleHookSync,
|
|
2270
|
+
doctorFlagContract,
|
|
2271
|
+
listExecutedModules,
|
|
2272
|
+
repoModuleSource,
|
|
2273
|
+
contentDelta,
|
|
2274
|
+
pathsEquivalent,
|
|
1128
2275
|
};
|