etymd 0.19.0 → 0.19.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # etymd
2
2
 
3
+ ## 0.19.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 51ecbc9: Generated pre-push hooks shellcheck every commit in the pushed range, each materialised from
8
+ git's object store, instead of reading the working tree. A push whose tip is clean but whose
9
+ earlier commits carry a broken script is now refused; the working tree no longer substitutes
10
+ for the pushed bytes; a push that creates a branch checks every commit no remote already has;
11
+ and a commit that cannot be materialised refuses the push rather than passing as unread.
12
+ Regenerate existing hooks with `etymd gates --yes` to apply the correction.
13
+
3
14
  ## 0.19.0
4
15
 
5
16
  ### 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-6DJMKZGK.js';
4
+ import { VERSION } from './chunk-KQFEKOON.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-2DRA7S67.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-GFUDKTC5.js';
3
+ import './chunk-UMKC3OWQ.js';
4
+ import './chunk-WKP72Z3J.js';
5
+ import './chunk-3E2IPCRY.js';
6
+ import './chunk-HI7NWPRA.js';
7
+ import './chunk-6DJMKZGK.js';
8
+ import './chunk-KQFEKOON.js';
9
+ import './chunk-P6ATKV2R.js';
10
+ import './chunk-2DRA7S67.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-6DJMKZGK.js';
4
+ import './chunk-KQFEKOON.js';
5
5
  import { ETYMD_DIR } from './chunk-P6ATKV2R.js';
6
- import './chunk-U7BCBJIH.js';
6
+ import './chunk-2DRA7S67.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 = "15";
4
4
 
5
5
  export { PACK_VERSION };
@@ -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-KQFEKOON.js';
3
+ import { PACK_VERSION } from './chunk-2DRA7S67.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';
@@ -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-PQISPOB2.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,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-UMKC3OWQ.js';
3
+ import { parseFailOnTier, meetsFailOn } from './chunk-WKP72Z3J.js';
4
4
  import { print, section, theme, renderLensCoverage, renderFindings, renderLedgerDiff } from './chunk-HI7NWPRA.js';
5
5
 
6
6
  // src/commands/audit.ts
