forgedev 1.1.3 → 1.3.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.
Files changed (167) hide show
  1. package/README.md +58 -10
  2. package/bin/chainproof.js +126 -0
  3. package/bin/devforge.js +2 -1
  4. package/package.json +33 -7
  5. package/src/chainproof-bridge.js +330 -0
  6. package/src/ci-mode.js +85 -0
  7. package/src/claude-configurator.js +87 -49
  8. package/src/cli.js +35 -12
  9. package/src/composer.js +159 -34
  10. package/src/doctor-checks-chainproof.js +106 -0
  11. package/src/doctor-checks.js +39 -20
  12. package/src/doctor-prompts.js +9 -9
  13. package/src/doctor.js +37 -4
  14. package/src/guided.js +3 -3
  15. package/src/index.js +31 -10
  16. package/src/init-mode.js +64 -11
  17. package/src/menu.js +178 -0
  18. package/src/prompts.js +5 -12
  19. package/src/recommender.js +134 -10
  20. package/src/scanner.js +57 -2
  21. package/src/uat-generator.js +204 -189
  22. package/src/update-check.js +9 -4
  23. package/src/update.js +1 -1
  24. package/src/utils.js +65 -6
  25. package/templates/ai/guardrails-py/backend/app/ai/__init__.py +29 -0
  26. package/templates/ai/guardrails-py/backend/app/ai/audit_log.py +133 -0
  27. package/templates/ai/guardrails-py/backend/app/ai/client.py.template +323 -0
  28. package/templates/ai/guardrails-py/backend/app/ai/health.py.template +157 -0
  29. package/templates/ai/guardrails-py/backend/app/ai/input_guard.py +98 -0
  30. package/templates/ai/guardrails-ts/src/lib/ai/audit-log.ts.template +164 -0
  31. package/templates/ai/guardrails-ts/src/lib/ai/client.ts.template +403 -0
  32. package/templates/ai/guardrails-ts/src/lib/ai/health.ts.template +165 -0
  33. package/templates/ai/guardrails-ts/src/lib/ai/index.ts.template +17 -0
  34. package/templates/ai/guardrails-ts/src/lib/ai/input-guard.ts.template +124 -0
  35. package/templates/auth/nextauth/src/lib/auth.ts.template +12 -7
  36. package/templates/backend/express/Dockerfile.template +18 -0
  37. package/templates/backend/express/package.json.template +33 -0
  38. package/templates/backend/express/src/index.ts.template +34 -0
  39. package/templates/backend/express/src/routes/health.ts.template +27 -0
  40. package/templates/backend/express/tsconfig.json +17 -0
  41. package/templates/backend/fastapi/backend/Dockerfile.template +5 -0
  42. package/templates/backend/fastapi/backend/app/api/health.py.template +1 -1
  43. package/templates/backend/fastapi/backend/app/core/config.py.template +1 -1
  44. package/templates/backend/fastapi/backend/app/core/errors.py +1 -1
  45. package/templates/backend/fastapi/backend/app/main.py.template +3 -1
  46. package/templates/backend/fastapi/backend/requirements.txt.template +2 -0
  47. package/templates/backend/hono/Dockerfile.template +18 -0
  48. package/templates/backend/hono/package.json.template +31 -0
  49. package/templates/backend/hono/src/index.ts.template +32 -0
  50. package/templates/backend/hono/src/routes/health.ts.template +27 -0
  51. package/templates/backend/hono/tsconfig.json +18 -0
  52. package/templates/base/docs/plans/.gitkeep +0 -0
  53. package/templates/base/docs/uat/UAT_CHECKLIST.csv.template +2 -0
  54. package/templates/base/docs/uat/UAT_TEMPLATE.md.template +22 -0
  55. package/templates/chainproof/base/.chainproof/config.json.template +11 -0
  56. package/templates/chainproof/base/.chainproof/mcp-server.mjs +310 -0
  57. package/templates/chainproof/base/.mcp.json +9 -0
  58. package/templates/chainproof/fastapi/.chainproof/middleware.json.template +14 -0
  59. package/templates/chainproof/nextjs/.chainproof/hooks.json.template +19 -0
  60. package/templates/chainproof/polyglot/.chainproof/config.json.template +21 -0
  61. package/templates/claude-code/agents/architect.md +25 -11
  62. package/templates/claude-code/agents/build-error-resolver.md +22 -7
  63. package/templates/claude-code/agents/chief-of-staff.md +42 -8
  64. package/templates/claude-code/agents/code-quality-reviewer.md +15 -1
  65. package/templates/claude-code/agents/database-reviewer.md +16 -2
  66. package/templates/claude-code/agents/deep-reviewer.md +191 -0
  67. package/templates/claude-code/agents/doc-updater.md +19 -5
  68. package/templates/claude-code/agents/docs-lookup.md +19 -5
  69. package/templates/claude-code/agents/e2e-runner.md +26 -12
  70. package/templates/claude-code/agents/enforcement-gate.md +102 -0
  71. package/templates/claude-code/agents/frontend-builder.md +188 -0
  72. package/templates/claude-code/agents/harness-optimizer.md +61 -0
  73. package/templates/claude-code/agents/loop-operator.md +27 -12
  74. package/templates/claude-code/agents/planner.md +21 -7
  75. package/templates/claude-code/agents/product-strategist.md +138 -0
  76. package/templates/claude-code/agents/production-readiness.md +14 -0
  77. package/templates/claude-code/agents/prompt-auditor.md +115 -0
  78. package/templates/claude-code/agents/refactor-cleaner.md +22 -8
  79. package/templates/claude-code/agents/security-reviewer.md +15 -0
  80. package/templates/claude-code/agents/spec-validator.md +45 -1
  81. package/templates/claude-code/agents/tdd-guide.md +21 -7
  82. package/templates/claude-code/agents/uat-validator.md +18 -0
  83. package/templates/claude-code/claude-md/base.md +15 -7
  84. package/templates/claude-code/claude-md/fastapi.md +8 -8
  85. package/templates/claude-code/claude-md/fullstack.md +6 -6
  86. package/templates/claude-code/claude-md/hono.md +18 -0
  87. package/templates/claude-code/claude-md/nextjs.md +5 -5
  88. package/templates/claude-code/claude-md/remix.md +18 -0
  89. package/templates/claude-code/commands/audit-security.md +14 -0
  90. package/templates/claude-code/commands/audit-spec.md +14 -0
  91. package/templates/claude-code/commands/audit-wiring.md +14 -0
  92. package/templates/claude-code/commands/build-fix.md +28 -0
  93. package/templates/claude-code/commands/build-ui.md +59 -0
  94. package/templates/claude-code/commands/code-review.md +54 -26
  95. package/templates/claude-code/commands/fix-loop.md +211 -0
  96. package/templates/claude-code/commands/full-audit.md +37 -8
  97. package/templates/claude-code/commands/generate-prd.md +1 -1
  98. package/templates/claude-code/commands/generate-sdd.md +74 -0
  99. package/templates/claude-code/commands/generate-uat.md +107 -35
  100. package/templates/claude-code/commands/help.md +68 -0
  101. package/templates/claude-code/commands/live-uat.md +268 -0
  102. package/templates/claude-code/commands/optimize-claude-md.md +15 -1
  103. package/templates/claude-code/commands/plan.md +3 -3
  104. package/templates/claude-code/commands/pre-pr.md +57 -19
  105. package/templates/claude-code/commands/product-strategist.md +21 -0
  106. package/templates/claude-code/commands/resume-session.md +10 -10
  107. package/templates/claude-code/commands/run-uat.md +59 -2
  108. package/templates/claude-code/commands/save-session.md +10 -10
  109. package/templates/claude-code/commands/simplify.md +36 -0
  110. package/templates/claude-code/commands/tdd.md +17 -18
  111. package/templates/claude-code/commands/verify-all.md +24 -0
  112. package/templates/claude-code/commands/verify-intent.md +55 -0
  113. package/templates/claude-code/commands/workflows.md +52 -37
  114. package/templates/claude-code/hooks/polyglot.json +10 -1
  115. package/templates/claude-code/hooks/python.json +10 -1
  116. package/templates/claude-code/hooks/scripts/autofix-polyglot.mjs +20 -10
  117. package/templates/claude-code/hooks/scripts/autofix-python.mjs +4 -5
  118. package/templates/claude-code/hooks/scripts/autofix-typescript.mjs +4 -4
  119. package/templates/claude-code/hooks/scripts/code-hygiene.mjs +293 -0
  120. package/templates/claude-code/hooks/scripts/guard-protected-files.mjs +2 -2
  121. package/templates/claude-code/hooks/scripts/pre-commit-gate.mjs +207 -0
  122. package/templates/claude-code/hooks/typescript.json +10 -1
  123. package/templates/claude-code/skills/ai-prompts/SKILL.md +119 -41
  124. package/templates/claude-code/skills/git-workflow/SKILL.md +6 -6
  125. package/templates/claude-code/skills/nextjs/SKILL.md +1 -1
  126. package/templates/claude-code/skills/playwright/SKILL.md +6 -5
  127. package/templates/claude-code/skills/security-api/SKILL.md +1 -1
  128. package/templates/claude-code/skills/security-web/SKILL.md +2 -1
  129. package/templates/claude-code/skills/testing-patterns/SKILL.md +9 -9
  130. package/templates/database/prisma-postgres/{.env.example → .env.example.template} +1 -0
  131. package/templates/database/sqlalchemy-postgres/{.env.example → .env.example.template} +1 -0
  132. package/templates/docs-portal/fastapi/backend/app/portal/__init__.py +0 -0
  133. package/templates/docs-portal/fastapi/backend/app/portal/__pycache__/docs_reader.cpython-314.pyc +0 -0
  134. package/templates/docs-portal/fastapi/backend/app/portal/docs_reader.py +201 -0
  135. package/templates/docs-portal/fastapi/backend/app/portal/html_renderer.py +229 -0
  136. package/templates/docs-portal/fastapi/backend/app/portal/router.py.template +35 -0
  137. package/templates/docs-portal/nextjs/src/app/portal/[category]/[slug]/page.tsx +81 -0
  138. package/templates/docs-portal/nextjs/src/app/portal/[category]/page.tsx +65 -0
  139. package/templates/docs-portal/nextjs/src/app/portal/layout.tsx.template +54 -0
  140. package/templates/docs-portal/nextjs/src/app/portal/page.tsx +85 -0
  141. package/templates/docs-portal/nextjs/src/components/portal/markdown-renderer.tsx +101 -0
  142. package/templates/docs-portal/nextjs/src/components/portal/mobile-portal-nav.tsx +81 -0
  143. package/templates/docs-portal/nextjs/src/components/portal/portal-nav.tsx +86 -0
  144. package/templates/docs-portal/nextjs/src/lib/docs.ts +139 -0
  145. package/templates/frontend/nextjs/package.json.template +3 -1
  146. package/templates/frontend/react/index.html.template +12 -0
  147. package/templates/frontend/react/package.json.template +34 -0
  148. package/templates/frontend/react/src/App.tsx.template +10 -0
  149. package/templates/frontend/react/src/index.css +1 -0
  150. package/templates/frontend/react/src/main.tsx +10 -0
  151. package/templates/frontend/react/tsconfig.json +17 -0
  152. package/templates/frontend/react/vite.config.ts.template +15 -0
  153. package/templates/frontend/react/vitest.config.ts +9 -0
  154. package/templates/frontend/remix/app/root.tsx.template +31 -0
  155. package/templates/frontend/remix/app/routes/_index.tsx.template +19 -0
  156. package/templates/frontend/remix/app/routes/api.health.ts.template +10 -0
  157. package/templates/frontend/remix/app/tailwind.css +1 -0
  158. package/templates/frontend/remix/package.json.template +39 -0
  159. package/templates/frontend/remix/tsconfig.json +18 -0
  160. package/templates/frontend/remix/vite.config.ts.template +7 -0
  161. package/templates/infra/github-actions/.github/workflows/ci.yml.template +52 -0
  162. package/templates/testing/pytest/backend/tests/__init__.py +0 -0
  163. package/templates/testing/pytest/backend/tests/conftest.py.template +11 -0
  164. package/templates/testing/pytest/backend/tests/test_health.py.template +10 -0
  165. package/templates/testing/vitest/vitest.config.ts.template +18 -0
  166. package/CLAUDE.md +0 -38
  167. package/templates/claude-code/commands/done.md +0 -19
