@boyingliu01/xp-gate 0.21.0 → 0.22.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-9.sh +142 -60
- package/bin/xp-gate.js +2 -2
- package/gate-9.sh +142 -59
- package/hooks/gate-9.sh +142 -59
- package/hooks/lib/coverage-total.sh +72 -0
- package/hooks/pre-commit +88 -27
- package/hooks/pre-push +32 -0
- package/lib/__tests__/arch.test.js +403 -0
- package/lib/__tests__/doctor-469-hook-env.test.js +200 -0
- package/lib/arch.js +336 -49
- package/lib/doctor.js +319 -13
- package/lib/mattpocock-license-verdict.json +1 -1
- package/package.json +1 -1
- package/plugins/dsh/src/command.test.ts +14 -2
- package/plugins/dsh/src/command.ts +8 -1
- package/plugins/dsh/src/index.test.ts +43 -26
- package/plugins/dsh/src/index.ts +164 -138
- package/plugins/opencode/README.md +5 -2
- package/plugins/opencode/index.ts +215 -164
package/lib/doctor.js
CHANGED
|
@@ -1252,7 +1252,7 @@ async function checkAdapters(checks, mode, gitDirPromise) {
|
|
|
1252
1252
|
* D) Env + CLI tools — fully independent, run IN PARALLEL
|
|
1253
1253
|
* E) TUI + Skills — independent, run IN PARALLEL
|
|
1254
1254
|
*/
|
|
1255
|
-
async function diagnoseAsync() {
|
|
1255
|
+
async function diagnoseAsync(refreshEnv = false) {
|
|
1256
1256
|
const checks = [];
|
|
1257
1257
|
let issues = 0;
|
|
1258
1258
|
|
|
@@ -1295,7 +1295,12 @@ async function diagnoseAsync() {
|
|
|
1295
1295
|
|
|
1296
1296
|
// Group C: Environment checks + CLI tools — fully parallel
|
|
1297
1297
|
const envPromise = checkEnv(checks);
|
|
1298
|
-
|
|
1298
|
+
// #469: the tool probes run in the hook environment; --refresh-env swaps in
|
|
1299
|
+
// the registry PATH so "installed but invisible" separates from "not
|
|
1300
|
+
// installed". The staleness check runs always: it is what makes a stale
|
|
1301
|
+
// terminal's green verdict honest.
|
|
1302
|
+
const cliToolsPromise = diagnoseCliToolsAsync(checks, { refreshEnv });
|
|
1303
|
+
const stalenessPromise = diagnosePathStaleness(checks);
|
|
1299
1304
|
|
|
1300
1305
|
// Group D: TUI + Skills (independent)
|
|
1301
1306
|
const tuiPromise = diagnoseTuiRegistration(checks);
|
|
@@ -1320,6 +1325,7 @@ async function diagnoseAsync() {
|
|
|
1320
1325
|
adaptersPromise,
|
|
1321
1326
|
envPromise,
|
|
1322
1327
|
cliToolsPromise,
|
|
1328
|
+
stalenessPromise,
|
|
1323
1329
|
tuiPromise,
|
|
1324
1330
|
skillsPromise,
|
|
1325
1331
|
driftPromise,
|
|
@@ -1335,23 +1341,273 @@ async function diagnoseAsync() {
|
|
|
1335
1341
|
return { checks, issues };
|
|
1336
1342
|
}
|
|
1337
1343
|
|
|
1344
|
+
/**
|
|
1345
|
+
* #469 layer 1: the hook environment is the authoritative visibility test.
|
|
1346
|
+
*
|
|
1347
|
+
* git executes hooks through `bash -c` (Git Bash on Windows, /bin/sh on
|
|
1348
|
+
* POSIX), so whether a gate tool is callable is decided by `command -v`
|
|
1349
|
+
* inside THAT shell — not by `where` in the doctor's cmd.exe, and not by the
|
|
1350
|
+
* doctor's own inherited PATH. Probing any other environment answers a
|
|
1351
|
+
* different question than the one the hook asks, which is exactly how
|
|
1352
|
+
* doctor came to report "healthy" on machines where every hook failed with
|
|
1353
|
+
* "command not found" (#469).
|
|
1354
|
+
*
|
|
1355
|
+
* @param {string} toolName
|
|
1356
|
+
* @param {string|null} bashPath — resolved bash (checkBash), null when absent
|
|
1357
|
+
* @param {NodeJS.ProcessEnv} [env] — environment override (--refresh-env)
|
|
1358
|
+
* @returns {Promise<string|null>} resolved path (bash's POSIX form on Windows) or null
|
|
1359
|
+
*/
|
|
1360
|
+
async function probeToolInHookEnv(toolName, bashPath, env) {
|
|
1361
|
+
const isWindows = process.platform === 'win32';
|
|
1362
|
+
// Interpolation guard (walkthrough FC-04): toolName goes into a shell
|
|
1363
|
+
// command string verbatim. Callers pass internal constants today, but this
|
|
1364
|
+
// function is exported — anything outside the tool-name character set is
|
|
1365
|
+
// rejected fail-closed rather than executed.
|
|
1366
|
+
if (!/^[A-Za-z0-9._-]+$/.test(toolName)) return null;
|
|
1367
|
+
const shell = isWindows ? (bashPath || 'bash') : '/bin/sh';
|
|
1368
|
+
const inner = `command -v ${toolName}`;
|
|
1369
|
+
const cmd = isWindows ? `"${shell}" -c "${inner}"` : `${shell} -c '${inner}'`;
|
|
1370
|
+
const result = await execWithTimeout(cmd, { stdio: ['ignore', 'pipe', 'pipe'], env });
|
|
1371
|
+
if (!result || !result.stdout) return null;
|
|
1372
|
+
const first = result.stdout.split('\n')[0].trim();
|
|
1373
|
+
return first || null;
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
/**
|
|
1377
|
+
* Split a Windows PATH-style list into entries. Accepts the raw string the
|
|
1378
|
+
* registry stores (`;`-separated) or an already-split array.
|
|
1379
|
+
*/
|
|
1380
|
+
function splitPathEntries(rawPath) {
|
|
1381
|
+
if (Array.isArray(rawPath)) return rawPath.filter(Boolean);
|
|
1382
|
+
return String(rawPath || '').split(';').map((s) => s.trim()).filter(Boolean);
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
/** Normalize a directory for set comparison: trailing separators, slashes, case. */
|
|
1386
|
+
function normalizeDirEntry(entry) {
|
|
1387
|
+
return String(entry || '')
|
|
1388
|
+
.replace(/[\\/]+$/, '')
|
|
1389
|
+
.replace(/\//g, '\\')
|
|
1390
|
+
.toLowerCase();
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
/**
|
|
1394
|
+
* Registry PATH entries the current process cannot see (#469 layer 2).
|
|
1395
|
+
*
|
|
1396
|
+
* On Windows the registry value and the process environment block are two
|
|
1397
|
+
* copies of PATH; anything written to the registry after this process
|
|
1398
|
+
* started is invisible to it (and to every hook it spawns). Such an entry
|
|
1399
|
+
* means "installed, but this terminal is too old to see it" — the false-green
|
|
1400
|
+
* class this issue exists to kill.
|
|
1401
|
+
*/
|
|
1402
|
+
function findStalePathEntries(processPath, registryEntries) {
|
|
1403
|
+
const visible = new Set(splitPathEntries(processPath).map(normalizeDirEntry));
|
|
1404
|
+
const stale = [];
|
|
1405
|
+
for (const entry of splitPathEntries(registryEntries)) {
|
|
1406
|
+
const norm = normalizeDirEntry(entry);
|
|
1407
|
+
if (!norm || visible.has(norm)) continue;
|
|
1408
|
+
if (stale.some((s) => normalizeDirEntry(s) === norm)) continue;
|
|
1409
|
+
stale.push(entry);
|
|
1410
|
+
}
|
|
1411
|
+
return stale;
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
/**
|
|
1415
|
+
* The process PATH extended with the registry entries it cannot see.
|
|
1416
|
+
*
|
|
1417
|
+
* Process PATH FIRST, registry-only entries appended: the purpose is not to
|
|
1418
|
+
* simulate a new terminal's resolution order but to make freshly-installed
|
|
1419
|
+
* tools VISIBLE to the probes without disturbing the resolution of the
|
|
1420
|
+
* environment this doctor run is diagnosing. Registry-first was tried and is
|
|
1421
|
+
* wrong: it re-resolves `bash` itself (System32's WSL stub can win), and the
|
|
1422
|
+
* probe layer dies with the shell it meant to use. Resolution-order fidelity
|
|
1423
|
+
* is irrelevant here — a stale entry is already reported by the PATH
|
|
1424
|
+
* freshness check, and `command -v` only needs the directory to be present.
|
|
1425
|
+
*/
|
|
1426
|
+
function buildRefreshedPath(processPath, registry) {
|
|
1427
|
+
const ordered = [
|
|
1428
|
+
...splitPathEntries(processPath),
|
|
1429
|
+
...findStalePathEntries(processPath, registry && registry.machine),
|
|
1430
|
+
...findStalePathEntries(processPath, registry && registry.user),
|
|
1431
|
+
];
|
|
1432
|
+
const out = [];
|
|
1433
|
+
const seen = new Set();
|
|
1434
|
+
for (const entry of ordered) {
|
|
1435
|
+
const norm = normalizeDirEntry(entry);
|
|
1436
|
+
if (!norm || seen.has(norm)) continue;
|
|
1437
|
+
seen.add(norm);
|
|
1438
|
+
out.push(entry);
|
|
1439
|
+
}
|
|
1440
|
+
return out.join(';');
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
/**
|
|
1444
|
+
* Extract the Path value from `reg query <key> /v Path` output:
|
|
1445
|
+
* " Path REG_EXPAND_SZ C:\a;C:\b"
|
|
1446
|
+
* Returns null when the value line is absent (bad key, wrong value name).
|
|
1447
|
+
*/
|
|
1448
|
+
function parseRegQueryPath(stdout) {
|
|
1449
|
+
const line = String(stdout || '')
|
|
1450
|
+
.split(/\r?\n/)
|
|
1451
|
+
.find((l) => /^\s+Path\s+REG_[A-Z_]+\s+\S/.test(l));
|
|
1452
|
+
if (!line) return null;
|
|
1453
|
+
return line.replace(/^\s+Path\s+REG_[A-Z_]+\s+/, '').trim();
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
async function queryRegistryPathValue(key) {
|
|
1457
|
+
const result = await execWithTimeout(`reg query "${key}" /v Path`, {
|
|
1458
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1459
|
+
});
|
|
1460
|
+
if (!result) return { entries: [], error: `reg query failed for ${key}` };
|
|
1461
|
+
const raw = parseRegQueryPath(result.stdout);
|
|
1462
|
+
if (!raw) return { entries: [], error: `no Path value under ${key}` };
|
|
1463
|
+
return { entries: splitPathEntries(raw), error: null };
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
/**
|
|
1467
|
+
* The persistent PATH from the registry — what a NEW terminal would see.
|
|
1468
|
+
* Fail-closed: an unreadable hive returns `error`, and the caller WARNs
|
|
1469
|
+
* instead of claiming a freshness it cannot know.
|
|
1470
|
+
*/
|
|
1471
|
+
async function readRegistryPath() {
|
|
1472
|
+
if (process.platform !== 'win32') {
|
|
1473
|
+
return { supported: false, user: [], machine: [], userError: null, machineError: null, error: null };
|
|
1474
|
+
}
|
|
1475
|
+
const user = await queryRegistryPathValue('HKCU\\Environment');
|
|
1476
|
+
const machine = await queryRegistryPathValue(
|
|
1477
|
+
'HKLM\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment'
|
|
1478
|
+
);
|
|
1479
|
+
// `error` is the combined view for callers that need a single boolean
|
|
1480
|
+
// ("could I read everything?"); userError/machineError carry the halves
|
|
1481
|
+
// separately so staleness can use partial evidence (walkthrough FC-07).
|
|
1482
|
+
return {
|
|
1483
|
+
supported: true,
|
|
1484
|
+
user: user.entries,
|
|
1485
|
+
machine: machine.entries,
|
|
1486
|
+
userError: user.error,
|
|
1487
|
+
machineError: machine.error,
|
|
1488
|
+
error: user.error || machine.error || null,
|
|
1489
|
+
};
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
/**
|
|
1493
|
+
* #469 layer 2: compare the process PATH against the registry.
|
|
1494
|
+
*
|
|
1495
|
+
* A registry entry the process cannot see means "installed, but this
|
|
1496
|
+
* terminal is too old to see it" — so a "Not found" verdict for a tool that
|
|
1497
|
+
* lives under such an entry is a false claim, and a "✓ All checks passed"
|
|
1498
|
+
* over a stale terminal is a false green. WARN, not FAIL: the machine may
|
|
1499
|
+
* be perfectly healthy; only this terminal is stale, and the remedy is a
|
|
1500
|
+
* new terminal (or `xp-gate doctor --refresh-env`).
|
|
1501
|
+
*/
|
|
1502
|
+
async function diagnosePathStaleness(checks, options = {}) {
|
|
1503
|
+
if (process.platform !== 'win32') return 0;
|
|
1504
|
+
const registry = options.registry || (await readRegistryPath());
|
|
1505
|
+
// Per-half fail-closed (walkthrough FC-07): a readable hive is still
|
|
1506
|
+
// evidence — discarding it because the OTHER half failed threw away partial
|
|
1507
|
+
// facts. Each half is compared independently; an unreadable half is
|
|
1508
|
+
// reported as an unknown, never as a PASS.
|
|
1509
|
+
const halves = [
|
|
1510
|
+
{ label: 'User', entries: registry.user, error: registry.userError },
|
|
1511
|
+
{ label: 'Machine', entries: registry.machine, error: registry.machineError },
|
|
1512
|
+
];
|
|
1513
|
+
const usable = halves.filter((h) => !h.error);
|
|
1514
|
+
const unreadable = halves.filter((h) => h.error);
|
|
1515
|
+
|
|
1516
|
+
let status = 'PASS';
|
|
1517
|
+
const parts = [];
|
|
1518
|
+
let stale;
|
|
1519
|
+
if (usable.length === 0) {
|
|
1520
|
+
// Fail closed: no readable hive is not evidence of freshness.
|
|
1521
|
+
checks.push({
|
|
1522
|
+
name: 'PATH freshness',
|
|
1523
|
+
status: 'WARN',
|
|
1524
|
+
detail: `Could not read the registry PATH (${halves.map((h) => `${h.label}: ${h.error}`).join('; ')}) — staleness unknown. After installing a tool, open a new terminal before trusting any "not found" verdict.`,
|
|
1525
|
+
});
|
|
1526
|
+
return 0;
|
|
1527
|
+
}
|
|
1528
|
+
stale = [
|
|
1529
|
+
...usable.flatMap((h) => findStalePathEntries(process.env.PATH || '', h.entries)),
|
|
1530
|
+
];
|
|
1531
|
+
if (unreadable.length > 0) {
|
|
1532
|
+
status = 'WARN';
|
|
1533
|
+
parts.push(`${unreadable.map((h) => `${h.label} hive unreadable (${h.error}), staleness unknown there`).join('; ')}`);
|
|
1534
|
+
}
|
|
1535
|
+
if (stale.length === 0) {
|
|
1536
|
+
parts.unshift(`process PATH matches the readable registry hive${usable.length === 2 ? 's' : ''} (${usable.map((h) => h.label).join(' + ')})`);
|
|
1537
|
+
checks.push({
|
|
1538
|
+
name: 'PATH freshness',
|
|
1539
|
+
status,
|
|
1540
|
+
detail: parts.join(' — '),
|
|
1541
|
+
});
|
|
1542
|
+
return 0;
|
|
1543
|
+
}
|
|
1544
|
+
status = 'WARN';
|
|
1545
|
+
// Which gate tools does THIS terminal lose to staleness? A tool visible in
|
|
1546
|
+
// the hook env only under the registry PATH is "installed but invisible" —
|
|
1547
|
+
// never "not installed".
|
|
1548
|
+
const bash = checkBash();
|
|
1549
|
+
const bashPath = bash.ok ? bash.path : null;
|
|
1550
|
+
const refreshedPath = buildRefreshedPath(process.env.PATH || '', registry);
|
|
1551
|
+
const invisible = [];
|
|
1552
|
+
if (bashPath) {
|
|
1553
|
+
await Promise.all(GATE_CLI_TOOLS.map(async (entry) => {
|
|
1554
|
+
const now = await probeToolInHookEnv(entry.tool, bashPath);
|
|
1555
|
+
if (now) return;
|
|
1556
|
+
const refreshed = await probeToolInHookEnv(entry.tool, bashPath, {
|
|
1557
|
+
...process.env,
|
|
1558
|
+
PATH: refreshedPath,
|
|
1559
|
+
});
|
|
1560
|
+
if (refreshed) invisible.push(`${entry.tool} → ${refreshed}`);
|
|
1561
|
+
}));
|
|
1562
|
+
}
|
|
1563
|
+
const preview = stale.length <= 3
|
|
1564
|
+
? stale.join(' ; ')
|
|
1565
|
+
: `${stale.slice(0, 3).join(' ; ')} ; …+${stale.length - 3} more`;
|
|
1566
|
+
parts.unshift(`PATH is stale — ${stale.length} registry entr${stale.length === 1 ? 'y' : 'ies'} invisible to this process (${preview})`);
|
|
1567
|
+
checks.push({
|
|
1568
|
+
name: 'PATH freshness',
|
|
1569
|
+
status: 'WARN',
|
|
1570
|
+
detail: parts.join(' — ')
|
|
1571
|
+
+ '. A tool installed after this terminal opened needs a NEW terminal — or re-run: xp-gate doctor --refresh-env'
|
|
1572
|
+
+ (invisible.length ? ` — installed-but-invisible gate tools: ${invisible.join(', ')}` : ''),
|
|
1573
|
+
});
|
|
1574
|
+
return 0;
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1338
1577
|
/**
|
|
1339
1578
|
* Async CLI tools check — runs each tool detection with a 3-second timeout
|
|
1340
1579
|
* using async exec. Tools are checked IN PARALLEL via Promise.allSettled.
|
|
1341
1580
|
* This mirrors checkCliTool logic but uses async exec for parallelization.
|
|
1342
1581
|
*
|
|
1582
|
+
* #469: the probe environment is the HOOK environment (`bash -c`), and
|
|
1583
|
+
* `options.refreshEnv` re-probes against the registry PATH — what a freshly
|
|
1584
|
+
* opened terminal would see — so "installed but invisible" is distinguishable
|
|
1585
|
+
* from "not installed".
|
|
1586
|
+
*
|
|
1343
1587
|
* @param {Array} checks
|
|
1588
|
+
* @param {{refreshEnv?: boolean}} [options]
|
|
1344
1589
|
* @returns {Promise<number>} issue count
|
|
1345
1590
|
*/
|
|
1346
|
-
async function diagnoseCliToolsAsync(checks) {
|
|
1591
|
+
async function diagnoseCliToolsAsync(checks, options = {}) {
|
|
1347
1592
|
let issues = 0;
|
|
1348
1593
|
const platform = process.platform;
|
|
1349
1594
|
const isWindows = platform === 'win32';
|
|
1350
1595
|
|
|
1596
|
+
const bash = checkBash();
|
|
1597
|
+
const bashPath = bash.ok ? bash.path : null;
|
|
1598
|
+
|
|
1599
|
+
let probeEnv;
|
|
1600
|
+
if (options.refreshEnv && isWindows) {
|
|
1601
|
+
const registry = await readRegistryPath();
|
|
1602
|
+
if (!registry.error) {
|
|
1603
|
+
probeEnv = { ...process.env, PATH: buildRefreshedPath(process.env.PATH || '', registry) };
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1351
1607
|
// Build parallel checks for each tool
|
|
1352
1608
|
const toolChecks = GATE_CLI_TOOLS.map(async (entry) => {
|
|
1353
1609
|
const toolName = entry.tool;
|
|
1354
|
-
const result = await detectCliToolAsync(toolName, isWindows);
|
|
1610
|
+
const result = await detectCliToolAsync(toolName, isWindows, { bashPath, env: probeEnv });
|
|
1355
1611
|
return { entry, toolName, result };
|
|
1356
1612
|
});
|
|
1357
1613
|
|
|
@@ -1388,13 +1644,33 @@ async function diagnoseCliToolsAsync(checks) {
|
|
|
1388
1644
|
* Async CLI tool detection — mirrors checkCliTool but uses async exec
|
|
1389
1645
|
* with a hard 3-second timeout per subprocess call.
|
|
1390
1646
|
*
|
|
1647
|
+
* #469: when bash is available the hook environment (`bash -c 'command -v'`)
|
|
1648
|
+
* is AUTHORITATIVE and there is no fall-through: a tool the hook cannot see
|
|
1649
|
+
* must not be reported available, however visible it is to `where` or to a
|
|
1650
|
+
* hardcoded local path. The where/which, direct-exec and local-path tiers
|
|
1651
|
+
* remain only for the no-bash case, where hooks cannot run at all
|
|
1652
|
+
* (checkEnv already FAILs Environment: Bash there).
|
|
1653
|
+
*
|
|
1391
1654
|
* @param {string} toolName
|
|
1392
1655
|
* @param {boolean} isWindows
|
|
1656
|
+
* @param {{bashPath?: string|null, env?: NodeJS.ProcessEnv}} [options]
|
|
1393
1657
|
* @returns {Promise<{available: boolean, version?: string, path?: string}>}
|
|
1394
1658
|
*/
|
|
1395
|
-
async function detectCliToolAsync(toolName, isWindows) {
|
|
1659
|
+
async function detectCliToolAsync(toolName, isWindows, options = {}) {
|
|
1660
|
+
const bashPath = options.bashPath || null;
|
|
1661
|
+
const env = options.env || undefined;
|
|
1662
|
+
|
|
1663
|
+
if (bashPath) {
|
|
1664
|
+
const hookEnvPath = await probeToolInHookEnv(toolName, bashPath, env);
|
|
1665
|
+
if (hookEnvPath) {
|
|
1666
|
+
const version = await getVersionAsync(hookEnvPath, isWindows, { bashPath, env });
|
|
1667
|
+
return { available: true, path: hookEnvPath, version };
|
|
1668
|
+
}
|
|
1669
|
+
return { available: false };
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1396
1672
|
const shell = isWindows ? 'cmd.exe' : '/bin/sh';
|
|
1397
|
-
const execOpts = { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], shell, timeout: EXEC_TIMEOUT_MS };
|
|
1673
|
+
const execOpts = { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], shell, timeout: EXEC_TIMEOUT_MS, env };
|
|
1398
1674
|
|
|
1399
1675
|
// Step 1: Locate tool via which/where (async, 3s timeout)
|
|
1400
1676
|
const locator = isWindows ? 'where' : 'which';
|
|
@@ -1431,12 +1707,29 @@ async function detectCliToolAsync(toolName, isWindows) {
|
|
|
1431
1707
|
|
|
1432
1708
|
/**
|
|
1433
1709
|
* Get version string from a tool path using async exec with 3s timeout.
|
|
1434
|
-
|
|
1435
|
-
|
|
1710
|
+
*
|
|
1711
|
+
* #469: a path resolved inside Git Bash is a POSIX path (`/c/...`), which
|
|
1712
|
+
* cmd.exe cannot execute — so the version probe stays in the shell that
|
|
1713
|
+
* resolved the path instead of failing with a misleading ENOENT.
|
|
1714
|
+
*/
|
|
1715
|
+
async function getVersionAsync(execPath, isWindows, options = {}) {
|
|
1716
|
+
const bashPath = options.bashPath || null;
|
|
1717
|
+
const env = options.env || undefined;
|
|
1718
|
+
// POSIX path test: a Git Bash-resolved path starts with '/', a Windows path
|
|
1719
|
+
// never does (drive letter or UNC). Enumerating prefixes (usr/, opt/, /c/)
|
|
1720
|
+
// was tried and leaks: /bin, /sbin, and every mount point outside the list
|
|
1721
|
+
// fall through to cmd.exe, which cannot run them (walkthrough FC-05).
|
|
1722
|
+
if (isWindows && bashPath && execPath.startsWith('/')) {
|
|
1723
|
+
const result = await execWithTimeout(
|
|
1724
|
+
`"${bashPath}" -c "${execPath} --version 2>/dev/null || ${execPath} -v 2>/dev/null"`,
|
|
1725
|
+
{ stdio: ['ignore', 'pipe', 'pipe'], env }
|
|
1726
|
+
);
|
|
1727
|
+
return result && result.stdout ? result.stdout.split('\n')[0] : undefined;
|
|
1728
|
+
}
|
|
1436
1729
|
const fallbackCmd = isWindows
|
|
1437
1730
|
? `"${execPath}" --version`
|
|
1438
1731
|
: `"${execPath}" --version 2>/dev/null || "${execPath}" -v 2>/dev/null`;
|
|
1439
|
-
const result = await execWithTimeout(fallbackCmd);
|
|
1732
|
+
const result = await execWithTimeout(fallbackCmd, { env });
|
|
1440
1733
|
return result && result.stdout ? result.stdout.split('\n')[0] : undefined;
|
|
1441
1734
|
}
|
|
1442
1735
|
|
|
@@ -2064,7 +2357,7 @@ function diagnoseLanguageTools() {
|
|
|
2064
2357
|
// #488: an unrecognised flag used to be silently ignored — the npm release
|
|
2065
2358
|
// that predates --sync-hooks made "doctor --sync-hooks" a silent no-op and the
|
|
2066
2359
|
// user believed a sync had happened. Fail loudly instead.
|
|
2067
|
-
const KNOWN_FLAGS = ['--fix', '--sync-hooks', '--force', '--install-tools', '--json', '--format'];
|
|
2360
|
+
const KNOWN_FLAGS = ['--fix', '--sync-hooks', '--force', '--install-tools', '--json', '--format', '--refresh-env'];
|
|
2068
2361
|
|
|
2069
2362
|
/**
|
|
2070
2363
|
* The accepted flag surface of `doctor`, as a pure function.
|
|
@@ -2097,6 +2390,10 @@ function doctorFlagContract(args) {
|
|
|
2097
2390
|
// Installing a toolchain changes the machine, not the repository, so it is
|
|
2098
2391
|
// never a side effect of --fix (#502). --fix reports the missing tools.
|
|
2099
2392
|
const installToolsFlag = args.includes('--install-tools');
|
|
2393
|
+
// #469: re-probe CLI tools against the registry PATH (what a freshly opened
|
|
2394
|
+
// terminal would see) instead of this process's possibly-stale snapshot.
|
|
2395
|
+
// Read-only: it changes the probe environment, never the machine.
|
|
2396
|
+
const refreshEnv = args.includes('--refresh-env');
|
|
2100
2397
|
const jsonMode = args.includes('--json') || (args.includes('--format') && formatValue === 'json');
|
|
2101
2398
|
if (args.includes('--format') && !['json', 'text'].includes(formatValue)) {
|
|
2102
2399
|
errors.push(`Unknown --format value: ${formatValue || '(none)'} — expected json or text`);
|
|
@@ -2118,11 +2415,11 @@ function doctorFlagContract(args) {
|
|
|
2118
2415
|
errors.push('--install-tools is only read by --fix, and it is the only flag that installs toolchains onto this machine');
|
|
2119
2416
|
errors.push('Pass --fix --install-tools, or drop --install-tools');
|
|
2120
2417
|
}
|
|
2121
|
-
return { errors, fixMode, syncHooks, force, installToolsFlag, jsonMode };
|
|
2418
|
+
return { errors, fixMode, syncHooks, force, installToolsFlag, jsonMode, refreshEnv };
|
|
2122
2419
|
}
|
|
2123
2420
|
|
|
2124
2421
|
async function doctor(args) {
|
|
2125
|
-
const { errors, fixMode, syncHooks, force, installToolsFlag, jsonMode } = doctorFlagContract(args);
|
|
2422
|
+
const { errors, fixMode, syncHooks, force, installToolsFlag, jsonMode, refreshEnv } = doctorFlagContract(args);
|
|
2126
2423
|
if (errors.length > 0) {
|
|
2127
2424
|
for (const error of errors) console.error(error);
|
|
2128
2425
|
return 1;
|
|
@@ -2206,7 +2503,7 @@ async function doctor(args) {
|
|
|
2206
2503
|
|
|
2207
2504
|
// Global timeout wrapper — ensures doctor completes within GLOBAL_DIAGNOSIS_TIMEOUT_MS
|
|
2208
2505
|
// even if network/subprocess checks stack up (fix #348: 43s on Windows).
|
|
2209
|
-
const diagnosisPromise = diagnoseAsync();
|
|
2506
|
+
const diagnosisPromise = diagnoseAsync(refreshEnv);
|
|
2210
2507
|
let diagnosisTimer = null;
|
|
2211
2508
|
const timeoutPromise = new Promise((resolve) => {
|
|
2212
2509
|
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);
|
|
@@ -2375,4 +2672,13 @@ module.exports = {
|
|
|
2375
2672
|
checkSpawnCapability,
|
|
2376
2673
|
execWithTimeout,
|
|
2377
2674
|
EBUSY_ATTRIBUTION,
|
|
2675
|
+
probeToolInHookEnv,
|
|
2676
|
+
detectCliToolAsync,
|
|
2677
|
+
splitPathEntries,
|
|
2678
|
+
normalizeDirEntry,
|
|
2679
|
+
findStalePathEntries,
|
|
2680
|
+
buildRefreshedPath,
|
|
2681
|
+
parseRegQueryPath,
|
|
2682
|
+
readRegistryPath,
|
|
2683
|
+
diagnosePathStaleness,
|
|
2378
2684
|
};
|
package/package.json
CHANGED
|
@@ -68,8 +68,20 @@ describe("buildCommand", () => {
|
|
|
68
68
|
)
|
|
69
69
|
})
|
|
70
70
|
|
|
71
|
-
|
|
72
|
-
|
|
71
|
+
// #447: the default path uses archlint's built-in rules — no --config.
|
|
72
|
+
// Forcing the old default (`--config architecture.yaml`) hit the pre-0.16
|
|
73
|
+
// schema that archlint itself rejects, so the flag is gone from the default.
|
|
74
|
+
it("builds gate-arch without a config by default", () => {
|
|
75
|
+
const cmd = buildCommand({ subcommand: "arch" })
|
|
76
|
+
expect(cmd).toContain("xp-gate arch")
|
|
77
|
+
expect(cmd).not.toContain("--config")
|
|
78
|
+
expect(cmd).not.toContain("--use-config")
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it("forwards an explicit config via the opt-in --use-config flag", () => {
|
|
82
|
+
expect(buildCommand({ subcommand: "arch", config: "custom.yaml" })).toContain(
|
|
83
|
+
"xp-gate arch --use-config 'custom.yaml'",
|
|
84
|
+
)
|
|
73
85
|
})
|
|
74
86
|
|
|
75
87
|
/**
|
|
@@ -62,7 +62,14 @@ function buildInner(options: BuildCommandOptions, dialect: ShellDialect): string
|
|
|
62
62
|
if (target !== undefined) tokens.push(q(target))
|
|
63
63
|
return tokens.join(" ")
|
|
64
64
|
}
|
|
65
|
-
|
|
65
|
+
// subcommand === "arch" — the only remaining member of the union.
|
|
66
|
+
// #447: the default path runs the pinned local @archlinter/cli with its
|
|
67
|
+
// built-in rules — no config needed. Forcing `--config architecture.yaml`
|
|
68
|
+
// (the old default) hit the pre-0.16 schema that archlint itself rejects.
|
|
69
|
+
// Only forward an explicitly provided config, via the opt-in --use-config.
|
|
70
|
+
const tokens = ["xp-gate", "arch"]
|
|
71
|
+
if (config !== undefined) tokens.push("--use-config", q(config))
|
|
72
|
+
return tokens.join(" ")
|
|
66
73
|
}
|
|
67
74
|
|
|
68
75
|
/**
|
|
@@ -8,34 +8,50 @@ interface CapturedTool {
|
|
|
8
8
|
execute: (args: unknown, exec: unknown) => Promise<unknown>
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
11
|
+
// Boy Scout (#447): the inline shell mock pushed makeCtx to 5 brace levels.
|
|
12
|
+
// The spec/result builders keep every function at ≤4.
|
|
13
|
+
function makeSpec(req: ShellExecRequest): ShellExecSpec {
|
|
14
|
+
return {
|
|
15
|
+
command: req.command,
|
|
16
|
+
workdir: req.workdir ?? "/workspace",
|
|
17
|
+
timeoutMs: req.timeoutMs ?? 1000,
|
|
18
|
+
stdoutMaxBytes: 1_000_000,
|
|
19
|
+
signal: req.signal,
|
|
20
|
+
sandboxPolicy: undefined,
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function makeRunResult(stdoutText: string): ShellRunResult {
|
|
25
|
+
return {
|
|
26
|
+
exitCode: 0,
|
|
27
|
+
signal: null,
|
|
28
|
+
timedOut: false,
|
|
29
|
+
aborted: false,
|
|
30
|
+
timeoutMs: 1000,
|
|
31
|
+
stdout: { text: stdoutText, truncated: false },
|
|
32
|
+
stderr: { text: "", truncated: false },
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function makeShell(stdoutText: string, requests: ShellExecRequest[]) {
|
|
37
|
+
return {
|
|
15
38
|
resolve(req: ShellExecRequest): ShellExecSpec {
|
|
16
39
|
requests.push(req)
|
|
17
|
-
return
|
|
18
|
-
command: req.command,
|
|
19
|
-
workdir: req.workdir ?? "/workspace",
|
|
20
|
-
timeoutMs: req.timeoutMs ?? 1000,
|
|
21
|
-
stdoutMaxBytes: 1_000_000,
|
|
22
|
-
signal: req.signal,
|
|
23
|
-
sandboxPolicy: undefined,
|
|
24
|
-
}
|
|
40
|
+
return makeSpec(req)
|
|
25
41
|
},
|
|
26
42
|
run(): Promise<ShellRunResult> {
|
|
27
|
-
return Promise.resolve(
|
|
28
|
-
exitCode: 0,
|
|
29
|
-
signal: null,
|
|
30
|
-
timedOut: false,
|
|
31
|
-
aborted: false,
|
|
32
|
-
timeoutMs: 1000,
|
|
33
|
-
stdout: { text: stdoutText, truncated: false },
|
|
34
|
-
stderr: { text: "", truncated: false },
|
|
35
|
-
})
|
|
43
|
+
return Promise.resolve(makeRunResult(stdoutText))
|
|
36
44
|
},
|
|
37
45
|
}
|
|
38
|
-
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function makeCtx(stdoutText: string) {
|
|
49
|
+
const captured: unknown[] = []
|
|
50
|
+
const requests: ShellExecRequest[] = []
|
|
51
|
+
const ctx = {
|
|
52
|
+
tools: { register: (def: unknown) => captured.push(def) },
|
|
53
|
+
shell: makeShell(stdoutText, requests),
|
|
54
|
+
} as unknown as Context
|
|
39
55
|
return { ctx, captured, requests }
|
|
40
56
|
}
|
|
41
57
|
|
|
@@ -82,16 +98,17 @@ describe("apply", () => {
|
|
|
82
98
|
|
|
83
99
|
/**
|
|
84
100
|
* @test REQ-DSH-004
|
|
85
|
-
* @intent 验证 gate-arch 工具执行生成 `xp-gate arch --config
|
|
101
|
+
* @intent 验证 gate-arch 工具执行生成 `xp-gate arch`(默认不带 --config,#447),显式 config 走 --use-config
|
|
86
102
|
* @covers AC-DSH-004-01
|
|
87
103
|
*/
|
|
88
|
-
it("gate-arch execute builds `xp-gate arch` with
|
|
104
|
+
it("gate-arch execute builds `xp-gate arch` with built-in rules by default", async () => {
|
|
89
105
|
const { ctx, captured, requests } = makeCtx("arch report")
|
|
90
106
|
apply(ctx)
|
|
91
107
|
const tool = captured.find((c) => (c as CapturedTool).name === "gate-arch") as CapturedTool
|
|
92
108
|
await tool.execute({}, { signal: new AbortController().signal })
|
|
93
|
-
expect(requests[0]?.command).toContain("xp-gate arch
|
|
109
|
+
expect(requests[0]?.command).toContain("xp-gate arch")
|
|
110
|
+
expect(requests[0]?.command).not.toContain("--config")
|
|
94
111
|
await tool.execute({ config: "custom.yaml" }, { signal: new AbortController().signal })
|
|
95
|
-
expect(requests[1]?.command).toContain("xp-gate arch --config 'custom.yaml'")
|
|
112
|
+
expect(requests[1]?.command).toContain("xp-gate arch --use-config 'custom.yaml'")
|
|
96
113
|
})
|
|
97
114
|
})
|