@jgiox/goodvibes 1.0.0 → 1.2.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/dist/index.js CHANGED
@@ -8,7 +8,7 @@ import { intro, outro, note, tasks } from "@clack/prompts";
8
8
 
9
9
  // src/steps/copy-templates.ts
10
10
  import { copy } from "fs-extra";
11
- import { readFile as readFile2 } from "fs/promises";
11
+ import { readFile as readFile2, rename } from "fs/promises";
12
12
  import { readdir } from "fs/promises";
13
13
  import { existsSync } from "fs";
14
14
  import { join, relative } from "path";
@@ -86,9 +86,25 @@ async function listTemplateFiles(templateDir) {
86
86
  await walk(templateDir);
87
87
  return results.sort();
88
88
  }
89
- async function copyTemplates(templateDir, destDir, dryRun, minimal) {
89
+ async function walkDir(dir, base) {
90
+ const results = [];
91
+ const entries = await readdir(dir, { withFileTypes: true });
92
+ for (const entry of entries) {
93
+ const fullPath = join(dir, entry.name);
94
+ if (entry.isDirectory()) {
95
+ results.push(...await walkDir(fullPath, base));
96
+ } else {
97
+ results.push(relative(base, fullPath));
98
+ }
99
+ }
100
+ return results;
101
+ }
102
+ async function copyTemplates(templateDir, destDir, dryRun, minimal, projectType = "both") {
103
+ const ciVariants = ["ci-node.yml", "ci-python.yml", "ci-both.yml"];
104
+ const selectedVariant = `ci-${projectType}.yml`;
90
105
  if (dryRun) {
91
- return listTemplateFiles(templateDir);
106
+ const all = await listTemplateFiles(templateDir);
107
+ return all.filter((p) => !ciVariants.some((v) => p.endsWith(v) && v !== selectedVariant));
92
108
  }
93
109
  await copy(templateDir, destDir, {
94
110
  overwrite: false,
@@ -97,14 +113,25 @@ async function copyTemplates(templateDir, destDir, dryRun, minimal) {
97
113
  if (src.endsWith("CLAUDE.md")) return false;
98
114
  if (minimal && src.includes(".github/workflows")) return false;
99
115
  if (relative(templateDir, src).includes("..")) return false;
116
+ for (const variant of ciVariants) {
117
+ if (src.endsWith(variant) && variant !== selectedVariant) return false;
118
+ }
100
119
  return true;
101
120
  }
102
121
  });
122
+ if (!minimal) {
123
+ const variantPath = join(destDir, ".github", "workflows", selectedVariant);
124
+ const ciPath = join(destDir, ".github", "workflows", "ci.yml");
125
+ if (existsSync(variantPath)) {
126
+ await rename(variantPath, ciPath);
127
+ }
128
+ }
103
129
  const claudeSrc = join(templateDir, "CLAUDE.md");
104
130
  const claudeDest = join(destDir, "CLAUDE.md");
105
131
  const templateContent = await readFile2(claudeSrc, "utf-8");
106
132
  await mergeClaude(claudeDest, templateContent);
107
- return listTemplateFiles(templateDir);
133
+ const destFiles = await walkDir(destDir, destDir);
134
+ return destFiles.sort();
108
135
  }
109
136
 
110
137
  // src/steps/install-headroom.ts
@@ -215,12 +242,25 @@ async function configureMcp(log) {
215
242
  }
216
243
  }
217
244
 
245
+ // src/utils/detect-project-type.ts
246
+ import { existsSync as existsSync2 } from "fs";
247
+ import { join as join2 } from "path";
248
+ function detectProjectType(cwd) {
249
+ const hasNode = existsSync2(join2(cwd, "package.json"));
250
+ const hasPython = existsSync2(join2(cwd, "pyproject.toml")) || existsSync2(join2(cwd, "requirements.txt"));
251
+ if (hasNode && hasPython) return "both";
252
+ if (hasNode) return "node";
253
+ if (hasPython) return "python";
254
+ return "both";
255
+ }
256
+
218
257
  // src/commands/init.ts
