etymd 0.5.0 → 0.6.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.
Files changed (39) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/README.md +4 -3
  3. package/dist/{approve-ASWRO6OD.js → approve-ZLOW4A7R.js} +6 -6
  4. package/dist/audit-ICT2JK7A.js +12 -0
  5. package/dist/{brief-HWD7CILP.js → brief-PDUGGMLI.js} +6 -6
  6. package/dist/{chunk-TADKOW6P.js → chunk-5BVKFJWM.js} +7 -3
  7. package/dist/{chunk-IGHES6AK.js → chunk-6DUDIVIC.js} +1 -1
  8. package/dist/{chunk-CYSEGWIW.js → chunk-BQGCLPHS.js} +2 -2
  9. package/dist/{chunk-R74SJ7HS.js → chunk-C7LBUFNU.js} +1 -1
  10. package/dist/{chunk-OJVQXB5Z.js → chunk-HLWCODYX.js} +1 -1
  11. package/dist/{chunk-YLNQQZ5F.js → chunk-IV3FYVTS.js} +11 -1
  12. package/dist/{chunk-NMZ3RHWW.js → chunk-K7QBTANO.js} +2 -2
  13. package/dist/{chunk-OX3LUOZR.js → chunk-KTIEYKFK.js} +46 -22
  14. package/dist/{chunk-WWBF4Y7K.js → chunk-OAFYLBVG.js} +1 -1
  15. package/dist/{chunk-QJRPKZPA.js → chunk-OESQNO2J.js} +77 -9
  16. package/dist/{chunk-LT67FU2Y.js → chunk-OKU3HXIH.js} +2 -2
  17. package/dist/{chunk-4KOP36RD.js → chunk-ULEWJ3ZK.js} +3 -3
  18. package/dist/{chunk-MMPV67FW.js → chunk-V2SOHPXF.js} +2 -2
  19. package/dist/cli.js +18 -18
  20. package/dist/config-UJRSCDMD.js +4 -0
  21. package/dist/{context-4DGXO7JR.js → context-ZEUI5ESZ.js} +5 -5
  22. package/dist/doctor-PORXFSYT.js +19 -0
  23. package/dist/{fleet-SAFXVN44.js → fleet-JHNQOLSY.js} +24 -17
  24. package/dist/{gates-D5SBAVXB.js → gates-KHC7HPIF.js} +21 -13
  25. package/dist/generate-UTKQ4GE6.js +6 -0
  26. package/dist/index.d.ts +21 -2
  27. package/dist/index.js +138 -31
  28. package/dist/{init-AVE2GDJ6.js → init-IPUPP4GB.js} +8 -8
  29. package/dist/ledger-B2XGANTH.js +5 -0
  30. package/dist/{scan-5DSECI43.js → scan-6L4L2GXT.js} +6 -6
  31. package/dist/scan-D4M7JXE3.js +5 -0
  32. package/dist/{screen-U7JMWZPS.js → screen-6R6NXDSY.js} +2 -2
  33. package/package.json +3 -4
  34. package/dist/audit-LPZOGOHE.js +0 -12
  35. package/dist/config-MX7OYI7F.js +0 -4
  36. package/dist/doctor-MJ6RBZ2Q.js +0 -19
  37. package/dist/generate-CBMTNN7P.js +0 -6
  38. package/dist/ledger-TMIV45AA.js +0 -5
  39. package/dist/scan-ES7IOXOC.js +0 -5
package/CHANGELOG.md CHANGED
@@ -1,5 +1,83 @@
1
1
  # etymd
2
2
 
