etymd 0.19.0 → 0.19.2

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.
Files changed (28) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/{approve-Y36ZO6IL.js → approve-Z4NMEGDM.js} +3 -3
  3. package/dist/audit-4PGVS2TW.js +11 -0
  4. package/dist/{brief-ILB6RMSQ.js → brief-2N6X3CFR.js} +3 -3
  5. package/dist/{chunk-U7BCBJIH.js → chunk-6FT7KSKU.js} +1 -1
  6. package/dist/{chunk-EYUW3UJU.js → chunk-DWPMANIC.js} +1 -1
  7. package/dist/{chunk-KIPD3N77.js → chunk-FG5XKFR2.js} +3 -3
  8. package/dist/{chunk-DLHC7QON.js → chunk-OKHNNWOV.js} +32 -15
  9. package/dist/{chunk-EZRK4MV3.js → chunk-TZGRV7UY.js} +1 -1
  10. package/dist/{chunk-L4KIIKXI.js → chunk-W6GGG4LG.js} +1 -1
  11. package/dist/{chunk-ZCOIZVSM.js → chunk-WIBN7PBH.js} +4 -4
  12. package/dist/{chunk-4IESOWHQ.js → chunk-WYJFZ2MB.js} +2 -2
  13. package/dist/{chunk-HSRHOVII.js → chunk-X5GR2EJK.js} +179 -59
  14. package/dist/cli.js +16 -16
  15. package/dist/{doctor-EHRADIUZ.js → doctor-7YG342YP.js} +6 -6
  16. package/dist/{fleet-KFF3N2O7.js → fleet-XZVQLY53.js} +8 -8
  17. package/dist/{gates-UJ23THID.js → gates-633B6G2T.js} +5 -5
  18. package/dist/{generate-6IPTCLYG.js → generate-S5KGZIIM.js} +3 -3
  19. package/dist/index.d.ts +1 -1
  20. package/dist/index.js +210 -73
  21. package/dist/{init-7HWVHZMF.js → init-SL3HEB3H.js} +12 -5
  22. package/dist/{premise-U6JIB2QG.js → premise-62AXRQUK.js} +4 -4
  23. package/dist/{propose-NZMEPZIL.js → propose-4UWLIFWJ.js} +6 -6
  24. package/dist/scan-M7STYIYQ.js +5 -0
  25. package/dist/{scan-DLKYG5BB.js → scan-VEHQEEXY.js} +3 -3
  26. package/package.json +1 -1
  27. package/dist/audit-RONSEYFS.js +0 -11
  28. package/dist/scan-V53PGAQ6.js +0 -5
package/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # etymd
2
2
 
3
+ ## 0.19.2
4
+
5
+ ### Patch Changes
6
+
7
+ - b46bbb9: Generated pre-push hooks check only the shell scripts each pushed commit changes, instead of
8
+ every script in every pushed commit's tree. A commit that changes the gate itself, its
9
+ classifier or a `.shellcheckrc` (deletions included) is still checked whole. So is the commit
10
+ that installs the gate, which reads a repo's existing scripts once. A commit with no shell
11
+ script says so. Pointer checks no longer accept an `@AGENTS.md` import inside a fenced code
12
+ block, an unreadable Claude Code version counts as too old instead of passing, and `init` warns
13
+ when a `CLAUDE.md` or `.claude/CLAUDE.md` hides `AGENTS.md`. Regenerate existing hooks with
14
+ `etymd gates --yes` to apply the change.
15
+
16
+ ## 0.19.1
17
+
18
+ ### Patch Changes
19
+
20
+ - 51ecbc9: Generated pre-push hooks shellcheck every commit in the pushed range, each materialised from
21
+ git's object store, instead of reading the working tree. A push whose tip is clean but whose
22
+ earlier commits carry a broken script is now refused; the working tree no longer substitutes
23
+ for the pushed bytes; a push that creates a branch checks every commit no remote already has;
24
+ and a commit that cannot be materialised refuses the push rather than passing as unread.
25
+ Regenerate existing hooks with `etymd gates --yes` to apply the correction.
26
+
3
27
  ## 0.19.0
4
28
 
5
29
  ### Minor Changes
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import { section, theme, print, renderBaselineDrift, glyph } from './chunk-HI7NWPRA.js';
3
- import { scanProject } from './chunk-DLHC7QON.js';
4
- import { VERSION } from './chunk-L4KIIKXI.js';
3
+ import { scanProject } from './chunk-OKHNNWOV.js';
4
+ import { VERSION } from './chunk-W6GGG4LG.js';
5
5
  import { readBaseline, summarizeBaselineDrift, isDriftEmpty, writeBaseline, deriveProfile } from './chunk-P6ATKV2R.js';
6
- import { PACK_VERSION } from './chunk-U7BCBJIH.js';
6
+ import { PACK_VERSION } from './chunk-6FT7KSKU.js';
7
7
  import './chunk-4VPBP6K6.js';
