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,502 @@
1
+ #!/usr/bin/env node
2
+ import { VERSION } from './chunk-U33XLT5I.js';
3
+ import { normalizeRelPath, isDirectory, readJson, git, matchesAnyGlob, pathExists, readText } from './chunk-WVYKYPKS.js';
4
+ import path from 'node:path';
5
+ import { promises } from 'node:fs';
6
+
7
+ // src/pack/version.ts
8
+ var PACK_VERSION = "2";
9
+ var IGNORED_DIRS = /* @__PURE__ */ new Set([
10
+ "node_modules",
11
+ ".git",
12
+ "dist",
13
+ "build",
14
+ "coverage",
15
+ ".next",
16
+ ".turbo",
17
+ ".cache",
18
+ ".pnpm",
19
+ "out"
20
+ ]);
21
+ var MEANINGFUL_DOT_DIRS = /* @__PURE__ */ new Set([
22
+ ".github",
23
+ ".gitlab",
24
+ ".claude",
25
+ ".cursor",
26
+ ".githooks",
27
+ ".husky",
28
+ ".changeset",
29
+ ".vscode",
30
+ ".storybook"
31
+ ]);
32
+ async function detectPackageManager(root, pkg) {
33
+ if (pkg?.packageManager) {
34
+ const name = pkg.packageManager.split("@")[0];
35
+ if (name === "pnpm" || name === "yarn" || name === "npm" || name === "bun") return name;
36
+ }
37
+ const lock = [
38
+ ["pnpm-lock.yaml", "pnpm"],
39
+ ["yarn.lock", "yarn"],
40
+ ["package-lock.json", "npm"],
41
+ ["bun.lockb", "bun"]
42
+ ];
43
+ for (const [file, pm] of lock) {
44
+ if (await pathExists(path.join(root, file))) return pm;
45
+ }
46
+ return "unknown";
47
+ }
48
+ async function detectWorkspace(root, pkg) {
49
+ if (await pathExists(path.join(root, "nx.json"))) return { kind: "nx", packageGlobs: [] };
50
+ if (await pathExists(path.join(root, "turbo.json"))) return { kind: "turbo", packageGlobs: [] };
51
+ if (await pathExists(path.join(root, "lerna.json"))) return { kind: "lerna", packageGlobs: [] };
52
+ const pnpmWs = await readText(path.join(root, "pnpm-workspace.yaml"));
53
+ if (pnpmWs) {
54
+ const globs = pnpmWs.split("\n").map((l) => l.match(/^\s*-\s*["']?([^"'#]+)["']?/)?.[1]?.trim()).filter((g) => Boolean(g));
55
+ return { kind: "pnpm", packageGlobs: globs };
56
+ }
57
+ if (pkg?.workspaces) {
58
+ const globs = Array.isArray(pkg.workspaces) ? pkg.workspaces : pkg.workspaces.packages ?? [];
59
+ return {
60
+ kind: pkg.packageManager?.startsWith("yarn") ? "yarn" : "npm",
61
+ packageGlobs: globs
62
+ };
63
+ }
64
+ return { kind: "none", packageGlobs: [] };
65
+ }
66
+ async function listWorkspacePackages(root, globs) {
67
+ const found = [];
68
+ const seen = /* @__PURE__ */ new Set();
69
+ const addFromDir = async (dir) => {
70
+ const pkgPath = path.join(dir, "package.json");
71
+ if (seen.has(pkgPath)) return;
72
+ const pkg = await readJson(pkgPath);
73
+ if (!pkg?.name) return;
74
+ seen.add(pkgPath);
75
+ found.push({
76
+ name: pkg.name,
77
+ dir: path.relative(root, dir) || ".",
78
+ private: Boolean(pkg.private),
79
+ version: pkg.version
80
+ });
81
+ };
82
+ for (const glob of globs) {
83
+ const base = glob.replace(/\/\*\*?$/, "");
84
+ const parent = path.join(root, base);
85
+ if (!await isDirectory(parent)) continue;
86
+ let entries;
87
+ try {
88
+ entries = await promises.readdir(parent);
89
+ } catch {
90
+ continue;
91
+ }
92
+ for (const entry of entries) {
93
+ if (IGNORED_DIRS.has(entry) || entry.startsWith(".")) continue;
94
+ const child = path.join(parent, entry);
95
+ if (await isDirectory(child)) await addFromDir(child);
96
+ }
97
+ }
98
+ return found.sort((a, b) => a.dir.localeCompare(b.dir));
99
+ }
100
+ async function expandFileGlobs(root, globs, cap = 500) {
101
+ if (!globs.length) return [];
102
+ const out = /* @__PURE__ */ new Set();
103
+ const walk = async (rel) => {
104
+ if (out.size >= cap) return;
105
+ let entries;
106
+ try {
107
+ entries = await promises.readdir(path.join(root, rel), { withFileTypes: true });
108
+ } catch {
109
+ return;
110
+ }
111
+ for (const entry of entries) {
112
+ if (out.size >= cap) return;
113
+ if (IGNORED_DIRS.has(entry.name)) continue;
114
+ const child = rel ? `${rel}/${entry.name}` : entry.name;
115
+ if (entry.isDirectory()) await walk(child);
116
+ else if (entry.isFile() && matchesAnyGlob(child, globs)) out.add(child);
117
+ }
118
+ };
119
+ const roots = /* @__PURE__ */ new Set();
120
+ for (const glob of globs) {
121
+ const segments = normalizeRelPath(glob).split("/");
122
+ const literal = [];
123
+ for (const seg of segments) {
124
+ if (/[*?]/.test(seg)) break;
125
+ literal.push(seg);
126
+ }
127
+ if (literal.length === segments.length) literal.pop();
128
+ roots.add(literal.join("/"));
129
+ }
130
+ const minimal = [...roots].filter(
131
+ (r) => ![...roots].some((other) => other !== r && (other === "" || r.startsWith(other + "/")))
132
+ );
133
+ for (const r of minimal) {
134
+ if (r && !await isDirectory(path.join(root, r))) continue;
135
+ await walk(r);
136
+ }
137
+ return [...out].sort();
138
+ }
139
+ var isMeta = (value) => /npm-run-all|run-s\b|run-p\b|concurrently|&&/.test(value);
140
+ var ROLE_LADDERS = [
141
+ {
142
+ role: "formatCheck",
143
+ ladder: [
144
+ (k) => /^format:check$/.test(k),
145
+ (k) => /^test:format(:local)?$/.test(k),
146
+ (k) => /^(prettier:check|check:format|format:ci)$/.test(k),
147
+ (_k, v) => /prettier(\s|.*\s)(-l\b|--check)/.test(v)
148
+ ]
149
+ },
150
+ {
151
+ role: "typecheck",
152
+ exclude: /generate|codegen|build|emit|watch/,
153
+ ladder: [
154
+ (k) => /^(typecheck|type-check)$/.test(k),
155
+ (k) => /^test:types(:local)?$/.test(k),
156
+ (k) => /^(types|check:types|types:check)$/.test(k),
157
+ (_k, v) => /tsc\b.*--noemit/i.test(v)
158
+ ]
159
+ },
160
+ {
161
+ role: "test",
162
+ exclude: /watch|coverage|snapshot|e2e|integration|debug|notsilent|mdx/,
163
+ ladder: [
164
+ (k) => /^test:unit(:local|:ci)?$/.test(k),
165
+ (k) => /^test:jest$/.test(k),
166
+ (k, v) => /^test$/.test(k) && !isMeta(v),
167
+ (k) => /^test$/.test(k)
168
+ ]
169
+ },
170
+ {
171
+ role: "lint",
172
+ exclude: /fix|format|staged|watch|style/,
173
+ ladder: [
174
+ (k) => /^lint$/.test(k),
175
+ (k) => /^test:lint(:local)?$/.test(k),
176
+ (k) => /^lint:/.test(k),
177
+ (_k, v) => /eslint\b/.test(v) && !/--fix/.test(v)
178
+ ]
179
+ },
180
+ {
181
+ role: "format",
182
+ exclude: /check|:ci$/,
183
+ ladder: [
184
+ (k) => /^format$/.test(k),
185
+ (k) => /^(format:write|format:prettier|format:fix|prettier)$/.test(k),
186
+ (_k, v) => /prettier\b.*--write/.test(v)
187
+ ]
188
+ },
189
+ {
190
+ role: "build",
191
+ exclude: /watch|storybook/,
192
+ ladder: [(k) => /^build$/.test(k), (k) => /^build:/.test(k)]
193
+ },
194
+ {
195
+ role: "dev",
196
+ ladder: [(k) => /^dev$/.test(k), (k) => /^start$/.test(k), (k) => /^dev:/.test(k)]
197
+ }
198
+ ];
199
+ function classifyCommands(scripts = {}) {
200
+ const out = { raw: scripts };
201
+ const entries = Object.entries(scripts);
202
+ for (const { role, exclude, ladder } of ROLE_LADDERS) {
203
+ for (const pattern of ladder) {
204
+ const hit = entries.find(([k, v]) => !exclude?.test(k) && pattern(k, v));
205
+ if (hit) {
206
+ out[role] = hit[0];
207
+ break;
208
+ }
209
+ }
210
+ }
211
+ return out;
212
+ }
213
+ var FRAMEWORK_MARKERS = [
214
+ ["next", "Next.js"],
215
+ ["@tanstack/react-start", "TanStack Start"],
216
+ ["@tanstack/start", "TanStack Start"],
217
+ ["@tanstack/react-router", "TanStack Router"],
218
+ ["react", "React"],
219
+ ["vue", "Vue"],
220
+ ["svelte", "Svelte"],
221
+ ["@angular/core", "Angular"],
222
+ ["express", "Express"],
223
+ ["fastify", "Fastify"],
224
+ ["@nestjs/core", "NestJS"],
225
+ ["@trpc/server", "tRPC"],
226
+ ["vite", "Vite"],
227
+ ["react-scripts", "CRA"],
228
+ ["@apollo/client", "Apollo"],
229
+ ["drizzle-orm", "Drizzle"],
230
+ ["prisma", "Prisma"],
231
+ ["@sanity/client", "Sanity"],
232
+ ["nx", "Nx"]
233
+ ];
234
+ function detectFrameworks(pkg) {
235
+ if (!pkg) return [];
236
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
237
+ const out = [];
238
+ for (const [marker, label] of FRAMEWORK_MARKERS) {
239
+ if (deps[marker] && !out.includes(label)) out.push(label);
240
+ }
241
+ return out;
242
+ }
243
+ async function detectCi(root) {
244
+ if (await pathExists(path.join(root, ".gitlab-ci.yml"))) {
245
+ return { system: "gitlab", files: [".gitlab-ci.yml"] };
246
+ }
247
+ const workflows = path.join(root, ".github", "workflows");
248
+ if (await isDirectory(workflows)) {
249
+ try {
250
+ const files = (await promises.readdir(workflows)).filter((f) => f.endsWith(".yml") || f.endsWith(".yaml")).map((f) => path.join(".github/workflows", f));
251
+ if (files.length) return { system: "github", files };
252
+ } catch {
253
+ }
254
+ }
255
+ return { system: "none", files: [] };
256
+ }
257
+ async function hasLintStagedConfig(root, pkg) {
258
+ if (pkg && pkg["lint-staged"]) return true;
259
+ for (const f of [
260
+ ".lintstagedrc",
261
+ ".lintstagedrc.json",
262
+ ".lintstagedrc.js",
263
+ "lint-staged.config.js"
264
+ ]) {
265
+ if (await pathExists(path.join(root, f))) return true;
266
+ }
267
+ return false;
268
+ }
269
+ async function detectHooks(root, hooksPath, pkg) {
270
+ const lintStaged = await hasLintStagedConfig(root, pkg);
271
+ if (hooksPath) {
272
+ if (hooksPath.replace(/\/+$/, "") === ".husky/_") {
273
+ const base2 = path.join(root, ".husky");
274
+ return {
275
+ source: "husky",
276
+ dir: ".husky",
277
+ preCommit: await pathExists(path.join(base2, "pre-commit")),
278
+ prePush: await pathExists(path.join(base2, "pre-push")),
279
+ commitMsg: await pathExists(path.join(base2, "commit-msg")),
280
+ lintStaged
281
+ };
282
+ }
283
+ const base = path.join(root, hooksPath);
284
+ return {
285
+ source: hooksPath === ".githooks" ? "githooks" : "custom",
286
+ dir: hooksPath,
287
+ preCommit: await pathExists(path.join(base, "pre-commit")),
288
+ prePush: await pathExists(path.join(base, "pre-push")),
289
+ commitMsg: await pathExists(path.join(base, "commit-msg")),
290
+ lintStaged
291
+ };
292
+ }
293
+ if (await isDirectory(path.join(root, ".githooks"))) {
294
+ const base = path.join(root, ".githooks");
295
+ return {
296
+ source: "githooks",
297
+ dir: ".githooks",
298
+ preCommit: await pathExists(path.join(base, "pre-commit")),
299
+ prePush: await pathExists(path.join(base, "pre-push")),
300
+ commitMsg: await pathExists(path.join(base, "commit-msg")),
301
+ lintStaged
302
+ };
303
+ }
304
+ if (await isDirectory(path.join(root, ".husky"))) {
305
+ const base = path.join(root, ".husky");
306
+ return {
307
+ source: "husky",
308
+ dir: ".husky",
309
+ preCommit: await pathExists(path.join(base, "pre-commit")),
310
+ prePush: await pathExists(path.join(base, "pre-push")),
311
+ commitMsg: await pathExists(path.join(base, "commit-msg")),
312
+ lintStaged
313
+ };
314
+ }
315
+ const legacyHooks = pkg?.husky?.hooks;
316
+ const configText = legacyHooks ? null : await readText(path.join(root, "husky.config.js"));
317
+ if (legacyHooks || configText) {
318
+ const hookNamed = (name) => legacyHooks ? Boolean(legacyHooks[name]) : Boolean(configText && configText.includes(name));
319
+ return {
320
+ source: "husky-legacy",
321
+ preCommit: hookNamed("pre-commit"),
322
+ prePush: hookNamed("pre-push"),
323
+ commitMsg: hookNamed("commit-msg"),
324
+ lintStaged
325
+ };
326
+ }
327
+ return { source: "none", preCommit: false, prePush: false, commitMsg: false, lintStaged };
328
+ }
329
+ var ARTIFACT_SPECS = [
330
+ { id: "agents", label: "AGENTS.md (operating contract)", path: "AGENTS.md", kind: "contract" },
331
+ {
332
+ id: "project-context",
333
+ label: "PROJECT_CONTEXT.md (ground-truth state)",
334
+ path: "PROJECT_CONTEXT.md",
335
+ kind: "state"
336
+ },
337
+ { id: "claude", label: "CLAUDE.md (Claude Code pointer)", path: "CLAUDE.md", kind: "adapter" },
338
+ {
339
+ id: "copilot",
340
+ label: "Copilot instructions",
341
+ path: ".github/copilot-instructions.md",
342
+ kind: "adapter"
343
+ },
344
+ {
345
+ id: "cursorrules",
346
+ label: ".cursorrules (legacy Cursor)",
347
+ path: ".cursorrules",
348
+ kind: "adapter"
349
+ },
350
+ { id: "cursor-rules", label: ".cursor/rules", path: ".cursor/rules", kind: "adapter" },
351
+ { id: "gemini", label: "GEMINI.md", path: "GEMINI.md", kind: "adapter" },
352
+ { id: "cline", label: ".clinerules", path: ".clinerules", kind: "adapter" },
353
+ { id: "windsurf", label: ".windsurfrules", path: ".windsurfrules", kind: "adapter" },
354
+ { id: "skills", label: "Agent skills (.claude/skills)", path: ".claude/skills", kind: "skill" },
355
+ {
356
+ id: "failure-modes-skill",
357
+ label: "Failure-modes register (skill)",
358
+ path: ".claude/skills/failure-modes",
359
+ kind: "skill"
360
+ },
361
+ {
362
+ id: "failure-modes-doc",
363
+ label: "Failure-modes register (doc)",
364
+ path: "docs/failure-modes.md",
365
+ kind: "other"
366
+ },
367
+ {
368
+ id: "sessions",
369
+ label: "Session archive (docs/sessions)",
370
+ path: "docs/sessions",
371
+ kind: "sessions"
372
+ },
373
+ { id: "githooks", label: "Tracked git hooks (.githooks)", path: ".githooks", kind: "gate" },
374
+ {
375
+ id: "inventory",
376
+ label: "Reuse inventory (docs/INVENTORY.md)",
377
+ path: "docs/INVENTORY.md",
378
+ kind: "map"
379
+ }
380
+ ];
381
+ async function detectArtifacts(root) {
382
+ return Promise.all(
383
+ ARTIFACT_SPECS.map(async (spec) => ({
384
+ ...spec,
385
+ exists: await pathExists(path.join(root, spec.path))
386
+ }))
387
+ );
388
+ }
389
+ async function walkTree(root) {
390
+ const state = { budget: 2e4, truncated: false };
391
+ const countFiles = async (dir) => {
392
+ if (state.budget <= 0) {
393
+ state.truncated = true;
394
+ return 0;
395
+ }
396
+ let total = 0;
397
+ let entries;
398
+ try {
399
+ entries = await promises.readdir(dir, { withFileTypes: true });
400
+ } catch {
401
+ return 0;
402
+ }
403
+ for (const entry of entries) {
404
+ if (entry.name.startsWith(".") || IGNORED_DIRS.has(entry.name)) continue;
405
+ if (entry.isDirectory()) {
406
+ total += await countFiles(path.join(dir, entry.name));
407
+ } else if (entry.isFile()) {
408
+ total += 1;
409
+ state.budget -= 1;
410
+ if (state.budget <= 0) {
411
+ state.truncated = true;
412
+ break;
413
+ }
414
+ }
415
+ }
416
+ return total;
417
+ };
418
+ let top;
419
+ try {
420
+ top = await promises.readdir(root, { withFileTypes: true });
421
+ } catch {
422
+ return { dirs: [], truncated: false };
423
+ }
424
+ const dirs = [];
425
+ for (const entry of top) {
426
+ if (!entry.isDirectory()) continue;
427
+ if (IGNORED_DIRS.has(entry.name)) continue;
428
+ if (entry.name.startsWith(".") && !MEANINGFUL_DOT_DIRS.has(entry.name)) continue;
429
+ dirs.push({ name: entry.name, files: await countFiles(path.join(root, entry.name)) });
430
+ }
431
+ dirs.sort((a, b) => b.files - a.files);
432
+ return { dirs, truncated: state.truncated };
433
+ }
434
+
435
+ // src/core/scan.ts
436
+ function inferTicketKey(subjects) {
437
+ const counts = /* @__PURE__ */ new Map();
438
+ for (const m of subjects.matchAll(/\b([A-Z][A-Z0-9]{1,9})-\d+\b/g)) {
439
+ const key = m[1];
440
+ counts.set(key, (counts.get(key) ?? 0) + 1);
441
+ }
442
+ let best;
443
+ let bestCount = 0;
444
+ for (const [key, count] of counts) {
445
+ if (count > bestCount) {
446
+ best = key;
447
+ bestCount = count;
448
+ }
449
+ }
450
+ return bestCount >= 3 ? best : void 0;
451
+ }
452
+ async function scanProject(root) {
453
+ const abs = path.resolve(root);
454
+ const rootPkg = await readJson(path.join(abs, "package.json"));
455
+ const [packageManager, workspace, ci, artifacts, tree, isRepo] = await Promise.all([
456
+ detectPackageManager(abs, rootPkg),
457
+ detectWorkspace(abs, rootPkg),
458
+ detectCi(abs),
459
+ detectArtifacts(abs),
460
+ walkTree(abs),
461
+ git(abs, ["rev-parse", "--is-inside-work-tree"]).then((r) => r === "true")
462
+ ]);
463
+ const [branch, head, hooksPathRaw, authorsRaw, subjectsRaw] = isRepo ? await Promise.all([
464
+ git(abs, ["rev-parse", "--abbrev-ref", "HEAD"]),
465
+ git(abs, ["rev-parse", "--short", "HEAD"]),
466
+ git(abs, ["config", "--get", "core.hooksPath"]),
467
+ git(abs, ["log", "-200", "--format=%ae"]),
468
+ git(abs, ["log", "-50", "--format=%s"])
469
+ ]) : [null, null, null, null, null];
470
+ const hooksPath = hooksPathRaw ?? void 0;
471
+ const hooks = await detectHooks(abs, hooksPath, rootPkg);
472
+ const packages = workspace.packageGlobs.length ? await listWorkspacePackages(abs, workspace.packageGlobs) : [];
473
+ const recentAuthors = authorsRaw ? new Set(authorsRaw.split("\n").filter(Boolean)).size : void 0;
474
+ return {
475
+ etymdVersion: VERSION,
476
+ packVersion: PACK_VERSION,
477
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
478
+ root: abs,
479
+ name: rootPkg?.name ?? path.basename(abs),
480
+ git: {
481
+ isRepo,
482
+ branch: branch ?? void 0,
483
+ head: head ?? void 0,
484
+ hooksPath,
485
+ husky: hooks.source === "husky" || hooks.source === "husky-legacy",
486
+ recentAuthors,
487
+ ticketKey: subjectsRaw ? inferTicketKey(subjectsRaw) : void 0
488
+ },
489
+ packageManager,
490
+ node: rootPkg?.engines?.node,
491
+ workspace: { kind: workspace.kind, packageGlobs: workspace.packageGlobs },
492
+ packages,
493
+ frameworks: detectFrameworks(rootPkg),
494
+ commands: classifyCommands(rootPkg?.scripts),
495
+ ci,
496
+ hooks,
497
+ artifacts,
498
+ tree
499
+ };
500
+ }
501
+
502
+ export { PACK_VERSION, expandFileGlobs, scanProject };
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env node
2
+ // package.json
3
+ var package_default = {
4
+ version: "0.1.0"};
5
+
6
+ // src/version.ts
7
+ var VERSION = package_default.version;
8
+
9
+ export { VERSION };