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.
- package/CHANGELOG.md +24 -0
- package/dist/{approve-Y36ZO6IL.js → approve-Z4NMEGDM.js} +3 -3
- package/dist/audit-4PGVS2TW.js +11 -0
- package/dist/{brief-ILB6RMSQ.js → brief-2N6X3CFR.js} +3 -3
- package/dist/{chunk-U7BCBJIH.js → chunk-6FT7KSKU.js} +1 -1
- package/dist/{chunk-EYUW3UJU.js → chunk-DWPMANIC.js} +1 -1
- package/dist/{chunk-KIPD3N77.js → chunk-FG5XKFR2.js} +3 -3
- package/dist/{chunk-DLHC7QON.js → chunk-OKHNNWOV.js} +32 -15
- package/dist/{chunk-EZRK4MV3.js → chunk-TZGRV7UY.js} +1 -1
- package/dist/{chunk-L4KIIKXI.js → chunk-W6GGG4LG.js} +1 -1
- package/dist/{chunk-ZCOIZVSM.js → chunk-WIBN7PBH.js} +4 -4
- package/dist/{chunk-4IESOWHQ.js → chunk-WYJFZ2MB.js} +2 -2
- package/dist/{chunk-HSRHOVII.js → chunk-X5GR2EJK.js} +179 -59
- package/dist/cli.js +16 -16
- package/dist/{doctor-EHRADIUZ.js → doctor-7YG342YP.js} +6 -6
- package/dist/{fleet-KFF3N2O7.js → fleet-XZVQLY53.js} +8 -8
- package/dist/{gates-UJ23THID.js → gates-633B6G2T.js} +5 -5
- package/dist/{generate-6IPTCLYG.js → generate-S5KGZIIM.js} +3 -3
- package/dist/index.d.ts +1 -1
- package/dist/index.js +210 -73
- package/dist/{init-7HWVHZMF.js → init-SL3HEB3H.js} +12 -5
- package/dist/{premise-U6JIB2QG.js → premise-62AXRQUK.js} +4 -4
- package/dist/{propose-NZMEPZIL.js → propose-4UWLIFWJ.js} +6 -6
- package/dist/scan-M7STYIYQ.js +5 -0
- package/dist/{scan-DLKYG5BB.js → scan-VEHQEEXY.js} +3 -3
- package/package.json +1 -1
- package/dist/audit-RONSEYFS.js +0 -11
- 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-
|
|
4
|
-
import { VERSION } from './chunk-
|
|
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-
|
|
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-
|
|
4
|
-
import './chunk-
|
|
3
|
+
import { scanProject } from './chunk-OKHNNWOV.js';
|
|
4
|
+
import './chunk-W6GGG4LG.js';
|
|
5
5
|
import { ETYMD_DIR } from './chunk-P6ATKV2R.js';
|
|
6
|
-
import './chunk-
|
|
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
|
-
import { expandFileGlobs } from './chunk-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
3
|
-
import { PACK_VERSION } from './chunk-
|
|
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 =
|
|
451
|
-
var DOT_CLAUDE_IMPORT_RE =
|
|
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
|
|
464
|
-
const
|
|
465
|
-
const
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
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
|
|
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 &&
|
|
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-
|
|
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
|
-
import { runAudit } from './chunk-
|
|
2
|
+
import { runAudit } from './chunk-FG5XKFR2.js';
|
|
3
3
|
import { readLedger } from './chunk-3E2IPCRY.js';
|
|
4
|
-
import { checkClaudePointer, CLAUDE_AGENTS_FALLBACK_VERSION } from './chunk-
|
|
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-
|
|
1010
|
-
const { scanProject } = await import('./scan-
|
|
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-
|
|
3
|
-
import { parseFailOnTier, meetsFailOn } from './chunk-
|
|
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-
|
|
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
|
-
|
|
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,
|
|
182
|
-
#
|
|
183
|
-
# are machine-local by design, so this is a NO-OP wherever no checker is installed:
|
|
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
|
-
#
|
|
260
|
-
#
|
|
261
|
-
#
|
|
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
|
|
266
|
-
#
|
|
267
|
-
#
|
|
268
|
-
#
|
|
269
|
-
#
|
|
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
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
[ "
|
|
294
|
-
|
|
295
|
-
|
|
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
|
|
303
|
-
#
|
|
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
|
|
311
|
-
#
|
|
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
|
-
|
|
331
|
-
|
|
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
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
|
-
|
|
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
|
|