@@ -2,7 +2,7 @@
2
2
  // Auto-fix lint issues on saved TypeScript or Python files (polyglot)
3
3
 
4
4
  import { execFileSync } from 'node:child_process';
5
- import { resolve } from 'node:path';
5
+ import { resolve, sep, relative } from 'node:path';
6
6
 
7
7
  let input = '';
8
8
  process.stdin.setEncoding('utf8');
@@ -19,21 +19,31 @@ process.stdin.on('end', () => {
19
19
  // Ensure path stays within the working directory
20
20
  const resolved = resolve(filePath);
21
21
  const cwd = resolve('.');
22
- if (!resolved.startsWith(cwd)) {
22
+ if (!resolved.startsWith(cwd + sep) && resolved !== cwd) {
23
23
  process.exit(0);
24
24
  }
25
25
 
26
26
  if (filePath.endsWith('.ts') || filePath.endsWith('.tsx')) {
27
- try {
28
- execFileSync('npx', ['eslint', '--fix', filePath], { stdio: 'pipe', cwd: 'frontend' });
29
- } catch {
30
- // eslint may exit non-zero for unfixable issues — that's okay
27
+ const frontendDir = resolve('frontend');
28
+ if (resolved.startsWith(frontendDir + sep)) {
29
+ const targetPath = relative(frontendDir, resolved);
30
+ if (targetPath.startsWith('..')) process.exit(0);
31
+ try {
32
+ execFileSync('npx', ['eslint', '--fix', targetPath], { stdio: 'pipe', cwd: 'frontend' });
33
+ } catch {
34
+ // eslint may exit non-zero for unfixable issues, that's okay
35
+ }
31
36
  }
32
37
  } else if (filePath.endsWith('.py')) {
33
- try {
34
- execFileSync('ruff', ['check', '--fix', filePath], { stdio: 'pipe', cwd: 'backend' });
35
- } catch {
36
- // ruff may exit non-zero for unfixable issues — that's okay
38
+ const backendDir = resolve('backend');
39
+ if (resolved.startsWith(backendDir + sep)) {
40
+ const targetPath = relative(backendDir, resolved);
41
+ if (targetPath.startsWith('..')) process.exit(0);
42
+ try {
43
+ execFileSync('ruff', ['check', '--fix', targetPath], { stdio: 'pipe', cwd: 'backend' });
44
+ } catch {
45
+ // ruff may exit non-zero for unfixable issues, that's okay
46
+ }
37
47
  }
38
48
  }
39
49
 
@@ -2,7 +2,7 @@
2
2
  // Auto-fix lint issues on saved Python files
3
3
 
4
4
  import { execFileSync } from 'node:child_process';
5
- import { resolve } from 'node:path';
5
+ import { resolve, sep } from 'node:path';
6
6
 
7
7
  let input = '';
8
8
  process.stdin.setEncoding('utf8');
@@ -19,18 +19,17 @@ process.stdin.on('end', () => {
19
19
  // Ensure path stays within the working directory
20
20
  const resolved = resolve(filePath);
21
21
  const cwd = resolve('.');
22
- if (!resolved.startsWith(cwd)) {
22
+ if (!resolved.startsWith(cwd + sep) && resolved !== cwd) {
23
23
  process.exit(0);
24
24
  }
25
25
 
26
26
  if (filePath.endsWith('.py')) {
27
27
  try {
28
- execFileSync('ruff', ['check', '--fix', filePath], { stdio: 'pipe', cwd: 'backend' });
28
+ execFileSync('ruff', ['check', '--fix', resolved], { stdio: 'pipe' });
29
29
  } catch {
30
- // ruff may exit non-zero for unfixable issues that's okay
30
+ // ruff may exit non-zero for unfixable issues, that's okay
31
31
  }
32
32
  }
33
-
34
33
  process.exit(0);
35
34
  } catch {
36
35
  process.exit(0);
@@ -2,7 +2,7 @@
2
2
  // Auto-fix lint issues on saved TypeScript files
3
3
 
4
4
  import { execFileSync } from 'node:child_process';
5
- import { resolve } from 'node:path';
5
+ import { resolve, sep } from 'node:path';
6
6
 
7
7
  let input = '';
8
8
  process.stdin.setEncoding('utf8');
@@ -19,15 +19,15 @@ process.stdin.on('end', () => {
19
19
  // Ensure path stays within the working directory
20
20
  const resolved = resolve(filePath);
21
21
  const cwd = resolve('.');
22
- if (!resolved.startsWith(cwd)) {
22
+ if (!resolved.startsWith(cwd + sep) && resolved !== cwd) {
23
23
  process.exit(0);
24
24
  }
25
25
 
26
26
  if (filePath.endsWith('.ts') || filePath.endsWith('.tsx')) {
27
27
  try {
28
- execFileSync('npx', ['eslint', '--fix', filePath], { stdio: 'pipe' });
28
+ execFileSync('npx', ['eslint', '--fix', resolved], { stdio: 'pipe' });
29
29
  } catch {
30
- // eslint may exit non-zero for unfixable issues that's okay
30
+ // eslint may exit non-zero for unfixable issues, that's okay
31
31
  }
32
32
  }
33
33
 
@@ -0,0 +1,293 @@
1
+ #!/usr/bin/env node
2
+ // Code Hygiene Gate — runs on Stop to enforce structural quality
3
+ // Checks: file length, duplicate code blocks, repeated functions, stale test files
4
+ // Exit 0 = pass (with warnings), Exit 2 = blocked (critical issues found)
5
+
6
+ import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
7
+ import { join, relative, extname, basename } from 'node:path';
8
+
9
+ // ── Config ──────────────────────────────────────────────────────────────────
10
+ const MAX_FILE_LINES = 300;
11
+ const MAX_FUNCTION_LINES = 50;
12
+ const MIN_DUPLICATE_LINES = 6; // minimum consecutive matching lines to flag
13
+ const MAX_FILES_PER_DIR = 20; // warn if a single directory has too many files
14
+ const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.py', '.go', '.rs']);
15
+ const IGNORE_DIRS = new Set(['node_modules', '.next', '__pycache__', '.git', 'dist', 'build', '.claude', 'coverage', '.venv', 'venv']);
16
+ const IGNORE_FILES = new Set(['package-lock.json', 'pnpm-lock.yaml', 'yarn.lock']);
17
+
18
+ // ── Helpers ─────────────────────────────────────────────────────────────────
19
+ function walk(dir, files = []) {
20
+ let entries;
21
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return files; }
22
+ for (const entry of entries) {
23
+ if (IGNORE_DIRS.has(entry.name)) continue;
24
+ const full = join(dir, entry.name);
25
+ if (entry.isDirectory()) {
26
+ walk(full, files);
27
+ } else if (SOURCE_EXTENSIONS.has(extname(entry.name)) && !IGNORE_FILES.has(entry.name)) {
28
+ files.push(full);
29
+ }
30
+ }
31
+ return files;
32
+ }
33
+
34
+ function readLines(filePath) {
35
+ try { return readFileSync(filePath, 'utf-8').split('\n'); } catch { return []; }
36
+ }
37
+
38
+ // ── Checks ──────────────────────────────────────────────────────────────────
39
+
40
+ function checkFileLengths(files, cwd) {
41
+ const warnings = [];
42
+ for (const file of files) {
43
+ const lines = readLines(file);
44
+ if (lines.length > MAX_FILE_LINES) {
45
+ warnings.push({
46
+ level: lines.length > MAX_FILE_LINES * 2 ? 'critical' : 'warning',
47
+ file: relative(cwd, file),
48
+ message: `${lines.length} lines (limit: ${MAX_FILE_LINES}). Split into smaller, focused modules.`,
49
+ });
50
+ }
51
+ }
52
+ return warnings;
53
+ }
54
+
55
+ function checkFunctionLengths(files, cwd) {
56
+ const warnings = [];
57
+ // Match common function declarations across JS/TS/Python
58
+ const fnPatterns = [
59
+ /^(?:export\s+)?(?:async\s+)?function\s+(\w+)/, // function foo()
60
+ /^(?:export\s+)?(?:const|let)\s+(\w+)\s*=\s*(?:async\s*)?\(/, // const foo = (
61
+ /^(?:export\s+)?(?:const|let)\s+(\w+)\s*=\s*(?:async\s*)?(?:\([^)]*\)|[^=])\s*=>/, // const foo = () =>
62
+ /^\s+(?:async\s+)?(\w+)\s*\([^)]*\)\s*\{/, // method() {
63
+ /^(?:async\s+)?def\s+(\w+)/, // def foo (Python)
64
+ ];
65
+
66
+ for (const file of files) {
67
+ const fileExt = extname(file);
68
+ if (fileExt === '.py') continue;
69
+
70
+ const lines = readLines(file);
71
+ let currentFn = null;
72
+ let fnStart = 0;
73
+ let braceDepth = 0;
74
+ let inFunction = false;
75
+
76
+ for (let i = 0; i < lines.length; i++) {
77
+ const line = lines[i];
78
+
79
+ // Check if this line starts a new function
80
+ let newFnStarted = false;
81
+ for (const pattern of fnPatterns) {
82
+ const match = line.match(pattern);
83
+ if (match) {
84
+ // Close previous function if open
85
+ if (inFunction && currentFn) {
86
+ const len = i - fnStart;
87
+ if (len > MAX_FUNCTION_LINES) {
88
+ warnings.push({
89
+ level: 'warning',
90
+ file: relative(cwd, file),
91
+ message: `Function "${currentFn}" is ${len} lines (limit: ${MAX_FUNCTION_LINES}) at line ${fnStart + 1}. Extract helper functions.`,
92
+ });
93
+ }
94
+ }
95
+ currentFn = match[1];
96
+ fnStart = i;
97
+ inFunction = true;
98
+ braceDepth = 0;
99
+ newFnStarted = true;
100
+ break;
101
+ }
102
+ }
103
+
104
+ // Skip brace tracking on the line that started a new function
105
+ if (newFnStarted) continue;
106
+
107
+ // Track brace depth for JS/TS
108
+ if (inFunction) {
109
+ for (const ch of line) {
110
+ if (ch === '{') braceDepth++;
111
+ if (ch === '}') braceDepth--;
112
+ }
113
+ if (braceDepth <= 0 && i > fnStart && line.trim()) {
114
+ const len = i - fnStart + 1;
115
+ if (len > MAX_FUNCTION_LINES) {
116
+ warnings.push({
117
+ level: 'warning',
118
+ file: relative(cwd, file),
119
+ message: `Function "${currentFn}" is ${len} lines (limit: ${MAX_FUNCTION_LINES}) at line ${fnStart + 1}. Extract helper functions.`,
120
+ });
121
+ }
122
+ inFunction = false;
123
+ currentFn = null;
124
+ }
125
+ }
126
+ }
127
+ }
128
+ return warnings;
129
+ }
130
+
131
+ function checkDuplicateBlocks(files, cwd) {
132
+ const warnings = [];
133
+ // Build a map of normalized line sequences -> locations
134
+ const blockMap = new Map();
135
+
136
+ for (const file of files) {
137
+ const lines = readLines(file).map(l => l.trim()).filter(l => l && !l.startsWith('//') && !l.startsWith('#') && !l.startsWith('*') && !l.startsWith('import') && !l.startsWith('from'));
138
+
139
+ // Sliding window of MIN_DUPLICATE_LINES
140
+ for (let i = 0; i <= lines.length - MIN_DUPLICATE_LINES; i++) {
141
+ const block = lines.slice(i, i + MIN_DUPLICATE_LINES).join('\n');
142
+ // Skip trivial blocks (mostly braces, returns, empty patterns)
143
+ if (block.replace(/[{}\s();\n]/g, '').length < 30) continue;
144
+
145
+ if (!blockMap.has(block)) {
146
+ blockMap.set(block, []);
147
+ }
148
+ blockMap.get(block).push({ file: relative(cwd, file), line: i + 1 });
149
+ }
150
+ }
151
+
152
+ // Report blocks found in multiple files
153
+ const reported = new Set();
154
+ for (const [block, locations] of blockMap) {
155
+ const uniqueFiles = [...new Set(locations.map(l => l.file))];
156
+ if (uniqueFiles.length < 2) continue;
157
+
158
+ const key = uniqueFiles.sort().join('|');
159
+ if (reported.has(key)) continue;
160
+ reported.add(key);
161
+
162
+ const preview = block.split('\n')[0].substring(0, 60);
163
+ warnings.push({
164
+ level: 'warning',
165
+ file: uniqueFiles[0],
166
+ message: `Duplicate code block found in ${uniqueFiles.length} files: "${preview}..." Also in: ${uniqueFiles.slice(1).join(', ')}. Extract to a shared utility.`,
167
+ });
168
+
169
+ // Limit duplicate reports to avoid noise
170
+ if (warnings.length > 5) break;
171
+ }
172
+
173
+ return warnings;
174
+ }
175
+
176
+ function checkDirectoryBloat(cwd) {
177
+ const warnings = [];
178
+
179
+ function checkDir(dir) {
180
+ let entries;
181
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
182
+
183
+ const sourceFiles = entries.filter(e =>
184
+ !e.isDirectory() && SOURCE_EXTENSIONS.has(extname(e.name)) && !IGNORE_FILES.has(e.name)
185
+ );
186
+
187
+ if (sourceFiles.length > MAX_FILES_PER_DIR) {
188
+ warnings.push({
189
+ level: 'warning',
190
+ file: relative(cwd, dir) || '.',
191
+ message: `Directory has ${sourceFiles.length} source files (limit: ${MAX_FILES_PER_DIR}). Consider grouping into subdirectories by feature or domain.`,
192
+ });
193
+ }
194
+
195
+ for (const entry of entries) {
196
+ if (entry.isDirectory() && !IGNORE_DIRS.has(entry.name)) {
197
+ checkDir(join(dir, entry.name));
198
+ }
199
+ }
200
+ }
201
+
202
+ checkDir(cwd);
203
+ return warnings;
204
+ }
205
+
206
+ function checkStaleTests(files, cwd) {
207
+ const warnings = [];
208
+ const sourceFiles = new Set(
209
+ files
210
+ .filter(f => !basename(f).includes('.test.') && !basename(f).includes('.spec.') && !f.includes('__tests__'))
211
+ .map(f => basename(f).replace(extname(f), ''))
212
+ );
213
+
214
+ const testFiles = files.filter(f =>
215
+ basename(f).includes('.test.') || basename(f).includes('.spec.') || f.includes('__tests__')
216
+ );
217
+
218
+ for (const testFile of testFiles) {
219
+ const testBase = basename(testFile)
220
+ .replace('.test', '')
221
+ .replace('.spec', '')
222
+ .replace(extname(testFile), '');
223
+
224
+ // If the source file this test corresponds to doesn't exist, flag it
225
+ if (testBase && !sourceFiles.has(testBase)) {
226
+ const lines = readLines(testFile);
227
+ // Check if the test file imports something that doesn't exist
228
+ const imports = lines.filter(l => l.includes('import') && l.includes('from'));
229
+ let hasDeadImport = false;
230
+ for (const imp of imports) {
231
+ const match = imp.match(/from\s+['"]([^'"]+)['"]/);
232
+ if (match && match[1].startsWith('.')) {
233
+ // Relative import - check if the file exists
234
+ const importPath = join(testFile, '..', match[1]);
235
+ const extensions = ['.ts', '.tsx', '.js', '.jsx', '.py', ''];
236
+ const exists = extensions.some(ext => existsSync(importPath + ext) || existsSync(importPath));
237
+ if (!exists) hasDeadImport = true;
238
+ }
239
+ }
240
+
241
+ if (hasDeadImport) {
242
+ warnings.push({
243
+ level: 'warning',
244
+ file: relative(cwd, testFile),
245
+ message: `Test file may be stale — imports reference files that no longer exist. Review and delete if no longer needed.`,
246
+ });
247
+ }
248
+ }
249
+ }
250
+
251
+ return warnings;
252
+ }
253
+
254
+ // ── Main ────────────────────────────────────────────────────────────────────
255
+
256
+ const cwd = process.cwd();
257
+ const files = walk(cwd);
258
+
259
+ const allWarnings = [
260
+ ...checkFileLengths(files, cwd),
261
+ ...checkFunctionLengths(files, cwd),
262
+ ...checkDuplicateBlocks(files, cwd),
263
+ ...checkDirectoryBloat(cwd),
264
+ ...checkStaleTests(files, cwd),
265
+ ];
266
+
267
+ const criticals = allWarnings.filter(w => w.level === 'critical');
268
+ const warnings = allWarnings.filter(w => w.level === 'warning');
269
+
270
+ if (allWarnings.length === 0) {
271
+ process.stderr.write('\n[code-hygiene] All clean. No structural issues found.\n');
272
+ process.exit(0);
273
+ }
274
+
275
+ process.stderr.write('\n[code-hygiene] Structural quality report:\n\n');
276
+
277
+ for (const w of criticals) {
278
+ process.stderr.write(` CRITICAL ${w.file}\n ${w.message}\n\n`);
279
+ }
280
+ for (const w of warnings) {
281
+ process.stderr.write(` WARNING ${w.file}\n ${w.message}\n\n`);
282
+ }
283
+
284
+ process.stderr.write(` Summary: ${criticals.length} critical, ${warnings.length} warnings\n\n`);
285
+
286
+ if (criticals.length > 0) {
287
+ process.stderr.write('[code-hygiene] BLOCKED: Fix critical issues before completing.\n');
288
+ process.stderr.write(' Tip: Use /simplify to auto-refactor long files and extract shared utilities.\n');
289
+ process.exit(2);
290
+ }
291
+
292
+ process.stderr.write('[code-hygiene] Passed with warnings. Consider running /simplify to clean up.\n');
293
+ process.exit(0);
@@ -22,11 +22,11 @@ process.stdin.on('end', () => {
22
22
  }
23
23
 
24
24
  // Block migration files
25
- if (filePath.includes('prisma/migrations/') || filePath.includes('alembic/versions/')) {
25
+ const normalizedPath = filePath.replace(/\\/g, '/');
26
+ if (normalizedPath.includes('prisma/migrations/') || normalizedPath.includes('alembic/versions/')) {
26
27
  process.stderr.write('BLOCKED: Do not modify migration files directly\n');
27
28
  process.exit(2);
28
29
  }
29
-
30
30
  process.exit(0);
31
31
  } catch {
32
32
  process.exit(0);
@@ -0,0 +1,207 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Pre-commit gate — the single quality checkpoint before any commit.
5
+ * Replaces /done. Triggered by PreToolUse hook on Bash when command contains "git commit".
6
+ *
7
+ * Phase 1 (automated, fast):
8
+ * - No .env files or secrets staged
9
+ * - No merge conflict markers
10
+ * - Lint passes
11
+ * - Tests pass
12
+ *
13
+ * Phase 2 (agent review):
14
+ * - Tells Claude to run code-quality-reviewer and security-reviewer on changed files
15
+ * - Only triggers if Phase 1 passes and changed files haven't been reviewed yet
16
+ *
17
+ * Exit 0 = allow commit
18
+ * Exit 2 = block commit with message
19
+ */
20
+
21
+ import { execSync } from 'node:child_process';
22
+ import fs from 'node:fs';
23
+ import path from 'node:path';
24
+
25
+ const input = process.env.CLAUDE_TOOL_INPUT || '{}';
26
+
27
+ let parsed;
28
+ try {
29
+ parsed = JSON.parse(input);
30
+ } catch {
31
+ process.exit(0);
32
+ }
33
+
34
+ const command = parsed.command || '';
35
+
36
+ // Only gate actual git commit commands
37
+ if (!command.match(/git\s+commit/)) {
38
+ process.exit(0);
39
+ }
40
+
41
+ // Skip amend-only or empty commits
42
+ if (command.includes('--allow-empty')) {
43
+ process.exit(0);
44
+ }
45
+
46
+ const errors = [];
47
+
48
+ // Get staged files once, reuse across checks
49
+ let stagedFiles = '';
50
+ try {
51
+ stagedFiles = execSync('git diff --cached --name-only', { encoding: 'utf-8' }).trim();
52
+ } catch {
53
+ // git not available
54
+ }
55
+
56
+ // === PHASE 1: Automated checks ===
57
+
58
+ // Check 1: No secrets staged
59
+ if (stagedFiles) {
60
+ const secretFilePatterns = ['.env', 'credentials', '.pem', '.key'];
61
+ const secretNamePatterns = ['secret'];
62
+ const flagged = stagedFiles.split('\n').filter(f => {
63
+ const base = path.basename(f).toLowerCase();
64
+ if (f.includes('.chainproof/keys/public.pem')) return false;
65
+ // Always flag secret file extensions regardless of language
66
+ if (secretFilePatterns.some(p => base.includes(p))) return true;
67
+ return secretNamePatterns.some(p => base.includes(p));
68
+ });
69
+ if (flagged.length > 0) {
70
+ errors.push(`Potential secrets staged: ${flagged.join(', ')}\nUnstage these files or confirm they are safe.`);
71
+ }
72
+ }
73
+
74
+ // Check 2: No merge conflict markers
75
+ try {
76
+ const staged = execSync('git diff --cached', { encoding: 'utf-8' });
77
+ if (staged.includes('<<<<<<<') || staged.includes('>>>>>>>')) {
78
+ errors.push('Merge conflict markers found in staged changes. Resolve conflicts first.');
79
+ }
80
+ } catch {
81
+ // skip
82
+ }
83
+
84
+ // Helper: run a shell command, push to errors on failure
85
+ function tryExec(cmd, label, timeout = 30000) {
86
+ try {
87
+ execSync(`${cmd} 2>&1`, { encoding: 'utf-8', timeout });
88
+ return true;
89
+ } catch (e) {
90
+ if (e.status || e.killed) {
91
+ const msg = e.killed ? 'Command timed out' : (e.stdout || e.message);
92
+ errors.push(`${label}:\n${String(msg).slice(0, 500)}`);
93
+ }
94
+ return false;
95
+ }
96
+ }
97
+
98
+ function readPkg(file = 'package.json') {
99
+ try {
100
+ return JSON.parse(fs.readFileSync(file, 'utf-8'));
101
+ } catch {
102
+ return null;
103
+ }
104
+ }
105
+
106
+ // Check 3: Lint
107
+ function runLint() {
108
+ if (fs.existsSync('package.json')) {
109
+ const pkg = readPkg();
110
+ if (!pkg) { errors.push('Lint skipped: package.json is malformed JSON'); return; }
111
+ if (pkg.scripts?.lint) { tryExec('npm run lint', 'Lint failed'); return; }
112
+ tryExec('npx eslint . --ext .js,.ts,.tsx', 'Lint failed');
113
+ return;
114
+ }
115
+ if (fs.existsSync('requirements.txt') || fs.existsSync('pyproject.toml')) {
116
+ tryExec('ruff check .', 'Lint failed');
117
+ return;
118
+ }
119
+ if (fs.existsSync('frontend/package.json')) {
120
+ tryExec('cd frontend && npx eslint .', 'Frontend lint failed');
121
+ }
122
+ if (fs.existsSync('backend/requirements.txt')) {
123
+ tryExec('cd backend && ruff check .', 'Backend lint failed');
124
+ }
125
+ }
126
+
127
+ // Check 4: Tests
128
+ function runTests() {
129
+ const testTimeout = 120000;
130
+ if (fs.existsSync('package.json')) {
131
+ const pkg = readPkg();
132
+ if (!pkg) { errors.push('Tests skipped: package.json is malformed JSON'); return; }
133
+ if (pkg.scripts?.test) { tryExec('npm test', 'Tests failed', testTimeout); return; }
134
+ tryExec('npx vitest run', 'Tests failed', testTimeout);
135
+ return;
136
+ }
137
+ if (fs.existsSync('requirements.txt') || fs.existsSync('pyproject.toml')) {
138
+ tryExec('pytest', 'Tests failed', testTimeout);
139
+ return;
140
+ }
141
+ if (fs.existsSync('frontend/package.json')) {
142
+ tryExec('cd frontend && npx vitest run', 'Frontend tests failed', testTimeout);
143
+ }
144
+ if (fs.existsSync('backend/requirements.txt')) {
145
+ tryExec('cd backend && pytest', 'Backend tests failed', testTimeout);
146
+ }
147
+ }
148
+
149
+ runLint();
150
+ runTests();
151
+
152
+ if (errors.length > 0) {
153
+ console.error('[pre-commit] BLOCKED - fix these issues first:\n');
154
+ errors.forEach(e => console.error(` ${e}\n`));
155
+ console.error('Run /build-fix to auto-resolve lint and build errors.');
156
+ process.exit(2);
157
+ }
158
+
159
+ // === PHASE 2: Agent review gate ===
160
+ // Check if changed files have been reviewed in this session.
161
+ // We use a marker file that gets created when agents complete review.
162
+
163
+ const reviewMarker = '.claude/.last-review';
164
+ let needsReview = false;
165
+
166
+ try {
167
+ if (!stagedFiles) {
168
+ // Nothing staged, allow
169
+ process.exit(0);
170
+ }
171
+
172
+ const stagedList = stagedFiles.split('\n').filter(f =>
173
+ f.endsWith('.js') || f.endsWith('.ts') || f.endsWith('.tsx') ||
174
+ f.endsWith('.py') || f.endsWith('.jsx') || f.endsWith('.mjs')
175
+ );
176
+
177
+ if (stagedList.length === 0) {
178
+ // No code files staged (just docs/config), skip review
179
+ process.exit(0);
180
+ }
181
+
182
+ if (fs.existsSync(reviewMarker)) {
183
+ try {
184
+ const review = JSON.parse(fs.readFileSync(reviewMarker, 'utf-8'));
185
+ const reviewedFiles = new Set(review.files || []);
186
+ const unreviewed = stagedList.filter(f => !reviewedFiles.has(f));
187
+ if (unreviewed.length > 0) {
188
+ needsReview = true;
189
+ }
190
+ } catch {
191
+ needsReview = true;
192
+ }
193
+ } else {
194
+ needsReview = true;
195
+ }
196
+ } catch {
197
+ // Can't determine, allow
198
+ process.exit(0);
199
+ }
200
+
201
+ if (needsReview) {
202
+ console.error('[pre-commit] Code review required before commit.\n');
203
+ console.error('Run code-quality-reviewer and security-reviewer agents on the changed files,');
204
+ console.error('then retry the commit. Or run /code-review to do this automatically.\n');
205
+ console.error('To mark files as reviewed, the agents will update .claude/.last-review.');
206
+ process.exit(2);
207
+ }
@@ -9,6 +9,15 @@
9
9
  "command": "node .claude/hooks/guard-protected-files.mjs"
10
10
  }
11
11
  ]
12
+ },
13
+ {
14
+ "matcher": "Bash",
15
+ "hooks": [
16
+ {
17
+ "type": "command",
18
+ "command": "node .claude/hooks/pre-commit-gate.mjs"
19
+ }
20
+ ]
12
21
  }
13
22
  ],
14
23
  "PostToolUse": [
@@ -27,7 +36,7 @@
27
36
  "hooks": [
28
37
  {
29
38
  "type": "command",
30
- "command": "npx tsc --noEmit 2>&1 && npx eslint . 2>&1"
39
+ "command": "node .claude/hooks/code-hygiene.mjs"
31
40
  }
32
41
  ]
33
42
  }