feat(#1754): CLI version-skew detection — warn when a global install shadows project-local GSD (#1755)

* feat(#1754): CLI version-skew detection — warn when a global install shadows project-local GSD

Addresses #1754 (approved-enhancement). Detects when the running gsd-tools.cjs
is outside the project root while a project-local install exists — the shadowing
scenario from #1748 where a stale global canary CLI (retired @gsd-build/sdk)
silently overrides project-local GSD.

Implementation (Node CLI entry-point, not shell snippet — avoids bloating 93
workflow files past their size caps):

- src/cli-skew-check.cts: pure function checkCliSkew({resolvedPath, projectRoot,
  projectLocalExists}) → string|null. Compares paths via path.relative; returns
  a warning when the resolved CLI is outside the project root AND a project-local
  install exists. Includes @gsd-build/sdk removal hint when the path matches.
  No I/O (pure), no gsd-sdk literal (avoids bug-2801 lint).
- gsd-core/bin/gsd-tools.cjs: wired at startup via the existing findProjectRoot
  resolver. Non-blocking (try/catch; advisory stderr warning, never gates).
- eslint.config.mjs: registers the new ADR-457 generated artifact in the ignores.
- tests: 6-case suite (skew/no-skew/legacy/normalization); all green.
- Golden fixtures regenerated (UPDATE_GOLDEN=1) for the new compiled artifact.
- docs/how-to/update-gsd.md: Diátaxis reference note for the skew warning.

Full suite: 3354 pass, 0 regressions (1 pre-existing local AGENTS.md failure).
lint:ci green.

Closes #1754

* chore(#1754): backfill changeset pr placeholder

* chore(#1754): regenerate INVENTORY-MANIFEST for the new cli-skew-check source module

---------

Co-authored-by: review-bot <review-bot@gsd>
This commit is contained in:
Tom Boucher
2026-06-26 12:19:39 -04:00
committed by GitHub
parent ae4c198a13
commit e075a41c86
23 changed files with 184 additions and 16 deletions

View File

@@ -0,0 +1,5 @@
---
type: Added
pr: 1755
---
**GSD now warns when a stale global CLI (e.g. a retired @gsd-build/sdk canary) shadows your project-local install** — the gsd-tools CLI startup detects when the running binary is outside the project root while a project-local install exists, and prints a remediation warning to stderr (non-blocking). (#1754)

View File

@@ -299,6 +299,7 @@
"check-command-router.cjs",
"cjs-command-router-adapter.cjs",
"cli-exit.cjs",
"cli-skew-check.cjs",
"clock.cjs",
"clusters.cjs",
"code-review-flags.cjs",

View File

@@ -138,3 +138,13 @@ Each GSD release may include installer migrations that rename, move, or retire m
- [Manual update](../manual-update.md)
- [Installer migrations](../installer-migrations.md)
- [Docs index](../README.md)
## CLI version-skew warning
GSD warns (to stderr, non-blocking) when the resolved `gsd-tools.cjs` is **outside your project root** while a project-local install exists — a sign that a global install (often a retired `@gsd-build/sdk` canary) is shadowing your project-local GSD. The warning names the resolved path and, for the `@gsd-build/sdk` case, gives the removal command:
```bash
npm uninstall -g @gsd-build/sdk
```
If you see this warning, remove the stale global package so `gsd_run` resolves the project-local install.

View File

@@ -192,6 +192,8 @@ export default tseslint.config(
'gsd-core/bin/lib/git-base-branch.cjs',
// ADR-1213: tsc-generated runtime artifact — lint the src/capability-writer.cts source.
'gsd-core/bin/lib/capability-writer.cjs',
// issue #1754: tsc-generated runtime artifact — lint the src/cli-skew-check.cts source.
'gsd-core/bin/lib/cli-skew-check.cjs',
// issue #1355: tsc-generated runtime artifact — lint the src/teams-status.cts source.
'gsd-core/bin/lib/teams-status.cjs',
// ADR-1372: tsc-generated runtime artifact — lint the src/markdown-sectionizer.cts source.

View File

@@ -204,6 +204,24 @@ const projectRoot = require('./lib/project-root.cjs');
// against any require/load-ordering edge where the export isn't bound yet
// when this entrypoint is first required (#604).
const findProjectRoot = (...args) => projectRoot.findProjectRoot(...args);
// #1754: CLI skew detection — warn (stderr, non-blocking) if this gsd-tools.cjs
// is NOT the project-local install while a project-local install exists. Catches
// the shadowing scenario from #1748 (stale global canary shadowing project-local).
try {
const _skew = require('./lib/cli-skew-check.cjs');
const _skewRoot = findProjectRoot(process.cwd());
if (_skewRoot) {
const _skewLocal = path.join(_skewRoot, '.claude', 'gsd-core', 'bin', 'gsd-tools.cjs');
const _skewWarn = _skew.checkCliSkew({
resolvedPath: path.resolve(__filename),
projectRoot: _skewRoot,
projectLocalExists: fs.existsSync(_skewLocal),
});
if (_skewWarn) process.stderr.write(_skewWarn + '\n');
}
} catch { /* advisory — never block */ }
const { getActiveWorkstream } = require('./lib/planning-workspace.cjs');
const { resolveActiveWorkstream, applyResolvedWorkstreamEnv } = require('./lib/active-workstream-store.cjs');
const state = require('./lib/state.cjs');

47
src/cli-skew-check.cts Normal file
View File

@@ -0,0 +1,47 @@
'use strict';
/**
* cli-skew-check.cts — CLI version-skew detection (#1754).
*
* Pure function: compares the resolved gsd-tools.cjs path to the project root.
* If the resolved CLI is OUTSIDE the project root while a project-local install
* EXISTS, returns a warning string (the caller writes it to stderr). Non-blocking.
*
* Catches the shadowing scenario from #1748: a stale global canary CLI (e.g.
* from the retired @gsd-build/sdk) shadowing the project-local GSD install.
*
* The function is PURE (no I/O) — the caller provides the resolved path, the
* project root, and whether a project-local install exists. This makes it
* trivially testable without filesystem setup.
*/
import path from 'node:path';
/**
* Check for CLI version skew.
*
* @param opts.resolvedPath - The absolute path of the running gsd-tools.cjs (__filename).
* @param opts.projectRoot - The project root (from findProjectRoot), or null if no project.
* @param opts.projectLocalExists - Whether a project-local gsd-tools.cjs exists.
* @returns A warning string if skew is detected, or null if no skew.
*/
export function checkCliSkew(opts: {
resolvedPath: string;
projectRoot: string | null;
projectLocalExists: boolean;
}): string | null {
const { resolvedPath, projectRoot, projectLocalExists } = opts;
// No project context or no project-local install → no skew possible.
if (!projectRoot || !projectLocalExists) return null;
// If the resolved CLI is under the project root, it IS a project-local install.
const rel = path.relative(projectRoot, resolvedPath);
if (!rel.startsWith('..')) return null;
// Resolved CLI is outside project root while a project-local install exists → SKEW.
const hint = resolvedPath.includes('@gsd-build')
? ' If @gsd-build/sdk: npm uninstall -g @gsd-build/sdk'
: '';
return `⚠ GSD: ${resolvedPath} may shadow project-local GSD.${hint}`;
}

View File

@@ -0,0 +1,85 @@
'use strict';
/**
* feat-1754-cli-skew-detection.test.cjs
*
* Tests for the CLI version-skew detection module (src/cli-skew-check.cts).
*
* The check warns (returns a string) when the running gsd-tools.cjs is NOT the
* project-local install while a project-local install EXISTS — the shadowing
* scenario from #1748 (a stale global canary from @gsd-build/sdk shadowing
* project-local 1.6.0).
*
* DEFECT class: environment / version skew (enhancement #1754)
*
* The function is PURE (no I/O — the caller provides paths + existence flags),
* making it trivially testable without filesystem setup.
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const { checkCliSkew } = require('../gsd-core/bin/lib/cli-skew-check.cjs');
describe('#1754: checkCliSkew — pure path-comparison skew detection', () => {
test('SKEW: resolved CLI outside project root + project-local exists → returns warning', () => {
const warning = checkCliSkew({
resolvedPath: '/opt/homebrew/bin/gsd-tools',
projectRoot: '/home/user/my-project',
projectLocalExists: true,
});
assert.ok(warning, 'Expected a warning string when resolved CLI is outside project root and project-local exists');
assert.ok(warning.includes('shadow') || warning.includes('outside') || warning.includes('may'),
`Warning should mention the shadowing/outside nature, got: "${warning}"`);
});
test('NO-SKEW: resolved CLI is the project-local install → returns null', () => {
const warning = checkCliSkew({
resolvedPath: '/home/user/my-project/.claude/gsd-core/bin/gsd-tools.cjs',
projectRoot: '/home/user/my-project',
projectLocalExists: true,
});
assert.strictEqual(warning, null, 'No warning expected when resolved CLI IS the project-local install');
});
test('NO-SKEW: resolved CLI outside project root but NO project-local install → returns null', () => {
const warning = checkCliSkew({
resolvedPath: '/usr/local/bin/gsd-tools',
projectRoot: '/home/user/my-project',
projectLocalExists: false,
});
assert.strictEqual(warning, null, 'No warning expected when no project-local install exists (legitimate global-only)');
});
test('NO-SKEW: projectRoot is null (no project context) → returns null', () => {
const warning = checkCliSkew({
resolvedPath: '/usr/local/bin/gsd-tools',
projectRoot: null,
projectLocalExists: false,
});
assert.strictEqual(warning, null, 'No warning expected when there is no project root');
});
test('LEGACY-SDK: resolved path contains @gsd-build → warning includes removal instructions', () => {
const warning = checkCliSkew({
resolvedPath: '/opt/homebrew/lib/node_modules/@gsd-build/sdk/bin/gsd-tools',
projectRoot: '/home/user/my-project',
projectLocalExists: true,
});
assert.ok(warning, 'Expected a warning for @gsd-build/sdk paths');
assert.ok(warning.includes('@gsd-build/sdk') || warning.includes('npm uninstall'),
`Warning should include @gsd-build/sdk removal instructions, got: "${warning}"`);
});
test('PATH-NORMALIZATION: resolved under project root via realpath → no false positive', () => {
// Even if the resolved path differs in symlink resolution, if it's under the
// project root, it's not a skew. The caller normalizes paths before calling.
const warning = checkCliSkew({
resolvedPath: path.resolve('/home/user/my-project/.claude/gsd-core/bin/gsd-tools.cjs'),
projectRoot: path.resolve('/home/user/my-project'),
projectLocalExists: true,
});
assert.strictEqual(warning, null, 'No warning when resolved path is under project root (even with realpath normalization)');
});
});

View File

@@ -38,7 +38,7 @@
"gsd-core/CHANGELOG.md": "e141e3fb369ff712",
"gsd-core/VERSION": "562368b20a64be95",
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
"gsd-core/bin/gsd-tools.cjs": "08cbf1f404d64b93",
"gsd-core/bin/gsd-tools.cjs": "46deb2174be356dd",
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
"gsd-core/bin/shared/config-schema.manifest.json": "67e4addbfd248a7c",

View File

@@ -107,7 +107,7 @@
"gsd-core/CHANGELOG.md": "e141e3fb369ff712",
"gsd-core/VERSION": "562368b20a64be95",
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
"gsd-core/bin/gsd-tools.cjs": "1f2ff4e80aa45439",
"gsd-core/bin/gsd-tools.cjs": "b7968e3e3af00249",
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
"gsd-core/bin/shared/config-schema.manifest.json": "67e4addbfd248a7c",

View File

@@ -37,7 +37,7 @@
"gsd-core/CHANGELOG.md": "e141e3fb369ff712",
"gsd-core/VERSION": "562368b20a64be95",
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
"gsd-core/bin/gsd-tools.cjs": "1f2ff4e80aa45439",
"gsd-core/bin/gsd-tools.cjs": "b7968e3e3af00249",
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
"gsd-core/bin/shared/config-schema.manifest.json": "67e4addbfd248a7c",

View File

@@ -41,7 +41,7 @@
"gsd-core/CHANGELOG.md": "e141e3fb369ff712",
"gsd-core/VERSION": "562368b20a64be95",
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
"gsd-core/bin/gsd-tools.cjs": "1f2ff4e80aa45439",
"gsd-core/bin/gsd-tools.cjs": "b7968e3e3af00249",
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
"gsd-core/bin/shared/config-schema.manifest.json": "67e4addbfd248a7c",

View File

@@ -107,7 +107,7 @@
"gsd-core/CHANGELOG.md": "e141e3fb369ff712",
"gsd-core/VERSION": "562368b20a64be95",
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
"gsd-core/bin/gsd-tools.cjs": "1f2ff4e80aa45439",
"gsd-core/bin/gsd-tools.cjs": "b7968e3e3af00249",
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
"gsd-core/bin/shared/config-schema.manifest.json": "67e4addbfd248a7c",

View File

@@ -73,7 +73,7 @@
"gsd-core/CHANGELOG.md": "e141e3fb369ff712",
"gsd-core/VERSION": "562368b20a64be95",
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
"gsd-core/bin/gsd-tools.cjs": "1f2ff4e80aa45439",
"gsd-core/bin/gsd-tools.cjs": "b7968e3e3af00249",
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
"gsd-core/bin/shared/config-schema.manifest.json": "67e4addbfd248a7c",

View File

@@ -39,7 +39,7 @@
"gsd-core/CHANGELOG.md": "e141e3fb369ff712",
"gsd-core/VERSION": "562368b20a64be95",
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
"gsd-core/bin/gsd-tools.cjs": "08cbf1f404d64b93",
"gsd-core/bin/gsd-tools.cjs": "46deb2174be356dd",
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
"gsd-core/bin/shared/config-schema.manifest.json": "67e4addbfd248a7c",

View File

@@ -107,7 +107,7 @@
"gsd-core/CHANGELOG.md": "e141e3fb369ff712",
"gsd-core/VERSION": "562368b20a64be95",
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
"gsd-core/bin/gsd-tools.cjs": "08cbf1f404d64b93",
"gsd-core/bin/gsd-tools.cjs": "46deb2174be356dd",
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
"gsd-core/bin/shared/config-schema.manifest.json": "67e4addbfd248a7c",

View File

@@ -107,7 +107,7 @@
"gsd-core/CHANGELOG.md": "e141e3fb369ff712",
"gsd-core/VERSION": "562368b20a64be95",
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
"gsd-core/bin/gsd-tools.cjs": "1f2ff4e80aa45439",
"gsd-core/bin/gsd-tools.cjs": "b7968e3e3af00249",
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
"gsd-core/bin/shared/config-schema.manifest.json": "67e4addbfd248a7c",

View File

@@ -38,7 +38,7 @@
"gsd-core/CHANGELOG.md": "e141e3fb369ff712",
"gsd-core/VERSION": "562368b20a64be95",
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
"gsd-core/bin/gsd-tools.cjs": "1f2ff4e80aa45439",
"gsd-core/bin/gsd-tools.cjs": "b7968e3e3af00249",
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
"gsd-core/bin/shared/config-schema.manifest.json": "67e4addbfd248a7c",

View File

@@ -107,7 +107,7 @@
"gsd-core/CHANGELOG.md": "e141e3fb369ff712",
"gsd-core/VERSION": "562368b20a64be95",
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
"gsd-core/bin/gsd-tools.cjs": "1f2ff4e80aa45439",
"gsd-core/bin/gsd-tools.cjs": "b7968e3e3af00249",
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
"gsd-core/bin/shared/config-schema.manifest.json": "67e4addbfd248a7c",

View File

@@ -74,7 +74,7 @@
"gsd-core/CHANGELOG.md": "e141e3fb369ff712",
"gsd-core/VERSION": "562368b20a64be95",
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
"gsd-core/bin/gsd-tools.cjs": "1f2ff4e80aa45439",
"gsd-core/bin/gsd-tools.cjs": "b7968e3e3af00249",
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
"gsd-core/bin/shared/config-schema.manifest.json": "67e4addbfd248a7c",

View File

@@ -107,7 +107,7 @@
"gsd-core/CHANGELOG.md": "e141e3fb369ff712",
"gsd-core/VERSION": "562368b20a64be95",
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
"gsd-core/bin/gsd-tools.cjs": "1f2ff4e80aa45439",
"gsd-core/bin/gsd-tools.cjs": "b7968e3e3af00249",
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
"gsd-core/bin/shared/config-schema.manifest.json": "67e4addbfd248a7c",

View File

@@ -38,7 +38,7 @@
"gsd-core/CHANGELOG.md": "e141e3fb369ff712",
"gsd-core/VERSION": "562368b20a64be95",
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
"gsd-core/bin/gsd-tools.cjs": "1f2ff4e80aa45439",
"gsd-core/bin/gsd-tools.cjs": "b7968e3e3af00249",
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
"gsd-core/bin/shared/config-schema.manifest.json": "67e4addbfd248a7c",

View File

@@ -38,7 +38,7 @@
"gsd-core/CHANGELOG.md": "e141e3fb369ff712",
"gsd-core/VERSION": "562368b20a64be95",
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
"gsd-core/bin/gsd-tools.cjs": "08cbf1f404d64b93",
"gsd-core/bin/gsd-tools.cjs": "46deb2174be356dd",
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
"gsd-core/bin/shared/config-schema.manifest.json": "67e4addbfd248a7c",

View File

@@ -38,7 +38,7 @@
"gsd-core/CHANGELOG.md": "e141e3fb369ff712",
"gsd-core/VERSION": "562368b20a64be95",
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
"gsd-core/bin/gsd-tools.cjs": "08cbf1f404d64b93",
"gsd-core/bin/gsd-tools.cjs": "46deb2174be356dd",
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
"gsd-core/bin/shared/config-schema.manifest.json": "67e4addbfd248a7c",