8
8
 
9
9
  // src/commands/approve.ts
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+ export { run } from './chunk-WYJFZ2MB.js';
3
+ import './chunk-FG5XKFR2.js';
4
+ import './chunk-DWPMANIC.js';
5
+ import './chunk-3E2IPCRY.js';
6
+ import './chunk-HI7NWPRA.js';
7
+ import './chunk-OKHNNWOV.js';
8
+ import './chunk-W6GGG4LG.js';
9
+ import './chunk-P6ATKV2R.js';
10
+ import './chunk-6FT7KSKU.js';
11
+ import './chunk-4VPBP6K6.js';
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import { print, theme } from './chunk-HI7NWPRA.js';
3
- import { scanProject } from './chunk-DLHC7QON.js';
4
- import './chunk-L4KIIKXI.js';
3
+ import { scanProject } from './chunk-OKHNNWOV.js';
4
+ import './chunk-W6GGG4LG.js';
5
5
  import { ETYMD_DIR } from './chunk-P6ATKV2R.js';
6
- import './chunk-U7BCBJIH.js';
6
+ import './chunk-6FT7KSKU.js';
7
7
  import './chunk-4VPBP6K6.js';
8
8
  import { promises } from 'fs';
9
9
  import path from 'path';
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
2
  // src/pack/version.ts
3
- var PACK_VERSION = "14";
3
+ var PACK_VERSION = "16";
4
4
 
5
5
  export { PACK_VERSION };
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { expandFileGlobs } from './chunk-DLHC7QON.js';
2
+ import { expandFileGlobs } from './chunk-OKHNNWOV.js';
3
3
  import { DEFAULT_CONFIG, CONFIG_FILE } from './chunk-P6ATKV2R.js';
4
4
  import { readText, isDirectory, matchesAnyGlob, normalizeRelPath, readJson, pathExists, git } from './chunk-4VPBP6K6.js';
5
5
  import path3 from 'path';
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
- import { stateFreshnessLens, rankFindings, listInstructionFiles, buildTruthEnv, emptyCounters, packageManagerUsage, checkDocRefs, listStateDocuments, loadDecisionLedger, checkDecisionRefs, checkTextClaims } from './chunk-EYUW3UJU.js';
2
+ import { stateFreshnessLens, rankFindings, listInstructionFiles, buildTruthEnv, emptyCounters, packageManagerUsage, checkDocRefs, listStateDocuments, loadDecisionLedger, checkDecisionRefs, checkTextClaims } from './chunk-DWPMANIC.js';
3
3
  import { readLedger, reconcileLedger, writeLedger, visibleFindings } from './chunk-3E2IPCRY.js';
4
4
  import { measureContext, contextFileLabel } from './chunk-HI7NWPRA.js';
5
- import { scanProject } from './chunk-DLHC7QON.js';
5
+ import { scanProject } from './chunk-OKHNNWOV.js';
6
6
  import { DEFAULT_CONFIG, ETYMD_DIR, writeCachedFacts, readBaseline, readConfig, deriveProfile, baselineCarriesMachinePath, BASELINE_FILE, CONFIG_FILE } from './chunk-P6ATKV2R.js';
7
- import { PACK_VERSION } from './chunk-U7BCBJIH.js';
7
+ import { PACK_VERSION } from './chunk-6FT7KSKU.js';
8
8
  import { pathExists, readText, readJson, isCiEnvironment, isExecutable } from './chunk-4VPBP6K6.js';
9
9
  import path from 'path';
10
10
  import YAML from 'yaml';
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { VERSION } from './chunk-L4KIIKXI.js';
3
- import { PACK_VERSION } from './chunk-U7BCBJIH.js';
2
+ import { VERSION } from './chunk-W6GGG4LG.js';
3
+ import { PACK_VERSION } from './chunk-6FT7KSKU.js';
4
4
  import { normalizeRelPath, isDirectory, pathExists, readText, readJson, git, matchesAnyGlob } from './chunk-4VPBP6K6.js';
5
5
  import path from 'path';
6
6
  import { execFile } from 'child_process';
@@ -447,8 +447,23 @@ async function detectArtifacts(root) {
447
447
  });
448
448
  return artifacts;
449
449
  }
