etymd 0.1.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.
@@ -0,0 +1,176 @@
1
+ #!/usr/bin/env node
2
+ import { PACK_VERSION } from './chunk-TCWKIMCU.js';
3
+ import { pathExists, readText } from './chunk-WVYKYPKS.js';
4
+ import { promises } from 'node:fs';
5
+ import path from 'node:path';
6
+
7
+ async function applyFiles(root, files, overwrite = /* @__PURE__ */ new Set()) {
8
+ const written = [];
9
+ const skipped = [];
10
+ for (const file of files) {
11
+ if (file.exists && !overwrite.has(file.path)) {
12
+ skipped.push(file.path);
13
+ continue;
14
+ }
15
+ const abs = path.join(root, file.path);
16
+ await promises.mkdir(path.dirname(abs), { recursive: true });
17
+ await promises.writeFile(abs, file.contents, "utf8");
18
+ if (file.executable) await promises.chmod(abs, 493);
19
+ written.push(file.path);
20
+ }
21
+ return { written, skipped };
22
+ }
23
+
24
+ // src/pack/templates.ts
25
+ function runPrefix(pm) {
26
+ switch (pm) {
27
+ case "pnpm":
28
+ return "pnpm";
29
+ case "yarn":
30
+ return "yarn";
31
+ case "bun":
32
+ return "bun run";
33
+ case "npm":
34
+ case "unknown":
35
+ default:
36
+ return "npm run";
37
+ }
38
+ }
39
+ function isSafeGateCommand(value) {
40
+ if (!value) return false;
41
+ return !/--write|--fix|\bcodegen\b|\bgenerate\b|-w\s|--watch/.test(value);
42
+ }
43
+ function mapVerifyCommand(facts) {
44
+ switch (facts.workspace.kind) {
45
+ case "nx":
46
+ return `${facts.packageManager === "yarn" ? "yarn" : "npx"} nx show projects`;
47
+ case "pnpm":
48
+ return "pnpm -r ls --depth -1";
49
+ case "yarn":
50
+ return "yarn workspaces info";
51
+ case "npm":
52
+ return "npm ls --workspaces --depth=0";
53
+ case "turbo":
54
+ case "lerna":
55
+ return "git ls-files '*/package.json'";
56
+ default:
57
+ return "git ls-files | head -50";
58
+ }
59
+ }
60
+ function doneDefinition(facts) {
61
+ const run = runPrefix(facts.packageManager);
62
+ const parts = [];
63
+ const c = facts.commands;
64
+ const formatCmd = c.formatCheck ?? (isSafeGateCommand(c.format ? c.raw[c.format] : void 0) ? c.format : void 0);
65
+ if (formatCmd) parts.push(`\`${run} ${formatCmd}\``);
66
+ if (c.typecheck) parts.push(`\`${run} ${c.typecheck}\``);
67
+ if (c.lint) parts.push(`\`${run} ${c.lint}\``);
68
+ if (c.test) parts.push(`\`${run} ${c.test}\``);
69
+ return parts;
70
+ }
71
+ function generateAgentsMd(facts) {
72
+ const run = runPrefix(facts.packageManager);
73
+ const done = doneDefinition(facts);
74
+ const frameworks = facts.frameworks.length ? facts.frameworks.join(", ") : "none detected";
75
+ const workspace = facts.workspace.kind === "none" ? "single package" : `${facts.workspace.kind} workspace`;
76
+ const topDirs = facts.tree.dirs.slice(0, 14);
77
+ return `# AGENTS.md
78
+
79
+ Operating contract for AI agents working in **${facts.name}**. One source of truth \u2014 most agents
80
+ (Claude Code, Codex, Cursor, Copilot, Gemini, \u2026) read this file natively. Kept true by
81
+ [etymd](https://www.npmjs.com/package/etymd): the commands, paths, and claims below are audited
82
+ against the actual repo \u2014 update this file when the repo changes, or \`etymd audit\` will tell you.
83
+
84
+ ## What this project is
85
+
86
+ <!-- One paragraph: what this does and who it is for. Run \`etymd brief\` to have your agent
87
+ draft it from the reckoning; refine by hand. -->
88
+
89
+ ## Stack
90
+
91
+ - **Shape:** ${workspace}${facts.packages.length ? ` (${facts.packages.length} packages)` : ""}, package manager **${facts.packageManager}**${facts.node ? `, Node ${facts.node}` : ""}.
92
+ - **Frameworks:** ${frameworks}.
93
+ - **CI:** ${facts.ci.system === "none" ? "none detected" : facts.ci.system}.
94
+
95
+ ## Working rules
96
+
97
+ - **Reuse-first.** Before writing any new helper/component/type: check the map below and the
98
+ surrounding code \u2014 a "new" thing usually exists.
99
+ - **Minimal diffs.** Never touch files outside the task's scope.
100
+ <!-- Add your project's own rules: commit/branch conventions, what the agent may and may not do,
101
+ org tooling constraints. Keep every rule TRUE \u2014 stale rules erode trust in the rest. -->
102
+
103
+ ## Repo map
104
+
105
+ > **Advisory, not authoritative** \u2014 re-verify with \`${mapVerifyCommand(facts)}\` before
106
+ > structure-sensitive changes, and update this section in the same change that moves files.
107
+
108
+ ${topDirs.length ? topDirs.map((d) => `- \`${d.name}/\` \u2014 ${d.files} files`).join("\n") : "- (single package; list the key files here)"}
109
+
110
+ ## Done =
111
+
112
+ ${done.length ? `A change is done when these are green:
113
+
114
+ ${done.map((d) => `- ${d}`).join("\n")}` : `Define the check commands that gate a change (test / lint / typecheck / format).`}
115
+
116
+ ## Commands
117
+
118
+ \`\`\`bash
119
+ ${[
120
+ facts.commands.dev && `${run} ${facts.commands.dev}`,
121
+ facts.commands.build && `${run} ${facts.commands.build}`,
122
+ facts.commands.test && `${run} ${facts.commands.test}`,
123
+ facts.commands.lint && `${run} ${facts.commands.lint}`,
124
+ facts.commands.typecheck && `${run} ${facts.commands.typecheck}`
125
+ ].filter(Boolean).join("\n") || "# add the project's key commands"}
126
+ \`\`\`
127
+
128
+ <!-- etymd pack v${PACK_VERSION} -->
129
+ `;
130
+ }
131
+ function generatePreCommitHook() {
132
+ return `#!/usr/bin/env sh
133
+ # etymd: process gate. Cheap, locally-knowable checks belong here (fast, blocks the commit).
134
+ exit 0
135
+ `;
136
+ }
137
+ function generatePrePushHook(facts) {
138
+ const run = runPrefix(facts.packageManager);
139
+ const c = facts.commands;
140
+ const candidates = [c.formatCheck, c.typecheck, c.lint];
141
+ const steps = candidates.filter((key) => Boolean(key) && isSafeGateCommand(c.raw[key])).map((key) => `${run} ${key}`);
142
+ const body = steps.length ? steps.map((s) => `echo "\u203A ${s}"
143
+ ${s} || exit 1`).join("\n") : 'echo "etymd: no correctness commands detected \u2014 add format:check / typecheck / lint"';
144
+ return `#!/usr/bin/env sh
145
+ # etymd: correctness gate. Mirrors CI cheapest-first; blocks the push on any failure.
146
+ ${body}
147
+ `;
148
+ }
149
+
150
+ // src/core/generate.ts
151
+ async function planWorkflow(root, facts, opts) {
152
+ const out = [];
153
+ const add = async (rel, contents, label, executable = false) => {
154
+ const abs = path.join(root, rel);
155
+ const exists = await pathExists(abs);
156
+ const existing = exists ? await readText(abs) : null;
157
+ out.push({
158
+ path: rel,
159
+ contents,
160
+ exists,
161
+ differs: exists ? existing !== contents : void 0,
162
+ executable,
163
+ label
164
+ });
165
+ };
166
+ if (opts.agents) {
167
+ await add("AGENTS.md", generateAgentsMd(facts), "Minimal operating contract (scaffold)");
168
+ }
169
+ if (opts.gates) {
170
+ await add(".githooks/pre-commit", generatePreCommitHook(), "Process gate (pre-commit)", true);
171
+ await add(".githooks/pre-push", generatePrePushHook(facts), "Correctness gate (pre-push)", true);
172
+ }
173
+ return out;
174
+ }
175
+
176
+ export { applyFiles, planWorkflow };
@@ -0,0 +1,120 @@
1
+ #!/usr/bin/env node
2
+ import { ETYMD_DIR } from './chunk-SMM2TW35.js';
3
+ import { pathExists, readText, wordCount, approxTokens } from './chunk-WVYKYPKS.js';
4
+ import path2 from 'node:path';
5
+ import { promises } from 'node:fs';
6
+
7
+ var CONFIG_FILE = path2.join(ETYMD_DIR, "config.json");
8
+ var DEFAULT_CONFIG = {
9
+ instructions: { include: [], exclude: [] },
10
+ context: { perFileWords: 4e3, totalWords: 8e3 }
11
+ };
12
+ function configPath(root) {
13
+ return path2.join(root, CONFIG_FILE);
14
+ }
15
+ function readGlobList(value, key, problems) {
16
+ if (value === void 0) return void 0;
17
+ if (!Array.isArray(value) || value.some((v) => typeof v !== "string")) {
18
+ problems.push(`${CONFIG_FILE}: \`${key}\` must be an array of glob strings \u2014 ignored.`);
19
+ return void 0;
20
+ }
21
+ return value.map((g) => g.trim()).filter(Boolean);
22
+ }
23
+ function readBudget(value, key, problems) {
24
+ if (value === void 0) return void 0;
25
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
26
+ problems.push(`${CONFIG_FILE}: \`${key}\` must be a positive number \u2014 ignored.`);
27
+ return void 0;
28
+ }
29
+ return value;
30
+ }
31
+ async function readConfig(root) {
32
+ const target = configPath(root);
33
+ const problems = [];
34
+ if (!await pathExists(target)) {
35
+ return { config: DEFAULT_CONFIG, present: false, problems };
36
+ }
37
+ const raw = await readText(target);
38
+ let parsed;
39
+ try {
40
+ parsed = JSON.parse(raw ?? "");
41
+ } catch (err) {
42
+ problems.push(
43
+ `${CONFIG_FILE} exists but is not valid JSON (${err instanceof Error ? err.message : String(err)}) \u2014 defaults used.`
44
+ );
45
+ return { config: DEFAULT_CONFIG, present: true, problems };
46
+ }
47
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
48
+ problems.push(`${CONFIG_FILE} must contain a JSON object \u2014 defaults used.`);
49
+ return { config: DEFAULT_CONFIG, present: true, problems };
50
+ }
51
+ const obj = parsed;
52
+ const instructions = obj.instructions ?? {};
53
+ const context = obj.context ?? {};
54
+ return {
55
+ present: true,
56
+ problems,
57
+ config: {
58
+ instructions: {
59
+ include: readGlobList(instructions.include, "instructions.include", problems) ?? DEFAULT_CONFIG.instructions.include,
60
+ exclude: readGlobList(instructions.exclude, "instructions.exclude", problems) ?? DEFAULT_CONFIG.instructions.exclude
61
+ },
62
+ context: {
63
+ perFileWords: readBudget(context.perFileWords, "context.perFileWords", problems) ?? DEFAULT_CONFIG.context.perFileWords,
64
+ totalWords: readBudget(context.totalWords, "context.totalWords", problems) ?? DEFAULT_CONFIG.context.totalWords
65
+ }
66
+ }
67
+ };
68
+ }
69
+ var ALWAYS_LOADED = [
70
+ { path: "AGENTS.md", role: "operating contract" },
71
+ { path: "CLAUDE.md", role: "Claude Code pointer" },
72
+ { path: "GEMINI.md", role: "Gemini pointer" },
73
+ { path: ".github/copilot-instructions.md", role: "Copilot instructions" },
74
+ { path: ".cursorrules", role: "Cursor rules (legacy)" }
75
+ ];
76
+ var EXTRACTION_THRESHOLD = DEFAULT_CONFIG.context.perFileWords;
77
+ function isAlwaysAppliedCursorRule(text) {
78
+ const fm = text.match(/^---\n([\s\S]*?)\n---/);
79
+ if (!fm) return true;
80
+ const frontmatter = fm[1] ?? "";
81
+ return /^\s*alwaysApply\s*:\s*true\s*$/m.test(frontmatter);
82
+ }
83
+ async function measureContext(root, perFileWords = EXTRACTION_THRESHOLD) {
84
+ const files = [];
85
+ for (const spec of ALWAYS_LOADED) {
86
+ const abs = path2.join(root, spec.path);
87
+ const text = await readText(abs);
88
+ if (text === null) continue;
89
+ const words = wordCount(text);
90
+ files.push({ path: spec.path, role: spec.role, words, approxTokens: approxTokens(words) });
91
+ }
92
+ const rulesDir = path2.join(root, ".cursor", "rules");
93
+ if (await pathExists(rulesDir)) {
94
+ try {
95
+ for (const entry of await promises.readdir(rulesDir)) {
96
+ if (!entry.endsWith(".mdc") && !entry.endsWith(".md")) continue;
97
+ const text = await readText(path2.join(rulesDir, entry));
98
+ if (text === null || !isAlwaysAppliedCursorRule(text)) continue;
99
+ const words = wordCount(text);
100
+ files.push({
101
+ path: `.cursor/rules/${entry}`,
102
+ role: "Cursor rule (always applied)",
103
+ words,
104
+ approxTokens: approxTokens(words)
105
+ });
106
+ }
107
+ } catch {
108
+ }
109
+ }
110
+ const totalWords = files.reduce((s, f) => s + f.words, 0);
111
+ return {
112
+ files: files.sort((a, b) => b.words - a.words),
113
+ totalWords,
114
+ totalApproxTokens: approxTokens(totalWords),
115
+ perFileWords,
116
+ extractionCandidates: files.filter((f) => f.words >= perFileWords)
117
+ };
118
+ }
119
+
120
+ export { CONFIG_FILE, DEFAULT_CONFIG, measureContext, readConfig };
@@ -0,0 +1,105 @@
1
+ #!/usr/bin/env node
2
+ import { readJson } from './chunk-WVYKYPKS.js';
3
+ import { promises } from 'node:fs';
4
+ import path from 'node:path';
5
+
6
+ function ledgerPath(root) {
7
+ return path.join(root, ".etymd", "ledger.json");
8
+ }
9
+ async function readLedger(root) {
10
+ return await readJson(ledgerPath(root)) ?? { version: 1, entries: [] };
11
+ }
12
+ async function writeLedger(root, ledger) {
13
+ const target = ledgerPath(root);
14
+ await promises.mkdir(path.dirname(target), { recursive: true });
15
+ await promises.writeFile(target, JSON.stringify(ledger, null, 2) + "\n", "utf8");
16
+ }
17
+ function belongsToAny(id, paths) {
18
+ return paths.some((p) => id.includes(`:${p}`));
19
+ }
20
+ function reconcileLedger(ledger, findings, now = (/* @__PURE__ */ new Date()).toISOString(), outOfScope = []) {
21
+ const byId = new Map(ledger.entries.map((e) => [e.id, e]));
22
+ const seen = /* @__PURE__ */ new Set();
23
+ const diff = {
24
+ new: [],
25
+ stillOpen: [],
26
+ regressed: [],
27
+ resolved: [],
28
+ dismissed: [],
29
+ accepted: [],
30
+ outOfScope: []
31
+ };
32
+ const entries = [];
33
+ for (const f of findings) {
34
+ seen.add(f.id);
35
+ const prev = byId.get(f.id);
36
+ if (!prev) {
37
+ diff.new.push(f);
38
+ entries.push({
39
+ id: f.id,
40
+ status: "open",
41
+ tier: f.tier,
42
+ claim: f.claim,
43
+ firstSeen: now,
44
+ lastSeen: now
45
+ });
46
+ continue;
47
+ }
48
+ if (prev.status === "dismissed") {
49
+ diff.dismissed.push(f);
50
+ entries.push({ ...prev, lastSeen: now });
51
+ continue;
52
+ }
53
+ if (prev.status === "accepted") {
54
+ diff.accepted.push(f);
55
+ entries.push({ ...prev, tier: f.tier, claim: f.claim, lastSeen: now });
56
+ continue;
57
+ }
58
+ if (prev.status === "done") {
59
+ diff.regressed.push(f);
60
+ entries.push({ ...prev, status: "regressed", tier: f.tier, claim: f.claim, lastSeen: now });
61
+ continue;
62
+ }
63
+ diff.stillOpen.push(f);
64
+ entries.push({ ...prev, tier: f.tier, claim: f.claim, lastSeen: now });
65
+ }
66
+ for (const prev of ledger.entries) {
67
+ if (seen.has(prev.id)) continue;
68
+ if (outOfScope.length && belongsToAny(prev.id, outOfScope)) {
69
+ diff.outOfScope.push(prev);
70
+ entries.push(prev);
71
+ continue;
72
+ }
73
+ if (prev.status === "open" || prev.status === "accepted" || prev.status === "regressed") {
74
+ diff.resolved.push(prev);
75
+ entries.push({ ...prev, status: "done", lastSeen: now });
76
+ } else {
77
+ entries.push(prev);
78
+ }
79
+ }
80
+ return { ledger: { version: 1, entries }, diff };
81
+ }
82
+ function visibleFindings(findings, ledger) {
83
+ const quieted = new Set(
84
+ ledger.entries.filter((e) => e.status === "dismissed" || e.status === "accepted").map((e) => e.id)
85
+ );
86
+ return findings.filter((f) => !quieted.has(f.id));
87
+ }
88
+ function resolveEntry(ledger, id, status, reason, now = (/* @__PURE__ */ new Date()).toISOString()) {
89
+ let updated = null;
90
+ const entries = ledger.entries.map((e) => {
91
+ if (e.id !== id) return e;
92
+ updated = {
93
+ ...e,
94
+ status,
95
+ // Keep an existing reason if none is supplied (e.g. re-accepting a once-dismissed item).
96
+ reason: reason ?? e.reason,
97
+ lastSeen: now
98
+ };
99
+ return updated;
100
+ });
101
+ if (!updated) return { ledger, entry: null };
102
+ return { ledger: { version: 1, entries }, entry: updated };
103
+ }
104
+
105
+ export { readLedger, reconcileLedger, resolveEntry, visibleFindings, writeLedger };
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env node
2
+ import { readJson } from './chunk-WVYKYPKS.js';
3
+ import { promises } from 'node:fs';
4
+ import path from 'node:path';
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
+ export { BASELINE_FILE, CACHE_DIR, ETYMD_DIR, baselineCarriesMachinePath, deriveProfile, isDriftEmpty, readBaseline, summarizeBaselineDrift, writeBaseline, writeCachedFacts };