fix(build): merge-time fixes for #500

- pre-push hook: skip with a notice when npm is not on PATH (GUI git
  clients and IDEs often run hooks with a minimal PATH), instead of
  blocking every push on "npm: not found"; real-push test with a
  stripped PATH
- test/git-hooks.test.ts: pin GIT_CONFIG_NOSYSTEM=1 and
  GIT_CONFIG_GLOBAL=/dev/null around the resolveGitHooksDir tests, so
  an exported global or a system core.hooksPath no longer fails them
- watch tsconfig.json, .prettierignore and .editorconfig too:
  typecheck and format:check read them
- check:browser-excludes: fail loudly when the vitest list output and
  the walked test/**/*.test.ts tree share no path (format drift would
  otherwise pass vacuously)
- Reword the PRE_PUSH_MARKER comment: bumping its version would make every
  installed v1 hook read as foreign and never refresh again.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-09-28 16:28:56 +02:00
parent a0fbd1d28d
commit dfd3df8289
4 changed files with 137 additions and 21 deletions
+39 -6
View File
@@ -63,17 +63,26 @@ function walk(dir) {
}
/**
* Every `*.test.ts` under `<root>/test` that imports a browser driver, as sorted
* repo-relative POSIX paths (the form `vitest list` prints).
* Every `*.test.ts` under `<root>/test`, as sorted repo-relative POSIX paths (the form
* `vitest list` prints).
*
* @param {string} root
* @returns {string[]}
*/
export function findTestFiles(root) {
return walk(join(root, 'test'))
.map((file) => relative(root, file).split(sep).join('/'))
.sort();
}
/**
* The subset of {@link findTestFiles} that imports a browser driver.
*
* @param {string} root
* @returns {string[]}
*/
export function findBrowserTests(root) {
return walk(join(root, 'test'))
.filter((file) => importsBrowserDriver(readFileSync(file, 'utf8')))
.map((file) => relative(root, file).split(sep).join('/'))
.sort();
return findTestFiles(root).filter((file) => importsBrowserDriver(readFileSync(join(root, file), 'utf8')));
}
/**
@@ -93,6 +102,19 @@ export function parseVitestFileList(output) {
);
}
/**
* Whether the `vitest list` paths and the walked tree name at least one file in common.
* False means the two sides are not speaking the same path format (absolute paths, backslashes
* or a new prefix after a vitest upgrade), and then {@link findLeaks} would find nothing
* against a perfectly non-empty listing.
*
* @param {Set<string>} ciFiles
* @param {string[]} testFiles
*/
export function listingMatchesTree(ciFiles, testFiles) {
return testFiles.some((file) => ciFiles.has(file));
}
/**
* @param {string[]} browserTests
* @param {Set<string>} ciFiles
@@ -103,6 +125,7 @@ export function findLeaks(browserTests, ciFiles) {
}
function main() {
const testFiles = findTestFiles(ROOT);
const browserTests = findBrowserTests(ROOT);
let collected;
@@ -124,6 +147,16 @@ function main() {
console.error('✗ `vitest list` reported no test files; refusing to pass on an empty CI set.');
process.exit(1);
}
// Same vacuous pass, one step removed: a listing whose paths never match the tree. This guard,
// not `vitest list --json`, is the answer to format drift: the JSON form prints absolute paths
// that would need canonicalizing against ROOT (symlinked checkouts), and its shape can drift too.
if (!listingMatchesTree(ciFiles, testFiles)) {
const sample = [...ciFiles].slice(0, 3).join(', ');
console.error(
`✗ none of the ${ciFiles.size} paths \`vitest list\` reported (e.g. ${sample}) is one of the ${testFiles.length} test/**/*.test.ts files; its output format has probably changed.`
);
process.exit(1);
}
const leaked = findLeaks(browserTests, ciFiles);
+16 -3
View File
@@ -28,7 +28,11 @@ import { execFileSync } from 'node:child_process';
import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
import { basename, dirname, join, resolve } from 'node:path';
/** Ownership marker. Bump the version suffix when the body changes meaningfully. */
/**
* Ownership marker. ⚠️ Never bump the version suffix: ownership is matched on this exact
* string, so a `v2` would read every installed `v1` hook as foreign and never refresh it.
* A changed body still reaches installed hooks, because the refresh compares the whole file.
*/
export const PRE_PUSH_MARKER = '# codeman-managed-hook: pre-push v1';
/**
@@ -52,8 +56,10 @@ export const PRE_PUSH_CHECKS = [
* check:frontend-syntax), config/ (eslint + vitest configs, test-suites.ts, the CLI
* catalogue), scripts/ (every check is a script there, and typecheck's second pass compiles
* one), test/ (check:browser-excludes scans it and runs `vitest list` over it),
* package.json + package-lock.json (check:lockfile) and install.sh (generate:cli-catalog
* --check diffs its generated block).
* package.json + package-lock.json (check:lockfile), install.sh (generate:cli-catalog
* --check diffs its generated block), tsconfig.json (typecheck, and
* config/tsconfig.scripts.json extends it) and .prettierignore + .editorconfig
* (format:check; the Prettier CLI honours .editorconfig by default).
*/
export const PRE_PUSH_WATCHED_PATHS = [
'src',
@@ -63,6 +69,9 @@ export const PRE_PUSH_WATCHED_PATHS = [
'package.json',
'package-lock.json',
'install.sh',
'tsconfig.json',
'.prettierignore',
'.editorconfig',
];
/**
@@ -95,6 +104,10 @@ if [ ! -d node_modules ]; then
exit 0
fi
# GUI git clients and IDEs often run hooks with a minimal PATH that lacks an nvm or
# Homebrew Node. Every check would then fail with "npm: not found", so skip instead.
command -v npm >/dev/null 2>&1 || { echo "pre-push: npm not on PATH, skipping checks."; exit 0; }
# git feeds us "<localref> <localsha> <remoteref> <remotesha>" per ref. A deletion has an
# all-zero local sha and no tree worth checking; if every ref is a deletion, skip.
# The checks below read the working tree, so they only say something about a pushed commit