etymd 0.3.2 → 0.4.0

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,33 @@
1
1
  # etymd
2
2
 
3
+ ## 0.4.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 69d1aad: Gate the shell surface: `etymd gates` now installs a shellcheck step in repos that have one.
8
+
9
+ Package scripts are not the only executable surface a repo has, and in some repos they are not
10
+ the main one. A tools or infra repo can be entirely `bootstrap/*.sh` plus `.githooks/*` with no
11
+ `package.json` at all — and those repos were told "no correctness commands detected", which reads
12
+ as "nothing to check" when it means "nothing was looked at".
13
+
14
+ The scan now counts tracked files a shell executes (`facts.shell.scripts`), by extension and by
15
+ shebang, and the generated pre-push gains a shellcheck step when that count is non-zero. Three
16
+ properties, each one a lesson from a gate that failed:
17
+
18
+ - Scripts are re-discovered inside the hook at push time, by shebang over tracked files, never
19
+ baked in as a list. A generated list is correct the day it is written and silently wrong the
20
+ first time someone adds a script.
21
+ - A missing shellcheck binary is a loud skip that names the install command, never a quiet pass.
22
+ A check that goes silent when its tool is absent looks installed on every machine and is
23
+ installed on one.
24
+ - The blocking bar is severity `warning`; style and info print as advice. A gate with a high
25
+ false-positive rate does not make a repo careful, it teaches everyone the bypass flag — and
26
+ that flag is shared with the content screen, which must never be bypassed.
27
+
28
+ The install summary lists the step too, so accepting the plan shows what will actually run.
29
+ Pack version 4.
30
+
3
31
  ## 0.3.2
4
32
 
5
33
  ### Patch Changes
package/README.md CHANGED
@@ -1,11 +1,11 @@
1
1
  # Etymd
2
2
 
3
3
  <div align="center">
4
- <img src="media/etymd-logo.png" width="520" alt="Etymd — a papyrus of written instructions, each line checked against the repository it describes">
4
+ <img src="https://raw.githubusercontent.com/triartleet/etymd/main/media/etymd-logo.png" width="520" alt="Etymd — a papyrus of written instructions, each line checked against the repository it describes">
5
5
  <p>
6
6
  <a href="https://www.npmjs.com/package/etymd"><img src="https://img.shields.io/npm/v/etymd.svg?label=npm&color=cb3837" alt="npm version"></a>
7
7
  <a href="https://github.com/triartleet/etymd/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/triartleet/etymd/ci.yml?branch=main&label=CI" alt="CI"></a>
8
- <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-green" alt="MIT license"></a>
8
+ <a href="https://github.com/triartleet/etymd/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-MIT-green" alt="MIT license"></a>
9
9
  </p>
10
10
  </div>
11
11
 
@@ -335,7 +335,7 @@ agent-context file: claims about your fleet (what exists, where, under which **p
335
335
  side of the wall an entry belongs to, `personal` or `corp`; the **wall** is the placement
336
336
  boundary between personal and employer content that the sweep polices). It rots like any
337
337
  AGENTS.md does, and `etymd fleet` keeps it true. Decision record:
338
- [`docs/decisions/004-fleet-truth-guard.md`](docs/decisions/004-fleet-truth-guard.md). Both the
338
+ [`docs/decisions/004-fleet-truth-guard.md`](https://github.com/triartleet/etymd/blob/main/docs/decisions/004-fleet-truth-guard.md). Both the
339
339
  registry schema and the fleet `--json` schema are **experimental through 0.2.x**.
340
340
 
341
341
  Two files beside each other — the split is the privacy model:
@@ -346,7 +346,7 @@ Two files beside each other — the split is the privacy model:
346
346
  {
347
347
  "registryVersion": 1,
348
348
  "root": "~/projects", // ~ expands on the consumer side — never a machine home
349
- "orientation": { "root": "north" }, // optional: the entry every other entry is guided by
349
+ "orientation": { "root": "notes" }, // optional: the entry every other entry is guided by
350
350
  "projects": [
351
351
  {
352
352
  "name": "web-app",
@@ -451,7 +451,7 @@ console.log(audit.findings) // one schema: claim · evidence · why · action ·
451
451
  ## The corpus (how this is validated)
452
452
 
453
453
  Etymd is developed against a corpus of real sibling repos rather than fixtures alone —
454
- [`sources.json`](sources.json) lists them by shape. Every heuristic here exists because a real
454
+ [`sources.json`](https://github.com/triartleet/etymd/blob/main/sources.json) lists them by shape. Every heuristic here exists because a real
455
455
  repo proved the previous one wrong, and each skip class in the truth lens is a false positive that
456
456
  a corpus run caught.
457
457
 
@@ -469,10 +469,10 @@ Without it — on a fresh clone or in CI — those suites skip cleanly and the r
469
469
 
470
470
  ## Decision record & roadmap
471
471
 
472
- [`docs/decisions/`](docs/decisions/) — 001 founding · 002 foundation re-lock · **003 the truth-guard
472
+ [`docs/decisions/`](https://github.com/triartleet/etymd/tree/main/docs/decisions) — 001 founding · 002 foundation re-lock · **003 the truth-guard
473
473
  pivot** (the current identity; includes the state-of-the-field investigation it rests on) ·
474
474
  **004 fleet mode** (the truth guard across your repositories).
475
- [`ROADMAP.md`](ROADMAP.md) — what's now / next / later, the pre-publish checklist, and the
475
+ [`ROADMAP.md`](https://github.com/triartleet/etymd/blob/main/ROADMAP.md) — what's now / next / later, the pre-publish checklist, and the
476
476
  accepted heuristic trade-offs.
477
477
 
478
478
  ## License
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import { section, theme, print, renderBaselineDrift, glyph } from './chunk-TADKOW6P.js';
3
- import { scanProject } from './chunk-7MOBQ4N5.js';
4
- import { VERSION } from './chunk-B3SUTZVY.js';
3
+ import { scanProject } from './chunk-SXCR6RRM.js';
4
+ import { VERSION } from './chunk-QYHROOMK.js';
5
5
  import { readBaseline, summarizeBaselineDrift, isDriftEmpty, writeBaseline, deriveProfile } from './chunk-R74SJ7HS.js';
6
- import { PACK_VERSION } from './chunk-CRJHIXJH.js';
6
+ import { PACK_VERSION } from './chunk-HGSLHNNT.js';
7
7
  import './chunk-YLNQQZ5F.js';
8
8
 
9
9
  // src/commands/approve.ts
@@ -1,12 +1,12 @@
1
1
  #!/usr/bin/env node
2
- export { run } from './chunk-WOBHQQ6H.js';
3
- import './chunk-WUGBAW6Z.js';
2
+ export { run } from './chunk-7K3BDNLW.js';
3
+ import './chunk-ECY7BLBT.js';
4
4
  import './chunk-LT67FU2Y.js';
5
5
  import './chunk-WWBF4Y7K.js';
6
6
  import './chunk-NMZ3RHWW.js';
7
7
  import './chunk-TADKOW6P.js';
8
- import './chunk-7MOBQ4N5.js';
9
- import './chunk-B3SUTZVY.js';
8
+ import './chunk-SXCR6RRM.js';
9
+ import './chunk-QYHROOMK.js';
10
10
  import './chunk-R74SJ7HS.js';
11
- import './chunk-CRJHIXJH.js';
11
+ import './chunk-HGSLHNNT.js';
12
12
  import './chunk-YLNQQZ5F.js';
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import { print, theme } from './chunk-TADKOW6P.js';
3
- import { scanProject } from './chunk-7MOBQ4N5.js';
4
- import './chunk-B3SUTZVY.js';
3
+ import { scanProject } from './chunk-SXCR6RRM.js';
4
+ import './chunk-QYHROOMK.js';
5
5
  import { ETYMD_DIR } from './chunk-R74SJ7HS.js';
6
- import './chunk-CRJHIXJH.js';
6
+ import './chunk-HGSLHNNT.js';
7
7
  import './chunk-YLNQQZ5F.js';
8
8
  import { promises } from 'node:fs';
9
9
  import path from 'node:path';
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { parseFailOnTier, runAudit, meetsFailOn } from './chunk-WUGBAW6Z.js';
2
+ import { parseFailOnTier, runAudit, meetsFailOn } from './chunk-ECY7BLBT.js';
3
3
  import { print, section, theme, renderLensCoverage, renderFindings, renderLedgerDiff } from './chunk-TADKOW6P.js';
4
4
 
5
5
  // src/commands/audit.ts
@@ -2,9 +2,9 @@
2
2
  import { measureContext } from './chunk-LT67FU2Y.js';
3
3
  import { readLedger, reconcileLedger, writeLedger, visibleFindings } from './chunk-WWBF4Y7K.js';
4
4
  import { DEFAULT_CONFIG, readConfig, CONFIG_FILE } from './chunk-NMZ3RHWW.js';
5
- import { scanProject, expandFileGlobs } from './chunk-7MOBQ4N5.js';
5
+ import { scanProject, expandFileGlobs } from './chunk-SXCR6RRM.js';
6
6
  import { ETYMD_DIR, writeCachedFacts, readBaseline, deriveProfile, baselineCarriesMachinePath, BASELINE_FILE } from './chunk-R74SJ7HS.js';
7
- import { PACK_VERSION } from './chunk-CRJHIXJH.js';
7
+ import { PACK_VERSION } from './chunk-HGSLHNNT.js';
8
8
  import { pathExists, readJson, git, readText, isDirectory, matchesAnyGlob, isCiEnvironment, normalizeRelPath } from './chunk-YLNQQZ5F.js';
9
9
  import path from 'node:path';
10
10
  import YAML from 'yaml';
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
2
  // src/pack/version.ts
3
- var PACK_VERSION = "3";
3
+ var PACK_VERSION = "4";
4
4
 
5
5
  export { PACK_VERSION };
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { DEFAULT_CONFIG } from './chunk-NMZ3RHWW.js';
3
- import { PACK_VERSION } from './chunk-CRJHIXJH.js';
3
+ import { PACK_VERSION } from './chunk-HGSLHNNT.js';
4
4
  import { readText, pathExists } from './chunk-YLNQQZ5F.js';
5
5
  import path from 'node:path';
6
6
 
@@ -158,6 +158,25 @@ ${localHookCall("commit-msg")}
158
158
  exit 0
159
159
  `;
160
160
  }
161
+ function shellcheckStep() {
162
+ return `
163
+ # Shell correctness. Scripts are discovered by shebang over TRACKED files at push time, so a
164
+ # script added later is covered without regenerating this hook.
165
+ if command -v shellcheck >/dev/null 2>&1; then
166
+ scripts=$(git ls-files -z \\
167
+ | xargs -0 -I{} sh -c 'head -1 "{}" 2>/dev/null | grep -qE "^#!.*[/ ](ba|da|z)?sh( |$)" && echo "{}"' \\
168
+ | sort)
169
+ if [ -n "$scripts" ]; then
170
+ echo "\u203A shellcheck ($(printf '%s\\n' "$scripts" | wc -l | tr -d ' ') scripts, blocking at severity=warning)"
171
+ printf '%s\\n' "$scripts" | xargs shellcheck -S warning || {
172
+ echo " fix, or justify inline with '# shellcheck disable=SCxxxx # why'"
173
+ exit 1
174
+ }
175
+ fi
176
+ else
177
+ echo "\u203A shellcheck skipped (not on PATH) \u2014 install it to gate this repo's shell scripts"
178
+ fi`;
179
+ }
161
180
  function generatePrePushHook(facts, gates) {
162
181
  const run = runPrefix(facts.packageManager);
163
182
  const c = facts.commands;
@@ -166,8 +185,14 @@ function generatePrePushHook(facts, gates) {
166
185
  const steps = candidates.filter(
167
186
  (key) => Boolean(key) && (allowed.has(key) || isSafeGateCommand(c.raw[key]))
168
187
  ).map((key) => `${run} ${key}`);
188
+ const shellStep = facts.shell?.scripts ? shellcheckStep() : "";
169
189
  const body = steps.length ? steps.map((s) => `echo "\u203A ${s}"
170
- ${s} || exit 1`).join("\n") : 'echo "etymd: no correctness commands detected \u2014 add format:check / typecheck / lint"';
190
+ ${s} || exit 1`).join("\n") : shellStep ? (
191
+ // A repo whose executable surface is shell HAS a correctness command — it just is not in
192
+ // package.json. Claiming "none detected" beside a step that is about to run would be the
193
+ // tool contradicting itself.
194
+ `echo "\u203A no package scripts \u2014 shell is this repo's checkable surface"`
195
+ ) : 'echo "etymd: no correctness commands detected \u2014 add format:check / typecheck / lint"';
171
196
  const failOn = gates?.failOn ?? "risk";
172
197
  const auditStep = `
173
198
  if command -v etymd >/dev/null 2>&1; then
@@ -180,7 +205,7 @@ fi`;
180
205
  # etymd: correctness gate. Mirrors CI cheapest-first; blocks the push on any failure.
181
206
 
182
207
  ${localHookCall("pre-push")}
183
- ${body}
208
+ ${body}${shellStep}
184
209
  ${auditStep}
185
210
 
186
211
  # Content screen, second pass \u2014 the WHOLE TREE rather than one diff. Catches anything committed
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  // package.json
3
3
  var package_default = {
4
- version: "0.3.2"};
4
+ version: "0.4.0"};
5
5
 
6
6
  // src/version.ts
7
7
  var VERSION = package_default.version;
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { VERSION } from './chunk-B3SUTZVY.js';
3
- import { PACK_VERSION } from './chunk-CRJHIXJH.js';
2
+ import { VERSION } from './chunk-QYHROOMK.js';
3
+ import { PACK_VERSION } from './chunk-HGSLHNNT.js';
4
4
  import { normalizeRelPath, isDirectory, readJson, git, matchesAnyGlob, pathExists, readText } from './chunk-YLNQQZ5F.js';
5
5
  import path from 'node:path';
6
6
  import { promises } from 'node:fs';
@@ -390,6 +390,33 @@ var ARTIFACT_SPECS = [
390
390
  kind: "map"
391
391
  }
392
392
  ];
393
+ async function firstLine(file) {
394
+ let handle;
395
+ try {
396
+ handle = await promises.open(file, "r");
397
+ const { buffer, bytesRead } = await handle.read(Buffer.alloc(128), 0, 128, 0);
398
+ return buffer.subarray(0, bytesRead).toString("utf8").split("\n", 1)[0] ?? "";
399
+ } catch {
400
+ return "";
401
+ } finally {
402
+ await handle?.close();
403
+ }
404
+ }
405
+ var SHELL_SHEBANG = /^#!\s*\/\S*\b(?:ba|da|z)?sh\b|^#!\s*\/usr\/bin\/env\s+(?:ba|da|z)?sh\b/;
406
+ var SHELL_EXT = /\.(?:sh|bash|zsh)$/;
407
+ async function detectShellSurface(root, isRepo) {
408
+ if (!isRepo) return { scripts: 0 };
409
+ const listed = await git(root, ["ls-files", "-z"]);
410
+ if (!listed) return { scripts: 0 };
411
+ const files = listed.split("\0").filter(Boolean);
412
+ const byExtension = files.filter((f) => SHELL_EXT.test(f));
413
+ const candidates = files.filter((f) => !path.basename(f).includes("."));
414
+ const SHEBANG_PROBE_CAP = 400;
415
+ const probed = await Promise.all(
416
+ candidates.slice(0, SHEBANG_PROBE_CAP).map(async (f) => SHELL_SHEBANG.test(await firstLine(path.join(root, f))))
417
+ );
418
+ return { scripts: byExtension.length + probed.filter(Boolean).length };
419
+ }
393
420
  async function detectArtifacts(root) {
394
421
  const artifacts = await Promise.all(
395
422
  ARTIFACT_SPECS.map(async (spec) => ({
@@ -537,6 +564,7 @@ async function scanProject(root, opts = {}) {
537
564
  ]) : [null, null, null, null, null];
538
565
  const hooksPath = hooksPathRaw ?? void 0;
539
566
  const hooks = await detectHooks(abs, hooksPath, rootPkg);
567
+ const shell = await detectShellSurface(abs, isRepo);
540
568
  const freshness = await collectFreshness(abs, isRepo, artifacts, opts.upstreamRemote);
541
569
  const packages = workspace.packageGlobs.length ? await listWorkspacePackages(abs, workspace.packageGlobs) : [];
542
570
  const recentAuthors = authorsRaw ? new Set(authorsRaw.split("\n").filter(Boolean)).size : void 0;
@@ -568,6 +596,7 @@ async function scanProject(root, opts = {}) {
568
596
  // vsce — which never runs prepublishOnly.
569
597
  publishRoute: !rootPkg ? "none" : rootPkg.engines?.vscode ? "vscode" : "npm",
570
598
  artifacts,
599
+ shell,
571
600
  freshness,
572
601
  tree
573
602
  };
package/dist/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { VERSION } from './chunk-B3SUTZVY.js';
2
+ import { VERSION } from './chunk-QYHROOMK.js';
3
3
  import path from 'node: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-IGIUGKSQ.js');
34
+ const { run } = await import('./audit-AU4R45TP.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 missing").option("-y, --yes", "accept defaults without prompting (never overwrites)").action(
46
46
  (opts, cmd) => action(async () => {
47
- const { run } = await import('./init-WYVDRQ2J.js');
47
+ const { run } = await import('./init-BYZJOCS6.js');
48
48
  await run({ cwd: resolveCwd(cmd), yes: opts.yes });
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-P255DGOI.js');
55
+ const { run } = await import('./approve-Y6TX4LTN.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-PZFF6LDA.js');
61
+ const { run } = await import('./scan-DPEOA3KZ.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-4LBUZHVD.js');
67
+ const { run } = await import('./doctor-OE5E36DX.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-SFZ2DR65.js');
79
+ const { run } = await import('./brief-TIKPGPFV.js');
80
80
  await run({ cwd: resolveCwd(cmd), human: opts.human });
81
81
  })
82
82
  );
@@ -111,7 +111,7 @@ var fleet = program.command("fleet").description(
111
111
  "exit non-zero when findings at/above this tier exist (risk|gap|polish)"
112
112
  ).action(
113
113
  (opts, cmd) => action(async () => {
114
- const { sweep } = await import('./fleet-Q6J5XIHH.js');
114
+ const { sweep } = await import('./fleet-LSN5XZ5Q.js');
115
115
  await sweep({
116
116
  cwd: resolveCwd(cmd),
117
117
  manifest: opts.manifest,
@@ -126,7 +126,7 @@ var fleet = program.command("fleet").description(
126
126
  );
127
127
  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(
128
128
  (_opts, cmd) => action(async () => {
129
- const { check } = await import('./fleet-Q6J5XIHH.js');
129
+ const { check } = await import('./fleet-LSN5XZ5Q.js');
130
130
  const opts = cmd.optsWithGlobals();
131
131
  await check({ cwd: resolveCwd(cmd), manifest: opts.manifest, json: opts.json });
132
132
  })
@@ -136,7 +136,7 @@ fleet.command("add").argument("<dir>", "directory of the project to register").d
136
136
  "public-repo | public-bound | private \u2014 mandatory for personal entries"
137
137
  ).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(
138
138
  (dir, opts, cmd) => action(async () => {
139
- const { add } = await import('./fleet-Q6J5XIHH.js');
139
+ const { add } = await import('./fleet-LSN5XZ5Q.js');
140
140
  const shared = cmd.optsWithGlobals();
141
141
  await add({
142
142
  cwd: resolveCwd(cmd),
@@ -154,7 +154,7 @@ fleet.command("dismiss").argument("<name>", "the registered project name").argum
154
154
  "Dismiss a project's finding from any cwd \u2014 corp ledgers persist beside the manifest"
155
155
  ).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(
156
156
  (name, id, _opts, cmd) => action(async () => {
157
- const { dismiss } = await import('./fleet-Q6J5XIHH.js');
157
+ const { dismiss } = await import('./fleet-LSN5XZ5Q.js');
158
158
  const opts = cmd.optsWithGlobals();
159
159
  await dismiss({
160
160
  cwd: resolveCwd(cmd),
@@ -169,7 +169,7 @@ fleet.command("accept").argument("<name>", "the registered project name").argume
169
169
  "Accept a project's finding as a known trade-off \u2014 corp ledgers persist beside the manifest"
170
170
  ).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(
171
171
  (name, id, _opts, cmd) => action(async () => {
172
- const { accept } = await import('./fleet-Q6J5XIHH.js');
172
+ const { accept } = await import('./fleet-LSN5XZ5Q.js');
173
173
  const opts = cmd.optsWithGlobals();
174
174
  await accept({ cwd: resolveCwd(cmd), manifest: opts.manifest, name, id, reason: opts.reason });
175
175
  })
@@ -191,7 +191,7 @@ program.command("screen").description(
191
191
  );
192
192
  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(
193
193
  (opts, cmd) => action(async () => {
194
- const { run } = await import('./gates-T5J3H7WL.js');
194
+ const { run } = await import('./gates-IJNAQ7DC.js');
195
195
  await run({ cwd: resolveCwd(cmd), ci: opts.ci, yes: opts.yes });
196
196
  })
197
197
  );
@@ -1,14 +1,14 @@
1
1
  #!/usr/bin/env node
2
- import { run } from './chunk-WOBHQQ6H.js';
3
- import './chunk-WUGBAW6Z.js';
2
+ import { run } from './chunk-7K3BDNLW.js';
3
+ import './chunk-ECY7BLBT.js';
4
4
  import './chunk-LT67FU2Y.js';
5
5
  import './chunk-WWBF4Y7K.js';
6
6
  import './chunk-NMZ3RHWW.js';
7
7
  import './chunk-TADKOW6P.js';
8
- import './chunk-7MOBQ4N5.js';
9
- import './chunk-B3SUTZVY.js';
8
+ import './chunk-SXCR6RRM.js';
9
+ import './chunk-QYHROOMK.js';
10
10
  import './chunk-R74SJ7HS.js';
11
- import './chunk-CRJHIXJH.js';
11
+ import './chunk-HGSLHNNT.js';
12
12
  import './chunk-YLNQQZ5F.js';
13
13
 
14
14
  // src/commands/doctor.ts
@@ -1,14 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  import { dismiss, accept } from './chunk-MMPV67FW.js';
3
- import { parseFailOnTier, meetsFailOn, runAudit } from './chunk-WUGBAW6Z.js';
3
+ import { parseFailOnTier, meetsFailOn, runAudit } from './chunk-ECY7BLBT.js';
4
4
  import './chunk-LT67FU2Y.js';
5
5
  import { readLedger } from './chunk-WWBF4Y7K.js';
6
6
  import { DEFAULT_CONFIG } from './chunk-NMZ3RHWW.js';
7
7
  import { print, theme, section, renderFleetRows, renderFleetNotes, renderFindings, TIER_BADGE, glyph } from './chunk-TADKOW6P.js';
8
- import { scanProject } from './chunk-7MOBQ4N5.js';
9
- import './chunk-B3SUTZVY.js';
8
+ import { scanProject } from './chunk-SXCR6RRM.js';
9
+ import './chunk-QYHROOMK.js';
10
10
  import { ETYMD_DIR } from './chunk-R74SJ7HS.js';
11
- import './chunk-CRJHIXJH.js';
11
+ import './chunk-HGSLHNNT.js';
12
12
  import { readText, pathExists, git, isDirectory } from './chunk-YLNQQZ5F.js';
13
13
  import { promises } from 'node:fs';
14
14
  import path from 'node:path';
@@ -687,8 +687,8 @@ async function checkCorpEmails(manifest, findings, disclosures) {
687
687
  }
688
688
  }
689
689
  async function checkGateDrift(manifest, findings, disclosures) {
690
- const { planWorkflow } = await import('./generate-I75IEXXF.js');
691
- const { scanProject: scanProject2 } = await import('./scan-IY3LCBJ6.js');
690
+ const { planWorkflow } = await import('./generate-JEREMVVO.js');
691
+ const { scanProject: scanProject2 } = await import('./scan-O3SM2ODH.js');
692
692
  const { readConfig } = await import('./config-MX7OYI7F.js');
693
693
  for (const entry of manifest.entries) {
694
694
  const root = entry.resolvedRoot;
@@ -1,12 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  import { applyFiles } from './chunk-EF6BPBG6.js';
3
- import { derivedCommands, planWorkflow, runPrefix, isSafeGateCommand } from './chunk-VFUYQLW7.js';
3
+ import { derivedCommands, planWorkflow, runPrefix, isSafeGateCommand } from './chunk-JTHZVEON.js';
4
4
  import { readConfig, CONFIG_FILE, configPath } from './chunk-NMZ3RHWW.js';
5
5
  import { section, print, glyph, theme, renderPlan } from './chunk-TADKOW6P.js';
6
- import { scanProject } from './chunk-7MOBQ4N5.js';
7
- import './chunk-B3SUTZVY.js';
6
+ import { scanProject } from './chunk-SXCR6RRM.js';
7
+ import './chunk-QYHROOMK.js';
8
8
  import './chunk-R74SJ7HS.js';
9
- import './chunk-CRJHIXJH.js';
9
+ import './chunk-HGSLHNNT.js';
10
10
  import { readText, git } from './chunk-YLNQQZ5F.js';
11
11
  import { promises } from 'node:fs';
12
12
  import path from 'node:path';
@@ -57,7 +57,9 @@ function publishGateKey(route) {
57
57
  }
58
58
  function printGateSummary(gates, facts, source) {
59
59
  const run2 = runPrefix(facts.packageManager);
60
- const cmds = gates.commands.length ? gates.commands.map((c) => `${run2} ${c}`).join(", ") : "none detected";
60
+ const shellStep = facts.shell?.scripts ? `shellcheck (${facts.shell.scripts} script${facts.shell.scripts === 1 ? "" : "s"})` : "";
61
+ const listed = [...gates.commands.map((c) => `${run2} ${c}`), ...shellStep ? [shellStep] : []];
62
+ const cmds = listed.length ? listed.join(", ") : "none detected";
61
63
  print(` ${glyph.bullet} ${theme.dim("pre-push runs")} ${theme.info(cmds)}`);
62
64
  print(` ${glyph.bullet} ${theme.dim("audit fails on")} ${theme.info(gates.failOn)}`);
63
65
  print(
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
- export { derivedCommands, planWorkflow } from './chunk-VFUYQLW7.js';
2
+ export { derivedCommands, planWorkflow } from './chunk-JTHZVEON.js';
3
3
  import './chunk-NMZ3RHWW.js';
4
4
  import './chunk-R74SJ7HS.js';
5
- import './chunk-CRJHIXJH.js';
5
+ import './chunk-HGSLHNNT.js';
6
6
  import './chunk-YLNQQZ5F.js';
package/dist/index.d.ts CHANGED
@@ -121,6 +121,23 @@ interface ProjectFacts {
121
121
  */
122
122
  publishRoute: "npm" | "vscode" | "none";
123
123
  artifacts: DetectedArtifact[];
124
+ /**
125
+ * The repo's SHELL surface: tracked files that a shell executes.
126
+ *
127
+ * Package scripts are not the only executable surface a repo has, and in some repos they are
128
+ * not the main one — a tools or infra repo can be entirely `bootstrap/*.sh` plus `.githooks/*`
129
+ * with no `package.json` at all. Those are exactly the repos that report "no correctness
130
+ * commands detected", which reads as "nothing to check" when it means "nothing was looked at".
131
+ *
132
+ * Counted, not listed: the generated hook re-discovers scripts at push time by shebang, so it
133
+ * stays correct as the repo grows. This number only decides WHETHER the step is worth writing.
134
+ *
135
+ * Optional for the same reason as `freshness`: a facts file written by an older etymd predates
136
+ * the field, and absent must read as "not measured" rather than "measured as zero".
137
+ */
138
+ shell?: {
139
+ scripts: number;
140
+ };
124
141
  /** Optional: baselines approved by older versions predate this fact. */
125
142
  freshness?: FreshnessFacts;
126
143
  tree: {
@@ -752,7 +769,7 @@ declare const gateIntegrityLens: Lens;
752
769
  * change meaning. Stamped into facts, baselines, and generated artifacts so drift against the
753
770
  * pack is computable and `harvest` has something to diff.
754
771
  */
755
- declare const PACK_VERSION = "3";
772
+ declare const PACK_VERSION = "4";
756
773
 
757
774
  declare const VERSION: string;
758
775
  declare const NAME: string;
package/dist/index.js CHANGED
@@ -19,7 +19,7 @@ var __export = (target, all) => {
19
19
  var PACK_VERSION;
20
20
  var init_version = __esm({
21
21
  "src/pack/version.ts"() {
22
- PACK_VERSION = "3";
22
+ PACK_VERSION = "4";
23
23
  }
24
24
  });
25
25
 
@@ -29,7 +29,7 @@ var init_package = __esm({
29
29
  "package.json"() {
30
30
  package_default = {
31
31
  name: "etymd",
32
- version: "0.3.2",
32
+ version: "0.4.0",
33
33
  description: "Keep your agent instructions true \u2014 verify AGENTS.md, CLAUDE.md, rules & skills against the actual repo, with drift caught over time and a regression ledger.",
34
34
  keywords: [
35
35
  "cli",
@@ -438,6 +438,31 @@ async function detectHooks(root, hooksPath, pkg) {
438
438
  }
439
439
  return { source: "none", preCommit: false, prePush: false, commitMsg: false, lintStaged };
440
440
  }
441
+ async function firstLine(file) {
442
+ let handle;
443
+ try {
444
+ handle = await promises.open(file, "r");
445
+ const { buffer, bytesRead } = await handle.read(Buffer.alloc(128), 0, 128, 0);
446
+ return buffer.subarray(0, bytesRead).toString("utf8").split("\n", 1)[0] ?? "";
447
+ } catch {
448
+ return "";
449
+ } finally {
450
+ await handle?.close();
451
+ }
452
+ }
453
+ async function detectShellSurface(root, isRepo) {
454
+ if (!isRepo) return { scripts: 0 };
455
+ const listed = await git(root, ["ls-files", "-z"]);
456
+ if (!listed) return { scripts: 0 };
457
+ const files = listed.split("\0").filter(Boolean);
458
+ const byExtension = files.filter((f) => SHELL_EXT.test(f));
459
+ const candidates = files.filter((f) => !path.basename(f).includes("."));
460
+ const SHEBANG_PROBE_CAP = 400;
461
+ const probed = await Promise.all(
462
+ candidates.slice(0, SHEBANG_PROBE_CAP).map(async (f) => SHELL_SHEBANG.test(await firstLine(path.join(root, f))))
463
+ );
464
+ return { scripts: byExtension.length + probed.filter(Boolean).length };
465
+ }
441
466
  async function detectArtifacts(root) {
442
467
  const artifacts = await Promise.all(
443
468
  ARTIFACT_SPECS.map(async (spec) => ({
@@ -504,7 +529,7 @@ async function walkTree(root) {
504
529
  dirs.sort((a, b) => b.files - a.files);
505
530
  return { dirs, truncated: state.truncated };
506
531
  }
507
- var IGNORED_DIRS, MEANINGFUL_DOT_DIRS, isMeta, ROLE_LADDERS, FRAMEWORK_MARKERS, ARTIFACT_SPECS;
532
+ var IGNORED_DIRS, MEANINGFUL_DOT_DIRS, isMeta, ROLE_LADDERS, FRAMEWORK_MARKERS, ARTIFACT_SPECS, SHELL_SHEBANG, SHELL_EXT;
508
533
  var init_detect = __esm({
509
534
  "src/core/detect.ts"() {
510
535
  init_util();
@@ -677,6 +702,8 @@ var init_detect = __esm({
677
702
  kind: "map"
678
703
  }
679
704
  ];
705
+ SHELL_SHEBANG = /^#!\s*\/\S*\b(?:ba|da|z)?sh\b|^#!\s*\/usr\/bin\/env\s+(?:ba|da|z)?sh\b/;
706
+ SHELL_EXT = /\.(?:sh|bash|zsh)$/;
680
707
  }
681
708
  });
682
709
 
@@ -764,6 +791,7 @@ async function scanProject(root, opts = {}) {
764
791
  ]) : [null, null, null, null, null];
765
792
  const hooksPath = hooksPathRaw ?? void 0;
766
793
  const hooks = await detectHooks(abs, hooksPath, rootPkg);
794
+ const shell = await detectShellSurface(abs, isRepo);
767
795
  const freshness = await collectFreshness(abs, isRepo, artifacts, opts.upstreamRemote);
768
796
  const packages = workspace.packageGlobs.length ? await listWorkspacePackages(abs, workspace.packageGlobs) : [];
769
797
  const recentAuthors = authorsRaw ? new Set(authorsRaw.split("\n").filter(Boolean)).size : void 0;
@@ -795,6 +823,7 @@ async function scanProject(root, opts = {}) {
795
823
  // vsce — which never runs prepublishOnly.
796
824
  publishRoute: !rootPkg ? "none" : rootPkg.engines?.vscode ? "vscode" : "npm",
797
825
  artifacts,
826
+ shell,
798
827
  freshness,
799
828
  tree
800
829
  };
@@ -1115,6 +1144,25 @@ ${localHookCall("commit-msg")}
1115
1144
  exit 0
1116
1145
  `;
1117
1146
  }
1147
+ function shellcheckStep() {
1148
+ return `
1149
+ # Shell correctness. Scripts are discovered by shebang over TRACKED files at push time, so a
1150
+ # script added later is covered without regenerating this hook.
1151
+ if command -v shellcheck >/dev/null 2>&1; then
1152
+ scripts=$(git ls-files -z \\
1153
+ | xargs -0 -I{} sh -c 'head -1 "{}" 2>/dev/null | grep -qE "^#!.*[/ ](ba|da|z)?sh( |$)" && echo "{}"' \\
1154
+ | sort)
1155
+ if [ -n "$scripts" ]; then
1156
+ echo "\u203A shellcheck ($(printf '%s\\n' "$scripts" | wc -l | tr -d ' ') scripts, blocking at severity=warning)"
1157
+ printf '%s\\n' "$scripts" | xargs shellcheck -S warning || {
1158
+ echo " fix, or justify inline with '# shellcheck disable=SCxxxx # why'"
1159
+ exit 1
1160
+ }
1161
+ fi
1162
+ else
1163
+ echo "\u203A shellcheck skipped (not on PATH) \u2014 install it to gate this repo's shell scripts"
1164
+ fi`;
1165
+ }
1118
1166
  function generatePrePushHook(facts, gates) {
1119
1167
  const run = runPrefix(facts.packageManager);
1120
1168
  const c = facts.commands;
@@ -1123,8 +1171,14 @@ function generatePrePushHook(facts, gates) {
1123
1171
  const steps = candidates.filter(
1124
1172
  (key) => Boolean(key) && (allowed.has(key) || isSafeGateCommand(c.raw[key]))
1125
1173
  ).map((key) => `${run} ${key}`);
1174
+ const shellStep = facts.shell?.scripts ? shellcheckStep() : "";
1126
1175
  const body = steps.length ? steps.map((s) => `echo "\u203A ${s}"
1127
- ${s} || exit 1`).join("\n") : 'echo "etymd: no correctness commands detected \u2014 add format:check / typecheck / lint"';
1176
+ ${s} || exit 1`).join("\n") : shellStep ? (
1177
+ // A repo whose executable surface is shell HAS a correctness command — it just is not in
1178
+ // package.json. Claiming "none detected" beside a step that is about to run would be the
1179
+ // tool contradicting itself.
1180
+ `echo "\u203A no package scripts \u2014 shell is this repo's checkable surface"`
1181
+ ) : 'echo "etymd: no correctness commands detected \u2014 add format:check / typecheck / lint"';
1128
1182
  const failOn = gates?.failOn ?? "risk";
1129
1183
  const auditStep = `
1130
1184
  if command -v etymd >/dev/null 2>&1; then
@@ -1137,7 +1191,7 @@ fi`;
1137
1191
  # etymd: correctness gate. Mirrors CI cheapest-first; blocks the push on any failure.
1138
1192
 
1139
1193
  ${localHookCall("pre-push")}
1140
- ${body}
1194
+ ${body}${shellStep}
1141
1195
  ${auditStep}
1142
1196
 
1143
1197
  # Content screen, second pass \u2014 the WHOLE TREE rather than one diff. Catches anything committed
@@ -1,12 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  import { applyFiles } from './chunk-EF6BPBG6.js';
3
- import { planWorkflow } from './chunk-VFUYQLW7.js';
3
+ import { planWorkflow } from './chunk-JTHZVEON.js';
4
4
  import './chunk-NMZ3RHWW.js';
5
5
  import { theme, renderFacts, print, renderPlan, section, glyph } from './chunk-TADKOW6P.js';
6
- import { scanProject } from './chunk-7MOBQ4N5.js';
7
- import { VERSION } from './chunk-B3SUTZVY.js';
6
+ import { scanProject } from './chunk-SXCR6RRM.js';
7
+ import { VERSION } from './chunk-QYHROOMK.js';
8
8
  import { writeCachedFacts, deriveProfile, writeBaseline, CACHE_DIR } from './chunk-R74SJ7HS.js';
9
- import { PACK_VERSION } from './chunk-CRJHIXJH.js';
9
+ import { PACK_VERSION } from './chunk-HGSLHNNT.js';
10
10
  import { git, readText } from './chunk-YLNQQZ5F.js';
11
11
  import { promises } from 'node:fs';
12
12
  import path from 'node:path';
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import { print, renderFacts, theme } from './chunk-TADKOW6P.js';
3
- import { scanProject } from './chunk-7MOBQ4N5.js';
4
- import './chunk-B3SUTZVY.js';
3
+ import { scanProject } from './chunk-SXCR6RRM.js';
4
+ import './chunk-QYHROOMK.js';
5
5
  import { writeCachedFacts } from './chunk-R74SJ7HS.js';
6
- import './chunk-CRJHIXJH.js';
6
+ import './chunk-HGSLHNNT.js';
7
7
  import './chunk-YLNQQZ5F.js';
8
8
 
9
9
  // src/commands/scan.ts
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+ export { scanProject } from './chunk-SXCR6RRM.js';
3
+ import './chunk-QYHROOMK.js';
4
+ import './chunk-HGSLHNNT.js';
5
+ import './chunk-YLNQQZ5F.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "etymd",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "description": "Keep your agent instructions true — verify AGENTS.md, CLAUDE.md, rules & skills against the actual repo, with drift caught over time and a regression ledger.",
5
5
  "keywords": [
6
6
  "cli",
@@ -1,5 +0,0 @@
1
- #!/usr/bin/env node
2
- export { scanProject } from './chunk-7MOBQ4N5.js';
3
- import './chunk-B3SUTZVY.js';
4
- import './chunk-CRJHIXJH.js';
5
- import './chunk-YLNQQZ5F.js';