450
- var ROOT_POINTER_IMPORT_RE = /^\s*@(\.\/)?AGENTS\.md\s*$/m;
451
- var DOT_CLAUDE_IMPORT_RE = /^\s*@\.\.\/AGENTS\.md\s*$/m;
450
+ var ROOT_POINTER_IMPORT_RE = /^ {0,3}@(\.\/)?AGENTS\.md[ \t]*$/m;
451
+ var DOT_CLAUDE_IMPORT_RE = /^ {0,3}@\.\.\/AGENTS\.md[ \t]*$/m;
452
+ function withoutFencedCode(text) {
453
+ const kept = [];
454
+ let fence = null;
455
+ for (const line of text.split("\n")) {
456
+ if (fence === null) {
457
+ const open = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line);
458
+ if (open?.[1] && !(open[1][0] === "`" && open[2]?.includes("`"))) fence = open[1];
459
+ else kept.push(line);
460
+ } else {
461
+ const close = /^ {0,3}(`{3,}|~{3,})[ \t]*$/.exec(line)?.[1];
462
+ if (close && close[0] === fence[0] && close.length >= fence.length) fence = null;
463
+ }
464
+ }
465
+ return kept.join("\n");
466
+ }
452
467
  var CLAUDE_AGENTS_FALLBACK_VERSION = "2.1.277";
453
468
  var pExecFile = promisify(execFile);
454
469
  var claudeVersionMemo;
@@ -457,18 +472,20 @@ function detectClaudeCodeVersion() {
457
472
  if (override !== void 0) {
458
473
  return Promise.resolve(override === "none" || override === "" ? null : override);
459
474
  }
460
- claudeVersionMemo ??= pExecFile("claude", ["--version"], { timeout: 5e3 }).then(({ stdout }) => /(\d+\.\d+\.\d+)/.exec(stdout)?.[1] ?? null).catch(() => null);
475
+ claudeVersionMemo ??= pExecFile("claude", ["--version"], { timeout: 5e3 }).then(({ stdout }) => /(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)(?=\s|$)/.exec(stdout)?.[1] ?? null).catch(() => null);
461
476
  return claudeVersionMemo;
462
477
  }
463
- function versionBefore(a, b) {
464
- const pa = a.split(".").map(Number);
465
- const pb = b.split(".").map(Number);
466
- for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
467
- const x = pa[i] ?? 0;
468
- const y = pb[i] ?? 0;
478
+ function mayPredate(a, b) {
479
+ const parse = (v) => /^(\d+)\.(\d+)\.(\d+)(-[0-9A-Za-z.-]+)?$/.exec(v.trim());
480
+ const pa = parse(a);
481
+ const pb = parse(b);
482
+ if (!pa || !pb) return true;
483
+ for (let i = 1; i <= 3; i++) {
484
+ const x = Number(pa[i]);
485
+ const y = Number(pb[i]);
469
486
  if (x !== y) return x < y;
470
487
  }
471
- return false;
488
+ return pa[4] !== void 0 && pb[4] === void 0;
472
489
  }
473
490
  async function checkClaudePointer(root, claudeVersion) {
474
491
  const agentsAbs = path.join(root, "AGENTS.md");
@@ -486,11 +503,11 @@ async function checkClaudePointer(root, claudeVersion) {
486
503
  return { ok: true, via: "same-file" };
487
504
  }
488
505
  const rootClaude = await readText(claudeAbs);
489
- if (rootClaude !== null && ROOT_POINTER_IMPORT_RE.test(rootClaude)) {
506
+ if (rootClaude !== null && ROOT_POINTER_IMPORT_RE.test(withoutFencedCode(rootClaude))) {
490
507
  return { ok: true, via: "root-import" };
491
508
  }
492
509
  const dotClaude = await readText(path.join(root, ".claude", "CLAUDE.md"));
493
- if (dotClaude !== null && DOT_CLAUDE_IMPORT_RE.test(dotClaude)) {
510
+ if (dotClaude !== null && DOT_CLAUDE_IMPORT_RE.test(withoutFencedCode(dotClaude))) {
494
511
  return { ok: true, via: "dotclaude-import" };
495
512
  }
496
513
  if (rootClaude !== null || dotClaude !== null) {
@@ -501,7 +518,7 @@ async function checkClaudePointer(root, claudeVersion) {
501
518
  };
502
519
  }
503
520
  const version = claudeVersion === void 0 ? await detectClaudeCodeVersion() : claudeVersion;
504
- if (version !== null && versionBefore(version, CLAUDE_AGENTS_FALLBACK_VERSION)) {
521
+ if (version !== null && mayPredate(version, CLAUDE_AGENTS_FALLBACK_VERSION)) {
505
522
  return {
506
523
  ok: false,
507
524
  kind: "old-reader",
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { isSafeGateCommand, generateAgentsMd, generateClaudePointerMd, isSelfBuildRepo, generatePreCommitHook, generateCommitMsgHook, generatePrePushHook, generateShellDiscoveryScript, generateArtifactCheckScript, fileOrigin } from './chunk-HSRHOVII.js';
2
+ import { isSafeGateCommand, generateAgentsMd, generateClaudePointerMd, isSelfBuildRepo, generatePreCommitHook, generateCommitMsgHook, generatePrePushHook, generateShellDiscoveryScript, generateArtifactCheckScript, fileOrigin } from './chunk-X5GR2EJK.js';
3
3
  import { DEFAULT_CONFIG } from './chunk-P6ATKV2R.js';
4
4
  import { readText, pathExists } from './chunk-4VPBP6K6.js';
5
5
  import path from 'path';
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  // package.json
3
3
  var package_default = {
4
- version: "0.19.0"};
4
+ version: "0.19.2"};
5
5
 
6
6
  // src/version.ts
7
7
  var VERSION = package_default.version;
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
- import { runAudit } from './chunk-KIPD3N77.js';
2
+ import { runAudit } from './chunk-FG5XKFR2.js';
3
3
  import { readLedger } from './chunk-3E2IPCRY.js';
4
- import { checkClaudePointer, CLAUDE_AGENTS_FALLBACK_VERSION } from './chunk-DLHC7QON.js';
4
+ import { checkClaudePointer, CLAUDE_AGENTS_FALLBACK_VERSION } from './chunk-OKHNNWOV.js';
5
5
  import { DEFAULT_CONFIG, ETYMD_DIR } from './chunk-P6ATKV2R.js';
6
6
  import { readText, isDirectory, git, pathExists } from './chunk-4VPBP6K6.js';
7
7
  import os from 'os';
@@ -1006,8 +1006,8 @@ async function checkGuardedEmails(manifest, findings, disclosures) {
1006
1006
  }
1007
1007
  }
1008
1008
  async function checkGateDrift(manifest, findings, disclosures) {
1009
- const { planWorkflow } = await import('./generate-6IPTCLYG.js');
1010
- const { scanProject } = await import('./scan-V53PGAQ6.js');
1009
+ const { planWorkflow } = await import('./generate-S5KGZIIM.js');
1010
+ const { scanProject } = await import('./scan-M7STYIYQ.js');
1011
1011
  const { readConfig } = await import('./config-724Y3IOB.js');
1012
1012
  for (const entry of manifest.entries) {
1013
1013
  const root = entry.resolvedRoot;
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { runAudit } from './chunk-KIPD3N77.js';
3
- import { parseFailOnTier, meetsFailOn } from './chunk-EYUW3UJU.js';
2
+ import { runAudit } from './chunk-FG5XKFR2.js';
3
+ import { parseFailOnTier, meetsFailOn } from './chunk-DWPMANIC.js';
4
4
  import { print, section, theme, renderLensCoverage, renderFindings, renderLedgerDiff } from './chunk-HI7NWPRA.js';
5
5
 
6
6
  // src/commands/audit.ts
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { PACK_VERSION } from './chunk-U7BCBJIH.js';
2
+ import { PACK_VERSION } from './chunk-6FT7KSKU.js';
3
3
  import { createHash } from 'crypto';
4
4
 
5
5
  var GENERATION_MARKER_RE = /^(?:# |<!-- )etymd:generated pack-v\S+ ([0-9a-f]{16})(?: -->)?$/;
@@ -161,15 +161,21 @@ function contentScreenCall(opts) {
161
161
  `${indent}fi`
162
162
  ].join("\n");
163
163
  }
164
- function localHookCall(hook) {
164
+ function localHookCall(hook, feedRefs = false) {
165
+ const refsCapture = feedRefs ? `# git hands the pushed refs to pre-push ONCE, on stdin \u2014 one line per ref:
166
+ # "<local ref> <local sha> <remote ref> <remote sha>". The shell gate below reads them too, so
167
+ # they are captured here and fed to each.
168
+ refs=$(cat)
169
+ ` : "";
170
+ const call = feedRefs ? `printf '%s\\n' "$refs" | "$LOCAL" "$@" || exit 1` : `"$LOCAL" "$@" || exit 1`;
165
171
  return `# Repo-owned checks. This file is generated and will be overwritten; \`.githooks/${hook}.local\`
166
172
  # is yours \u2014 etymd never reads, writes, or regenerates it. Put project-specific guards there.
167
173
  # A guard running tests that build fixture repositories should scrub git's exported GIT_* names
168
174
  # first \u2014 a child git inherits them and ignores its cwd, so the suite would hit the real repo:
169
175
  # env $(env | grep -o '^GIT_[A-Za-z0-9_]*' | sed 's/^/-u /') <your command>
170
176
  LOCAL="$(dirname "$0")/${hook}.local"
171
- if [ -x "$LOCAL" ]; then
172
- "$LOCAL" "$@" || exit 1
177
+ ${refsCapture}if [ -x "$LOCAL" ]; then
178
+ ${call}
173
179
  fi`;
174
180
  }
175
181
  function generatePreCommitHook(selfBuild = false) {
@@ -178,10 +184,10 @@ function generatePreCommitHook(selfBuild = false) {
178
184
 
179
185
  ${localHookCall("pre-commit")}
180
186
 
181
- # Content screen \u2014 staged file bytes. Refuses to commit environment, guarded-side or identity
182
- # detail into a repo whose history is (or could become) public. The checker and its patterns
183
- # are machine-local by design, so this is a NO-OP wherever no checker is installed: safe to
184
- # commit anywhere, active only where you opted in.
187
+ # Content screen \u2014 staged file bytes. Refuses to commit detail about your environment, work
188
+ # or identity into a repo whose history is (or could become) public. The checker and its
189
+ # patterns are machine-local by design, so this is a NO-OP wherever no checker is installed:
190
+ # safe to commit anywhere, active only where you opted in.
185
191
  #
186
192
  # Bypass, with a reason: git commit --no-verify
187
193
  ${contentGateResolution(selfBuild)}
@@ -256,17 +262,18 @@ function generateShellDiscoveryScript() {
256
262
  # side, positional here). Verdicts land in the scratch: scripts (NUL-delimited matches) and one
257
263
  # dot per decision into count / zsh-count / skip-count, tallied by the hook after the pipeline.
258
264
  #
259
- # A path with nothing readable behind it \u2014 a submodule entry, a dangling symlink, a file
260
- # deleted from the worktree while still tracked \u2014 cannot lie about its contents, so it is a
261
- # disclosed skip, never a block: an absent worktree file is routine dirty state. A regular file
265
+ # The hook runs it from inside a commit materialised from git's object store, so paths resolve
266
+ # against that tree, never the working tree. A path with nothing readable behind it \u2014 a
267
+ # submodule entry, a dangling symlink \u2014 cannot lie about its contents, so it is a disclosed
268
+ # skip, never a block. A regular file
262
269
  # that EXISTS but cannot be read is the other branch \u2014 coverage would silently shrink, so it
263
270
  # fails, naming the path.
264
271
  #
265
- # The two \`[ "$?" -eq 1 ]\` guards are the match/error protocol: grep reports "no match" as 1
266
- # and a failure as 2 or more, and only the first is a verdict. Dropping the guard would let a
267
- # failing matcher pass as "not a shell script" \u2014 the exact silent coverage-shrink the
268
- # fail-closed rules exist to prevent. The checker does not associate \`$?\` with the enclosing
269
- # if-condition, which is why this shape survives the pass it serves; keep it that way.
272
+ # The match/error protocol: grep reports "no match" as 1 and a failure as 2 or more, and only
273
+ # the first is a verdict. Letting a failure fall through would pass a broken matcher as "not a
274
+ # shell script" \u2014 the exact silent coverage-shrink the fail-closed rules exist to prevent. The
275
+ # status is captured on the grep's own line (\`|| st=$?\`), so no command added later can come
276
+ # between the grep and the check and silently replace the status being read.
270
277
  work=$1
271
278
  shift
272
279
  for file do
@@ -282,33 +289,42 @@ for file do
282
289
  exit 1
283
290
  }
284
291
  head -n 1 "$work/head-bytes" > "$work/first-line" || exit 1
285
- if grep -qE "^#!.*[/ ](ba|da)?sh( |$)" "$work/first-line"; then
286
- printf "./%s\\0" "$file" >> "$work/scripts" || exit 1
287
- printf . >> "$work/count" || exit 1
288
- else
289
- [ "$?" -eq 1 ] || exit 1
290
- if grep -qE "^#!.*[/ ]zsh( |$)" "$work/first-line"; then
291
- printf . >> "$work/zsh-count" || exit 1
292
- else
293
- [ "$?" -eq 1 ] || exit 1
294
- fi
295
- fi
292
+ st=0
293
+ grep -qE "^#!.*[/ ](ba|da)?sh( |$)" "$work/first-line" || st=$?
294
+ case $st in
295
+ (0)
296
+ printf "./%s\\0" "$file" >> "$work/scripts" || exit 1
297
+ printf . >> "$work/count" || exit 1 ;;
298
+ (1)
299
+ st=0
300
+ grep -qE "^#!.*[/ ]zsh( |$)" "$work/first-line" || st=$?
301
+ case $st in
302
+ (0) printf . >> "$work/zsh-count" || exit 1 ;;
303
+ (1) ;;
304
+ (*) exit 1 ;;
305
+ esac ;;
306
+ (*) exit 1 ;;
307
+ esac
296
308
  done
297
309
  `);
298
310
  }
299
311
  function shellcheckStep() {
300
312
  return `
301
313
  # Shell correctness. Scripts are discovered by shebang over TRACKED files at push time, so a
302
- # script added later is covered without regenerating this hook. The classifier is
303
- # discover-shell-scripts.sh beside this hook \u2014 tracked and shebanged like what it classifies,
314
+ # script added later is covered without regenerating this hook. The commits BEING PUSHED are
315
+ # the bytes that ship, so each one is materialised from git's object store and checked there \u2014
316
+ # never the working tree (wrong in both directions: a fixed tree let an unfixed commit ship, and
317
+ # a dirty tree shared by several sessions blocked an unrelated push) and never the tip alone (a
318
+ # bad commit under a clean tip shipped while the gate read only the tip's fix). The classifier
319
+ # is discover-shell-scripts.sh beside this hook \u2014 tracked and shebanged like what it classifies,
304
320
  # so the scan it implements finds and checks it too. zsh is NOT in the checked set:
305
321
  # the checker cannot parse it (SC1071 is a parser-level error no inline directive can silence),
306
322
  # so checking it would fail every push on the parser, not on the script. Excluded \u2014 and said so
307
323
  # at run time below, because a coverage hole that is silent is indistinguishable from coverage.
308
324
  # The same honesty splits the unreadable: a regular file that exists but cannot be read blocks
309
325
  # the push (coverage would otherwise silently shrink), while a tracked path with nothing readable
310
- # behind it \u2014 a submodule entry, a dangling symlink, a file deleted from the worktree while
311
- # still tracked \u2014 is counted and said so below as skipped, never a block.
326
+ # behind it \u2014 a submodule entry, a dangling symlink \u2014 is counted and said so below as skipped,
327
+ # never a block.
312
328
  #
313
329
  # "the checker", not its name, on purpose: a comment whose first word is that name is read as
314
330
  # a DIRECTIVE, and an unparseable directive is itself an error (SC1072/SC1073). A hook that
@@ -327,39 +343,143 @@ if command -v shellcheck >/dev/null 2>&1; then
327
343
  shellcheck_tmp=$(mktemp -d) || exit 1
328
344
  trap 'rm -rf "$shellcheck_tmp"' 0
329
345
  trap 'exit 1' 1 2 3 15
330
- git ls-files -z > "$shellcheck_tmp/tracked" || {
331
- echo "etymd: cannot enumerate tracked files for shellcheck" >&2
346
+ # Every commit in each pushed range, never the tip alone: pushing two commits \u2014 a bad
347
+ # script, then its fix \u2014 passed a tip-only read while the bad commit landed on the remote.
348
+ # An all-zero local sha is a delete (nothing to check). An all-zero REMOTE sha is a new
349
+ # branch: everything no remote already has is being pushed, so the range is the local sha
350
+ # minus every remote-tracking ref \u2014 commits a remote already received were gated when they
351
+ # landed there, and the residue is exactly this push's new commits. A remote sha this clone
352
+ # has never seen (the remote moved on since the last fetch) cannot bound a range, so it takes
353
+ # the same new-branch rule instead of refusing a push the gate could have read. Enumeration
354
+ # failure refuses the push: a range the gate could not list is a range it did not read.
355
+ # (pattern) with both parens: bash 3.2 (macOS /bin/sh) cannot parse an unbalanced )
356
+ # in a case pattern.
357
+ : > "$shellcheck_tmp/shas" || exit 1
358
+ printf '%s\\n' "$refs" | while read -r _lref lsha _rref rsha; do
359
+ case "$lsha" in
360
+ (*[!0]*) ;;
361
+ (*) continue ;;
362
+ esac
363
+ case "$rsha" in
364
+ (*[!0]*)
365
+ if git cat-file -e "\${rsha}^{commit}" 2>/dev/null; then
366
+ git rev-list "$rsha..$lsha"
367
+ else
368
+ git rev-list "$lsha" --not --remotes
369
+ fi ;;
370
+ (*) git rev-list "$lsha" --not --remotes ;;
371
+ esac >> "$shellcheck_tmp/shas" || exit 1
372
+ done || {
373
+ echo "etymd: could not enumerate the commits being pushed for shellcheck" >&2
332
374
  exit 1
333
375
  }
334
- : > "$shellcheck_tmp/scripts" && : > "$shellcheck_tmp/count" && : > "$shellcheck_tmp/zsh-count" && : > "$shellcheck_tmp/skip-count" || exit 1
335
- xargs -0 "$(dirname "$0")/discover-shell-scripts.sh" "$shellcheck_tmp" < "$shellcheck_tmp/tracked" || {
336
- echo "etymd: shell script discovery failed; shellcheck coverage is incomplete" >&2
337
- exit 1
338
- }
339
- count=$(wc -c < "$shellcheck_tmp/count") || exit 1
340
- zsh_count=$(wc -c < "$shellcheck_tmp/zsh-count") || exit 1
341
- skip_count=$(wc -c < "$shellcheck_tmp/skip-count") || exit 1
342
- if [ "$skip_count" -gt 0 ]; then
343
- echo "\u203A shellcheck: $((skip_count)) tracked path(s) with nothing readable behind them (submodule, dangling symlink, or deleted from the worktree) \u2014 not checked, not failed"
344
- fi
345
- if [ "$zsh_count" -gt 0 ]; then
346
- echo "\u203A shellcheck: $((zsh_count)) zsh script(s) excluded \u2014 shellcheck cannot parse zsh (SC1071); not checked, not failed"
376
+ shas=$(sort -u "$shellcheck_tmp/shas") || exit 1
377
+ if [ -n "$shas" ]; then
378
+ # Said up front: a first push from a fresh clone can carry the whole history, and a long
379
+ # silent hook reads as a hung one.
380
+ echo "\u203A shellcheck: $(printf '%s\\n' "$shas" | wc -l | tr -d ' ') commit(s) in the pushed range"
381
+ else
382
+ echo "\u203A shellcheck: no commit in the pushed refs (deletes only, or nothing on stdin) \u2014 nothing to check"
347
383
  fi
348
- if [ "$count" -gt 0 ]; then
349
- echo "\u203A shellcheck ($((count)) scripts, blocking at severity=warning)"
350
- xargs -0 shellcheck -S warning -- < "$shellcheck_tmp/scripts" || {
351
- echo " fix, or justify inline with '# shellcheck disable=SCxxxx # why'"
384
+ # Resolved once, before any cd: the classifier runs inside each materialised tree, and a
385
+ # relative hook path would no longer point at it from there.
386
+ discover="$(cd "$(dirname "$0")" && pwd)/discover-shell-scripts.sh" || exit 1
387
+ for sha in $shas; do
388
+ # A fresh directory per commit, removed once that commit is checked so a long push does
389
+ # not pile up one full tree per commit; the subshell trap above still cleans the root on
390
+ # every early exit.
391
+ tree=$(mktemp -d "$shellcheck_tmp/commit.XXXXXX") || exit 1
392
+ # Checked out through a scratch index, never \`git archive\`: archive honours the
393
+ # export-ignore and export-subst attributes, so a script marked export-ignore would leave
394
+ # the checked set without a word. read-tree + checkout-index writes every tracked blob.
395
+ # Large-file pointers stay pointers \u2014 they are never shell, and a push must not need the
396
+ # network to be checked.
397
+ if ! git cat-file -e "\${sha}^{commit}" 2>/dev/null \\
398
+ || ! GIT_INDEX_FILE="$shellcheck_tmp/index" git read-tree "$sha" 2>/dev/null \\
399
+ || ! GIT_INDEX_FILE="$shellcheck_tmp/index" GIT_LFS_SKIP_SMUDGE=1 \\
400
+ git checkout-index -a -f --prefix="$tree/" 2>/dev/null; then
401
+ echo "\u2717 shellcheck: could not materialise $(git rev-parse --short "$sha" 2>/dev/null || echo "$sha") \u2014 refusing the push rather than certifying bytes this gate did not read" >&2
402
+ exit 1
403
+ fi
404
+ if [ -n "$(git ls-tree -r --name-only "$sha")" ] && [ -z "$(ls -A "$tree")" ]; then
405
+ echo "\u2717 shellcheck: $(git rev-parse --short "$sha" 2>/dev/null || echo "$sha") extracted to an empty tree \u2014 refusing the push" >&2
406
+ exit 1
407
+ fi
408
+ : > "$shellcheck_tmp/scripts" && : > "$shellcheck_tmp/count" && : > "$shellcheck_tmp/zsh-count" && : > "$shellcheck_tmp/skip-count" || exit 1
409
+ # Only the paths this commit adds, copies, modifies, renames or retypes against its first
410
+ # parent. A script the commit did not touch has its parent's bytes, and that parent was
411
+ # either gated when it reached the remote or sits in this range and is checked here; a
412
+ # merge diffed against its first parent brings in everything the other side added.
413
+ # Checking every script in every commit multiplied the cost by the range length, so a long
414
+ # push of a script-heavy repo ran long enough to look hung. Two changes alter the verdict
415
+ # on bytes nobody touched \u2014 the classifier deciding what is a script, and a .shellcheckrc
416
+ # deciding what is a finding \u2014 so a commit that touches either, deletion included (a
417
+ # removed .shellcheckrc re-enables every check it disabled), is checked whole. So is a
418
+ # commit that adds or changes this hook: a repo adopting the gate after the fact has
419
+ # scripts no gate ever read, and the commit installing it is where they get read once.
420
+ short=$(git rev-parse --short "$sha" 2>/dev/null || echo "$sha")
421
+ if git rev-parse -q --verify "\${sha}^1^{commit}" >/dev/null 2>&1; then
422
+ git diff-tree -r -z --name-only --no-commit-id "\${sha}^1" "$sha" > "$shellcheck_tmp/touched" \\
423
+ && git diff-tree -r -z --name-only --no-commit-id --diff-filter=ACMRT "\${sha}^1" "$sha" > "$shellcheck_tmp/tracked"
424
+ else
425
+ git diff-tree -r -z --name-only --no-commit-id --root "$sha" > "$shellcheck_tmp/touched" \\
426
+ && cp "$shellcheck_tmp/touched" "$shellcheck_tmp/tracked"
427
+ fi || {
428
+ echo "etymd: cannot enumerate the paths $short changes for shellcheck" >&2
429
+ exit 1
430
+ }
431
+ where="changed in $short"
432
+ # Through a file, not a pipe: a pipeline reports only grep's status, and a failing tr would
433
+ # hand grep nothing \u2014 a "no match" that silently takes the narrow path.
434
+ tr '\\000' '\\n' < "$shellcheck_tmp/touched" > "$shellcheck_tmp/touched-lines" || exit 1
435
+ # grep: 1 is "no match", a verdict; anything higher is the matcher failing. The status is
436
+ # taken on the grep's own line so nothing added later can come between them.
437
+ st=0
438
+ grep -qE '(^|/)(\\.shellcheckrc|discover-shell-scripts\\.sh|pre-push)$' "$shellcheck_tmp/touched-lines" || st=$?
439
+ case $st in
440
+ (0)
441
+ where="in $short (whole tree: the gate, its classifier or a .shellcheckrc changed)"
442
+ git ls-tree -r -z --name-only "$sha" > "$shellcheck_tmp/tracked" || {
443
+ echo "etymd: cannot enumerate the tree of $short for shellcheck" >&2
444
+ exit 1
445
+ } ;;
446
+ (1) ;;
447
+ (*)
448
+ echo "etymd: shell script discovery failed; cannot tell whether $short changes the gate, its classifier or a .shellcheckrc" >&2
449
+ exit 1 ;;
450
+ esac
451
+ ( cd "$tree" && xargs -0 "$discover" "$shellcheck_tmp" ) < "$shellcheck_tmp/tracked" || {
452
+ echo "etymd: shell script discovery failed; shellcheck coverage is incomplete" >&2
352
453
  exit 1
353
454
  }
354
- # Everything below the blocking bar, shown once the push is already cleared. Never affects
355
- # the exit code \u2014 advice that can fail a push is not advice.
356
- advice=$(xargs -0 shellcheck -S style -f gcc -- < "$shellcheck_tmp/scripts" 2>/dev/null \\
357
- | grep -v ': warning:\\|: error:' || true)
358
- if [ -n "$advice" ]; then
359
- echo " \xB7 style/info (not blocking):"
360
- printf '%s\\n' "$advice" | sed 's/^/ /'
455
+ count=$(wc -c < "$shellcheck_tmp/count") || exit 1
456
+ zsh_count=$(wc -c < "$shellcheck_tmp/zsh-count") || exit 1
457
+ skip_count=$(wc -c < "$shellcheck_tmp/skip-count") || exit 1
458
+ if [ "$skip_count" -gt 0 ]; then
459
+ echo "\u203A shellcheck: $((skip_count)) tracked path(s) with nothing readable behind them (submodule or dangling symlink) \u2014 not checked, not failed"
361
460
  fi
362
- fi
461
+ if [ "$zsh_count" -gt 0 ]; then
462
+ echo "\u203A shellcheck: $((zsh_count)) zsh script(s) excluded \u2014 shellcheck cannot parse zsh (SC1071); not checked, not failed"
463
+ fi
464
+ if [ "$count" -eq 0 ]; then
465
+ echo "\u203A shellcheck: no shell script $where \u2014 nothing to check there"
466
+ else
467
+ echo "\u203A shellcheck ($((count)) scripts $where, blocking at severity=warning)"
468
+ ( cd "$tree" && xargs -0 shellcheck -S warning -- < "$shellcheck_tmp/scripts" ) || {
469
+ echo " fix, or justify inline with '# shellcheck disable=SCxxxx # why'"
470
+ exit 1
471
+ }
472
+ # Everything below the blocking bar, shown once the push is already cleared. Never affects
473
+ # the exit code \u2014 advice that can fail a push is not advice.
474
+ advice=$( ( cd "$tree" && xargs -0 shellcheck -S style -f gcc -- < "$shellcheck_tmp/scripts" 2>/dev/null ) \\
475
+ | grep -v ': warning:\\|: error:' || true)
476
+ if [ -n "$advice" ]; then
477
+ echo " \xB7 style/info (not blocking):"
478
+ printf '%s\\n' "$advice" | sed 's/^/ /'
479
+ fi
480
+ fi
481
+ rm -rf "$tree" || exit 1
482
+ done
363
483
  ) || exit 1
364
484
  else
365
485
  echo "\u203A shellcheck skipped (not on PATH) \u2014 install it to gate this repo's shell scripts"
@@ -401,7 +521,7 @@ fi`;
401
521
  return stampGenerated(`#!/usr/bin/env sh
402
522
  # etymd: correctness gate. Mirrors CI cheapest-first; blocks the push on any failure.
403
523
 
404
- ${localHookCall("pre-push")}${runner}
524
+ ${localHookCall("pre-push", true)}${runner}
405
525
  ${body}${shellStep}
406
526
  ${auditStep}
407
527