3
+ ## 0.6.0
4
+
5
+ ### Minor Changes
6
+
7
+ - b02943b: Local gates: the tool now reads and rewrites what it generated, instead of guessing about it.
8
+
9
+ Two defects in the local-gate machinery, both silent, both affecting every project using
10
+ `etymd gates`. Decision record:
11
+ [`docs/decisions/006-local-gate-provenance.md`](docs/decisions/006-local-gate-provenance.md).
12
+
13
+ **`gate-integrity` follows the `<hook>.local` include.** Every generated hook opens by sourcing a
14
+ sibling companion — the sanctioned place for project-specific guards, which etymd never reads,
15
+ writes or regenerates. The lens stopped at the hook file, so a check placed in the companion ran
16
+ on every push, blocked it, and was still reported as enforced only in CI. The only way to silence
17
+ that was a dismissal, which is meant for a false positive or an accepted trade-off, not for a
18
+ blind spot. The lens now resolves the companion for `pre-commit`, `pre-push` and `commit-msg` and
19
+ counts what it enforces.
20
+
21
+ Two conditions keep it honest, both mirroring what the shell actually does: the hook must
22
+ genuinely reference the companion, and the companion must pass the same `[ -x ]` test the hook
23
+ applies before running it. A present-but-not-executable companion is reported as inert — named,
24
+ with the `chmod +x` that would make it run — rather than credited with checks that never fire.
25
+ Both outcomes are disclosed in the lens report.
26
+
27
+ **A stale generated hook is no longer indistinguishable from a hand-edited one.** `etymd gates`
28
+ refused to overwrite any hook whose content differed from what it would generate, printing
29
+ `kept (hand-edited)`. That protection is right for a customised hook and wrong for a merely stale
30
+ one — generated by an older etymd, or before the repo's package manager or scripts changed. In
31
+ that case the refusal preserved a gate that had stopped matching the repo, while `etymd audit`
32
+ went on reporting the gaps the refused rewrite would have closed; the only escape was deleting the
33
+ file, which nobody would think to try.
34
+
35
+ Generated shell files now carry a stamp as their last line, whose digest covers the file minus
36
+ that line. A file that still hashes to its own stamp is byte-for-byte what etymd wrote and cannot
37
+ contain anyone's work, so it is regenerated freely; any edit breaks the match and the file is
38
+ kept. `[differs]` is no longer one bucket — the plan tags a stale file `[stale]` and says it is
39
+ regenerating, and says why it is keeping the others.
40
+
41
+ A hook generated before the stamp existed carries no stamp and cannot be proven untouched, so it
42
+ is kept — now with the reason and the way out stated, instead of a dead end. One regeneration
43
+ makes it provable from then on.
44
+
45
+ **Provenance applies to everything the pack generates, not just hooks.** The `AGENTS.md` scaffold
46
+ is stamped too, in an HTML comment so it stays invisible in the rendered document; its stamp
47
+ replaces the bare `<!-- etymd pack vN -->` line, carrying the same version plus the provenance.
48
+ `.etymd/config.json` is excluded — it holds the user's recorded decisions, is merged into rather
49
+ than generated, and JSON has nowhere to hide a stamp.
50
+
51
+ **New finding: a companion that cannot run.** `gate-integrity/companion-not-executable` reports a
52
+ `<hook>.local` that a hook calls, that exists, and that provably lacks the execute bit — the check
53
+ inside is skipped silently on every run, and git tracks exactly one permission bit, so the dead
54
+ gate reaches every clone. Emitted at `gap` rather than `risk`: risk is reserved for what makes an
55
+ agent do the wrong thing (`hooks-not-wired` earns it because every gate is dead there), and it
56
+ also means an upgrade cannot fail the `--fail-on risk` gate in a repo whose code did not change.
57
+ The action names both halves of the fix, since `chmod +x` alone does not survive a fresh clone.
58
+
59
+ The accusation is guarded: "not executable" is trusted only where the hook beside it HAS the bit,
60
+ which is what proves the bit means anything on that filesystem. Where it cannot be established the
61
+ checks are counted and the uncertainty is disclosed, so a checkout without POSIX mode bits is
62
+ never told its working gate is dead.
63
+
64
+ **The fleet sweep stops recommending a command that would refuse.** `fleet` reported every
65
+ differing gate under one finding whose action was "re-run `etymd gates`" — advice that does not
66
+ work for a hand-edited hook. A provably-edited gate is now disclosed as a customisation rather
67
+ than reported as drift; stale and unstamped gates stay in the finding, since being unable to prove
68
+ a file was touched is not evidence that it was.
69
+
70
+ `PACK_VERSION` moves to 6: the generated files change meaning.
71
+
72
+ ### Patch Changes
73
+
74
+ - `yaml` moves from 2.7.0 to 2.9.0, clearing GHSA-48c2-rrv3-qjmp (stack overflow on deeply nested
75
+ collections) from the dependency tree of everyone who installs etymd. The lens that parses CI
76
+ files already wrapped every `YAML.parse` in a try/catch, so a hostile `.gitlab-ci.yml` was
77
+ caught and disclosed as a parse error rather than crashing anything — verified against a
78
+ 200,000-level nested file before and after. The bump is about not shipping a known-vulnerable
79
+ dependency, not about a reachable exploit.
80
+
3
81
  ## 0.5.0
