etymd 0.5.0 → 0.7.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 +114 -0
- package/README.md +9 -4
- package/dist/{approve-ASWRO6OD.js → approve-TXF3MA3G.js} +6 -6
- package/dist/audit-354X7QN6.js +12 -0
- package/dist/{brief-HWD7CILP.js → brief-QUYHRWH6.js} +6 -6
- package/dist/{chunk-TADKOW6P.js → chunk-5BVKFJWM.js} +7 -3
- package/dist/{chunk-IGHES6AK.js → chunk-6DUDIVIC.js} +1 -1
- package/dist/{chunk-CYSEGWIW.js → chunk-7LVCGCHB.js} +2 -2
- package/dist/{chunk-R74SJ7HS.js → chunk-C7LBUFNU.js} +1 -1
- package/dist/{chunk-YLNQQZ5F.js → chunk-IV3FYVTS.js} +11 -1
- package/dist/{chunk-NMZ3RHWW.js → chunk-K7QBTANO.js} +2 -2
- package/dist/{chunk-OX3LUOZR.js → chunk-KTIEYKFK.js} +46 -22
- package/dist/{chunk-QJRPKZPA.js → chunk-LXEYYITD.js} +146 -12
- package/dist/{chunk-WWBF4Y7K.js → chunk-OAFYLBVG.js} +1 -1
- package/dist/{chunk-OJVQXB5Z.js → chunk-OGIHOTLP.js} +1 -1
- package/dist/{chunk-LT67FU2Y.js → chunk-OKU3HXIH.js} +2 -2
- package/dist/{chunk-MMPV67FW.js → chunk-V2SOHPXF.js} +2 -2
- package/dist/{chunk-4KOP36RD.js → chunk-XJMN2HMH.js} +3 -3
- package/dist/cli.js +18 -18
- package/dist/config-UJRSCDMD.js +4 -0
- package/dist/{context-4DGXO7JR.js → context-ZEUI5ESZ.js} +5 -5
- package/dist/doctor-2BU6WCI6.js +19 -0
- package/dist/{fleet-SAFXVN44.js → fleet-3UQ65X4Y.js} +24 -17
- package/dist/{gates-D5SBAVXB.js → gates-MYNOZCON.js} +21 -13
- package/dist/generate-UTKQ4GE6.js +6 -0
- package/dist/index.d.ts +26 -2
- package/dist/index.js +207 -34
- package/dist/{init-AVE2GDJ6.js → init-52PR24Q4.js} +8 -8
- package/dist/ledger-B2XGANTH.js +5 -0
- package/dist/{scan-5DSECI43.js → scan-GSDICQCX.js} +6 -6
- package/dist/scan-M6RKDNYR.js +5 -0
- package/dist/{screen-U7JMWZPS.js → screen-6R6NXDSY.js} +2 -2
- package/package.json +3 -4
- package/dist/audit-LPZOGOHE.js +0 -12
- package/dist/config-MX7OYI7F.js +0 -4
- package/dist/doctor-MJ6RBZ2Q.js +0 -19
- package/dist/generate-CBMTNN7P.js +0 -6
- package/dist/ledger-TMIV45AA.js +0 -5
- package/dist/scan-ES7IOXOC.js +0 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,119 @@
|
|
|
1
1
|
# etymd
|
|
2
2
|
|
|
3
|
+
## 0.7.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 7371c8b: Decisions records can require fields of their own, and etymd holds no opinion about which.
|
|
8
|
+
|
|
9
|
+
A decisions file already opts into per-entry format checks with a marker. It can now append field
|
|
10
|
+
names to it — `<!-- decisions-format: 1 fields=Owner,Rollback -->` — and every entry after the
|
|
11
|
+
marker must carry each one. `Owner:` and `**Owner:**` both count. Decision record:
|
|
12
|
+
[`docs/decisions/007-declared-entry-fields.md`](docs/decisions/007-declared-entry-fields.md).
|
|
13
|
+
|
|
14
|
+
**The tool ships the shape, not the vocabulary.** Etymd never interprets a declared name and has no
|
|
15
|
+
list of fields it thinks a record ought to have; it verifies that a line introducing the name
|
|
16
|
+
exists, and that is all. Which fields are worth requiring is the file's position to take, not the
|
|
17
|
+
tool's — a name coined for one project's process is exactly the kind of opinion this package does
|
|
18
|
+
not ship, however cleanly it would mechanize.
|
|
19
|
+
|
|
20
|
+
**Nothing is dropped in silence.** A name that cannot be used as a field, a marker version this
|
|
21
|
+
build does not know, and a redeclared `Scope` are each disclosed in the lens report. A file that
|
|
22
|
+
believes it declared a requirement, and is quietly audited without it, would come back clean for
|
|
23
|
+
the one reason this tool exists to reject — so the failure is stated rather than absorbed.
|
|
24
|
+
Declarable names are letters, digits, spaces, `-` and `_`, which keeps regex metacharacters out of
|
|
25
|
+
the matcher by construction.
|
|
26
|
+
|
|
27
|
+
**Enforcement is every-entry, with no keyword trigger.** Requiring the fields only on entries whose
|
|
28
|
+
prose looks like it is claiming something would flag an entry that mentions a closed tab or a fixed
|
|
29
|
+
price, and a false "your file is lying" costs more trust than a missed one. Every entry after the
|
|
30
|
+
marker is held to the same rule, which is what `Scope:` already does and takes one sentence to
|
|
31
|
+
explain.
|
|
32
|
+
|
|
33
|
+
**Existing files are unaffected.** `fields=` is an extension rather than a new format version: a
|
|
34
|
+
marker without it behaves exactly as before, pre-marker entries stay untouched, and a repo that
|
|
35
|
+
declares nothing sees no change. The declaration lives on the marker rather than in
|
|
36
|
+
`.etymd/config.json` so the requirement sits beside the entries it governs, which also lets a repo
|
|
37
|
+
keep two decision records with different obligations.
|
|
38
|
+
|
|
39
|
+
## 0.6.0
|
|
40
|
+
|
|
41
|
+
### Minor Changes
|
|
42
|
+
|
|
43
|
+
- b02943b: Local gates: the tool now reads and rewrites what it generated, instead of guessing about it.
|
|
44
|
+
|
|
45
|
+
Two defects in the local-gate machinery, both silent, both affecting every project using
|
|
46
|
+
`etymd gates`. Decision record:
|
|
47
|
+
[`docs/decisions/006-local-gate-provenance.md`](docs/decisions/006-local-gate-provenance.md).
|
|
48
|
+
|
|
49
|
+
**`gate-integrity` follows the `<hook>.local` include.** Every generated hook opens by sourcing a
|
|
50
|
+
sibling companion — the sanctioned place for project-specific guards, which etymd never reads,
|
|
51
|
+
writes or regenerates. The lens stopped at the hook file, so a check placed in the companion ran
|
|
52
|
+
on every push, blocked it, and was still reported as enforced only in CI. The only way to silence
|
|
53
|
+
that was a dismissal, which is meant for a false positive or an accepted trade-off, not for a
|
|
54
|
+
blind spot. The lens now resolves the companion for `pre-commit`, `pre-push` and `commit-msg` and
|
|
55
|
+
counts what it enforces.
|
|
56
|
+
|
|
57
|
+
Two conditions keep it honest, both mirroring what the shell actually does: the hook must
|
|
58
|
+
genuinely reference the companion, and the companion must pass the same `[ -x ]` test the hook
|
|
59
|
+
applies before running it. A present-but-not-executable companion is reported as inert — named,
|
|
60
|
+
with the `chmod +x` that would make it run — rather than credited with checks that never fire.
|
|
61
|
+
Both outcomes are disclosed in the lens report.
|
|
62
|
+
|
|
63
|
+
**A stale generated hook is no longer indistinguishable from a hand-edited one.** `etymd gates`
|
|
64
|
+
refused to overwrite any hook whose content differed from what it would generate, printing
|
|
65
|
+
`kept (hand-edited)`. That protection is right for a customised hook and wrong for a merely stale
|
|
66
|
+
one — generated by an older etymd, or before the repo's package manager or scripts changed. In
|
|
67
|
+
that case the refusal preserved a gate that had stopped matching the repo, while `etymd audit`
|
|
68
|
+
went on reporting the gaps the refused rewrite would have closed; the only escape was deleting the
|
|
69
|
+
file, which nobody would think to try.
|
|
70
|
+
|
|
71
|
+
Generated shell files now carry a stamp as their last line, whose digest covers the file minus
|
|
72
|
+
that line. A file that still hashes to its own stamp is byte-for-byte what etymd wrote and cannot
|
|
73
|
+
contain anyone's work, so it is regenerated freely; any edit breaks the match and the file is
|
|
74
|
+
kept. `[differs]` is no longer one bucket — the plan tags a stale file `[stale]` and says it is
|
|
75
|
+
regenerating, and says why it is keeping the others.
|
|
76
|
+
|
|
77
|
+
A hook generated before the stamp existed carries no stamp and cannot be proven untouched, so it
|
|
78
|
+
is kept — now with the reason and the way out stated, instead of a dead end. One regeneration
|
|
79
|
+
makes it provable from then on.
|
|
80
|
+
|
|
81
|
+
**Provenance applies to everything the pack generates, not just hooks.** The `AGENTS.md` scaffold
|
|
82
|
+
is stamped too, in an HTML comment so it stays invisible in the rendered document; its stamp
|
|
83
|
+
replaces the bare `<!-- etymd pack vN -->` line, carrying the same version plus the provenance.
|
|
84
|
+
`.etymd/config.json` is excluded — it holds the user's recorded decisions, is merged into rather
|
|
85
|
+
than generated, and JSON has nowhere to hide a stamp.
|
|
86
|
+
|
|
87
|
+
**New finding: a companion that cannot run.** `gate-integrity/companion-not-executable` reports a
|
|
88
|
+
`<hook>.local` that a hook calls, that exists, and that provably lacks the execute bit — the check
|
|
89
|
+
inside is skipped silently on every run, and git tracks exactly one permission bit, so the dead
|
|
90
|
+
gate reaches every clone. Emitted at `gap` rather than `risk`: risk is reserved for what makes an
|
|
91
|
+
agent do the wrong thing (`hooks-not-wired` earns it because every gate is dead there), and it
|
|
92
|
+
also means an upgrade cannot fail the `--fail-on risk` gate in a repo whose code did not change.
|
|
93
|
+
The action names both halves of the fix, since `chmod +x` alone does not survive a fresh clone.
|
|
94
|
+
|
|
95
|
+
The accusation is guarded: "not executable" is trusted only where the hook beside it HAS the bit,
|
|
96
|
+
which is what proves the bit means anything on that filesystem. Where it cannot be established the
|
|
97
|
+
checks are counted and the uncertainty is disclosed, so a checkout without POSIX mode bits is
|
|
98
|
+
never told its working gate is dead.
|
|
99
|
+
|
|
100
|
+
**The fleet sweep stops recommending a command that would refuse.** `fleet` reported every
|
|
101
|
+
differing gate under one finding whose action was "re-run `etymd gates`" — advice that does not
|
|
102
|
+
work for a hand-edited hook. A provably-edited gate is now disclosed as a customisation rather
|
|
103
|
+
than reported as drift; stale and unstamped gates stay in the finding, since being unable to prove
|
|
104
|
+
a file was touched is not evidence that it was.
|
|
105
|
+
|
|
106
|
+
`PACK_VERSION` moves to 6: the generated files change meaning.
|
|
107
|
+
|
|
108
|
+
### Patch Changes
|
|
109
|
+
|
|
110
|
+
- `yaml` moves from 2.7.0 to 2.9.0, clearing GHSA-48c2-rrv3-qjmp (stack overflow on deeply nested
|
|
111
|
+
collections) from the dependency tree of everyone who installs etymd. The lens that parses CI
|
|
112
|
+
files already wrapped every `YAML.parse` in a try/catch, so a hostile `.gitlab-ci.yml` was
|
|
113
|
+
caught and disclosed as a parse error rather than crashing anything — verified against a
|
|
114
|
+
200,000-level nested file before and after. The bump is about not shipping a known-vulnerable
|
|
115
|
+
dependency, not about a reachable exploit.
|
|
116
|
+
|
|
3
117
|
## 0.5.0
|
|
4
118
|
|
|
5
119
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -147,7 +147,11 @@ _relative_ — a state doc is stale only when the repo moved past it, so a dorma
|
|
|
147
147
|
state is current; a tracked file with uncommitted edits is treated fresh-now (the refresh is
|
|
148
148
|
already on disk) and disclosed. Decisions records get format checks (`Scope:` presence, a
|
|
149
149
|
`Revisit:` date that, once past, becomes a finding) — opt in by adding the literal marker
|
|
150
|
-
`<!-- decisions-format: 1 -->` anywhere in the file; forward-only, never retroactive.
|
|
150
|
+
`<!-- decisions-format: 1 -->` anywhere in the file; forward-only, never retroactive. A file can
|
|
151
|
+
require field names of its own by appending them to the marker —
|
|
152
|
+
`<!-- decisions-format: 1 fields=Owner,Rollback -->` — and each is then checked on every entry.
|
|
153
|
+
Etymd ships no field vocabulary and reads no meaning into the names; it verifies only that what
|
|
154
|
+
the file declared is present, and discloses any name it could not use. Duplicate
|
|
151
155
|
or out-of-order `D-NNN` ids are flagged with a rename action even without the marker — an
|
|
152
156
|
append race is a defect in the file's own convention, not a format opinion.
|
|
153
157
|
|
|
@@ -494,9 +498,10 @@ Without it — on a fresh clone or in CI — those suites skip cleanly and the r
|
|
|
494
498
|
|
|
495
499
|
[`docs/decisions/`](https://github.com/triartleet/etymd/tree/main/docs/decisions) — 001 founding · 002 foundation re-lock · **003 the truth-guard
|
|
496
500
|
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
|
-
|
|
499
|
-
accepted heuristic
|
|
501
|
+
**004 fleet mode** (the truth guard across your repositories) · 005 declared rules (design only) ·
|
|
502
|
+
**006 local gate provenance** (what the tool may read, and what it may rewrite).
|
|
503
|
+
[`ROADMAP.md`](https://github.com/triartleet/etymd/blob/main/ROADMAP.md) — what's now / next / later, and the accepted heuristic
|
|
504
|
+
trade-offs.
|
|
500
505
|
|
|
501
506
|
## License
|
|
502
507
|
|
|
@@ -1,10 +1,10 @@
|
|
|
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-
|
|
7
|
-
import './chunk-
|
|
2
|
+
import { section, theme, print, renderBaselineDrift, glyph } from './chunk-5BVKFJWM.js';
|
|
3
|
+
import { scanProject } from './chunk-XJMN2HMH.js';
|
|
4
|
+
import { VERSION } from './chunk-OGIHOTLP.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-7LVCGCHB.js';
|
|
3
|
+
import './chunk-LXEYYITD.js';
|
|
4
|
+
import './chunk-OKU3HXIH.js';
|
|
5
|
+
import './chunk-OAFYLBVG.js';
|
|
6
|
+
import './chunk-K7QBTANO.js';
|
|
7
|
+
import './chunk-5BVKFJWM.js';
|
|
8
|
+
import './chunk-XJMN2HMH.js';
|
|
9
|
+
import './chunk-OGIHOTLP.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-
|
|
3
|
-
import { scanProject } from './chunk-
|
|
4
|
-
import './chunk-
|
|
5
|
-
import { ETYMD_DIR } from './chunk-
|
|
6
|
-
import './chunk-
|
|
7
|
-
import './chunk-
|
|
2
|
+
import { print, theme } from './chunk-5BVKFJWM.js';
|
|
3
|
+
import { scanProject } from './chunk-XJMN2HMH.js';
|
|
4
|
+
import './chunk-OGIHOTLP.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
|
|
135
|
-
|
|
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,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-LXEYYITD.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) {
|
|
@@ -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-
|
|
3
|
-
import { pathExists, readText } from './chunk-
|
|
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-
|
|
3
|
-
import { PACK_VERSION } from './chunk-
|
|
4
|
-
import { readText, pathExists } from './chunk-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
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
|
|
307
|
+
differs,
|
|
308
|
+
drift: origin === "pack" ? "stale" : origin,
|
|
285
309
|
executable,
|
|
286
310
|
label
|
|
287
311
|
});
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { measureContext } from './chunk-
|
|
3
|
-
import { readLedger, reconcileLedger, writeLedger, visibleFindings } from './chunk-
|
|
4
|
-
import { DEFAULT_CONFIG, readConfig, CONFIG_FILE } from './chunk-
|
|
5
|
-
import { scanProject, expandFileGlobs } from './chunk-
|
|
6
|
-
import { ETYMD_DIR, writeCachedFacts, readBaseline, deriveProfile, baselineCarriesMachinePath, BASELINE_FILE } from './chunk-
|
|
7
|
-
import { PACK_VERSION } from './chunk-
|
|
8
|
-
import { pathExists, readJson, git, readText, isDirectory, matchesAnyGlob, isCiEnvironment, normalizeRelPath } from './chunk-
|
|
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-XJMN2HMH.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
|
-
|
|
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 {
|
|
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);
|
|
@@ -1109,7 +1177,52 @@ var instructionTruthLens = {
|
|
|
1109
1177
|
};
|
|
1110
1178
|
var LENS_ID4 = "state-freshness";
|
|
1111
1179
|
var DECISIONS_FORMAT_MARKER = "<!-- decisions-format: 1 -->";
|
|
1180
|
+
var KNOWN_FORMAT_VERSION = 1;
|
|
1181
|
+
var MARKER_RE = /<!--\s*decisions-format:\s*(\d+)([^>]*?)-->/;
|
|
1182
|
+
var FIELD_NAME_RE = /^[A-Za-z0-9 _-]+$/;
|
|
1183
|
+
var BUILT_IN_FIELDS = /* @__PURE__ */ new Set(["scope"]);
|
|
1112
1184
|
var MS_PER_DAY = 864e5;
|
|
1185
|
+
function parseDecisionsFormat(text) {
|
|
1186
|
+
const m = MARKER_RE.exec(text);
|
|
1187
|
+
if (!m) return null;
|
|
1188
|
+
const problems = [];
|
|
1189
|
+
const fields = [];
|
|
1190
|
+
const version = Number(m[1]);
|
|
1191
|
+
if (version !== KNOWN_FORMAT_VERSION) {
|
|
1192
|
+
problems.push(
|
|
1193
|
+
`declares decisions-format version ${version}; this etymd understands version ${KNOWN_FORMAT_VERSION} \u2014 checked as version ${KNOWN_FORMAT_VERSION}.`
|
|
1194
|
+
);
|
|
1195
|
+
}
|
|
1196
|
+
const attrs = (m[2] ?? "").trim();
|
|
1197
|
+
if (!attrs) return { fields, problems };
|
|
1198
|
+
const declared = /^fields=(.*)$/.exec(attrs);
|
|
1199
|
+
if (!declared) {
|
|
1200
|
+
problems.push(`marker attribute \`${attrs}\` is not understood \u2014 ignored (only \`fields=\`).`);
|
|
1201
|
+
return { fields, problems };
|
|
1202
|
+
}
|
|
1203
|
+
const seen = new Set(BUILT_IN_FIELDS);
|
|
1204
|
+
for (const raw of declared[1].split(",")) {
|
|
1205
|
+
const name = raw.trim();
|
|
1206
|
+
if (!name) continue;
|
|
1207
|
+
if (!FIELD_NAME_RE.test(name)) {
|
|
1208
|
+
problems.push(
|
|
1209
|
+
`declared field \`${name}\` is not a usable field name (letters, digits, spaces, \`-\`, \`_\`) \u2014 not checked.`
|
|
1210
|
+
);
|
|
1211
|
+
continue;
|
|
1212
|
+
}
|
|
1213
|
+
const key = name.toLowerCase();
|
|
1214
|
+
if (seen.has(key)) continue;
|
|
1215
|
+
seen.add(key);
|
|
1216
|
+
fields.push(name);
|
|
1217
|
+
}
|
|
1218
|
+
if (fields.length === 0 && problems.length === 0) {
|
|
1219
|
+
problems.push("marker declares `fields=` with no field names \u2014 no extra fields checked.");
|
|
1220
|
+
}
|
|
1221
|
+
return { fields, problems };
|
|
1222
|
+
}
|
|
1223
|
+
function hasField(block, name) {
|
|
1224
|
+
return new RegExp(`${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[\\s*]*:`).test(block);
|
|
1225
|
+
}
|
|
1113
1226
|
function parseDecisionEntries(text) {
|
|
1114
1227
|
const headings = [...text.matchAll(/^## .*$/gm)];
|
|
1115
1228
|
const entries = [];
|
|
@@ -1165,9 +1278,23 @@ function checkIdSequence(file, entries) {
|
|
|
1165
1278
|
}
|
|
1166
1279
|
return findings;
|
|
1167
1280
|
}
|
|
1168
|
-
function checkFormatFields(file, entries, today) {
|
|
1281
|
+
function checkFormatFields(file, entries, today, declaredFields) {
|
|
1169
1282
|
const findings = [];
|
|
1170
1283
|
for (const entry of entries) {
|
|
1284
|
+
for (const field of declaredFields) {
|
|
1285
|
+
if (hasField(entry.block, field)) continue;
|
|
1286
|
+
findings.push({
|
|
1287
|
+
id: `${LENS_ID4}/field-missing:${file}:${entry.id}:${field}`,
|
|
1288
|
+
lens: LENS_ID4,
|
|
1289
|
+
tier: "gap",
|
|
1290
|
+
claim: `${file} ${entry.id} has no ${field}: field`,
|
|
1291
|
+
evidence: [`${file}: ${entry.id}`, `${file} marker declares required field \`${field}\``],
|
|
1292
|
+
why: "The file declares this field required on every entry after the marker; whatever reads the record for it finds nothing here.",
|
|
1293
|
+
action: `Add a ${field}: line to ${entry.id}.`,
|
|
1294
|
+
effort: "S",
|
|
1295
|
+
confidence: "high"
|
|
1296
|
+
});
|
|
1297
|
+
}
|
|
1171
1298
|
if (!/Scope[\s*]*:/.test(entry.block)) {
|
|
1172
1299
|
findings.push({
|
|
1173
1300
|
id: `${LENS_ID4}/scope-missing:${file}:${entry.id}`,
|
|
@@ -1283,14 +1410,21 @@ var stateFreshnessLens = {
|
|
|
1283
1410
|
}
|
|
1284
1411
|
const entries = parseDecisionEntries(text);
|
|
1285
1412
|
findings.push(...checkIdSequence(a.path, entries));
|
|
1286
|
-
|
|
1413
|
+
const format = parseDecisionsFormat(text);
|
|
1414
|
+
if (!format) {
|
|
1287
1415
|
disclosures.push(
|
|
1288
1416
|
`${a.path} carries no \`${DECISIONS_FORMAT_MARKER}\` marker \u2014 format checks skipped (forward-only, never retroactive); id-sequence checks still ran.`
|
|
1289
1417
|
);
|
|
1290
1418
|
outOfScope.push(a.path);
|
|
1291
1419
|
continue;
|
|
1292
1420
|
}
|
|
1293
|
-
|
|
1421
|
+
for (const problem of format.problems) disclosures.push(`${a.path}: ${problem}`);
|
|
1422
|
+
if (format.fields.length > 0) {
|
|
1423
|
+
disclosures.push(
|
|
1424
|
+
`${a.path} declares required entry fields: ${format.fields.join(", ")} \u2014 checked on every entry (etymd attaches no meaning to the names).`
|
|
1425
|
+
);
|
|
1426
|
+
}
|
|
1427
|
+
findings.push(...checkFormatFields(a.path, entries, today, format.fields));
|
|
1294
1428
|
}
|
|
1295
1429
|
disclosures.push(
|
|
1296
1430
|
`Thresholds: staleAfterDays ${budgets.staleAfterDays} (3x escalates to risk), state budget ${budgets.maxChars} chars (${budgets.staleAfterDays === DEFAULT_CONFIG.state.staleAfterDays && budgets.maxChars === DEFAULT_CONFIG.state.maxChars ? `defaults \u2014 override under \`state\` in ${CONFIG_FILE}` : `set in ${CONFIG_FILE}`}). Decisions artifacts are exempt from age \u2014 old decisions are history, not defects.`
|