etymd 0.12.0 → 0.14.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 +112 -0
- package/dist/{approve-PP2QJQ3Y.js → approve-QDOH2E7V.js} +5 -5
- package/dist/audit-42OHF7ON.js +10 -0
- package/dist/{brief-GDLJEG6X.js → brief-ZUQK3CFL.js} +5 -5
- package/dist/{chunk-YFNRDXUY.js → chunk-623W6KVC.js} +1 -1
- package/dist/{chunk-5BVKFJWM.js → chunk-HI7NWPRA.js} +79 -4
- package/dist/{chunk-2VLNI3L2.js → chunk-HOR4M6EC.js} +1 -1
- package/dist/{chunk-MTVEJ33D.js → chunk-HWC6PKJM.js} +2 -2
- package/dist/{chunk-O3PHW4OP.js → chunk-LT7JMVV3.js} +127 -11
- package/dist/{chunk-DWL2IKZH.js → chunk-P6ATKV2R.js} +65 -3
- package/dist/{chunk-SWA4NQHB.js → chunk-PAMWS665.js} +2 -2
- package/dist/{chunk-HRJJQCMT.js → chunk-UFNETE6P.js} +69 -20
- package/dist/{chunk-LSZGCKIQ.js → chunk-YQZDYDAK.js} +1 -1
- package/dist/cli.js +20 -20
- package/dist/{config-XAH6PA5G.js → config-724Y3IOB.js} +1 -2
- package/dist/{context-F63RSIBH.js → context-JGKU4M7Z.js} +2 -4
- package/dist/doctor-CE5SD4JW.js +17 -0
- package/dist/{fleet-5NVOUIZD.js → fleet-F3XDEDCV.js} +10 -12
- package/dist/{gates-OS5V3P4C.js → gates-F4YPKWJR.js} +6 -7
- package/dist/generate-KEX75XNG.js +5 -0
- package/dist/index.d.ts +9 -1
- package/dist/index.js +223 -40
- package/dist/{init-EKQ33WYS.js → init-AHA7VRRI.js} +14 -13
- package/dist/ledger-T54JDHGP.js +6 -0
- package/dist/scan-QRTH6HAP.js +5 -0
- package/dist/{scan-XS4JPS7J.js → scan-TVX3FKK4.js} +5 -5
- package/dist/{screen-UF7VZ26C.js → screen-E4FC7W5N.js} +5 -4
- package/package.json +1 -1
- package/dist/audit-BXHJUTMJ.js +0 -12
- package/dist/chunk-F75Q43BC.js +0 -59
- package/dist/chunk-JHZ2BN4U.js +0 -67
- package/dist/doctor-7SLSUSVK.js +0 -19
- package/dist/generate-PMCP37DC.js +0 -6
- package/dist/ledger-4FOZ5HAB.js +0 -5
- package/dist/scan-N4BKVL5F.js +0 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,117 @@
|
|
|
1
1
|
# etymd
|
|
2
2
|
|
|
3
|
+
## 0.14.0
|
|
4
|
+
|
|
5
|
+
Knowledge pack **v8 → v12**: every generated hook and script is regenerated by `etymd gates`.
|
|
6
|
+
Versions 9, 10 and 11 were claimed by work that was reworked before release and stay retired,
|
|
7
|
+
so a pack number cited in a baseline always means exactly one pack.
|
|
8
|
+
|
|
9
|
+
### Minor Changes
|
|
10
|
+
|
|
11
|
+
- 3056213: Three fixes to the generated gates and the onboarding flow:
|
|
12
|
+
|
|
13
|
+
- **zsh is out of the shellcheck scan.** The generated pre-push discovered shell
|
|
14
|
+
scripts by a shebang pattern that included zsh, but shellcheck cannot parse zsh — SC1071 is a
|
|
15
|
+
parser-level error no inline directive silences — so a repo whose executable surface is zsh
|
|
16
|
+
could never push. zsh shebangs are now excluded from the checked set and the hook prints the
|
|
17
|
+
exclusion (count + reason) at run time instead of going quiet about coverage.
|
|
18
|
+
- **`~/` home paths are no longer repo file references.** Prose like "global rules in
|
|
19
|
+
`~/.claude/CLAUDE.md` apply on top" made the audit demand a repo-root CLAUDE.md that was
|
|
20
|
+
never meant to exist — the sentence points at the reader's machine. Home-path mentions of
|
|
21
|
+
well-known docs are now skipped and disclosed, like absolute tokens; one ordinary mention
|
|
22
|
+
still makes the doc a live claim.
|
|
23
|
+
- **`init` no longer scaffolds AGENTS.md unasked.** `init -y` in a repo without a contract used
|
|
24
|
+
to write template prose nobody reviewed (and baseline it, making later deletion read as
|
|
25
|
+
drift). The scaffold is now opt-in via `--with-agents`; interactive runs still ask first.
|
|
26
|
+
|
|
27
|
+
- aab88c5: A content gate that cannot run is now caught, and says why.
|
|
28
|
+
|
|
29
|
+
The screen doors resolve their checker at run time, from outside the repo — so whether the gate
|
|
30
|
+
will actually run is the one thing reading the hook cannot tell you. When the resolved checker
|
|
31
|
+
turns out not to be a screener, the commit door failed closed on that program's own bare error
|
|
32
|
+
and the push door, advisory by design, skipped its whole-tree pass in silence. A repo could read
|
|
33
|
+
as screened for months while nothing screened it.
|
|
34
|
+
|
|
35
|
+
Two changes, one class:
|
|
36
|
+
|
|
37
|
+
- **The hooks explain themselves.** After a failed screen — and only then, so a clean run pays
|
|
38
|
+
nothing — the hook asks the checker whether it understands `screen` at all. If it does not, it
|
|
39
|
+
prints a line naming the version floor (`screen` needs etymd 0.11+) and the override, instead of
|
|
40
|
+
leaving the checker's unexplained "unknown command" as the last word. A screener reporting a
|
|
41
|
+
real finding is left to speak for itself.
|
|
42
|
+
- **`etymd audit` and `etymd doctor` check reachability.** Gate integrity now resolves the screen
|
|
43
|
+
runner exactly as the hook would and asks it the same question, reporting a risk-tier finding
|
|
44
|
+
when a checker resolves but cannot screen. No checker installed stays the designed no-op —
|
|
45
|
+
disclosed, never a finding — and a probe that could not be carried out claims nothing.
|
|
46
|
+
|
|
47
|
+
- 6536081: Generated hooks no longer guess at the content screener by path.
|
|
48
|
+
|
|
49
|
+
The emitted content-screen resolution used to include a bare `[ -x ./dist/cli.js ]` check ahead
|
|
50
|
+
of the `etymd` on PATH. That path is simply where a great many CLI projects build, so any repo
|
|
51
|
+
that builds its own binary there had the hook invoke THAT binary as the screener: it does not
|
|
52
|
+
know `screen`, so the commit door failed closed on every commit, while the push door — which
|
|
53
|
+
ignores the screen's exit status by design — skipped the whole-tree pass in silence. The trap
|
|
54
|
+
armed itself on a plain dependency install, since that runs the repo's build.
|
|
55
|
+
|
|
56
|
+
Resolution is now `CONTENT_GATE`, then whatever `etymd` is on PATH. The dev-build arm remains
|
|
57
|
+
for the repo that develops the screener itself, where gating on an unreleased build is the point,
|
|
58
|
+
but it is decided at generation time from the manifest name and emitted nowhere else.
|
|
59
|
+
|
|
60
|
+
Regenerate with `etymd gates` to pick this up; a repo needing a different runner for one
|
|
61
|
+
invocation still has `CONTENT_GATE`.
|
|
62
|
+
|
|
63
|
+
- 0c0bcc6: gates: generated pre-push steps run scrubbed of git's exported GIT\_\* environment
|
|
64
|
+
|
|
65
|
+
Git exports `GIT_DIR` / `GIT_WORK_TREE` / `GIT_INDEX_FILE` / … to every hook it
|
|
66
|
+
runs, and a child `git` that inherits them ignores its cwd. A test suite that
|
|
67
|
+
builds fixture repositories by shelling out to `git` therefore operates on the
|
|
68
|
+
REAL repository — committing into it, moving its refs — while the same suite
|
|
69
|
+
outside a hook is harmless, which is exactly why the defect hides: nothing run
|
|
70
|
+
from CI ever reproduces it. This changes the generated pre-push (`etymd gates`):
|
|
71
|
+
|
|
72
|
+
- **Every gate step runs through a `run_gate()` helper** that strips every
|
|
73
|
+
exported `GIT_*` name before the command runs — each name found in the
|
|
74
|
+
environment, not a fixed list, because git adds variables over time. A step
|
|
75
|
+
that genuinely means the repository finds it again from its working
|
|
76
|
+
directory, which for a hook is the repo root.
|
|
77
|
+
- **The audit and shellcheck steps stay unscrubbed**, deliberately: audit
|
|
78
|
+
operates on the repo it is invoked in and never descends into fixtures, and
|
|
79
|
+
shellcheck's `git ls-files` must see the real repo.
|
|
80
|
+
- **The `.local` companion note documents the wrap**, so a hand-written guard
|
|
81
|
+
that runs tests can scrub the same way — the companion is where repo-owned
|
|
82
|
+
suites live.
|
|
83
|
+
|
|
84
|
+
Existing hooks pick this up on the next `etymd gates` run.
|
|
85
|
+
|
|
86
|
+
### Patch Changes
|
|
87
|
+
|
|
88
|
+
- 1cae71b: Context economy counts a file once when two instruction names are the same file.
|
|
89
|
+
|
|
90
|
+
`AGENTS.md` symlinked to `CLAUDE.md` is the ordinary way to serve harnesses that read different
|
|
91
|
+
names. Both names were measured separately, which doubled the reported always-loaded footprint —
|
|
92
|
+
enough to manufacture an over-budget finding out of nothing — and fired the heavy-file finding
|
|
93
|
+
twice for one file. Candidates resolving to the same inode (symlink or hardlink) are now counted
|
|
94
|
+
once and reported under every name they answer to, in `etymd audit` and `etymd context` alike,
|
|
95
|
+
with the merge stated in the lens disclosures.
|
|
96
|
+
|
|
97
|
+
## 0.13.0
|
|
98
|
+
|
|
99
|
+
### Minor Changes
|
|
100
|
+
|
|
101
|
+
- 9b45d54: screen: the machine-path check now also skips on upstream-owned files
|
|
102
|
+
|
|
103
|
+
The upstream-ownership exemption (0.12.0) skipped vocabulary-class patterns on files
|
|
104
|
+
byte-identical to public upstream, but the absolute-home-path check still fired on
|
|
105
|
+
them. Forks of container-based projects carry upstream docs full of generic container
|
|
106
|
+
paths (the `/home/<user>/...` shape), so a sync kept blocking on paths that are
|
|
107
|
+
already public in the upstream repo and never name the local machine.
|
|
108
|
+
|
|
109
|
+
An upstream-owned file is byte-identical to a public upstream blob, so any path it
|
|
110
|
+
names is already public and cannot leak this machine. The machine-path check is now
|
|
111
|
+
skipped for upstream-owned files, exactly like vocabulary-class patterns. Secret-class
|
|
112
|
+
patterns stay absolute on every file, and the machine-path check still fires on all
|
|
113
|
+
non-upstream files (the common case) unchanged.
|
|
114
|
+
|
|
3
115
|
## 0.12.0
|
|
4
116
|
|
|
5
117
|
### Minor Changes
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { section, theme, print, renderBaselineDrift, glyph } from './chunk-
|
|
3
|
-
import { scanProject } from './chunk-
|
|
4
|
-
import { VERSION } from './chunk-
|
|
5
|
-
import { readBaseline, summarizeBaselineDrift, isDriftEmpty, writeBaseline, deriveProfile } from './chunk-
|
|
6
|
-
import { PACK_VERSION } from './chunk-
|
|
2
|
+
import { section, theme, print, renderBaselineDrift, glyph } from './chunk-HI7NWPRA.js';
|
|
3
|
+
import { scanProject } from './chunk-HWC6PKJM.js';
|
|
4
|
+
import { VERSION } from './chunk-623W6KVC.js';
|
|
5
|
+
import { readBaseline, summarizeBaselineDrift, isDriftEmpty, writeBaseline, deriveProfile } from './chunk-P6ATKV2R.js';
|
|
6
|
+
import { PACK_VERSION } from './chunk-YQZDYDAK.js';
|
|
7
7
|
import './chunk-4VPBP6K6.js';
|
|
8
8
|
|
|
9
9
|
// src/commands/approve.ts
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
export { run } from './chunk-PAMWS665.js';
|
|
3
|
+
import './chunk-LT7JMVV3.js';
|
|
4
|
+
import './chunk-3E2IPCRY.js';
|
|
5
|
+
import './chunk-HI7NWPRA.js';
|
|
6
|
+
import './chunk-HWC6PKJM.js';
|
|
7
|
+
import './chunk-623W6KVC.js';
|
|
8
|
+
import './chunk-P6ATKV2R.js';
|
|
9
|
+
import './chunk-YQZDYDAK.js';
|
|
10
|
+
import './chunk-4VPBP6K6.js';
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { print, theme } from './chunk-
|
|
3
|
-
import { scanProject } from './chunk-
|
|
4
|
-
import './chunk-
|
|
5
|
-
import { ETYMD_DIR } from './chunk-
|
|
6
|
-
import './chunk-
|
|
2
|
+
import { print, theme } from './chunk-HI7NWPRA.js';
|
|
3
|
+
import { scanProject } from './chunk-HWC6PKJM.js';
|
|
4
|
+
import './chunk-623W6KVC.js';
|
|
5
|
+
import { ETYMD_DIR } from './chunk-P6ATKV2R.js';
|
|
6
|
+
import './chunk-YQZDYDAK.js';
|
|
7
7
|
import './chunk-4VPBP6K6.js';
|
|
8
8
|
import { promises } from 'fs';
|
|
9
9
|
import path from 'path';
|
|
@@ -1,6 +1,77 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { DEFAULT_CONFIG } from './chunk-P6ATKV2R.js';
|
|
3
|
+
import { readText, pathExists, approxTokens, wordCount } from './chunk-4VPBP6K6.js';
|
|
4
|
+
import { promises } from 'fs';
|
|
5
|
+
import path from 'path';
|
|
2
6
|
import pc from 'picocolors';
|
|
3
7
|
|
|
8
|
+
var ALWAYS_LOADED = [
|
|
9
|
+
{ path: "AGENTS.md", role: "operating contract" },
|
|
10
|
+
{ path: "PROJECT_CONTEXT.md", role: "ground-truth state" },
|
|
11
|
+
{ path: "CLAUDE.md", role: "Claude Code pointer" },
|
|
12
|
+
{ path: "GEMINI.md", role: "Gemini pointer" },
|
|
13
|
+
{ path: ".github/copilot-instructions.md", role: "Copilot instructions" },
|
|
14
|
+
{ path: ".cursorrules", role: "Cursor rules (legacy)" }
|
|
15
|
+
];
|
|
16
|
+
var EXTRACTION_THRESHOLD = DEFAULT_CONFIG.context.perFileWords;
|
|
17
|
+
function isAlwaysAppliedCursorRule(text) {
|
|
18
|
+
const fm = text.match(/^---\n([\s\S]*?)\n---/);
|
|
19
|
+
if (!fm) return true;
|
|
20
|
+
const frontmatter = fm[1] ?? "";
|
|
21
|
+
return /^\s*alwaysApply\s*:\s*true\s*$/m.test(frontmatter);
|
|
22
|
+
}
|
|
23
|
+
async function inodeKey(abs) {
|
|
24
|
+
try {
|
|
25
|
+
const st = await promises.stat(abs);
|
|
26
|
+
return st.ino ? `${st.dev}:${st.ino}` : null;
|
|
27
|
+
} catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function contextFileLabel(file) {
|
|
32
|
+
return file.aliases?.length ? [file.path, ...file.aliases].join(" \u2192 ") : file.path;
|
|
33
|
+
}
|
|
34
|
+
async function measureContext(root, perFileWords = EXTRACTION_THRESHOLD) {
|
|
35
|
+
const files = [];
|
|
36
|
+
const byInode = /* @__PURE__ */ new Map();
|
|
37
|
+
const record = async (rel, role, text) => {
|
|
38
|
+
const words = wordCount(text);
|
|
39
|
+
const key = await inodeKey(path.join(root, rel));
|
|
40
|
+
const seen = key ? byInode.get(key) : void 0;
|
|
41
|
+
if (seen) {
|
|
42
|
+
seen.aliases = [...seen.aliases ?? [], rel];
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const file = { path: rel, role, words, approxTokens: approxTokens(words) };
|
|
46
|
+
if (key) byInode.set(key, file);
|
|
47
|
+
files.push(file);
|
|
48
|
+
};
|
|
49
|
+
for (const spec of ALWAYS_LOADED) {
|
|
50
|
+
const text = await readText(path.join(root, spec.path));
|
|
51
|
+
if (text === null) continue;
|
|
52
|
+
await record(spec.path, spec.role, text);
|
|
53
|
+
}
|
|
54
|
+
const rulesDir = path.join(root, ".cursor", "rules");
|
|
55
|
+
if (await pathExists(rulesDir)) {
|
|
56
|
+
try {
|
|
57
|
+
for (const entry of await promises.readdir(rulesDir)) {
|
|
58
|
+
if (!entry.endsWith(".mdc") && !entry.endsWith(".md")) continue;
|
|
59
|
+
const text = await readText(path.join(rulesDir, entry));
|
|
60
|
+
if (text === null || !isAlwaysAppliedCursorRule(text)) continue;
|
|
61
|
+
await record(`.cursor/rules/${entry}`, "Cursor rule (always applied)", text);
|
|
62
|
+
}
|
|
63
|
+
} catch {
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const totalWords = files.reduce((s, f) => s + f.words, 0);
|
|
67
|
+
return {
|
|
68
|
+
files: files.sort((a, b) => b.words - a.words),
|
|
69
|
+
totalWords,
|
|
70
|
+
totalApproxTokens: approxTokens(totalWords),
|
|
71
|
+
perFileWords,
|
|
72
|
+
extractionCandidates: files.filter((f) => f.words >= perFileWords)
|
|
73
|
+
};
|
|
74
|
+
}
|
|
4
75
|
var theme = {
|
|
5
76
|
brand: (s) => pc.magenta(pc.bold(s)),
|
|
6
77
|
heading: (s) => pc.bold(s),
|
|
@@ -103,11 +174,13 @@ function renderContext(budget, threshold) {
|
|
|
103
174
|
print(` ${theme.dim("no always-loaded agent files found \u2014 nothing loads every session yet")}`);
|
|
104
175
|
return;
|
|
105
176
|
}
|
|
106
|
-
const pathWidth = maxWidth(budget.files.map((f) => f
|
|
177
|
+
const pathWidth = maxWidth(budget.files.map((f) => contextFileLabel(f)));
|
|
107
178
|
for (const f of budget.files) {
|
|
108
179
|
const heavy = f.words >= threshold;
|
|
109
180
|
const words = heavy ? theme.warn(`${f.words} w`) : `${f.words} w`;
|
|
110
|
-
print(
|
|
181
|
+
print(
|
|
182
|
+
` ${pad(theme.info(contextFileLabel(f)), pathWidth)} ${pad(words, 12)} ${theme.dim(f.role)}`
|
|
183
|
+
);
|
|
111
184
|
}
|
|
112
185
|
print();
|
|
113
186
|
keyValues([
|
|
@@ -122,7 +195,9 @@ function renderContext(budget, threshold) {
|
|
|
122
195
|
` ${theme.dim("These load every session but are rarely all needed. Move to an on-demand skill:")}`
|
|
123
196
|
);
|
|
124
197
|
for (const f of budget.extractionCandidates) {
|
|
125
|
-
print(
|
|
198
|
+
print(
|
|
199
|
+
` ${glyph.arrow} ${theme.info(contextFileLabel(f))} ${theme.dim(`(${f.words} words)`)}`
|
|
200
|
+
);
|
|
126
201
|
}
|
|
127
202
|
} else {
|
|
128
203
|
print(` ${glyph.ok} ${theme.dim("lean \u2014 no single file is heavy enough to extract yet")}`);
|
|
@@ -262,4 +337,4 @@ function renderLedger(ledger) {
|
|
|
262
337
|
}
|
|
263
338
|
}
|
|
264
339
|
|
|
265
|
-
export { TIER_BADGE, glyph, print, renderBaselineDrift, renderContext, renderFacts, renderFindings, renderFleetNotes, renderFleetRows, renderLedger, renderLedgerDiff, renderLensCoverage, renderPlan, section, theme };
|
|
340
|
+
export { TIER_BADGE, contextFileLabel, glyph, measureContext, print, renderBaselineDrift, renderContext, renderFacts, renderFindings, renderFleetNotes, renderFleetRows, renderLedger, renderLedgerDiff, renderLensCoverage, renderPlan, section, theme };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { readLedger, resolveEntry, writeLedger } from './chunk-3E2IPCRY.js';
|
|
3
|
-
import { print, renderLedger, glyph, theme } from './chunk-
|
|
3
|
+
import { print, renderLedger, glyph, theme } from './chunk-HI7NWPRA.js';
|
|
4
4
|
|
|
5
5
|
// src/commands/ledger.ts
|
|
6
6
|
async function list(opts) {
|
|
@@ -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-623W6KVC.js';
|
|
3
|
+
import { PACK_VERSION } from './chunk-YQZDYDAK.js';
|
|
4
4
|
import { normalizeRelPath, isDirectory, readJson, git, matchesAnyGlob, pathExists, readText } from './chunk-4VPBP6K6.js';
|
|
5
5
|
import path from 'path';
|
|
6
6
|
import { promises } from 'fs';
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { measureContext } from './chunk-F75Q43BC.js';
|
|
3
2
|
import { readLedger, reconcileLedger, writeLedger, visibleFindings } from './chunk-3E2IPCRY.js';
|
|
4
|
-
import {
|
|
5
|
-
import { scanProject, expandFileGlobs } from './chunk-
|
|
6
|
-
import { ETYMD_DIR, writeCachedFacts, readBaseline, deriveProfile, baselineCarriesMachinePath, BASELINE_FILE } from './chunk-
|
|
7
|
-
import { PACK_VERSION } from './chunk-
|
|
3
|
+
import { measureContext, contextFileLabel } from './chunk-HI7NWPRA.js';
|
|
4
|
+
import { scanProject, expandFileGlobs } from './chunk-HWC6PKJM.js';
|
|
5
|
+
import { DEFAULT_CONFIG, ETYMD_DIR, writeCachedFacts, readBaseline, readConfig, deriveProfile, baselineCarriesMachinePath, BASELINE_FILE, CONFIG_FILE } from './chunk-P6ATKV2R.js';
|
|
6
|
+
import { PACK_VERSION } from './chunk-YQZDYDAK.js';
|
|
8
7
|
import { pathExists, readJson, readText, isDirectory, matchesAnyGlob, git, normalizeRelPath, isCiEnvironment, isExecutable } from './chunk-4VPBP6K6.js';
|
|
9
8
|
import path from 'path';
|
|
10
9
|
import YAML from 'yaml';
|
|
10
|
+
import { execFile } from 'child_process';
|
|
11
|
+
import { promisify } from 'util';
|
|
11
12
|
import { promises } from 'fs';
|
|
12
13
|
|
|
13
14
|
// src/engine/finding.ts
|
|
@@ -39,13 +40,15 @@ var contextEconomyLens = {
|
|
|
39
40
|
const budgets = ctx.config?.config.context ?? DEFAULT_CONFIG.context;
|
|
40
41
|
const budget = await measureContext(ctx.root, budgets.perFileWords);
|
|
41
42
|
const findings = [];
|
|
43
|
+
const aliased = budget.files.filter((f) => f.aliases?.length);
|
|
42
44
|
for (const f of budget.extractionCandidates) {
|
|
45
|
+
const label = contextFileLabel(f);
|
|
43
46
|
findings.push({
|
|
44
47
|
id: `${LENS_ID}/heavy-file:${f.path}`,
|
|
45
48
|
lens: LENS_ID,
|
|
46
49
|
tier: "gap",
|
|
47
|
-
claim: `${
|
|
48
|
-
evidence: [`${
|
|
50
|
+
claim: `${label} loads ${f.words} words (~${f.approxTokens} tokens) into every session`,
|
|
51
|
+
evidence: [`${label}: ${f.words} words`],
|
|
49
52
|
why: "Reference material that loads every session taxes attention and cost on tasks that never need it.",
|
|
50
53
|
action: "Extract the reference bulk into an on-demand skill/doc and keep a pointer.",
|
|
51
54
|
effort: "M",
|
|
@@ -58,7 +61,7 @@ var contextEconomyLens = {
|
|
|
58
61
|
lens: LENS_ID,
|
|
59
62
|
tier: "gap",
|
|
60
63
|
claim: `The always-loaded footprint is ${budget.totalWords} words (~${budget.totalApproxTokens} tokens) \u2014 over the ${budgets.totalWords}-word budget`,
|
|
61
|
-
evidence: budget.files.slice(0, 5).map((f) => `${f
|
|
64
|
+
evidence: budget.files.slice(0, 5).map((f) => `${contextFileLabel(f)}: ${f.words}w`),
|
|
62
65
|
why: "Every session pays this before the task begins; instruction-following degrades as the resident context grows.",
|
|
63
66
|
action: "Run `etymd context` for the per-file breakdown and extract the heaviest block.",
|
|
64
67
|
effort: "M",
|
|
@@ -73,6 +76,9 @@ var contextEconomyLens = {
|
|
|
73
76
|
status: "ran",
|
|
74
77
|
disclosures: [
|
|
75
78
|
...ctx.config?.problems ?? [],
|
|
79
|
+
...aliased.map(
|
|
80
|
+
(f) => `${contextFileLabel(f)} are one file (same inode) \u2014 counted once, at ${f.words} words.`
|
|
81
|
+
),
|
|
76
82
|
`Budgets: ${budgets.perFileWords} words/file, ${budgets.totalWords} words total (${budgets.perFileWords === DEFAULT_CONFIG.context.perFileWords && budgets.totalWords === DEFAULT_CONFIG.context.totalWords ? `defaults \u2014 override under \`context\` in ${CONFIG_FILE}` : `set in ${CONFIG_FILE}`}). Scoped Cursor rules excluded.`
|
|
77
83
|
],
|
|
78
84
|
findings
|
|
@@ -473,6 +479,70 @@ async function buildGateInventory(root, facts) {
|
|
|
473
479
|
commitlintDep
|
|
474
480
|
};
|
|
475
481
|
}
|
|
482
|
+
var pExecFile = promisify(execFile);
|
|
483
|
+
var HOOK_FILES = ["pre-commit", "pre-push", "commit-msg"];
|
|
484
|
+
var SCREEN_CALL_RE = /"\$GATE"\s+screen\b/;
|
|
485
|
+
var DEV_BUILD_ARM = "[ -x ./dist/cli.js ]";
|
|
486
|
+
async function probeScreener(root, facts) {
|
|
487
|
+
const absent = {
|
|
488
|
+
present: false,
|
|
489
|
+
doors: [],
|
|
490
|
+
runner: null,
|
|
491
|
+
source: null,
|
|
492
|
+
answersScreen: null
|
|
493
|
+
};
|
|
494
|
+
const dir = facts.hooks.dir;
|
|
495
|
+
if (!dir) return absent;
|
|
496
|
+
const doors = [];
|
|
497
|
+
let devBuildArm = false;
|
|
498
|
+
for (const name of HOOK_FILES) {
|
|
499
|
+
const text = await readText(path.join(root, dir, name));
|
|
500
|
+
if (!text || !SCREEN_CALL_RE.test(text)) continue;
|
|
501
|
+
doors.push(`${dir}/${name}`);
|
|
502
|
+
if (text.includes(DEV_BUILD_ARM)) devBuildArm = true;
|
|
503
|
+
}
|
|
504
|
+
if (!doors.length) return absent;
|
|
505
|
+
let runner = null;
|
|
506
|
+
let source = null;
|
|
507
|
+
const override = process.env.CONTENT_GATE;
|
|
508
|
+
if (override) {
|
|
509
|
+
runner = override;
|
|
510
|
+
source = "CONTENT_GATE";
|
|
511
|
+
} else if (devBuildArm && await pathExists(path.join(root, "dist", "cli.js"))) {
|
|
512
|
+
runner = path.join(root, "dist", "cli.js");
|
|
513
|
+
source = "dev-build";
|
|
514
|
+
} else {
|
|
515
|
+
runner = "etymd";
|
|
516
|
+
source = "path";
|
|
517
|
+
}
|
|
518
|
+
try {
|
|
519
|
+
await pExecFile(runner, ["screen", "--help"], { cwd: root, timeout: 1e4 });
|
|
520
|
+
return { present: true, doors, runner, source, answersScreen: true };
|
|
521
|
+
} catch (err) {
|
|
522
|
+
const e = err;
|
|
523
|
+
if (e.code === "ENOENT") {
|
|
524
|
+
return {
|
|
525
|
+
present: true,
|
|
526
|
+
doors,
|
|
527
|
+
runner: null,
|
|
528
|
+
source,
|
|
529
|
+
answersScreen: null,
|
|
530
|
+
skipped: `no checker named \`${runner}\` is installed \u2014 the screen doors no-op here by design, and nothing was verified.`
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
if (e.killed || e.code === "ETIMEDOUT") {
|
|
534
|
+
return {
|
|
535
|
+
present: true,
|
|
536
|
+
doors,
|
|
537
|
+
runner,
|
|
538
|
+
source,
|
|
539
|
+
answersScreen: null,
|
|
540
|
+
skipped: `\`${runner} screen --help\` did not return within 10s \u2014 the runner was not verified either way.`
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
return { present: true, doors, runner, source, answersScreen: false };
|
|
544
|
+
}
|
|
545
|
+
}
|
|
476
546
|
|
|
477
547
|
// src/lenses/gate-integrity/lens.ts
|
|
478
548
|
var LENS_ID2 = "gate-integrity";
|
|
@@ -616,6 +686,28 @@ function deriveGateFindings(inv, disclosures = []) {
|
|
|
616
686
|
function localToolInHook(inv, tool) {
|
|
617
687
|
return inv.local.preCommit.includes(tool) || inv.local.prePush.includes(tool) || inv.local.commitMsg.includes(tool);
|
|
618
688
|
}
|
|
689
|
+
function deriveScreenerFindings(probe, disclosures) {
|
|
690
|
+
if (!probe.present) return [];
|
|
691
|
+
if (probe.skipped) {
|
|
692
|
+
disclosures.push(`Content screen: ${probe.skipped}`);
|
|
693
|
+
return [];
|
|
694
|
+
}
|
|
695
|
+
if (probe.answersScreen !== false) return [];
|
|
696
|
+
const via = probe.source === "CONTENT_GATE" ? "resolved from the CONTENT_GATE override" : probe.source === "dev-build" ? "resolved to this repo's own ./dist/cli.js build" : "resolved from PATH";
|
|
697
|
+
return [
|
|
698
|
+
finding({
|
|
699
|
+
id: `${LENS_ID2}/content-screen-unrunnable`,
|
|
700
|
+
tier: "risk",
|
|
701
|
+
kind: "truth",
|
|
702
|
+
claim: `The content screen resolves to \`${probe.runner}\`, which does not understand \`screen\` \u2014 the gate cannot run`,
|
|
703
|
+
evidence: [...probe.doors, `${probe.runner} screen --help \u2192 failed (${via})`],
|
|
704
|
+
why: "The commit door fails closed on an error that explains nothing, and the push door \u2014 advisory by design \u2014 skips the whole-tree pass in silence, so the repo reads as screened when nothing screened it.",
|
|
705
|
+
action: "Install or upgrade etymd (`screen` needs 0.11+), or point CONTENT_GATE at a checker that provides it, then re-run `etymd gates` to refresh the hooks.",
|
|
706
|
+
effort: "S",
|
|
707
|
+
confidence: "high"
|
|
708
|
+
})
|
|
709
|
+
];
|
|
710
|
+
}
|
|
619
711
|
var gateIntegrityLens = {
|
|
620
712
|
id: LENS_ID2,
|
|
621
713
|
version: "1",
|
|
@@ -656,6 +748,7 @@ var gateIntegrityLens = {
|
|
|
656
748
|
if (inv.ci.system === "none")
|
|
657
749
|
disclosures.push("No CI configuration found \u2014 CI\u2194local comparisons skipped.");
|
|
658
750
|
const findings = deriveGateFindings(inv, disclosures);
|
|
751
|
+
findings.push(...deriveScreenerFindings(await probeScreener(ctx.root, ctx.facts), disclosures));
|
|
659
752
|
return {
|
|
660
753
|
lens: LENS_ID2,
|
|
661
754
|
version: "1",
|
|
@@ -1186,8 +1279,23 @@ var KNOWN_DOC_REFS = [
|
|
|
1186
1279
|
"DECISIONS.md",
|
|
1187
1280
|
"GEMINI.md"
|
|
1188
1281
|
];
|
|
1282
|
+
var PATH_TOKEN_CHARS = /[A-Za-z0-9_.$~/-]/;
|
|
1189
1283
|
function extractDocRefs(text) {
|
|
1190
|
-
|
|
1284
|
+
const refs = [];
|
|
1285
|
+
let tildeSkipped = 0;
|
|
1286
|
+
for (const name of KNOWN_DOC_REFS) {
|
|
1287
|
+
let claimed = false;
|
|
1288
|
+
let at = text.indexOf(name);
|
|
1289
|
+
while (at !== -1) {
|
|
1290
|
+
let head = at;
|
|
1291
|
+
while (head > 0 && PATH_TOKEN_CHARS.test(text[head - 1])) head -= 1;
|
|
1292
|
+
if (text[head] === "~") tildeSkipped += 1;
|
|
1293
|
+
else claimed = true;
|
|
1294
|
+
at = text.indexOf(name, at + name.length);
|
|
1295
|
+
}
|
|
1296
|
+
if (claimed) refs.push(name);
|
|
1297
|
+
}
|
|
1298
|
+
return { refs, tildeSkipped };
|
|
1191
1299
|
}
|
|
1192
1300
|
|
|
1193
1301
|
// src/lenses/instruction-truth/lens.ts
|
|
@@ -1310,6 +1418,7 @@ var instructionTruthLens = {
|
|
|
1310
1418
|
const nodeModulesInstalled = await pathExists(path.join(root, "node_modules"));
|
|
1311
1419
|
const manifestExists = await pathExists(path.join(root, "package.json")) || facts.packages.length > 0;
|
|
1312
1420
|
let totalFilteredSkipped = 0;
|
|
1421
|
+
let totalTildeSkipped = 0;
|
|
1313
1422
|
let binaryResolved = 0;
|
|
1314
1423
|
let unverifiableCommands = 0;
|
|
1315
1424
|
let gitignoredSkipped = 0;
|
|
@@ -1399,7 +1508,9 @@ var instructionTruthLens = {
|
|
|
1399
1508
|
break;
|
|
1400
1509
|
}
|
|
1401
1510
|
}
|
|
1402
|
-
|
|
1511
|
+
const { refs: docRefs, tildeSkipped } = extractDocRefs(file.text);
|
|
1512
|
+
totalTildeSkipped += tildeSkipped;
|
|
1513
|
+
for (const ref of docRefs) {
|
|
1403
1514
|
if (await pathExists(path.join(root, ref))) continue;
|
|
1404
1515
|
findings.push(
|
|
1405
1516
|
finding2({
|
|
@@ -1504,6 +1615,11 @@ var instructionTruthLens = {
|
|
|
1504
1615
|
`${placeholderSkipped} path claim(s) are naming stand-ins (e.g. \`my-custom-skill\`) rather than real references; skipped, not flagged.`
|
|
1505
1616
|
);
|
|
1506
1617
|
}
|
|
1618
|
+
if (totalTildeSkipped) {
|
|
1619
|
+
disclosures.push(
|
|
1620
|
+
`${totalTildeSkipped} well-known doc mention(s) sit inside \`~/\` home paths (e.g. \`~/.claude/CLAUDE.md\`) \u2014 machine-global files, not this repo's; skipped, not flagged.`
|
|
1621
|
+
);
|
|
1622
|
+
}
|
|
1507
1623
|
if (stateDocs.length) {
|
|
1508
1624
|
disclosures.push(
|
|
1509
1625
|
`Checked ${stateDocs.length} state document(s) for command, path, and decision-reference claims (same skip classes as instruction files); decision ids resolved against ${ledgerSources.length ? ledgerSources.join(", ") : "nothing \u2014 no decisions file with D-NNN entries"}.`
|
|
@@ -1531,7 +1647,7 @@ var instructionTruthLens = {
|
|
|
1531
1647
|
);
|
|
1532
1648
|
}
|
|
1533
1649
|
disclosures.push(
|
|
1534
|
-
`Checked ${files.length} instruction file(s); commands resolved against root + ${facts.packages.length} workspace manifest(s) plus installed binaries; paths matched against root and package roots. Heuristics: workspace-filtered commands skipped (${totalFilteredSkipped}); tokens without a recognized extension treated as prose (a dir claim needs a trailing slash); gitignored claims unverifiable; create-this and stand-in path claims skipped; absolute/globbed/placeholder tokens skipped; framework-pattern staleness not checked.`
|
|
1650
|
+
`Checked ${files.length} instruction file(s); commands resolved against root + ${facts.packages.length} workspace manifest(s) plus installed binaries; paths matched against root and package roots. Heuristics: workspace-filtered commands skipped (${totalFilteredSkipped}); tokens without a recognized extension treated as prose (a dir claim needs a trailing slash); gitignored claims unverifiable; create-this and stand-in path claims skipped; absolute/globbed/placeholder tokens skipped; doc mentions inside \`~/\` home paths skipped; framework-pattern staleness not checked.`
|
|
1535
1651
|
);
|
|
1536
1652
|
return {
|
|
1537
1653
|
lens: LENS_ID4,
|
|
@@ -1,8 +1,70 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
3
|
-
import { pathExists, readText } from './chunk-4VPBP6K6.js';
|
|
2
|
+
import { readJson, pathExists, readText } from './chunk-4VPBP6K6.js';
|
|
4
3
|
import path from 'path';
|
|
4
|
+
import { promises } from 'fs';
|
|
5
5
|
|
|
6
|
+
var ETYMD_DIR = ".etymd";
|
|
7
|
+
var CACHE_DIR = path.join(ETYMD_DIR, "cache");
|
|
8
|
+
var CACHE_FACTS_FILE = path.join(CACHE_DIR, "facts.json");
|
|
9
|
+
var BASELINE_FILE = path.join(ETYMD_DIR, "baseline.json");
|
|
10
|
+
function cacheFactsPath(root) {
|
|
11
|
+
return path.join(root, CACHE_FACTS_FILE);
|
|
12
|
+
}
|
|
13
|
+
function baselinePath(root) {
|
|
14
|
+
return path.join(root, BASELINE_FILE);
|
|
15
|
+
}
|
|
16
|
+
async function writeJsonFile(target, data) {
|
|
17
|
+
await promises.mkdir(path.dirname(target), { recursive: true });
|
|
18
|
+
await promises.writeFile(target, JSON.stringify(data, null, 2) + "\n", "utf8");
|
|
19
|
+
return target;
|
|
20
|
+
}
|
|
21
|
+
async function writeCachedFacts(root, facts) {
|
|
22
|
+
return writeJsonFile(cacheFactsPath(root), facts);
|
|
23
|
+
}
|
|
24
|
+
function withoutMachinePath(facts) {
|
|
25
|
+
return { ...facts, root: "." };
|
|
26
|
+
}
|
|
27
|
+
function baselineCarriesMachinePath(baseline) {
|
|
28
|
+
const root = baseline.facts?.root;
|
|
29
|
+
return typeof root === "string" && root !== "." && (root.startsWith("/") || /^[A-Za-z]:[\\/]/.test(root));
|
|
30
|
+
}
|
|
31
|
+
async function writeBaseline(root, baseline) {
|
|
32
|
+
return writeJsonFile(baselinePath(root), {
|
|
33
|
+
...baseline,
|
|
34
|
+
facts: withoutMachinePath(baseline.facts)
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
async function readBaseline(root) {
|
|
38
|
+
return readJson(baselinePath(root));
|
|
39
|
+
}
|
|
40
|
+
function deriveProfile(facts) {
|
|
41
|
+
return (facts.git.recentAuthors ?? 1) > 2 ? "team" : "solo";
|
|
42
|
+
}
|
|
43
|
+
var DRIFT_ROLES = ["test", "lint", "typecheck", "format", "formatCheck", "build", "dev"];
|
|
44
|
+
function summarizeBaselineDrift(old, fresh) {
|
|
45
|
+
const commands = [];
|
|
46
|
+
for (const role of DRIFT_ROLES) {
|
|
47
|
+
const from = old.commands[role];
|
|
48
|
+
const to = fresh.commands[role];
|
|
49
|
+
if (from !== to) commands.push({ role, from, to });
|
|
50
|
+
}
|
|
51
|
+
const presentIds = (f) => new Set(f.artifacts.filter((a) => a.exists).map((a) => a.id));
|
|
52
|
+
const oldArtifacts = presentIds(old);
|
|
53
|
+
const freshArtifacts = presentIds(fresh);
|
|
54
|
+
const labelOf = (id) => fresh.artifacts.find((a) => a.id === id)?.label ?? old.artifacts.find((a) => a.id === id)?.label ?? id;
|
|
55
|
+
const artifactsAdded = [...freshArtifacts].filter((id) => !oldArtifacts.has(id)).map(labelOf);
|
|
56
|
+
const artifactsRemoved = [...oldArtifacts].filter((id) => !freshArtifacts.has(id)).map(labelOf);
|
|
57
|
+
const oldDirs = new Set(old.tree.dirs.map((d) => d.name));
|
|
58
|
+
const freshDirs = new Set(fresh.tree.dirs.map((d) => d.name));
|
|
59
|
+
const dirsAdded = [...freshDirs].filter((d) => !oldDirs.has(d));
|
|
60
|
+
const dirsRemoved = [...oldDirs].filter((d) => !freshDirs.has(d));
|
|
61
|
+
return { commands, artifactsAdded, artifactsRemoved, dirsAdded, dirsRemoved };
|
|
62
|
+
}
|
|
63
|
+
function isDriftEmpty(d) {
|
|
64
|
+
return d.commands.length === 0 && d.artifactsAdded.length === 0 && d.artifactsRemoved.length === 0 && d.dirsAdded.length === 0 && d.dirsRemoved.length === 0;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// src/core/config.ts
|
|
6
68
|
var CONFIG_FILE = path.join(ETYMD_DIR, "config.json");
|
|
7
69
|
var DEFAULT_CONFIG = {
|
|
8
70
|
instructions: { include: [], exclude: [] },
|
|
@@ -106,4 +168,4 @@ async function readConfig(root) {
|
|
|
106
168
|
};
|
|
107
169
|
}
|
|
108
170
|
|
|
109
|
-
export { CONFIG_FILE, DEFAULT_CONFIG, configPath, readConfig };
|
|
171
|
+
export { BASELINE_FILE, CACHE_DIR, CONFIG_FILE, DEFAULT_CONFIG, ETYMD_DIR, baselineCarriesMachinePath, configPath, deriveProfile, isDriftEmpty, readBaseline, readConfig, summarizeBaselineDrift, writeBaseline, writeCachedFacts };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { parseFailOnTier, runAudit, meetsFailOn } from './chunk-
|
|
3
|
-
import { print, section, theme, renderLensCoverage, renderFindings, renderLedgerDiff } from './chunk-
|
|
2
|
+
import { parseFailOnTier, runAudit, meetsFailOn } from './chunk-LT7JMVV3.js';
|
|
3
|
+
import { print, section, theme, renderLensCoverage, renderFindings, renderLedgerDiff } from './chunk-HI7NWPRA.js';
|
|
4
4
|
|
|
5
5
|
// src/commands/audit.ts
|
|
6
6
|
async function run(opts) {
|