4
82
 
5
83
  ### Minor Changes
package/README.md CHANGED
@@ -494,9 +494,10 @@ Without it — on a fresh clone or in CI — those suites skip cleanly and the r
494
494
 
495
495
  [`docs/decisions/`](https://github.com/triartleet/etymd/tree/main/docs/decisions) — 001 founding · 002 foundation re-lock · **003 the truth-guard
496
496
  pivot** (the current identity; includes the state-of-the-field investigation it rests on) ·
497
- **004 fleet mode** (the truth guard across your repositories).
498
- [`ROADMAP.md`](https://github.com/triartleet/etymd/blob/main/ROADMAP.md) — what's now / next / later, the pre-publish checklist, and the
499
- accepted heuristic trade-offs.
497
+ **004 fleet mode** (the truth guard across your repositories) · 005 declared rules (design only) ·
498
+ **006 local gate provenance** (what the tool may read, and what it may rewrite).
499
+ [`ROADMAP.md`](https://github.com/triartleet/etymd/blob/main/ROADMAP.md) — what's now / next / later, and the accepted heuristic
500
+ trade-offs.
500
501
 
501
502
  ## License
502
503
 
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
- import { section, theme, print, renderBaselineDrift, glyph } from './chunk-TADKOW6P.js';
3
- import { scanProject } from './chunk-4KOP36RD.js';
4
- import { VERSION } from './chunk-OJVQXB5Z.js';
5
- import { readBaseline, summarizeBaselineDrift, isDriftEmpty, writeBaseline, deriveProfile } from './chunk-R74SJ7HS.js';
6
- import { PACK_VERSION } from './chunk-IGHES6AK.js';
7
- import './chunk-YLNQQZ5F.js';
2
+ import { section, theme, print, renderBaselineDrift, glyph } from './chunk-5BVKFJWM.js';
3
+ import { scanProject } from './chunk-ULEWJ3ZK.js';
4
+ import { VERSION } from './chunk-HLWCODYX.js';
5
+ import { readBaseline, summarizeBaselineDrift, isDriftEmpty, writeBaseline, deriveProfile } from './chunk-C7LBUFNU.js';
6
+ import { PACK_VERSION } from './chunk-6DUDIVIC.js';
7
+ import './chunk-IV3FYVTS.js';
8
8
 
9
9
  // src/commands/approve.ts
10
10
  async function run(opts) {
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env node
2
+ export { run } from './chunk-BQGCLPHS.js';
3
+ import './chunk-OESQNO2J.js';
4
+ import './chunk-OKU3HXIH.js';
5
+ import './chunk-OAFYLBVG.js';
6
+ import './chunk-K7QBTANO.js';
7
+ import './chunk-5BVKFJWM.js';
8
+ import './chunk-ULEWJ3ZK.js';
9
+ import './chunk-HLWCODYX.js';
10
+ import './chunk-C7LBUFNU.js';
11
+ import './chunk-6DUDIVIC.js';
12
+ import './chunk-IV3FYVTS.js';
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
- import { print, theme } from './chunk-TADKOW6P.js';
3
- import { scanProject } from './chunk-4KOP36RD.js';
4
- import './chunk-OJVQXB5Z.js';
5
- import { ETYMD_DIR } from './chunk-R74SJ7HS.js';
6
- import './chunk-IGHES6AK.js';
7
- import './chunk-YLNQQZ5F.js';
2
+ import { print, theme } from './chunk-5BVKFJWM.js';
3
+ import { scanProject } from './chunk-ULEWJ3ZK.js';
4
+ import './chunk-HLWCODYX.js';
5
+ import { ETYMD_DIR } from './chunk-C7LBUFNU.js';
6
+ import './chunk-6DUDIVIC.js';
7
+ import './chunk-IV3FYVTS.js';
8
8
  import { promises } from 'node:fs';
9
9
  import path from 'node:path';
10
10
 
@@ -128,11 +128,15 @@ function renderContext(budget, threshold) {
128
128
  print(` ${glyph.ok} ${theme.dim("lean \u2014 no single file is heavy enough to extract yet")}`);
129
129
  }
130
130
  }
131
- function renderPlan(files) {
131
+ function renderPlan(files, opts = {}) {
132
132
  section("Plan");
133
133
  for (const f of files) {
134
- const tag = !f.exists ? theme.ok("create") : f.differs ? theme.warn("differs") : theme.dim("same");
135
- print(` ${pc.dim("[")}${tag}${pc.dim("]")} ${theme.info(f.path)} ${theme.dim(f.label)}`);
134
+ const stale = f.differs && f.drift === "stale";
135
+ const tag = !f.exists ? theme.ok("create") : stale ? theme.warn("stale") : f.differs ? theme.warn("differs") : theme.dim("same");
136
+ const note = !f.differs ? "" : stale ? opts.regeneratesStale ? " \u2014 unedited since etymd generated it; regenerating" : " \u2014 unedited since etymd generated it" : f.drift === "edited" ? " \u2014 hand-edited since etymd generated it; keeping" : " \u2014 no etymd generation stamp; keeping";
137
+ print(
138
+ ` ${pc.dim("[")}${tag}${pc.dim("]")} ${theme.info(f.path)} ${theme.dim(f.label)}${theme.dim(note)}`
139
+ );
136
140
  }
137
141
  }
138
142
  var TIER_BADGE = {
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
2
  // src/pack/version.ts
3
- var PACK_VERSION = "5";
3
+ var PACK_VERSION = "6";
4
4
 
5
5
  export { PACK_VERSION };
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { parseFailOnTier, runAudit, meetsFailOn } from './chunk-QJRPKZPA.js';
3
- import { print, section, theme, renderLensCoverage, renderFindings, renderLedgerDiff } from './chunk-TADKOW6P.js';
2
+ import { parseFailOnTier, runAudit, meetsFailOn } from './chunk-OESQNO2J.js';
3
+ import { print, section, theme, renderLensCoverage, renderFindings, renderLedgerDiff } from './chunk-5BVKFJWM.js';
4
4
 
5
5
  // src/commands/audit.ts
6
6
  async function run(opts) {
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { readJson } from './chunk-YLNQQZ5F.js';
2
+ import { readJson } from './chunk-IV3FYVTS.js';
3
3
  import { promises } from 'node:fs';
4
4
  import path from 'node:path';
5
5
 
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  // package.json
3
3
  var package_default = {
4
- version: "0.5.0"};
4
+ version: "0.6.0"};
5
5
 
6
6
  // src/version.ts
7
7
  var VERSION = package_default.version;
@@ -34,6 +34,16 @@ async function isDirectory(p) {
34
34
  return false;
35
35
  }
36
36
  }
37
+ async function isExecutable(p) {
38
+ try {
39
+ const stat = await promises.stat(p);
40
+ if (!stat.isFile()) return false;
41
+ if (process.platform === "win32") return null;
42
+ return (stat.mode & 73) !== 0;
43
+ } catch {
44
+ return false;
45
+ }
46
+ }
37
47
  async function git(root, args) {
38
48
  try {
39
49
  const { stdout } = await pExecFile("git", args, { cwd: root, timeout: 4e3 });
@@ -99,4 +109,4 @@ function matchesAnyGlob(p, globs) {
99
109
  return globs.some((g) => matchesGlob(p, g));
100
110
  }
101
111
 
102
- export { approxTokens, git, isCiEnvironment, isDirectory, matchesAnyGlob, normalizeRelPath, pathExists, readJson, readText, wordCount };
112
+ export { approxTokens, git, isCiEnvironment, isDirectory, isExecutable, matchesAnyGlob, normalizeRelPath, pathExists, readJson, readText, wordCount };
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { ETYMD_DIR } from './chunk-R74SJ7HS.js';
3
- import { pathExists, readText } from './chunk-YLNQQZ5F.js';
2
+ import { ETYMD_DIR } from './chunk-C7LBUFNU.js';
3
+ import { pathExists, readText } from './chunk-IV3FYVTS.js';
4
4
  import path from 'node:path';
5
5
 
6
6
  var CONFIG_FILE = path.join(ETYMD_DIR, "config.json");
@@ -1,10 +1,29 @@
1
1
  #!/usr/bin/env node
2
- import { DEFAULT_CONFIG } from './chunk-NMZ3RHWW.js';
3
- import { PACK_VERSION } from './chunk-IGHES6AK.js';
4
- import { readText, pathExists } from './chunk-YLNQQZ5F.js';
2
+ import { DEFAULT_CONFIG } from './chunk-K7QBTANO.js';
3
+ import { PACK_VERSION } from './chunk-6DUDIVIC.js';
4
+ import { readText, pathExists } from './chunk-IV3FYVTS.js';
5
5
  import path from 'node:path';
6
+ import { createHash } from 'node:crypto';
6
7
 
7
- // src/pack/templates.ts
8
+ var GENERATION_MARKER_RE = /^(?:# |<!-- )etymd:generated pack-v\S+ ([0-9a-f]{16})(?: -->)?$/;
9
+ function digestOf(body) {
10
+ return createHash("sha256").update(body).digest("hex").slice(0, 16);
11
+ }
12
+ function stampGenerated(body, comment = "sh") {
13
+ const marker = `etymd:generated pack-v${PACK_VERSION} ${digestOf(body)}`;
14
+ return `${body}${comment === "md" ? `<!-- ${marker} -->` : `# ${marker}`}
15
+ `;
16
+ }
17
+ function fileOrigin(text) {
18
+ const lines = text.split("\n");
19
+ for (let i = lines.length - 1; i >= 0; i--) {
20
+ const match = GENERATION_MARKER_RE.exec(lines[i]);
21
+ if (!match) continue;
22
+ const body = [...lines.slice(0, i), ...lines.slice(i + 1)].join("\n");
23
+ return digestOf(body) === match[1] ? "pack" : "edited";
24
+ }
25
+ return "unstamped";
26
+ }
8
27
  function runPrefix(pm) {
9
28
  switch (pm) {
10
29
  case "pnpm":
@@ -57,7 +76,8 @@ function generateAgentsMd(facts) {
57
76
  const frameworks = facts.frameworks.length ? facts.frameworks.join(", ") : "none detected";
58
77
  const workspace = facts.workspace.kind === "none" ? "single package" : `${facts.workspace.kind} workspace`;
59
78
  const topDirs = facts.tree.dirs.slice(0, 14);
60
- return `# AGENTS.md
79
+ return stampGenerated(
80
+ `# AGENTS.md
61
81
 
62
82
  Operating contract for AI agents working in **${facts.name}**. One source of truth \u2014 most agents
63
83
  (Claude Code, Codex, Cursor, Copilot, Gemini, \u2026) read this file natively. Kept true by
@@ -100,16 +120,17 @@ ${done.map((d) => `- ${d}`).join("\n")}` : `Define the check commands that gate
100
120
 
101
121
  \`\`\`bash
102
122
  ${[
103
- facts.commands.dev && `${run} ${facts.commands.dev}`,
104
- facts.commands.build && `${run} ${facts.commands.build}`,
105
- facts.commands.test && `${run} ${facts.commands.test}`,
106
- facts.commands.lint && `${run} ${facts.commands.lint}`,
107
- facts.commands.typecheck && `${run} ${facts.commands.typecheck}`
108
- ].filter(Boolean).join("\n") || "# add the project's key commands"}
123
+ facts.commands.dev && `${run} ${facts.commands.dev}`,
124
+ facts.commands.build && `${run} ${facts.commands.build}`,
125
+ facts.commands.test && `${run} ${facts.commands.test}`,
126
+ facts.commands.lint && `${run} ${facts.commands.lint}`,
127
+ facts.commands.typecheck && `${run} ${facts.commands.typecheck}`
128
+ ].filter(Boolean).join("\n") || "# add the project's key commands"}
109
129
  \`\`\`
110
130
 
111
- <!-- etymd pack v${PACK_VERSION} -->
112
- `;
131
+ `,
132
+ "md"
133
+ );
113
134
  }
114
135
  var CONTENT_GATE_RESOLUTION = `GATE="\${CONTENT_GATE:-$(command -v etymd || true)}"`;
115
136
  function localHookCall(hook) {
@@ -121,7 +142,7 @@ if [ -x "$LOCAL" ]; then
121
142
  fi`;
122
143
  }
123
144
  function generatePreCommitHook() {
124
- return `#!/usr/bin/env sh
145
+ return stampGenerated(`#!/usr/bin/env sh
125
146
  # etymd: process gate. Cheap, locally-knowable checks belong here (fast, blocks the commit).
126
147
 
127
148
  ${localHookCall("pre-commit")}
@@ -138,10 +159,10 @@ if [ -x "$GATE" ]; then
138
159
  fi
139
160
 
140
161
  exit 0
141
- `;
162
+ `);
142
163
  }
143
164
  function generateCommitMsgHook() {
144
- return `#!/usr/bin/env sh
165
+ return stampGenerated(`#!/usr/bin/env sh
145
166
  # etymd: content screen \u2014 the commit message itself.
146
167
  #
147
168
  # The staged-content gate reads file bytes and never sees the message, yet a message is as
@@ -156,7 +177,7 @@ fi
156
177
  ${localHookCall("commit-msg")}
157
178
 
158
179
  exit 0
159
- `;
180
+ `);
160
181
  }
161
182
  function shellcheckStep() {
162
183
  return `
@@ -209,7 +230,7 @@ if command -v etymd >/dev/null 2>&1; then
209
230
  else
210
231
  echo "\u203A etymd audit skipped (not on PATH)"
211
232
  fi`;
212
- return `#!/usr/bin/env sh
233
+ return stampGenerated(`#!/usr/bin/env sh
213
234
  # etymd: correctness gate. Mirrors CI cheapest-first; blocks the push on any failure.
214
235
 
215
236
  ${localHookCall("pre-push")}
@@ -225,10 +246,10 @@ if [ -x "$GATE" ] && [ "\${CONTENT_GATE_PREPUSH:-1}" = "1" ]; then
225
246
  fi
226
247
 
227
248
  exit 0
228
- `;
249
+ `);
229
250
  }
230
251
  function generateArtifactCheckScript() {
231
- return `#!/usr/bin/env sh
252
+ return stampGenerated(`#!/usr/bin/env sh
232
253
  # etymd: content screen \u2014 the published ARTIFACT, not the repository.
233
254
  #
234
255
  # Wire it into the irreversible moment:
@@ -257,7 +278,7 @@ fi
257
278
 
258
279
  "$GATE" screen --dir "$WORK" || exit 1
259
280
  exit 0
260
- `;
281
+ `);
261
282
  }
262
283
 
263
284
  // src/core/generate.ts
@@ -277,11 +298,14 @@ async function planWorkflow(root, facts, opts) {
277
298
  const abs = path.join(root, rel);
278
299
  const exists = await pathExists(abs);
279
300
  const existing = exists ? await readText(abs) : null;
301
+ const differs = exists ? existing !== contents : void 0;
302
+ const origin = differs && existing !== null ? fileOrigin(existing) : void 0;
280
303
  out.push({
281
304
  path: rel,
282
305
  contents,
283
306
  exists,
284
- differs: exists ? existing !== contents : void 0,
307
+ differs,
308
+ drift: origin === "pack" ? "stale" : origin,
285
309
  executable,
286
310
  label
287
311
  });
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { readJson } from './chunk-YLNQQZ5F.js';
2
+ import { readJson } from './chunk-IV3FYVTS.js';
3
3
  import { promises } from 'node:fs';
4
4
  import path from 'node:path';
5
5
 
@@ -1,11 +1,11 @@
1
1
  #!/usr/bin/env node
2
- import { measureContext } from './chunk-LT67FU2Y.js';
3
- import { readLedger, reconcileLedger, writeLedger, visibleFindings } from './chunk-WWBF4Y7K.js';
4
- import { DEFAULT_CONFIG, readConfig, CONFIG_FILE } from './chunk-NMZ3RHWW.js';
5
- import { scanProject, expandFileGlobs } from './chunk-4KOP36RD.js';
6
- import { ETYMD_DIR, writeCachedFacts, readBaseline, deriveProfile, baselineCarriesMachinePath, BASELINE_FILE } from './chunk-R74SJ7HS.js';
7
- import { PACK_VERSION } from './chunk-IGHES6AK.js';
8
- import { pathExists, readJson, git, readText, isDirectory, matchesAnyGlob, isCiEnvironment, normalizeRelPath } from './chunk-YLNQQZ5F.js';
2
+ import { measureContext } from './chunk-OKU3HXIH.js';
3
+ import { readLedger, reconcileLedger, writeLedger, visibleFindings } from './chunk-OAFYLBVG.js';
4
+ import { DEFAULT_CONFIG, readConfig, CONFIG_FILE } from './chunk-K7QBTANO.js';
5
+ import { scanProject, expandFileGlobs } from './chunk-ULEWJ3ZK.js';
6
+ import { ETYMD_DIR, writeCachedFacts, readBaseline, deriveProfile, baselineCarriesMachinePath, BASELINE_FILE } from './chunk-C7LBUFNU.js';
7
+ import { PACK_VERSION } from './chunk-6DUDIVIC.js';
8
+ import { pathExists, readJson, git, readText, isDirectory, matchesAnyGlob, isCiEnvironment, normalizeRelPath, isExecutable } from './chunk-IV3FYVTS.js';
9
9
  import path from 'node:path';
10
10
  import YAML from 'yaml';
11
11
  import { promises } from 'node:fs';
@@ -302,13 +302,43 @@ function parseGithubWorkflow(text, file, scripts) {
302
302
  }
303
303
  return { jobs, parseErrors };
304
304
  }
305
+ async function companionOf(root, hooksDir, hook, hookText, scripts) {
306
+ const rel = path.posix.join(hooksDir.split(path.sep).join("/"), `${hook}.local`);
307
+ if (!hookText.includes(`${hook}.local`)) return null;
308
+ const abs = path.join(root, hooksDir, `${hook}.local`);
309
+ const text = await readText(abs);
310
+ if (text === null) return null;
311
+ const [executable, hookExecutable] = await Promise.all([
312
+ isExecutable(abs),
313
+ isExecutable(path.join(root, hooksDir, hook))
314
+ ]);
315
+ if (executable === false && hookExecutable === true) return { rel, tools: [], state: "inert" };
316
+ return {
317
+ rel,
318
+ tools: matchTools(text, scripts),
319
+ state: executable === true ? "counted" : "unverified"
320
+ };
321
+ }
305
322
  async function localHookTools(root, facts, scripts) {
306
323
  const hooks = facts.hooks;
307
324
  const empty = [];
325
+ const companions = [];
326
+ const unverifiedCompanions = [];
327
+ const inertCompanions = [];
308
328
  const readHook = async (name) => {
309
329
  if (!hooks.dir) return empty;
310
330
  const text = await readText(path.join(root, hooks.dir, name));
311
- return text ? matchTools(text, scripts) : empty;
331
+ if (!text) return empty;
332
+ const tools = new Set(matchTools(text, scripts));
333
+ const companion = await companionOf(root, hooks.dir, name, text, scripts);
334
+ if (companion) {
335
+ if (companion.state === "inert") inertCompanions.push(companion.rel);
336
+ else {
337
+ (companion.state === "counted" ? companions : unverifiedCompanions).push(companion.rel);
338
+ for (const t of companion.tools) tools.add(t);
339
+ }
340
+ }
341
+ return [...tools];
312
342
  };
313
343
  let preCommit = [];
314
344
  let prePush = [];
@@ -352,7 +382,17 @@ async function localHookTools(root, facts, scripts) {
352
382
  }
353
383
  }
354
384
  const wired = hooks.source === "custom" || hooks.source === "husky" || hooks.source === "husky-legacy" ? true : hooks.source === "githooks" ? facts.git.hooksPath === hooks.dir : false;
355
- return { source: hooks.source, wired, preCommit, prePush, commitMsg, lintStaged };
385
+ return {
386
+ source: hooks.source,
387
+ wired,
388
+ preCommit,
389
+ prePush,
390
+ commitMsg,
391
+ lintStaged,
392
+ companions,
393
+ unverifiedCompanions,
394
+ inertCompanions
395
+ };
356
396
  }
357
397
  async function detectThresholds(root, scripts, ciJobs) {
358
398
  const sonarConfigured = await pathExists(path.join(root, "sonar-project.properties")) || ciJobs.some((j) => j.tools.includes("sonar"));
@@ -485,6 +525,24 @@ function deriveGateFindings(inv, disclosures = []) {
485
525
  );
486
526
  }
487
527
  }
528
+ if (inv.local.inertCompanions?.length) {
529
+ const files = inv.local.inertCompanions;
530
+ findings.push(
531
+ finding({
532
+ id: `${LENS_ID2}/companion-not-executable`,
533
+ tier: "gap",
534
+ kind: "truth",
535
+ claim: `${files.join(", ")} ${files.length === 1 ? "is" : "are"} called by a hook but not executable \u2014 the checks inside never run`,
536
+ evidence: files,
537
+ why: "The hook guards the call with `[ -x ]`, so it skips the file in silence \u2014 the repo looks guarded and is not, on every clone that carries the same mode bit.",
538
+ // chmod alone does not survive a fresh clone: git tracks exactly one permission bit, so
539
+ // the fix has to reach the index or the dead gate propagates to everyone else.
540
+ action: `Run \`chmod +x ${files[0]}\` and commit the mode with \`git update-index --chmod=+x ${files[0]}\`.`,
541
+ effort: "S",
542
+ confidence: "high"
543
+ })
544
+ );
545
+ }
488
546
  for (const tool of CORRECTNESS_TOOLS) {
489
547
  if (!enforcedCiTools.has(tool) || localTools.has(tool)) continue;
490
548
  const jobs = enforcedCiJobs.filter((j) => j.tools.includes(tool));
@@ -585,6 +643,16 @@ var gateIntegrityLens = {
585
643
  }
586
644
  if (inv.ci.parseErrors.length)
587
645
  disclosures.push(...inv.ci.parseErrors.map((e) => `CI parse: ${e}`));
646
+ if (inv.local.companions.length) {
647
+ disclosures.push(
648
+ `Checks in ${inv.local.companions.join(", ")} are counted as locally enforced \u2014 the generated hook calls the companion, and etymd reads it without owning it.`
649
+ );
650
+ }
651
+ if (inv.local.unverifiedCompanions.length) {
652
+ disclosures.push(
653
+ `Checks in ${inv.local.unverifiedCompanions.join(", ")} are counted as locally enforced, but the execute bit could not be judged here (no POSIX mode bits, or the hook beside it is not executable either) \u2014 whether ${inv.local.unverifiedCompanions.length === 1 ? "it runs" : "they run"} was not verified.`
654
+ );
655
+ }
588
656
  if (inv.ci.system === "none")
589
657
  disclosures.push("No CI configuration found \u2014 CI\u2194local comparisons skipped.");
590
658
  const findings = deriveGateFindings(inv, disclosures);
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { DEFAULT_CONFIG } from './chunk-NMZ3RHWW.js';
3
- import { readText, wordCount, approxTokens, pathExists } from './chunk-YLNQQZ5F.js';
2
+ import { DEFAULT_CONFIG } from './chunk-K7QBTANO.js';
3
+ import { readText, wordCount, approxTokens, pathExists } from './chunk-IV3FYVTS.js';
4
4
  import { promises } from 'node:fs';
5
5
  import path from 'node:path';
6
6
 
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
- import { VERSION } from './chunk-OJVQXB5Z.js';
3
- import { PACK_VERSION } from './chunk-IGHES6AK.js';
4
- import { normalizeRelPath, isDirectory, readJson, git, matchesAnyGlob, pathExists, readText } from './chunk-YLNQQZ5F.js';
2
+ import { VERSION } from './chunk-HLWCODYX.js';
3
+ import { PACK_VERSION } from './chunk-6DUDIVIC.js';
4
+ import { normalizeRelPath, isDirectory, readJson, git, matchesAnyGlob, pathExists, readText } from './chunk-IV3FYVTS.js';
5
5
  import path from 'node:path';
6
6
  import { promises } from 'node:fs';
7
7
 
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { readLedger, resolveEntry, writeLedger } from './chunk-WWBF4Y7K.js';
3
- import { print, renderLedger, glyph, theme } from './chunk-TADKOW6P.js';
2
+ import { readLedger, resolveEntry, writeLedger } from './chunk-OAFYLBVG.js';
3
+ import { print, renderLedger, glyph, theme } from './chunk-5BVKFJWM.js';
4
4
 
5
5
  // src/commands/ledger.ts
6
6
  async function list(opts) {