219
258
  function registerInitCommand(program2) {
220
259
  program2.command("init").description("Bootstrap a project with goodvibes configuration").option("--dry-run", "Preview files without writing to disk").option("--minimal", "Skip headroom install and CI workflows").action(async (options) => {
221
260
  const dryRun = options.dryRun ?? false;
222
261
  const minimal = options.minimal ?? false;
223
262
  const cwd = process.cwd();
263
+ const projectType = detectProjectType(cwd);
224
264
  const templateDir = resolveTemplatesDir();
225
265
  intro("goodvibes init");
226
266
  if (dryRun) {
@@ -229,7 +269,7 @@ function registerInitCommand(program2) {
229
269
  note(
230
270
  [
231
271
  "1. Open this project in Claude Code",
232
- "2. Run /plugin marketplace add DietrichGebert/ponytail",
272
+ "2. In Claude Code CLI: /plugin marketplace add DietrichGebert/ponytail",
233
273
  "3. Start coding \u2014 CLAUDE.md rules are already active"
234
274
  ].join("\n"),
235
275
  "Next steps"
@@ -242,7 +282,7 @@ function registerInitCommand(program2) {
242
282
  {
243
283
  title: "Copying template files",
244
284
  task: async (message) => {
245
- const files = await copyTemplates(templateDir, cwd, false, minimal);
285
+ const files = await copyTemplates(templateDir, cwd, false, minimal, projectType);
246
286
  createdFiles.push(...files);
247
287
  return `Copied ${files.length} files`;
248
288
  }
@@ -270,7 +310,7 @@ function registerInitCommand(program2) {
270
310
  note(createdFiles.join("\n"), "Files created");
271
311
  const nextSteps = [
272
312
  "1. Open this project in Claude Code",
273
- "2. Run /plugin marketplace add DietrichGebert/ponytail",
313
+ "2. In Claude Code CLI: /plugin marketplace add DietrichGebert/ponytail",
274
314
  "3. Start coding \u2014 CLAUDE.md rules are already active"
275
315
  ].join("\n");
276
316
  note(nextSteps, "Next steps");
@@ -278,6 +318,161 @@ function registerInitCommand(program2) {
278
318
  });
279
319
  }
280
320
 
321
+ // src/commands/upgrade.ts
322
+ import { intro as intro2, outro as outro2, note as note2, tasks as tasks2 } from "@clack/prompts";
323
+ import { copy as copy2, pathExists as pathExists2 } from "fs-extra";
324
+ import { readFile as readFile3, rename as rename2 } from "fs/promises";
325
+ import { existsSync as existsSync3 } from "fs";
326
+ import { join as join3, relative as relative2 } from "path";
327
+ import { execa as execa4 } from "execa";
328
+ import { createRequire } from "module";
329
+ var _require = createRequire(import.meta.url);
330
+ var _GV_UPGRADING = "_GV_UPGRADING";
331
+ async function checkLatestNpmVersion() {
332
+ try {
333
+ const { stdout } = await execa4("npm", ["view", "@jgiox/goodvibes", "version"]);
334
+ return stdout.trim() || null;
335
+ } catch {
336
+ return null;
337
+ }
338
+ }
339
+ function getInstalledVersion() {
340
+ try {
341
+ const pkg = _require("../../package.json");
342
+ return pkg.version ?? null;
343
+ } catch {
344
+ return null;
345
+ }
346
+ }
347
+ async function selfUpdateNpm(version) {
348
+ await execa4("npm", ["install", "-g", `@jgiox/goodvibes@${version}`], { stdio: "inherit" });
349
+ }
350
+ var MANAGED_FIXED = /* @__PURE__ */ new Set([
351
+ "CLAUDE.md",
352
+ ".github/workflows/ci.yml",
353
+ ".github/workflows/security.yml",
354
+ ".github/workflows/dependency-review.yml",
355
+ ".github/dependabot.yml"
356
+ ]);
357
+ async function detectInstalledVersion(cwd) {
358
+ const claudePath = join3(cwd, "CLAUDE.md");
359
+ if (!await pathExists2(claudePath)) return null;
360
+ const content = await readFile3(claudePath, "utf-8");
361
+ return extractVersion(content);
362
+ }
363
+ async function detectBundledVersion(templateDir) {
364
+ if (!templateDir) return null;
365
+ const claudeSrc = join3(templateDir, "CLAUDE.md");
366
+ if (!await pathExists2(claudeSrc)) return null;
367
+ const content = await readFile3(claudeSrc, "utf-8");
368
+ return extractVersion(content);
369
+ }
370
+ async function computeChanges(templateDir, destDir, projectType) {
371
+ const allFiles = await listTemplateFiles(templateDir) ?? [];
372
+ const managedFiles = allFiles.filter(
373
+ (f) => f.startsWith(".claude/skills/") || MANAGED_FIXED.has(f) || // ci.yml variant maps to the fixed set already — include it for comparison
374
+ f === `.github/workflows/ci-${projectType}.yml`
375
+ );
376
+ const results = [];
377
+ for (const rel of managedFiles) {
378
+ const destRel = rel === `.github/workflows/ci-${projectType}.yml` ? ".github/workflows/ci.yml" : rel;
379
+ const srcPath = join3(templateDir, rel);
380
+ const destPath = join3(destDir, destRel);
381
+ if (!await pathExists2(destPath)) {
382
+ results.push({ path: destRel, status: "new" });
383
+ continue;
384
+ }
385
+ const srcContent = await readFile3(srcPath, "utf-8");
386
+ const destContent = await readFile3(destPath, "utf-8");
387
+ results.push({ path: destRel, status: srcContent === destContent ? "unchanged" : "changed" });
388
+ }
389
+ return results.sort((a, b) => a.path.localeCompare(b.path));
390
+ }
391
+ function formatChangeSummary(changes) {
392
+ if (changes.length === 0) return "(no managed files found)";
393
+ const symbol = { changed: "~", unchanged: "=", new: "+" };
394
+ return changes.map((c) => `${symbol[c.status] ?? "?"} ${c.path}`).join("\n");
395
+ }
396
+ async function upgradeTemplates(templateDir, destDir, projectType) {
397
+ const ciVariants = ["ci-node.yml", "ci-python.yml", "ci-both.yml"];
398
+ const selectedVariant = `ci-${projectType}.yml`;
399
+ const claudeDest = join3(destDir, "CLAUDE.md");
400
+ await copy2(templateDir, destDir, {
401
+ overwrite: true,
402
+ errorOnExist: false,
403
+ filter: (src) => {
404
+ if (src.endsWith("CLAUDE.md")) return false;
405
+ if (relative2(templateDir, src).includes("..")) return false;
406
+ if (src.includes(".claude/skills/")) return true;
407
+ for (const v of ciVariants) {
408
+ if (src.endsWith(v) && v !== selectedVariant) return false;
409
+ }
410
+ if (src.includes(".github/workflows/")) return true;
411
+ return false;
412
+ }
413
+ });
414
+ const variantPath = join3(destDir, ".github", "workflows", selectedVariant);
415
+ const ciPath = join3(destDir, ".github", "workflows", "ci.yml");
416
+ if (existsSync3(variantPath)) {
417
+ await rename2(variantPath, ciPath);
418
+ }
419
+ const claudeSrc = join3(templateDir, "CLAUDE.md");
420
+ const templateContent = await readFile3(claudeSrc, "utf-8");
421
+ await mergeClaude(claudeDest, templateContent);
422
+ const allDest = await listTemplateFiles(destDir) ?? [];
423
+ return allDest.filter((f) => f.startsWith(".claude/skills/") || f.startsWith(".github/workflows/") || f === "CLAUDE.md").sort();
424
+ }
425
+ function registerUpgradeCommand(program2) {
426
+ program2.command("upgrade").description("Update goodvibes-managed files to the latest version").option("--dry-run", "Preview what would change without writing").action(async (options) => {
427
+ const dryRun = options.dryRun ?? false;
428
+ const cwd = process.cwd();
429
+ intro2("goodvibes upgrade");
430
+ if (!process.env[_GV_UPGRADING]) {
431
+ const current = getInstalledVersion();
432
+ const latest = await checkLatestNpmVersion();
433
+ if (latest && current && !versionGte(current, latest)) {
434
+ note2(`Updating goodvibes ${current} \u2192 ${latest}\u2026`, "New version available");
435
+ await selfUpdateNpm(latest);
436
+ note2(`Updated to ${latest} \u2014 re-applying templates\u2026`, "\u2713");
437
+ const { execa: execaFn } = await import("execa");
438
+ await execaFn(process.argv[1], process.argv.slice(2), {
439
+ stdio: "inherit",
440
+ env: { ...process.env, [_GV_UPGRADING]: "1" }
441
+ });
442
+ process.exit(0);
443
+ return;
444
+ }
445
+ }
446
+ const templateDir = resolveTemplatesDir();
447
+ const projectType = detectProjectType(cwd);
448
+ const installedVersion = await detectInstalledVersion(cwd);
449
+ const bundledVersion = await detectBundledVersion(templateDir);
450
+ if (installedVersion && bundledVersion && versionGte(installedVersion, bundledVersion)) {
451
+ outro2(`Already up to date (v${installedVersion})`);
452
+ return;
453
+ }
454
+ const changes = await computeChanges(templateDir, cwd, projectType);
455
+ note2(formatChangeSummary(changes), "What will change");
456
+ if (dryRun) {
457
+ outro2("Run without --dry-run to apply these changes.");
458
+ return;
459
+ }
460
+ const updated = [];
461
+ await tasks2([
462
+ {
463
+ title: "Upgrading managed files",
464
+ task: async () => {
465
+ const files = await upgradeTemplates(templateDir, cwd, projectType);
466
+ updated.push(...files);
467
+ return `Updated ${files.length} files`;
468
+ }
469
+ }
470
+ ]);
471
+ note2(updated.join("\n") || "(no files changed)", "Files updated");
472
+ outro2("Upgrade complete!");
473
+ });
474
+ }
475
+
281
476
  // src/index.ts
282
477
  var [major] = process.version.replace("v", "").split(".").map(Number);
283
478
  if (major < 20) {
@@ -292,4 +487,5 @@ Install the latest LTS from https://nodejs.org
292
487
  var program = new Command();
293
488
  program.name("goodvibes").version("1.0.0").description("One-command bootstrap for vibe coding projects");
294
489
  registerInitCommand(program);
490
+ registerUpgradeCommand(program);
295
491
  await program.parseAsync(process.argv);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jgiox/goodvibes",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "One-command bootstrap for vibe coding projects",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,7 +8,7 @@
8
8
  },
9
9
  "main": "./dist/index.js",
10
10
  "engines": {
11
- "node": ">=20.0.0"
11
+ "node": ">=20.12.0"
12
12
  },
13
13
  "files": [
14
14
  "dist",
@@ -38,14 +38,17 @@
38
38
  },
39
39
  "dependencies": {
40
40
  "commander": "^13",
41
- "@clack/prompts": "^0.9",
41
+ "@clack/prompts": "^1",
42
42
  "fs-extra": "^11",
43
43
  "execa": "^9"
44
44
  },
45
45
  "devDependencies": {
46
46
  "tsup": "^8",
47
- "typescript": "^5.5",
48
- "@types/node": "^20",
49
- "vitest": "^2"
47
+ "typescript": "^6",
48
+ "@types/node": "^26",
49
+ "vitest": "^4"
50
+ },
51
+ "overrides": {
52
+ "esbuild": "^0.28.1"
50
53
  }
51
54
  }
@@ -0,0 +1,20 @@
1
+ version: 2
2
+
3
+ updates:
4
+ - package-ecosystem: "github-actions"
5
+ directory: "/"
6
+ schedule:
7
+ interval: "weekly"
8
+ open-pull-requests-limit: 5
9
+
10
+ - package-ecosystem: "npm"
11
+ directory: "/"
12
+ schedule:
13
+ interval: "weekly"
14
+ open-pull-requests-limit: 5
15
+
16
+ - package-ecosystem: "pip"
17
+ directory: "/"
18
+ schedule:
19
+ interval: "weekly"
20
+ open-pull-requests-limit: 5
@@ -0,0 +1,66 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ jobs:
10
+ test-node:
11
+ name: Node.js tests (Node ${{ matrix.node }})
12
+ runs-on: ubuntu-latest
13
+ strategy:
14
+ matrix:
15
+ node: ['20', '22']
16
+ steps:
17
+ - uses: actions/checkout@v7
18
+
19
+ - uses: actions/setup-node@v6
20
+ with:
21
+ node-version: ${{ matrix.node }}
22
+ cache: 'npm'
23
+ # ponytail: no cache-dependency-path — beginner projects are single-package, not monorepos
24
+
25
+ # npm install (not npm ci) — new projects may lack package-lock.json on first push
26
+ - name: Install dependencies
27
+ run: npm install
28
+
29
+ - name: Build
30
+ run: npm run build --if-present
31
+
32
+ - name: Test
33
+ run: npm test --if-present
34
+
35
+ - name: Lint
36
+ run: npm run lint --if-present
37
+
38
+ test-python:
39
+ name: Python tests (Python ${{ matrix.python }})
40
+ runs-on: ubuntu-latest
41
+ strategy:
42
+ matrix:
43
+ python: ['3.10', '3.11', '3.12']
44
+ steps:
45
+ - uses: actions/checkout@v7
46
+
47
+ # python-version must be explicit — setup-uv does not auto-read matrix.python (Pitfall 4)
48
+ - uses: astral-sh/setup-uv@v8.2.0
49
+ with:
50
+ python-version: ${{ matrix.python }}
51
+
52
+ - name: Install dependencies
53
+ run: |
54
+ if [ -f "pyproject.toml" ]; then
55
+ uv sync --all-extras 2>/dev/null || uv sync
56
+ elif [ -f "requirements.txt" ]; then
57
+ uv pip install -r requirements.txt
58
+ fi
59
+
60
+ - name: Run tests
61
+ run: |
62
+ if find . -name "test_*.py" -not -path "./.venv/*" | grep -q .; then
63
+ uv run --extra dev pytest -x -q
64
+ else
65
+ echo "No tests found — add a tests/ directory to get started"
66
+ fi
@@ -0,0 +1,36 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ jobs:
10
+ test:
11
+ name: Node.js tests (Node ${{ matrix.node }})
12
+ runs-on: ubuntu-latest
13
+ strategy:
14
+ matrix:
15
+ node: ['20', '22']
16
+ steps:
17
+ - uses: actions/checkout@v7
18
+
19
+ - uses: actions/setup-node@v6
20
+ with:
21
+ node-version: ${{ matrix.node }}
22
+ cache: 'npm'
23
+ # ponytail: no cache-dependency-path — beginner projects are single-package, not monorepos
24
+
25
+ # npm install (not npm ci) — new projects may lack package-lock.json on first push
26
+ - name: Install dependencies
27
+ run: npm install
28
+
29
+ - name: Build
30
+ run: npm run build --if-present
31
+
32
+ - name: Test
33
+ run: npm test --if-present
34
+
35
+ - name: Lint
36
+ run: npm run lint --if-present
@@ -0,0 +1,38 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ jobs:
10
+ test:
11
+ name: Python tests (Python ${{ matrix.python }})
12
+ runs-on: ubuntu-latest
13
+ strategy:
14
+ matrix:
15
+ python: ['3.10', '3.11', '3.12']
16
+ steps:
17
+ - uses: actions/checkout@v7
18
+
19
+ # python-version must be explicit — setup-uv does not auto-read matrix.python (Pitfall 4)
20
+ - uses: astral-sh/setup-uv@v8.2.0
21
+ with:
22
+ python-version: ${{ matrix.python }}
23
+
24
+ - name: Install dependencies
25
+ run: |
26
+ if [ -f "pyproject.toml" ]; then
27
+ uv sync --all-extras 2>/dev/null || uv sync
28
+ elif [ -f "requirements.txt" ]; then
29
+ uv pip install -r requirements.txt
30
+ fi
31
+
32
+ - name: Run tests
33
+ run: |
34
+ if find . -name "test_*.py" -not -path "./.venv/*" | grep -q .; then
35
+ uv run --extra dev pytest -x -q
36
+ else
37
+ echo "No tests found — add a tests/ directory to get started"
38
+ fi
@@ -0,0 +1,15 @@
1
+ name: Dependency Review
2
+
3
+ on:
4
+ pull_request:
5
+ branches: [main]
6
+
7
+ permissions:
8
+ contents: read
9
+
10
+ jobs:
11
+ dependency-review:
12
+ runs-on: ubuntu-latest
13
+ steps:
14
+ - uses: actions/checkout@v7
15
+ - uses: actions/dependency-review-action@v5
@@ -0,0 +1,46 @@
1
+ name: Security scan
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+ schedule:
9
+ # Weekly scan on Monday at 08:00 UTC
10
+ - cron: '0 8 * * 1'
11
+
12
+ jobs:
13
+ analyze:
14
+ name: Analyze
15
+ runs-on: ubuntu-latest
16
+ permissions:
17
+ security-events: write
18
+ actions: read
19
+ contents: read
20
+
21
+ steps:
22
+ - uses: actions/checkout@v7
23
+
24
+ - name: Detect languages
25
+ id: langs
26
+ run: |
27
+ py=$(find . -name "*.py" -not -path "./.venv/*" | head -1)
28
+ js=$(find . \( -name "*.js" -o -name "*.ts" \) -not -path "*/node_modules/*" | head -1)
29
+ langs=""
30
+ [ -n "$py" ] && langs="python"
31
+ [ -n "$js" ] && [ -n "$langs" ] && langs="python,javascript-typescript"
32
+ [ -n "$js" ] && [ -z "$langs" ] && langs="javascript-typescript"
33
+ [ -z "$langs" ] && langs="python"
34
+ echo "value=$langs" >> $GITHUB_OUTPUT
35
+
36
+ - name: Initialize CodeQL
37
+ uses: github/codeql-action/init@v4
38
+ with:
39
+ languages: ${{ steps.langs.outputs.value }}
40
+ queries: security-extended
41
+
42
+ - name: Autobuild
43
+ uses: github/codeql-action/autobuild@v4
44
+
45
+ - name: Perform CodeQL Analysis
46
+ uses: github/codeql-action/analyze@v4
@@ -1,7 +1,7 @@
1
1
  # CLAUDE.md
2
2
 
3
3
  <!-- goodvibes:start -->
4
- # goodvibes: v1.0.0
4
+ # goodvibes: v1.2.0
5
5
 
6
6
  ## Engineering Rules
7
7
 
@@ -49,6 +49,10 @@ Must flag immediately: SQL injection, XSS, command injection, path traversal, br
49
49
  Each entry must include: date, task summary, files changed, why the change was made, tests run, docs updated.
50
50
  Rules: do not rewrite history. Additive entries only. Keep it short, factual, and readable.
51
51
 
52
+ ### Push to remote
53
+ **Push to GitHub after every completed task or end of session.**
54
+ A commit that only exists locally is one machine failure away from being lost. Run `git push origin <branch>` after each commit, or at minimum before stopping for the day. Never leave completed work unpushed for more than one session.
55
+
52
56
  ## Ponytail — Minimalism Ruleset
53
57
 
54
58
  You are a lazy senior developer. Lazy means efficient, not careless. You have
@@ -94,4 +98,27 @@ that prevents data loss, security measures, accessibility basics, anything
94
98
  explicitly requested. User insists on the full version → build it.
95
99
 
96
100
  Never lazy about understanding the problem. Trace the whole thing first.
101
+
102
+ ## Testing
103
+
104
+ **Inline comments:** Only write a comment when WHY is non-obvious. Never describe what the
105
+ code does. No docstrings for self-evident functions. One line max.
106
+
107
+ **Unit tests:** Mock all external calls (subprocess, network, filesystem). Test one function
108
+ in isolation. File: `src/steps/foo.ts` → `src/steps/foo.test.ts` (TS) or `tests/test_foo.py`
109
+ (Python). Every public function gets at least one test. Use vitest (TS) or pytest + pytest-mock
110
+ (Python). Never run real uv/pip/claude/npm in a unit test.
111
+
112
+ **Integration tests:** Use a real temporary directory, no mocks for file ops. Verify that
113
+ modules work together. Live in `tests/integration/` or `src/**/*.integration.test.ts`.
114
+ Run with: `npm run test:integration` or `pytest tests/integration/`.
115
+
116
+ **Regression tests:** For every bug fix — write a failing test reproducing the bug BEFORE the
117
+ fix. Commit the failing test first (RED), then the fix (GREEN), in separate commits.
118
+ Test name must reference the symptom: `test_install_headroom_does_not_throw_on_cpp_failure`.
119
+
120
+ **Test naming:** Test names are sentences describing the expected behavior.
121
+ - TS: `it('returns null when Python version is below 3.10')`
122
+ - Python: `def test_returns_none_when_python_below_3_10()`
123
+ Never name tests `test_1`, `test_happy_path`, or `test_works`.
97
124
  <!-- goodvibes:end -->