@@ -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.1"};
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-UMKC3OWQ.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-6DJMKZGK.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-62GQSCKH.js');
1010
+ const { scanProject } = await import('./scan-F6OW4ANK.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,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { PACK_VERSION } from './chunk-U7BCBJIH.js';
2
+ import { PACK_VERSION } from './chunk-2DRA7S67.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,9 +262,10 @@ 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
  #
@@ -299,16 +306,20 @@ done
299
306
  function shellcheckStep() {
300
307
  return `
301
308
  # 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,
309
+ # script added later is covered without regenerating this hook. The commits BEING PUSHED are
310
+ # the bytes that ship, so each one is materialised from git's object store and checked there \u2014
311
+ # never the working tree (wrong in both directions: a fixed tree let an unfixed commit ship, and
312
+ # a dirty tree shared by several sessions blocked an unrelated push) and never the tip alone (a
313
+ # bad commit under a clean tip shipped while the gate read only the tip's fix). The classifier
314
+ # is discover-shell-scripts.sh beside this hook \u2014 tracked and shebanged like what it classifies,
304
315
  # so the scan it implements finds and checks it too. zsh is NOT in the checked set:
305
316
  # the checker cannot parse it (SC1071 is a parser-level error no inline directive can silence),
306
317
  # so checking it would fail every push on the parser, not on the script. Excluded \u2014 and said so
307
318
  # at run time below, because a coverage hole that is silent is indistinguishable from coverage.
308
319
  # The same honesty splits the unreadable: a regular file that exists but cannot be read blocks
309
320
  # 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.
321
+ # behind it \u2014 a submodule entry, a dangling symlink \u2014 is counted and said so below as skipped,
322
+ # never a block.
312
323
  #
313
324
  # "the checker", not its name, on purpose: a comment whose first word is that name is read as
314
325
  # a DIRECTIVE, and an unparseable directive is itself an error (SC1072/SC1073). A hook that
@@ -327,39 +338,97 @@ if command -v shellcheck >/dev/null 2>&1; then
327
338
  shellcheck_tmp=$(mktemp -d) || exit 1
328
339
  trap 'rm -rf "$shellcheck_tmp"' 0
329
340
  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
332
- exit 1
333
- }
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
341
+ # Every commit in each pushed range, never the tip alone: pushing two commits \u2014 a bad
342
+ # script, then its fix \u2014 passed a tip-only read while the bad commit landed on the remote.
343
+ # An all-zero local sha is a delete (nothing to check). An all-zero REMOTE sha is a new
344
+ # branch: everything no remote already has is being pushed, so the range is the local sha
345
+ # minus every remote-tracking ref \u2014 commits a remote already received were gated when they
346
+ # landed there, and the residue is exactly this push's new commits. A remote sha this clone
347
+ # has never seen (the remote moved on since the last fetch) cannot bound a range, so it takes
348
+ # the same new-branch rule instead of refusing a push the gate could have read. Enumeration
349
+ # failure refuses the push: a range the gate could not list is a range it did not read.
350
+ # (pattern) with both parens: bash 3.2 (macOS /bin/sh) cannot parse an unbalanced )
351
+ # in a case pattern.
352
+ : > "$shellcheck_tmp/shas" || exit 1
353
+ printf '%s\\n' "$refs" | while read -r _lref lsha _rref rsha; do
354
+ case "$lsha" in
355
+ (*[!0]*) ;;
356
+ (*) continue ;;
357
+ esac
358
+ case "$rsha" in
359
+ (*[!0]*)
360
+ if git cat-file -e "\${rsha}^{commit}" 2>/dev/null; then
361
+ git rev-list "$rsha..$lsha"
362
+ else
363
+ git rev-list "$lsha" --not --remotes
364
+ fi ;;
365
+ (*) git rev-list "$lsha" --not --remotes ;;
366
+ esac >> "$shellcheck_tmp/shas" || exit 1
367
+ done || {
368
+ echo "etymd: could not enumerate the commits being pushed for shellcheck" >&2
337
369
  exit 1
338
370
  }
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"
347
- 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'"
371
+ shas=$(sort -u "$shellcheck_tmp/shas") || exit 1
372
+ [ -n "$shas" ] || echo "\u203A shellcheck: no commit in the pushed refs (deletes only, or nothing on stdin) \u2014 nothing to check"
373
+ # Resolved once, before any cd: the classifier runs inside each materialised tree, and a
374
+ # relative hook path would no longer point at it from there.
375
+ discover="$(cd "$(dirname "$0")" && pwd)/discover-shell-scripts.sh" || exit 1
376
+ for sha in $shas; do
377
+ # A fresh directory per commit, removed once that commit is checked so a long push does
378
+ # not pile up one full tree per commit; the subshell trap above still cleans the root on
379
+ # every early exit.
380
+ tree=$(mktemp -d "$shellcheck_tmp/commit.XXXXXX") || exit 1
381
+ # Checked out through a scratch index, never \`git archive\`: archive honours the
382
+ # export-ignore and export-subst attributes, so a script marked export-ignore would leave
383
+ # the checked set without a word. read-tree + checkout-index writes every tracked blob.
384
+ # Large-file pointers stay pointers \u2014 they are never shell, and a push must not need the
385
+ # network to be checked.
386
+ if ! git cat-file -e "\${sha}^{commit}" 2>/dev/null \\
387
+ || ! GIT_INDEX_FILE="$shellcheck_tmp/index" git read-tree "$sha" 2>/dev/null \\
388
+ || ! GIT_INDEX_FILE="$shellcheck_tmp/index" GIT_LFS_SKIP_SMUDGE=1 \\
389
+ git checkout-index -a -f --prefix="$tree/" 2>/dev/null; then
390
+ 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
391
+ exit 1
392
+ fi
393
+ if [ -n "$(git ls-tree -r --name-only "$sha")" ] && [ -z "$(ls -A "$tree")" ]; then
394
+ echo "\u2717 shellcheck: $(git rev-parse --short "$sha" 2>/dev/null || echo "$sha") extracted to an empty tree \u2014 refusing the push" >&2
395
+ exit 1
396
+ fi
397
+ : > "$shellcheck_tmp/scripts" && : > "$shellcheck_tmp/count" && : > "$shellcheck_tmp/zsh-count" && : > "$shellcheck_tmp/skip-count" || exit 1
398
+ git ls-tree -r -z --name-only "$sha" > "$shellcheck_tmp/tracked" || {
399
+ echo "etymd: cannot enumerate the tree of $(git rev-parse --short "$sha" 2>/dev/null || echo "$sha") for shellcheck" >&2
400
+ exit 1
401
+ }
402
+ ( cd "$tree" && xargs -0 "$discover" "$shellcheck_tmp" ) < "$shellcheck_tmp/tracked" || {
403
+ echo "etymd: shell script discovery failed; shellcheck coverage is incomplete" >&2
352
404
  exit 1
353
405
  }
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/^/ /'
406
+ count=$(wc -c < "$shellcheck_tmp/count") || exit 1
407
+ zsh_count=$(wc -c < "$shellcheck_tmp/zsh-count") || exit 1
408
+ skip_count=$(wc -c < "$shellcheck_tmp/skip-count") || exit 1
409
+ if [ "$skip_count" -gt 0 ]; then
410
+ echo "\u203A shellcheck: $((skip_count)) tracked path(s) with nothing readable behind them (submodule or dangling symlink) \u2014 not checked, not failed"
361
411
  fi
362
- fi
412
+ if [ "$zsh_count" -gt 0 ]; then
413
+ echo "\u203A shellcheck: $((zsh_count)) zsh script(s) excluded \u2014 shellcheck cannot parse zsh (SC1071); not checked, not failed"
414
+ fi
415
+ if [ "$count" -gt 0 ]; then
416
+ echo "\u203A shellcheck ($((count)) scripts in $(git rev-parse --short "$sha" 2>/dev/null || echo "$sha"), blocking at severity=warning)"
417
+ ( cd "$tree" && xargs -0 shellcheck -S warning -- < "$shellcheck_tmp/scripts" ) || {
418
+ echo " fix, or justify inline with '# shellcheck disable=SCxxxx # why'"
419
+ exit 1
420
+ }
421
+ # Everything below the blocking bar, shown once the push is already cleared. Never affects
422
+ # the exit code \u2014 advice that can fail a push is not advice.
423
+ advice=$( ( cd "$tree" && xargs -0 shellcheck -S style -f gcc -- < "$shellcheck_tmp/scripts" 2>/dev/null ) \\
424
+ | grep -v ': warning:\\|: error:' || true)
425
+ if [ -n "$advice" ]; then
426
+ echo " \xB7 style/info (not blocking):"
427
+ printf '%s\\n' "$advice" | sed 's/^/ /'
428
+ fi
429
+ fi
430
+ rm -rf "$tree" || exit 1
431
+ done
363
432
  ) || exit 1
364
433
  else
365
434
  echo "\u203A shellcheck skipped (not on PATH) \u2014 install it to gate this repo's shell scripts"
@@ -401,7 +470,7 @@ fi`;
401
470
  return stampGenerated(`#!/usr/bin/env sh
402
471
  # etymd: correctness gate. Mirrors CI cheapest-first; blocks the push on any failure.
403
472
 
404
- ${localHookCall("pre-push")}${runner}
473
+ ${localHookCall("pre-push", true)}${runner}
405
474
  ${body}${shellStep}
406
475
  ${auditStep}
407
476
 
@@ -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-WKP72Z3J.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-6DJMKZGK.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-2DRA7S67.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,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { expandFileGlobs } from './chunk-DLHC7QON.js';
2
+ import { expandFileGlobs } from './chunk-6DJMKZGK.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';
package/dist/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { VERSION } from './chunk-L4KIIKXI.js';
2
+ import { VERSION } from './chunk-KQFEKOON.js';
3
3
  import path from 'path';
4
4
  import { Command } from 'commander';
5
5
  import pc from 'picocolors';
@@ -31,7 +31,7 @@ program.command("audit").description("Verify every instruction claim against the
31
31
  "exit non-zero when findings at/above this tier exist (risk|gap|polish)"
32
32
  ).action(
33
33
  (opts, cmd) => action(async () => {
34
- const { run } = await import('./audit-RONSEYFS.js');
34
+ const { run } = await import('./audit-5N2TU2X7.js');
35
35
  await run({
36
36
  cwd: resolveCwd(cmd),
37
37
  json: opts.json,
@@ -44,7 +44,7 @@ program.command("audit").description("Verify every instruction claim against the
44
44
  );
45
45
  program.command("init").description("Onboard the truth guard: approve the baseline; scaffold AGENTS.md only if asked").option("-y, --yes", "accept defaults without prompting (never overwrites)").option("--with-agents", "also scaffold a minimal AGENTS.md where none exists (off by default)").action(
46
46
  (opts, cmd) => action(async () => {
47
- const { run } = await import('./init-7HWVHZMF.js');
47
+ const { run } = await import('./init-G4XXFXVD.js');
48
48
  await run({ cwd: resolveCwd(cmd), yes: opts.yes, withAgents: opts.withAgents });
49
49
  })
50
50
  );
@@ -52,19 +52,19 @@ program.command("approve").description(
52
52
  "Re-approve the committed baseline after intentional structural changes (non-interactive)"
53
53
  ).action(
54
54
  (_opts, cmd) => action(async () => {
55
- const { run } = await import('./approve-Y36ZO6IL.js');
55
+ const { run } = await import('./approve-J6JU7MBX.js');
56
56
  await run({ cwd: resolveCwd(cmd) });
57
57
  })
58
58
  );
59
59
  program.command("scan").description("Deterministically reckon the project into a facts index").option("--json", "print the raw facts as JSON").option("--no-save", "do not write the .etymd cache").action(
60
60
  (opts, cmd) => action(async () => {
61
- const { run } = await import('./scan-DLKYG5BB.js');
61
+ const { run } = await import('./scan-PJH32H7C.js');
62
62
  await run({ cwd: resolveCwd(cmd), json: opts.json, save: opts.save });
63
63
  })
64
64
  );
65
65
  program.command("doctor").description('Alias for `audit --truth` \u2014 "are the recorded instructions still true?"').option("--json", "print findings as JSON").action(
66
66
  (opts, cmd) => action(async () => {
67
- const { run } = await import('./doctor-EHRADIUZ.js');
67
+ const { run } = await import('./doctor-264Q5PLY.js');
68
68
  await run({ cwd: resolveCwd(cmd), json: opts.json });
69
69
  })
70
70
  );
@@ -76,7 +76,7 @@ program.command("context").description("Measure the always-loaded context footpr
76
76
  );
77
77
  program.command("brief").description("Emit a grounded briefing for the in-repo agent to complete the semantic layer").option("--human", "write a human onboarding brief instead of the agent briefing").action(
78
78
  (opts, cmd) => action(async () => {
79
- const { run } = await import('./brief-ILB6RMSQ.js');
79
+ const { run } = await import('./brief-NHHILXIH.js');
80
80
  await run({ cwd: resolveCwd(cmd), human: opts.human });
81
81
  })
82
82
  );
@@ -90,7 +90,7 @@ program.command("premise").argument("[task]", "the task you are about to hand an
90
90
  "exit non-zero when findings at/above this tier exist (risk|gap|polish)"
91
91
  ).action(
92
92
  (task, opts, cmd) => action(async () => {
93
- const { run } = await import('./premise-U6JIB2QG.js');
93
+ const { run } = await import('./premise-RNMAKMVA.js');
94
94
  await run({
95
95
  cwd: resolveCwd(cmd),
96
96
  task,
@@ -132,7 +132,7 @@ var fleet = program.command("fleet").description(
132
132
  "exit non-zero when findings at/above this tier exist (risk|gap|polish)"
133
133
  ).action(
134
134
  (opts, cmd) => action(async () => {
135
- const { sweep } = await import('./fleet-KFF3N2O7.js');
135
+ const { sweep } = await import('./fleet-VT625Q2J.js');
136
136
  await sweep({
137
137
  cwd: resolveCwd(cmd),
138
138
  manifest: opts.manifest,
@@ -147,7 +147,7 @@ var fleet = program.command("fleet").description(
147
147
  );
148
148
  fleet.command("check").description("Validate the manifest pair only \u2014 no lenses: dangling mappings, duplicates, leaks").option("--manifest <file>", "the fleet manifest \u2014 required unless the cwd holds registry.json").option("--json", "print the findings as JSON (EXPERIMENTAL through 0.2.x)").action(
149
149
  (_opts, cmd) => action(async () => {
150
- const { check } = await import('./fleet-KFF3N2O7.js');
150
+ const { check } = await import('./fleet-VT625Q2J.js');
151
151
  const opts = cmd.optsWithGlobals();
152
152
  await check({ cwd: resolveCwd(cmd), manifest: opts.manifest, json: opts.json });
153
153
  })
@@ -156,7 +156,7 @@ fleet.command("board").description(
156
156
  "Render the fleet board: every project's milestones (contract key `milestones`) plus a ranked initiatives table"
157
157
  ).option("--manifest <file>", "the fleet manifest \u2014 required unless the cwd holds registry.json").option("--initiatives <file>", "the hand-edited initiatives table (rank | id | initiative | \u2026)").option("--out <file>", "write the Markdown board here instead of printing it").option("--json", "print the board as JSON (EXPERIMENTAL)").action(
158
158
  (opts, cmd) => action(async () => {
159
- const { board } = await import('./fleet-KFF3N2O7.js');
159
+ const { board } = await import('./fleet-VT625Q2J.js');
160
160
  const shared = cmd.optsWithGlobals();
161
161
  await board({
162
162
  cwd: resolveCwd(cmd),
@@ -172,7 +172,7 @@ fleet.command("add").argument("<dir>", "directory of the project to register").d
172
172
  "public-repo | public-bound | private \u2014 mandatory for personal entries"
173
173
  ).option("-y, --yes", "skip prompts; every mandatory value must be passed as a flag").option("--manifest <file>", "the fleet manifest \u2014 required unless the cwd holds registry.json").action(
174
174
  (dir, opts, cmd) => action(async () => {
175
- const { add } = await import('./fleet-KFF3N2O7.js');
175
+ const { add } = await import('./fleet-VT625Q2J.js');
176
176
  const shared = cmd.optsWithGlobals();
177
177
  await add({
178
178
  cwd: resolveCwd(cmd),
@@ -195,7 +195,7 @@ fleet.command("dismiss").argument("<name>", "the registered project name").argum
195
195
  "Dismiss a project's finding from any cwd \u2014 guarded ledgers persist beside the manifest"
196
196
  ).requiredOption("--reason <text>", "why it is dismissed \u2014 recorded so the decision survives").option("--manifest <file>", "the fleet manifest \u2014 required unless the cwd holds registry.json").action(
197
197
  (name, id, _opts, cmd) => action(async () => {
198
- const { dismiss } = await import('./fleet-KFF3N2O7.js');
198
+ const { dismiss } = await import('./fleet-VT625Q2J.js');
199
199
  const opts = cmd.optsWithGlobals();
200
200
  await dismiss({
201
201
  cwd: resolveCwd(cmd),
@@ -210,7 +210,7 @@ fleet.command("accept").argument("<name>", "the registered project name").argume
210
210
  "Accept a project's finding as a known trade-off \u2014 guarded ledgers persist beside the manifest"
211
211
  ).option("--reason <text>", "optional note on why the trade-off is accepted").option("--manifest <file>", "the fleet manifest \u2014 required unless the cwd holds registry.json").action(
212
212
  (name, id, _opts, cmd) => action(async () => {
213
- const { accept } = await import('./fleet-KFF3N2O7.js');
213
+ const { accept } = await import('./fleet-VT625Q2J.js');
214
214
  const opts = cmd.optsWithGlobals();
215
215
  await accept({ cwd: resolveCwd(cmd), manifest: opts.manifest, name, id, reason: opts.reason });
216
216
  })
@@ -225,7 +225,7 @@ program.command("propose").description(
225
225
  "the fleet manifest \u2014 runs a read-only sweep now (no delta baseline move)"
226
226
  ).option("--from <file>", "read a stored `etymd fleet --json` output instead of sweeping").option("--json", "print the proposal/1 records as JSON (EXPERIMENTAL)").action(
227
227
  (opts, cmd) => action(async () => {
228
- const { run } = await import('./propose-NZMEPZIL.js');
228
+ const { run } = await import('./propose-35JXMEPH.js');
229
229
  await run({
230
230
  cwd: resolveCwd(cmd),
231
231
  manifest: opts.manifest,
@@ -256,7 +256,7 @@ program.command("screen").description(
256
256
  );
257
257
  program.command("gates").description("Install the local git-hook gates (process \u2192 pre-commit, correctness \u2192 pre-push)").option("--ci", "note about the CI review gate (ships later; local gates install now)").option("-y, --yes", "skip prompts; never overwrites a hand-edited hook").action(
258
258
  (opts, cmd) => action(async () => {
259
- const { run } = await import('./gates-UJ23THID.js');
259
+ const { run } = await import('./gates-YUMSTKUG.js');
260
260
  await run({ cwd: resolveCwd(cmd), ci: opts.ci, yes: opts.yes });
261
261
  })
262
262
  );
@@ -1,13 +1,13 @@
1
1
  #!/usr/bin/env node
2
- import { run } from './chunk-4IESOWHQ.js';
3
- import './chunk-KIPD3N77.js';
4
- import './chunk-EYUW3UJU.js';
2
+ import { run } from './chunk-GFUDKTC5.js';
3
+ import './chunk-UMKC3OWQ.js';
4
+ import './chunk-WKP72Z3J.js';
5
5
  import './chunk-3E2IPCRY.js';
6
6
  import './chunk-HI7NWPRA.js';
7
- import './chunk-DLHC7QON.js';
8
- import './chunk-L4KIIKXI.js';
7
+ import './chunk-6DJMKZGK.js';
8
+ import './chunk-KQFEKOON.js';
9
9
  import './chunk-P6ATKV2R.js';
10
- import './chunk-U7BCBJIH.js';
10
+ import './chunk-2DRA7S67.js';
11
11
  import './chunk-4VPBP6K6.js';
12
12
 
13
13
  // src/commands/doctor.ts
@@ -1,15 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
  import { dismiss, accept } from './chunk-HOR4M6EC.js';
3
- import { sweepFleet, FLEET_JSON_SCHEMA, checkManifest, FLEET_TRUST_VALUES, isFleetTrust, MILESTONES_FILE, parseMilestones, parseInitiatives, BOARD_JSON_SCHEMA, renderBoard, loadFleetManifest, fleetLedgerTarget, ensureFindingRecorded } from './chunk-ZCOIZVSM.js';
4
- import './chunk-KIPD3N77.js';
5
- import { parseFailOnTier, meetsFailOn } from './chunk-EYUW3UJU.js';
3
+ import { sweepFleet, FLEET_JSON_SCHEMA, checkManifest, FLEET_TRUST_VALUES, isFleetTrust, MILESTONES_FILE, parseMilestones, parseInitiatives, BOARD_JSON_SCHEMA, renderBoard, loadFleetManifest, fleetLedgerTarget, ensureFindingRecorded } from './chunk-M7XDP6FT.js';
4
+ import './chunk-UMKC3OWQ.js';
5
+ import { parseFailOnTier, meetsFailOn } from './chunk-WKP72Z3J.js';
6
6
  import './chunk-3E2IPCRY.js';
7
- import { generateClaudePointerMd } from './chunk-HSRHOVII.js';
7
+ import { generateClaudePointerMd } from './chunk-PQISPOB2.js';
8
8
  import { print, theme, section, renderFleetRows, renderFleetNotes, renderFindings, TIER_BADGE, glyph } from './chunk-HI7NWPRA.js';
9
- import { checkClaudePointer, scanProject } from './chunk-DLHC7QON.js';
10
- import './chunk-L4KIIKXI.js';
9
+ import { checkClaudePointer, scanProject } from './chunk-6DJMKZGK.js';
10
+ import './chunk-KQFEKOON.js';
11
11
  import './chunk-P6ATKV2R.js';
12
- import './chunk-U7BCBJIH.js';
12
+ import './chunk-2DRA7S67.js';
13
13
  import { readText, pathExists, git } from './chunk-4VPBP6K6.js';
14
14
  import { promises } from 'fs';
15
15
  import os from 'os';
@@ -1,12 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  import { applyFiles } from './chunk-GWGKEPRX.js';
3
- import { deriveFailOn, derivedCommands, planWorkflow } from './chunk-EZRK4MV3.js';
4
- import { runPrefix, isSafeGateCommand } from './chunk-HSRHOVII.js';
3
+ import { deriveFailOn, derivedCommands, planWorkflow } from './chunk-7N5JDXQG.js';
4
+ import { runPrefix, isSafeGateCommand } from './chunk-PQISPOB2.js';
5
5
  import { section, print, glyph, theme, renderPlan } from './chunk-HI7NWPRA.js';
6
- import { scanProject } from './chunk-DLHC7QON.js';
7
- import './chunk-L4KIIKXI.js';
6
+ import { scanProject } from './chunk-6DJMKZGK.js';
7
+ import './chunk-KQFEKOON.js';
8
8
  import { readConfig, CONFIG_FILE, configPath } from './chunk-P6ATKV2R.js';
9
- import './chunk-U7BCBJIH.js';
9
+ import './chunk-2DRA7S67.js';
10
10
  import { readText, git } from './chunk-4VPBP6K6.js';
11
11
  import { promises } from 'fs';
12
12
  import path from 'path';
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
- export { deriveFailOn, derivedCommands, planWorkflow, riskReachability } from './chunk-EZRK4MV3.js';
3
- import './chunk-HSRHOVII.js';
2
+ export { deriveFailOn, derivedCommands, planWorkflow, riskReachability } from './chunk-7N5JDXQG.js';
3
+ import './chunk-PQISPOB2.js';
4
4
  import './chunk-P6ATKV2R.js';
5
- import './chunk-U7BCBJIH.js';
5
+ import './chunk-2DRA7S67.js';
6
6
  import './chunk-4VPBP6K6.js';
package/dist/index.d.ts CHANGED
@@ -1127,7 +1127,7 @@ declare const gateIntegrityLens: Lens;
1127
1127
  * Gaps in the sequence are deliberate: a number stays retired even when the change that took it
1128
1128
  * never shipped, so a version cited in a baseline or a record means exactly one pack, ever.
1129
1129
  */
1130
- declare const PACK_VERSION = "14";
1130
+ declare const PACK_VERSION = "15";
1131
1131
 
1132
1132
  declare const VERSION: string;
1133
1133
  declare const NAME: string;
package/dist/index.js CHANGED
@@ -20,7 +20,7 @@ var __export = (target, all) => {
20
20
  var PACK_VERSION;
21
21
  var init_version = __esm({
22
22
  "src/pack/version.ts"() {
23
- PACK_VERSION = "14";
23
+ PACK_VERSION = "15";
24
24
  }
25
25
  });
26
26
 
@@ -30,7 +30,7 @@ var init_package = __esm({
30
30
  "package.json"() {
31
31
  package_default = {
32
32
  name: "etymd",
33
- version: "0.19.0",
33
+ version: "0.19.1",
34
34
  description: "Keep your agent instructions true \u2014 verify AGENTS.md, CLAUDE.md, rules, skills, and the task you hand an agent against the actual repo, with drift caught over time and a regression ledger.",
35
35
  keywords: [
36
36
  "cli",
@@ -1237,15 +1237,21 @@ function contentScreenCall(opts) {
1237
1237
  `${indent}fi`
1238
1238
  ].join("\n");
1239
1239
  }
1240
- function localHookCall(hook) {
1240
+ function localHookCall(hook, feedRefs = false) {
1241
+ const refsCapture = feedRefs ? `# git hands the pushed refs to pre-push ONCE, on stdin \u2014 one line per ref:
1242
+ # "<local ref> <local sha> <remote ref> <remote sha>". The shell gate below reads them too, so
1243
+ # they are captured here and fed to each.
1244
+ refs=$(cat)
1245
+ ` : "";
1246
+ const call = feedRefs ? `printf '%s\\n' "$refs" | "$LOCAL" "$@" || exit 1` : `"$LOCAL" "$@" || exit 1`;
1241
1247
  return `# Repo-owned checks. This file is generated and will be overwritten; \`.githooks/${hook}.local\`
1242
1248
  # is yours \u2014 etymd never reads, writes, or regenerates it. Put project-specific guards there.
1243
1249
  # A guard running tests that build fixture repositories should scrub git's exported GIT_* names
1244
1250
  # first \u2014 a child git inherits them and ignores its cwd, so the suite would hit the real repo:
1245
1251
  # env $(env | grep -o '^GIT_[A-Za-z0-9_]*' | sed 's/^/-u /') <your command>
1246
1252
  LOCAL="$(dirname "$0")/${hook}.local"
1247
- if [ -x "$LOCAL" ]; then
1248
- "$LOCAL" "$@" || exit 1
1253
+ ${refsCapture}if [ -x "$LOCAL" ]; then
1254
+ ${call}
1249
1255
  fi`;
1250
1256
  }
1251
1257
  function generatePreCommitHook(selfBuild = false) {
@@ -1254,10 +1260,10 @@ function generatePreCommitHook(selfBuild = false) {
1254
1260
 
1255
1261
  ${localHookCall("pre-commit")}
1256
1262
 
1257
- # Content screen \u2014 staged file bytes. Refuses to commit environment, guarded-side or identity
1258
- # detail into a repo whose history is (or could become) public. The checker and its patterns
1259
- # are machine-local by design, so this is a NO-OP wherever no checker is installed: safe to
1260
- # commit anywhere, active only where you opted in.
1263
+ # Content screen \u2014 staged file bytes. Refuses to commit detail about your environment, work
1264
+ # or identity into a repo whose history is (or could become) public. The checker and its
1265
+ # patterns are machine-local by design, so this is a NO-OP wherever no checker is installed:
1266
+ # safe to commit anywhere, active only where you opted in.
1261
1267
  #
1262
1268
  # Bypass, with a reason: git commit --no-verify
1263
1269
  ${contentGateResolution(selfBuild)}
@@ -1318,9 +1324,10 @@ function generateShellDiscoveryScript() {
1318
1324
  # side, positional here). Verdicts land in the scratch: scripts (NUL-delimited matches) and one
1319
1325
  # dot per decision into count / zsh-count / skip-count, tallied by the hook after the pipeline.
1320
1326
  #
1321
- # A path with nothing readable behind it \u2014 a submodule entry, a dangling symlink, a file
1322
- # deleted from the worktree while still tracked \u2014 cannot lie about its contents, so it is a
1323
- # disclosed skip, never a block: an absent worktree file is routine dirty state. A regular file
1327
+ # The hook runs it from inside a commit materialised from git's object store, so paths resolve
1328
+ # against that tree, never the working tree. A path with nothing readable behind it \u2014 a
1329
+ # submodule entry, a dangling symlink \u2014 cannot lie about its contents, so it is a disclosed
1330
+ # skip, never a block. A regular file
1324
1331
  # that EXISTS but cannot be read is the other branch \u2014 coverage would silently shrink, so it
1325
1332
  # fails, naming the path.
1326
1333
  #
@@ -1361,16 +1368,20 @@ done
1361
1368
  function shellcheckStep() {
1362
1369
  return `
1363
1370
  # Shell correctness. Scripts are discovered by shebang over TRACKED files at push time, so a
1364
- # script added later is covered without regenerating this hook. The classifier is
1365
- # discover-shell-scripts.sh beside this hook \u2014 tracked and shebanged like what it classifies,
1371
+ # script added later is covered without regenerating this hook. The commits BEING PUSHED are
1372
+ # the bytes that ship, so each one is materialised from git's object store and checked there \u2014
1373
+ # never the working tree (wrong in both directions: a fixed tree let an unfixed commit ship, and
1374
+ # a dirty tree shared by several sessions blocked an unrelated push) and never the tip alone (a
1375
+ # bad commit under a clean tip shipped while the gate read only the tip's fix). The classifier
1376
+ # is discover-shell-scripts.sh beside this hook \u2014 tracked and shebanged like what it classifies,
1366
1377
  # so the scan it implements finds and checks it too. zsh is NOT in the checked set:
1367
1378
  # the checker cannot parse it (SC1071 is a parser-level error no inline directive can silence),
1368
1379
  # so checking it would fail every push on the parser, not on the script. Excluded \u2014 and said so
1369
1380
  # at run time below, because a coverage hole that is silent is indistinguishable from coverage.
1370
1381
  # The same honesty splits the unreadable: a regular file that exists but cannot be read blocks
1371
1382
  # the push (coverage would otherwise silently shrink), while a tracked path with nothing readable
1372
- # behind it \u2014 a submodule entry, a dangling symlink, a file deleted from the worktree while
1373
- # still tracked \u2014 is counted and said so below as skipped, never a block.
1383
+ # behind it \u2014 a submodule entry, a dangling symlink \u2014 is counted and said so below as skipped,
1384
+ # never a block.
1374
1385
  #
1375
1386
  # "the checker", not its name, on purpose: a comment whose first word is that name is read as
1376
1387
  # a DIRECTIVE, and an unparseable directive is itself an error (SC1072/SC1073). A hook that
@@ -1389,39 +1400,97 @@ if command -v shellcheck >/dev/null 2>&1; then
1389
1400
  shellcheck_tmp=$(mktemp -d) || exit 1
1390
1401
  trap 'rm -rf "$shellcheck_tmp"' 0
1391
1402
  trap 'exit 1' 1 2 3 15
1392
- git ls-files -z > "$shellcheck_tmp/tracked" || {
1393
- echo "etymd: cannot enumerate tracked files for shellcheck" >&2
1394
- exit 1
1395
- }
1396
- : > "$shellcheck_tmp/scripts" && : > "$shellcheck_tmp/count" && : > "$shellcheck_tmp/zsh-count" && : > "$shellcheck_tmp/skip-count" || exit 1
1397
- xargs -0 "$(dirname "$0")/discover-shell-scripts.sh" "$shellcheck_tmp" < "$shellcheck_tmp/tracked" || {
1398
- echo "etymd: shell script discovery failed; shellcheck coverage is incomplete" >&2
1403
+ # Every commit in each pushed range, never the tip alone: pushing two commits \u2014 a bad
1404
+ # script, then its fix \u2014 passed a tip-only read while the bad commit landed on the remote.
1405
+ # An all-zero local sha is a delete (nothing to check). An all-zero REMOTE sha is a new
1406
+ # branch: everything no remote already has is being pushed, so the range is the local sha
1407
+ # minus every remote-tracking ref \u2014 commits a remote already received were gated when they
1408
+ # landed there, and the residue is exactly this push's new commits. A remote sha this clone
1409
+ # has never seen (the remote moved on since the last fetch) cannot bound a range, so it takes
1410
+ # the same new-branch rule instead of refusing a push the gate could have read. Enumeration
1411
+ # failure refuses the push: a range the gate could not list is a range it did not read.
1412
+ # (pattern) with both parens: bash 3.2 (macOS /bin/sh) cannot parse an unbalanced )
1413
+ # in a case pattern.
1414
+ : > "$shellcheck_tmp/shas" || exit 1
1415
+ printf '%s\\n' "$refs" | while read -r _lref lsha _rref rsha; do
1416
+ case "$lsha" in
1417
+ (*[!0]*) ;;
1418
+ (*) continue ;;
1419
+ esac
1420
+ case "$rsha" in
1421
+ (*[!0]*)
1422
+ if git cat-file -e "\${rsha}^{commit}" 2>/dev/null; then
1423
+ git rev-list "$rsha..$lsha"
1424
+ else
1425
+ git rev-list "$lsha" --not --remotes
1426
+ fi ;;
1427
+ (*) git rev-list "$lsha" --not --remotes ;;
1428
+ esac >> "$shellcheck_tmp/shas" || exit 1
1429
+ done || {
1430
+ echo "etymd: could not enumerate the commits being pushed for shellcheck" >&2
1399
1431
  exit 1
1400
1432
  }
1401
- count=$(wc -c < "$shellcheck_tmp/count") || exit 1
1402
- zsh_count=$(wc -c < "$shellcheck_tmp/zsh-count") || exit 1
1403
- skip_count=$(wc -c < "$shellcheck_tmp/skip-count") || exit 1
1404
- if [ "$skip_count" -gt 0 ]; then
1405
- 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"
1406
- fi
1407
- if [ "$zsh_count" -gt 0 ]; then
1408
- echo "\u203A shellcheck: $((zsh_count)) zsh script(s) excluded \u2014 shellcheck cannot parse zsh (SC1071); not checked, not failed"
1409
- fi
1410
- if [ "$count" -gt 0 ]; then
1411
- echo "\u203A shellcheck ($((count)) scripts, blocking at severity=warning)"
1412
- xargs -0 shellcheck -S warning -- < "$shellcheck_tmp/scripts" || {
1413
- echo " fix, or justify inline with '# shellcheck disable=SCxxxx # why'"
1433
+ shas=$(sort -u "$shellcheck_tmp/shas") || exit 1
1434
+ [ -n "$shas" ] || echo "\u203A shellcheck: no commit in the pushed refs (deletes only, or nothing on stdin) \u2014 nothing to check"
1435
+ # Resolved once, before any cd: the classifier runs inside each materialised tree, and a
1436
+ # relative hook path would no longer point at it from there.
1437
+ discover="$(cd "$(dirname "$0")" && pwd)/discover-shell-scripts.sh" || exit 1
1438
+ for sha in $shas; do
1439
+ # A fresh directory per commit, removed once that commit is checked so a long push does
1440
+ # not pile up one full tree per commit; the subshell trap above still cleans the root on
1441
+ # every early exit.
1442
+ tree=$(mktemp -d "$shellcheck_tmp/commit.XXXXXX") || exit 1
1443
+ # Checked out through a scratch index, never \`git archive\`: archive honours the
1444
+ # export-ignore and export-subst attributes, so a script marked export-ignore would leave
1445
+ # the checked set without a word. read-tree + checkout-index writes every tracked blob.
1446
+ # Large-file pointers stay pointers \u2014 they are never shell, and a push must not need the
1447
+ # network to be checked.
1448
+ if ! git cat-file -e "\${sha}^{commit}" 2>/dev/null \\
1449
+ || ! GIT_INDEX_FILE="$shellcheck_tmp/index" git read-tree "$sha" 2>/dev/null \\
1450
+ || ! GIT_INDEX_FILE="$shellcheck_tmp/index" GIT_LFS_SKIP_SMUDGE=1 \\
1451
+ git checkout-index -a -f --prefix="$tree/" 2>/dev/null; then
1452
+ 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
1453
+ exit 1
1454
+ fi
1455
+ if [ -n "$(git ls-tree -r --name-only "$sha")" ] && [ -z "$(ls -A "$tree")" ]; then
1456
+ echo "\u2717 shellcheck: $(git rev-parse --short "$sha" 2>/dev/null || echo "$sha") extracted to an empty tree \u2014 refusing the push" >&2
1457
+ exit 1
1458
+ fi
1459
+ : > "$shellcheck_tmp/scripts" && : > "$shellcheck_tmp/count" && : > "$shellcheck_tmp/zsh-count" && : > "$shellcheck_tmp/skip-count" || exit 1
1460
+ git ls-tree -r -z --name-only "$sha" > "$shellcheck_tmp/tracked" || {
1461
+ echo "etymd: cannot enumerate the tree of $(git rev-parse --short "$sha" 2>/dev/null || echo "$sha") for shellcheck" >&2
1462
+ exit 1
1463
+ }
1464
+ ( cd "$tree" && xargs -0 "$discover" "$shellcheck_tmp" ) < "$shellcheck_tmp/tracked" || {
1465
+ echo "etymd: shell script discovery failed; shellcheck coverage is incomplete" >&2
1414
1466
  exit 1
1415
1467
  }
1416
- # Everything below the blocking bar, shown once the push is already cleared. Never affects
1417
- # the exit code \u2014 advice that can fail a push is not advice.
1418
- advice=$(xargs -0 shellcheck -S style -f gcc -- < "$shellcheck_tmp/scripts" 2>/dev/null \\
1419
- | grep -v ': warning:\\|: error:' || true)
1420
- if [ -n "$advice" ]; then
1421
- echo " \xB7 style/info (not blocking):"
1422
- printf '%s\\n' "$advice" | sed 's/^/ /'
1468
+ count=$(wc -c < "$shellcheck_tmp/count") || exit 1
1469
+ zsh_count=$(wc -c < "$shellcheck_tmp/zsh-count") || exit 1
1470
+ skip_count=$(wc -c < "$shellcheck_tmp/skip-count") || exit 1
1471
+ if [ "$skip_count" -gt 0 ]; then
1472
+ echo "\u203A shellcheck: $((skip_count)) tracked path(s) with nothing readable behind them (submodule or dangling symlink) \u2014 not checked, not failed"
1423
1473
  fi
1424
- fi
1474
+ if [ "$zsh_count" -gt 0 ]; then
1475
+ echo "\u203A shellcheck: $((zsh_count)) zsh script(s) excluded \u2014 shellcheck cannot parse zsh (SC1071); not checked, not failed"
1476
+ fi
1477
+ if [ "$count" -gt 0 ]; then
1478
+ echo "\u203A shellcheck ($((count)) scripts in $(git rev-parse --short "$sha" 2>/dev/null || echo "$sha"), blocking at severity=warning)"
1479
+ ( cd "$tree" && xargs -0 shellcheck -S warning -- < "$shellcheck_tmp/scripts" ) || {
1480
+ echo " fix, or justify inline with '# shellcheck disable=SCxxxx # why'"
1481
+ exit 1
1482
+ }
1483
+ # Everything below the blocking bar, shown once the push is already cleared. Never affects
1484
+ # the exit code \u2014 advice that can fail a push is not advice.
1485
+ advice=$( ( cd "$tree" && xargs -0 shellcheck -S style -f gcc -- < "$shellcheck_tmp/scripts" 2>/dev/null ) \\
1486
+ | grep -v ': warning:\\|: error:' || true)
1487
+ if [ -n "$advice" ]; then
1488
+ echo " \xB7 style/info (not blocking):"
1489
+ printf '%s\\n' "$advice" | sed 's/^/ /'
1490
+ fi
1491
+ fi
1492
+ rm -rf "$tree" || exit 1
1493
+ done
1425
1494
  ) || exit 1
1426
1495
  else
1427
1496
  echo "\u203A shellcheck skipped (not on PATH) \u2014 install it to gate this repo's shell scripts"
@@ -1455,7 +1524,7 @@ fi`;
1455
1524
  return stampGenerated(`#!/usr/bin/env sh
1456
1525
  # etymd: correctness gate. Mirrors CI cheapest-first; blocks the push on any failure.
1457
1526
 
1458
- ${localHookCall("pre-push")}${runner}
1527
+ ${localHookCall("pre-push", true)}${runner}
1459
1528
  ${body}${shellStep}
1460
1529
  ${auditStep}
1461
1530
 
@@ -1,12 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  import { applyFiles } from './chunk-GWGKEPRX.js';
3
- import { planWorkflow } from './chunk-EZRK4MV3.js';
4
- import './chunk-HSRHOVII.js';
3
+ import { planWorkflow } from './chunk-7N5JDXQG.js';
4
+ import './chunk-PQISPOB2.js';
5
5
  import { theme, renderFacts, print, renderPlan, section, glyph } from './chunk-HI7NWPRA.js';
6
- import { scanProject } from './chunk-DLHC7QON.js';
7
- import { VERSION } from './chunk-L4KIIKXI.js';
6
+ import { scanProject } from './chunk-6DJMKZGK.js';
7
+ import { VERSION } from './chunk-KQFEKOON.js';
8
8
  import { writeCachedFacts, deriveProfile, writeBaseline, CACHE_DIR } from './chunk-P6ATKV2R.js';
9
- import { PACK_VERSION } from './chunk-U7BCBJIH.js';
9
+ import { PACK_VERSION } from './chunk-2DRA7S67.js';
10
10
  import { git, readText } from './chunk-4VPBP6K6.js';
11
11
  import { promises } from 'fs';
12
12
  import path from 'path';
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
- import { parseFailOnTier, meetsFailOn, buildTruthEnv, emptyCounters, checkTextClaims, checkDocRefs, loadDecisionLedger, checkDecisionRefs, rankFindings, PATH_TOKEN_RE, HOSTNAME_RE, KNOWN_EXTENSIONS, NAMESPACE_IDENT, NAMESPACE_STOP } from './chunk-EYUW3UJU.js';
2
+ import { parseFailOnTier, meetsFailOn, buildTruthEnv, emptyCounters, checkTextClaims, checkDocRefs, loadDecisionLedger, checkDecisionRefs, rankFindings, PATH_TOKEN_RE, HOSTNAME_RE, KNOWN_EXTENSIONS, NAMESPACE_IDENT, NAMESPACE_STOP } from './chunk-WKP72Z3J.js';
3
3
  import { print, section, theme, renderFindings } from './chunk-HI7NWPRA.js';
4
- import { scanProject } from './chunk-DLHC7QON.js';
5
- import './chunk-L4KIIKXI.js';
4
+ import { scanProject } from './chunk-6DJMKZGK.js';
5
+ import './chunk-KQFEKOON.js';
6
6
  import { ETYMD_DIR } from './chunk-P6ATKV2R.js';
7
- import './chunk-U7BCBJIH.js';
7
+ import './chunk-2DRA7S67.js';
8
8
  import { readText, pathExists } from './chunk-4VPBP6K6.js';
9
9
  import path from 'path';
10
10
  import { promises } from 'fs';
@@ -1,13 +1,13 @@
1
1
  #!/usr/bin/env node
2
- import { sweepFleet, loadFleetManifest, FLEET_JSON_SCHEMA } from './chunk-ZCOIZVSM.js';
3
- import './chunk-KIPD3N77.js';
4
- import './chunk-EYUW3UJU.js';
2
+ import { sweepFleet, loadFleetManifest, FLEET_JSON_SCHEMA } from './chunk-M7XDP6FT.js';
3
+ import './chunk-UMKC3OWQ.js';
4
+ import './chunk-WKP72Z3J.js';
5
5
  import './chunk-3E2IPCRY.js';
6
6
  import { print, section, theme, renderFleetNotes } from './chunk-HI7NWPRA.js';
7
- import './chunk-DLHC7QON.js';
8
- import './chunk-L4KIIKXI.js';
7
+ import './chunk-6DJMKZGK.js';
8
+ import './chunk-KQFEKOON.js';
9
9
  import './chunk-P6ATKV2R.js';
10
- import './chunk-U7BCBJIH.js';
10
+ import './chunk-2DRA7S67.js';
11
11
  import { readText } from './chunk-4VPBP6K6.js';
12
12
  import path from 'path';
13
13
 
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+ export { scanProject } from './chunk-6DJMKZGK.js';
3
+ import './chunk-KQFEKOON.js';
4
+ import './chunk-2DRA7S67.js';
5
+ import './chunk-4VPBP6K6.js';
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import { print, renderFacts, theme } from './chunk-HI7NWPRA.js';
3
- import { scanProject } from './chunk-DLHC7QON.js';
4
- import './chunk-L4KIIKXI.js';
3
+ import { scanProject } from './chunk-6DJMKZGK.js';
4
+ import './chunk-KQFEKOON.js';
5
5
  import { writeCachedFacts } from './chunk-P6ATKV2R.js';
6
- import './chunk-U7BCBJIH.js';
6
+ import './chunk-2DRA7S67.js';
7
7
  import './chunk-4VPBP6K6.js';
8
8
 
9
9
  // src/commands/scan.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "etymd",
3
- "version": "0.19.0",
3
+ "version": "0.19.1",
4
4
  "description": "Keep your agent instructions true — verify AGENTS.md, CLAUDE.md, rules, skills, and the task you hand an agent against the actual repo, with drift caught over time and a regression ledger.",
5
5
  "keywords": [
6
6
  "cli",
@@ -1,11 +0,0 @@
1
- #!/usr/bin/env node
2
- export { run } from './chunk-4IESOWHQ.js';
3
- import './chunk-KIPD3N77.js';
4
- import './chunk-EYUW3UJU.js';
5
- import './chunk-3E2IPCRY.js';
6
- import './chunk-HI7NWPRA.js';
7
- import './chunk-DLHC7QON.js';
8
- import './chunk-L4KIIKXI.js';
9
- import './chunk-P6ATKV2R.js';
10
- import './chunk-U7BCBJIH.js';
11
- import './chunk-4VPBP6K6.js';
@@ -1,5 +0,0 @@
1
- #!/usr/bin/env node
2
- export { scanProject } from './chunk-DLHC7QON.js';
3
- import './chunk-L4KIIKXI.js';
4
- import './chunk-U7BCBJIH.js';
5
- import './chunk-4VPBP6K6.js';