wogiflow 2.7.1 → 2.8.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/.claude/commands/wogi-audit.md +156 -8
- package/.claude/commands/wogi-start.md +276 -0
- package/lib/workspace-channel-server.js +19 -4
- package/lib/workspace-routing.js +15 -5
- package/lib/workspace.js +10 -0
- package/package.json +1 -1
- package/scripts/flow-audit-gates.js +766 -0
- package/scripts/flow-runtime-verification.js +782 -0
|
@@ -0,0 +1,766 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Wogi Flow - Audit Gate 0: Pre-Agent Baseline Checks
|
|
5
|
+
*
|
|
6
|
+
* Runs BEFORE the 7 analysis agents to establish a project health baseline.
|
|
7
|
+
* These are hard, verifiable checks — not AI judgment. They produce quantitative
|
|
8
|
+
* metrics that cap the final audit score.
|
|
9
|
+
*
|
|
10
|
+
* A project that can't build should never score higher than D, regardless
|
|
11
|
+
* of how elegant its architecture is.
|
|
12
|
+
*
|
|
13
|
+
* Commands:
|
|
14
|
+
* flow-audit-gates.js run — Run all Gate 0 checks
|
|
15
|
+
* flow-audit-gates.js build — Build check only
|
|
16
|
+
* flow-audit-gates.js typecheck — Typecheck only
|
|
17
|
+
* flow-audit-gates.js lint — Lint check only
|
|
18
|
+
* flow-audit-gates.js lint-config — Lint config integrity
|
|
19
|
+
* flow-audit-gates.js tests — Test check only
|
|
20
|
+
* flow-audit-gates.js scripts — Package.json script completeness
|
|
21
|
+
* flow-audit-gates.js dead-exports — Find exported functions with no importers
|
|
22
|
+
* flow-audit-gates.js eslint-disable — Count eslint-disable comments
|
|
23
|
+
* flow-audit-gates.js dep-health — Dependency health (outdated, deprecated)
|
|
24
|
+
* flow-audit-gates.js git-health — Git history health indicators
|
|
25
|
+
* flow-audit-gates.js env-hygiene — Environment/config hygiene
|
|
26
|
+
* flow-audit-gates.js test-coverage — Test coverage metrics
|
|
27
|
+
* flow-audit-gates.js framework — Auto-detect framework
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
'use strict';
|
|
31
|
+
|
|
32
|
+
const { execFileSync } = require('node:child_process');
|
|
33
|
+
const fs = require('node:fs');
|
|
34
|
+
const path = require('node:path');
|
|
35
|
+
|
|
36
|
+
const { PATHS, getConfig, safeJsonParse, color } = require('./flow-utils');
|
|
37
|
+
|
|
38
|
+
// ============================================================
|
|
39
|
+
// Score Cap Thresholds
|
|
40
|
+
// ============================================================
|
|
41
|
+
|
|
42
|
+
/** Grade values for comparison (higher = better) */
|
|
43
|
+
const GRADE_VALUES = { 'A+': 97, 'A': 93, 'A-': 90, 'B+': 87, 'B': 83, 'B-': 80, 'C+': 77, 'C': 73, 'C-': 70, 'D+': 67, 'D': 63, 'D-': 60, 'F': 50 };
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Convert a numeric score to a letter grade.
|
|
47
|
+
* @param {number} score — 0-100
|
|
48
|
+
* @returns {string} letter grade
|
|
49
|
+
*/
|
|
50
|
+
function scoreToGrade(score) {
|
|
51
|
+
if (score >= 97) return 'A+';
|
|
52
|
+
if (score >= 93) return 'A';
|
|
53
|
+
if (score >= 90) return 'A-';
|
|
54
|
+
if (score >= 87) return 'B+';
|
|
55
|
+
if (score >= 83) return 'B';
|
|
56
|
+
if (score >= 80) return 'B-';
|
|
57
|
+
if (score >= 77) return 'C+';
|
|
58
|
+
if (score >= 73) return 'C';
|
|
59
|
+
if (score >= 70) return 'C-';
|
|
60
|
+
if (score >= 67) return 'D+';
|
|
61
|
+
if (score >= 63) return 'D';
|
|
62
|
+
if (score >= 60) return 'D-';
|
|
63
|
+
return 'F';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ============================================================
|
|
67
|
+
// Gate 0 Checks
|
|
68
|
+
// ============================================================
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Run a project script and capture results.
|
|
72
|
+
* @param {string} scriptName — npm script name (e.g., 'build', 'typecheck')
|
|
73
|
+
* @param {number} [timeout=60000] — timeout in ms
|
|
74
|
+
* @returns {{ exists: boolean, passed: boolean, output: string, errorCount: number }}
|
|
75
|
+
*/
|
|
76
|
+
function runProjectScript(scriptName, timeout = 60000) {
|
|
77
|
+
const pkgPath = path.join(PATHS.root, 'package.json');
|
|
78
|
+
const pkg = safeJsonParse(pkgPath, {});
|
|
79
|
+
const scripts = pkg.scripts || {};
|
|
80
|
+
|
|
81
|
+
if (!scripts[scriptName]) {
|
|
82
|
+
return { exists: false, passed: false, output: `Script "${scriptName}" not defined in package.json`, errorCount: 0 };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
const output = execFileSync('npm', ['run', scriptName], {
|
|
87
|
+
cwd: PATHS.root,
|
|
88
|
+
encoding: 'utf-8',
|
|
89
|
+
timeout,
|
|
90
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
91
|
+
env: { ...process.env, FORCE_COLOR: '0', NO_COLOR: '1' }
|
|
92
|
+
});
|
|
93
|
+
return { exists: true, passed: true, output: output.substring(0, 5000), errorCount: 0 };
|
|
94
|
+
} catch (err) {
|
|
95
|
+
const output = (err.stdout || '') + (err.stderr || '');
|
|
96
|
+
// Count error lines
|
|
97
|
+
const errorCount = (output.match(/error TS\d+|Error:|ERROR/gi) || []).length;
|
|
98
|
+
return { exists: true, passed: false, output: output.substring(0, 5000), errorCount };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Gate: Build check — does the project compile?
|
|
104
|
+
*/
|
|
105
|
+
function checkBuild() {
|
|
106
|
+
const result = runProjectScript('build', 120000);
|
|
107
|
+
let scoreCap = 100;
|
|
108
|
+
if (result.exists && !result.passed) scoreCap = 63; // D max
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
gate: 'build',
|
|
112
|
+
...result,
|
|
113
|
+
scoreCap,
|
|
114
|
+
severity: result.passed ? 'pass' : 'critical',
|
|
115
|
+
message: !result.exists ? 'No build script defined' :
|
|
116
|
+
result.passed ? 'Build succeeds' :
|
|
117
|
+
`Build FAILS — project cannot be deployed (cap: D)`
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Gate: Typecheck — how many type errors?
|
|
123
|
+
*/
|
|
124
|
+
function checkTypecheck() {
|
|
125
|
+
const result = runProjectScript('typecheck', 120000);
|
|
126
|
+
|
|
127
|
+
// Parse error count from output
|
|
128
|
+
let errorCount = result.errorCount;
|
|
129
|
+
if (!errorCount && result.output) {
|
|
130
|
+
// Try to parse TS error count from "Found N errors" pattern
|
|
131
|
+
const match = result.output.match(/Found (\d+) errors?/i);
|
|
132
|
+
if (match) errorCount = parseInt(match[1], 10);
|
|
133
|
+
// Or count "error TS" lines
|
|
134
|
+
if (!errorCount) {
|
|
135
|
+
errorCount = (result.output.match(/error TS\d+/g) || []).length;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
let scoreCap = 100;
|
|
140
|
+
if (errorCount > 500) scoreCap = 67; // D+ max
|
|
141
|
+
else if (errorCount > 100) scoreCap = 73; // C max
|
|
142
|
+
else if (errorCount > 50) scoreCap = 77; // C+ max
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
gate: 'typecheck',
|
|
146
|
+
...result,
|
|
147
|
+
errorCount,
|
|
148
|
+
scoreCap,
|
|
149
|
+
severity: !result.exists ? 'info' :
|
|
150
|
+
result.passed ? 'pass' :
|
|
151
|
+
errorCount > 100 ? 'critical' : 'high',
|
|
152
|
+
message: !result.exists ? 'No typecheck script defined' :
|
|
153
|
+
result.passed ? 'Typecheck passes (0 errors)' :
|
|
154
|
+
`Typecheck FAILS: ${errorCount} error(s) (cap: ${scoreToGrade(scoreCap)})`
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Gate: Lint check — count errors vs warnings.
|
|
160
|
+
*/
|
|
161
|
+
function checkLint() {
|
|
162
|
+
const result = runProjectScript('lint', 60000);
|
|
163
|
+
|
|
164
|
+
// Parse error/warning counts
|
|
165
|
+
let errorCount = 0;
|
|
166
|
+
let warningCount = 0;
|
|
167
|
+
if (result.output) {
|
|
168
|
+
// ESLint format: "N problems (X errors, Y warnings)"
|
|
169
|
+
const match = result.output.match(/(\d+) problems?\s*\((\d+) errors?,\s*(\d+) warnings?\)/);
|
|
170
|
+
if (match) {
|
|
171
|
+
errorCount = parseInt(match[2], 10);
|
|
172
|
+
warningCount = parseInt(match[3], 10);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
let scoreCap = 100;
|
|
177
|
+
if (errorCount > 50) scoreCap = 73; // C max
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
gate: 'lint',
|
|
181
|
+
...result,
|
|
182
|
+
errorCount,
|
|
183
|
+
warningCount,
|
|
184
|
+
scoreCap,
|
|
185
|
+
severity: !result.exists ? 'info' :
|
|
186
|
+
errorCount > 50 ? 'critical' :
|
|
187
|
+
errorCount > 0 ? 'high' :
|
|
188
|
+
warningCount > 50 ? 'medium' : 'pass',
|
|
189
|
+
message: !result.exists ? 'No lint script defined' :
|
|
190
|
+
errorCount === 0 && warningCount === 0 ? 'Lint passes (0 errors, 0 warnings)' :
|
|
191
|
+
`Lint: ${errorCount} error(s), ${warningCount} warning(s)${scoreCap < 100 ? ` (cap: ${scoreToGrade(scoreCap)})` : ''}`
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Gate: Lint config integrity — detect downgraded rules.
|
|
197
|
+
* Checks if rules that should be 'error' (per recommended presets) are set to 'warn' or 'off'.
|
|
198
|
+
*/
|
|
199
|
+
function checkLintConfigIntegrity() {
|
|
200
|
+
const result = {
|
|
201
|
+
gate: 'lint-config',
|
|
202
|
+
exists: false,
|
|
203
|
+
downgradedRules: [],
|
|
204
|
+
scorePenalty: 0,
|
|
205
|
+
severity: 'pass',
|
|
206
|
+
message: 'No lint config found'
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
// Find ESLint config
|
|
210
|
+
const configFiles = [
|
|
211
|
+
'eslint.config.js', 'eslint.config.ts', 'eslint.config.mjs',
|
|
212
|
+
'.eslintrc.js', '.eslintrc.json', '.eslintrc.yml', '.eslintrc'
|
|
213
|
+
];
|
|
214
|
+
|
|
215
|
+
let configPath = null;
|
|
216
|
+
for (const f of configFiles) {
|
|
217
|
+
const p = path.join(PATHS.root, f);
|
|
218
|
+
if (fs.existsSync(p)) { configPath = p; break; }
|
|
219
|
+
}
|
|
220
|
+
if (!configPath) return result;
|
|
221
|
+
|
|
222
|
+
result.exists = true;
|
|
223
|
+
let content;
|
|
224
|
+
try {
|
|
225
|
+
content = fs.readFileSync(configPath, 'utf-8');
|
|
226
|
+
} catch (_err) {
|
|
227
|
+
result.message = 'Lint config found but unreadable';
|
|
228
|
+
return result;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Rules that should be 'error' per recommended presets
|
|
232
|
+
const SHOULD_BE_ERROR = [
|
|
233
|
+
'react-hooks/rules-of-hooks',
|
|
234
|
+
'react-hooks/exhaustive-deps',
|
|
235
|
+
'no-undef',
|
|
236
|
+
'no-unused-vars',
|
|
237
|
+
'@typescript-eslint/no-unused-vars',
|
|
238
|
+
'no-dupe-keys',
|
|
239
|
+
'no-duplicate-case',
|
|
240
|
+
'no-unreachable',
|
|
241
|
+
'no-constant-condition',
|
|
242
|
+
'no-empty-pattern',
|
|
243
|
+
'no-self-assign',
|
|
244
|
+
'no-unsafe-negation',
|
|
245
|
+
'no-loss-of-precision',
|
|
246
|
+
'no-import-assign'
|
|
247
|
+
];
|
|
248
|
+
|
|
249
|
+
for (const rule of SHOULD_BE_ERROR) {
|
|
250
|
+
// Check for patterns like: 'rule-name': 'warn' or "rule-name": "warn" or 'rule-name': 'off'
|
|
251
|
+
const escaped = rule.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
252
|
+
const warnPattern = new RegExp(`['"]${escaped}['"]\\s*:\\s*['"](?:warn|off|0)['"]`);
|
|
253
|
+
if (warnPattern.test(content)) {
|
|
254
|
+
result.downgradedRules.push(rule);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (result.downgradedRules.length > 0) {
|
|
259
|
+
result.scorePenalty = Math.min(15, result.downgradedRules.length * 3);
|
|
260
|
+
result.severity = result.downgradedRules.length > 3 ? 'critical' : 'high';
|
|
261
|
+
result.message = `Lint config: ${result.downgradedRules.length} rule(s) downgraded from error to warn/off (-${result.scorePenalty} pts): ${result.downgradedRules.join(', ')}`;
|
|
262
|
+
} else {
|
|
263
|
+
result.message = 'Lint config integrity: no downgraded rules detected';
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return result;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Gate: Tests — do tests pass?
|
|
271
|
+
*/
|
|
272
|
+
function checkTests() {
|
|
273
|
+
const result = runProjectScript('test', 120000);
|
|
274
|
+
return {
|
|
275
|
+
gate: 'tests',
|
|
276
|
+
...result,
|
|
277
|
+
scoreCap: 100, // Test failure doesn't cap, but is a HIGH finding
|
|
278
|
+
severity: !result.exists ? 'info' :
|
|
279
|
+
result.passed ? 'pass' : 'high',
|
|
280
|
+
message: !result.exists ? 'No test script defined' :
|
|
281
|
+
result.passed ? 'Tests pass' : 'Tests FAIL'
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Gate: Package.json script completeness.
|
|
287
|
+
*/
|
|
288
|
+
function checkScriptCompleteness() {
|
|
289
|
+
const pkgPath = path.join(PATHS.root, 'package.json');
|
|
290
|
+
const pkg = safeJsonParse(pkgPath, {});
|
|
291
|
+
const scripts = pkg.scripts || {};
|
|
292
|
+
|
|
293
|
+
const EXPECTED = ['build', 'test', 'lint', 'typecheck', 'dev'];
|
|
294
|
+
const missing = EXPECTED.filter(s => !scripts[s] && !scripts[`${s}:check`]);
|
|
295
|
+
|
|
296
|
+
return {
|
|
297
|
+
gate: 'scripts',
|
|
298
|
+
missing,
|
|
299
|
+
present: EXPECTED.filter(s => scripts[s] || scripts[`${s}:check`]),
|
|
300
|
+
severity: missing.length > 2 ? 'high' : missing.length > 0 ? 'medium' : 'pass',
|
|
301
|
+
message: missing.length === 0 ? 'All expected scripts present' :
|
|
302
|
+
`Missing scripts: ${missing.join(', ')} (${missing.length}/${EXPECTED.length})`
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ============================================================
|
|
307
|
+
// Extended Checks (run alongside agents)
|
|
308
|
+
// ============================================================
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Count eslint-disable comments across the project.
|
|
312
|
+
*/
|
|
313
|
+
function countEslintDisables() {
|
|
314
|
+
try {
|
|
315
|
+
const output = execFileSync('grep', [
|
|
316
|
+
'-r', 'eslint-disable',
|
|
317
|
+
'--include=*.ts', '--include=*.tsx', '--include=*.js', '--include=*.jsx',
|
|
318
|
+
'-c', '.'
|
|
319
|
+
], { cwd: PATHS.root, encoding: 'utf-8', timeout: 15000, stdio: ['pipe', 'pipe', 'pipe'] }).toString();
|
|
320
|
+
const lines = output.trim().split('\n').filter(Boolean);
|
|
321
|
+
let total = 0;
|
|
322
|
+
const byFile = [];
|
|
323
|
+
for (const line of lines) {
|
|
324
|
+
const match = line.match(/:(\d+)$/);
|
|
325
|
+
if (match) {
|
|
326
|
+
const count = parseInt(match[1], 10);
|
|
327
|
+
total += count;
|
|
328
|
+
if (count > 3) byFile.push({ file: line.replace(/:(\d+)$/, ''), count });
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
return {
|
|
332
|
+
total,
|
|
333
|
+
byFile: byFile.sort((a, b) => b.count - a.count).slice(0, 10),
|
|
334
|
+
severity: total > 50 ? 'high' : total > 20 ? 'medium' : total > 0 ? 'low' : 'pass'
|
|
335
|
+
};
|
|
336
|
+
} catch (_err) {
|
|
337
|
+
return { total: 0, byFile: [], severity: 'pass' };
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Detect project framework from package.json.
|
|
343
|
+
*/
|
|
344
|
+
function detectFramework() {
|
|
345
|
+
const pkgPath = path.join(PATHS.root, 'package.json');
|
|
346
|
+
const pkg = safeJsonParse(pkgPath, {});
|
|
347
|
+
const allDeps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
|
|
348
|
+
|
|
349
|
+
const frameworks = [];
|
|
350
|
+
if (allDeps['next']) frameworks.push({ name: 'nextjs', version: allDeps['next'] });
|
|
351
|
+
if (allDeps['react']) frameworks.push({ name: 'react', version: allDeps['react'] });
|
|
352
|
+
if (allDeps['vue']) frameworks.push({ name: 'vue', version: allDeps['vue'] });
|
|
353
|
+
if (allDeps['svelte'] || allDeps['@sveltejs/kit']) frameworks.push({ name: 'svelte', version: allDeps['svelte'] || allDeps['@sveltejs/kit'] });
|
|
354
|
+
if (allDeps['@angular/core']) frameworks.push({ name: 'angular', version: allDeps['@angular/core'] });
|
|
355
|
+
if (allDeps['@nestjs/core']) frameworks.push({ name: 'nestjs', version: allDeps['@nestjs/core'] });
|
|
356
|
+
if (allDeps['express']) frameworks.push({ name: 'express', version: allDeps['express'] });
|
|
357
|
+
if (allDeps['fastify']) frameworks.push({ name: 'fastify', version: allDeps['fastify'] });
|
|
358
|
+
if (allDeps['hono']) frameworks.push({ name: 'hono', version: allDeps['hono'] });
|
|
359
|
+
|
|
360
|
+
const hasTypescript = !!allDeps['typescript'];
|
|
361
|
+
const hasMonorepo = fs.existsSync(path.join(PATHS.root, 'packages')) ||
|
|
362
|
+
fs.existsSync(path.join(PATHS.root, 'apps')) ||
|
|
363
|
+
(pkg.workspaces && pkg.workspaces.length > 0);
|
|
364
|
+
|
|
365
|
+
return { frameworks, hasTypescript, hasMonorepo, language: hasTypescript ? 'typescript' : 'javascript' };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Check git health indicators.
|
|
370
|
+
*/
|
|
371
|
+
function checkGitHealth() {
|
|
372
|
+
const result = {
|
|
373
|
+
commitFrequency: 'unknown',
|
|
374
|
+
staleBranches: 0,
|
|
375
|
+
uncommittedFiles: 0,
|
|
376
|
+
conventionalCommits: false,
|
|
377
|
+
recentCommits: 0
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
try {
|
|
381
|
+
// Commits in last 30 days
|
|
382
|
+
const recent = execSync('git log --oneline --since="30 days ago" 2>/dev/null | wc -l', {
|
|
383
|
+
cwd: PATHS.root, encoding: 'utf-8', timeout: 5000
|
|
384
|
+
}).trim();
|
|
385
|
+
result.recentCommits = parseInt(recent, 10) || 0;
|
|
386
|
+
result.commitFrequency = result.recentCommits > 60 ? 'high' :
|
|
387
|
+
result.recentCommits > 20 ? 'medium' :
|
|
388
|
+
result.recentCommits > 0 ? 'low' : 'inactive';
|
|
389
|
+
|
|
390
|
+
// Stale branches (>30 days, not merged)
|
|
391
|
+
try {
|
|
392
|
+
const branches = execSync('git branch -r --no-merged 2>/dev/null | wc -l', {
|
|
393
|
+
cwd: PATHS.root, encoding: 'utf-8', timeout: 5000
|
|
394
|
+
}).trim();
|
|
395
|
+
result.staleBranches = parseInt(branches, 10) || 0;
|
|
396
|
+
} catch (_err) { /* non-critical */ }
|
|
397
|
+
|
|
398
|
+
// Uncommitted changes
|
|
399
|
+
try {
|
|
400
|
+
const status = execSync('git status --porcelain 2>/dev/null | wc -l', {
|
|
401
|
+
cwd: PATHS.root, encoding: 'utf-8', timeout: 5000
|
|
402
|
+
}).trim();
|
|
403
|
+
result.uncommittedFiles = parseInt(status, 10) || 0;
|
|
404
|
+
} catch (_err) { /* non-critical */ }
|
|
405
|
+
|
|
406
|
+
// Check if last 10 commits use conventional format
|
|
407
|
+
try {
|
|
408
|
+
const msgs = execSync('git log --oneline -10 2>/dev/null', {
|
|
409
|
+
cwd: PATHS.root, encoding: 'utf-8', timeout: 5000
|
|
410
|
+
}).trim();
|
|
411
|
+
const lines = msgs.split('\n').filter(Boolean);
|
|
412
|
+
const conventional = lines.filter(l => /^\w+ (feat|fix|docs|style|refactor|perf|test|chore|ci|build)\(?/.test(l));
|
|
413
|
+
result.conventionalCommits = conventional.length >= lines.length * 0.5;
|
|
414
|
+
} catch (_err) { /* non-critical */ }
|
|
415
|
+
} catch (_err) {
|
|
416
|
+
// Git not available
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
return result;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Check environment/config hygiene.
|
|
424
|
+
*/
|
|
425
|
+
function checkEnvHygiene() {
|
|
426
|
+
const checks = [];
|
|
427
|
+
|
|
428
|
+
// .env.example exists?
|
|
429
|
+
const hasEnvExample = fs.existsSync(path.join(PATHS.root, '.env.example'));
|
|
430
|
+
const hasEnv = fs.existsSync(path.join(PATHS.root, '.env'));
|
|
431
|
+
const hasGitignore = fs.existsSync(path.join(PATHS.root, '.gitignore'));
|
|
432
|
+
|
|
433
|
+
checks.push({
|
|
434
|
+
check: '.env.example',
|
|
435
|
+
status: hasEnvExample ? 'pass' : (hasEnv ? 'fail' : 'na'),
|
|
436
|
+
message: hasEnvExample ? '.env.example exists' :
|
|
437
|
+
hasEnv ? '.env exists but no .env.example — other devs can\'t set up' : 'No .env files'
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
// .env in .gitignore?
|
|
441
|
+
if (hasGitignore && hasEnv) {
|
|
442
|
+
let gitignore = '';
|
|
443
|
+
try {
|
|
444
|
+
gitignore = fs.readFileSync(path.join(PATHS.root, '.gitignore'), 'utf-8');
|
|
445
|
+
} catch (_err) {
|
|
446
|
+
// Unreadable — skip this check
|
|
447
|
+
}
|
|
448
|
+
const envIgnored = gitignore.split('\n').some(l => l.trim() === '.env' || l.trim() === '.env*');
|
|
449
|
+
checks.push({
|
|
450
|
+
check: '.env in .gitignore',
|
|
451
|
+
status: envIgnored ? 'pass' : 'fail',
|
|
452
|
+
message: envIgnored ? '.env is gitignored' : 'WARNING: .env is NOT in .gitignore — secrets may be committed'
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// CI config exists?
|
|
457
|
+
const ciConfigs = [
|
|
458
|
+
'.github/workflows',
|
|
459
|
+
'.gitlab-ci.yml',
|
|
460
|
+
'Jenkinsfile',
|
|
461
|
+
'.circleci/config.yml',
|
|
462
|
+
'bitbucket-pipelines.yml'
|
|
463
|
+
];
|
|
464
|
+
const hasCI = ciConfigs.some(c => fs.existsSync(path.join(PATHS.root, c)));
|
|
465
|
+
checks.push({
|
|
466
|
+
check: 'CI configuration',
|
|
467
|
+
status: hasCI ? 'pass' : 'fail',
|
|
468
|
+
message: hasCI ? 'CI pipeline configured' : 'No CI configuration found — no automated quality enforcement'
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
// Docker?
|
|
472
|
+
const hasDocker = fs.existsSync(path.join(PATHS.root, 'Dockerfile')) ||
|
|
473
|
+
fs.existsSync(path.join(PATHS.root, 'docker-compose.yml'));
|
|
474
|
+
if (hasDocker) {
|
|
475
|
+
checks.push({ check: 'Docker', status: 'info', message: 'Dockerfile/docker-compose found' });
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
return checks;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// ============================================================
|
|
482
|
+
// Score Capping
|
|
483
|
+
// ============================================================
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Calculate the Gate 0 score cap from all gate results.
|
|
487
|
+
* The final audit score = min(gate0_cap, weighted_agent_score)
|
|
488
|
+
*
|
|
489
|
+
* @param {Array<Object>} gateResults — results from all gates
|
|
490
|
+
* @returns {{ scoreCap: number, grade: string, penalties: number, reasons: string[] }}
|
|
491
|
+
*/
|
|
492
|
+
function calculateScoreCap(gateResults) {
|
|
493
|
+
let cap = 100;
|
|
494
|
+
let penalties = 0;
|
|
495
|
+
const reasons = [];
|
|
496
|
+
|
|
497
|
+
for (const gate of gateResults) {
|
|
498
|
+
if (gate.scoreCap !== undefined && gate.scoreCap < cap) {
|
|
499
|
+
cap = gate.scoreCap;
|
|
500
|
+
reasons.push(`${gate.gate}: ${gate.message}`);
|
|
501
|
+
}
|
|
502
|
+
if (gate.scorePenalty) {
|
|
503
|
+
penalties += gate.scorePenalty;
|
|
504
|
+
reasons.push(`${gate.gate}: -${gate.scorePenalty} pts (${gate.message})`);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const effectiveCap = Math.max(50, cap - penalties);
|
|
509
|
+
|
|
510
|
+
return {
|
|
511
|
+
scoreCap: effectiveCap,
|
|
512
|
+
grade: scoreToGrade(effectiveCap),
|
|
513
|
+
penalties,
|
|
514
|
+
reasons
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// ============================================================
|
|
519
|
+
// Trend Comparison
|
|
520
|
+
// ============================================================
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Compare current gate results with previous audit.
|
|
524
|
+
* @param {Object} currentResults — from runAllGates()
|
|
525
|
+
* @param {Object} [previousAudit] — from last-audit.json
|
|
526
|
+
* @returns {Array<Object>} deltas
|
|
527
|
+
*/
|
|
528
|
+
function compareTrend(currentResults, previousAudit) {
|
|
529
|
+
if (!previousAudit || !previousAudit.gate0) return [];
|
|
530
|
+
|
|
531
|
+
const deltas = [];
|
|
532
|
+
const prev = previousAudit.gate0;
|
|
533
|
+
|
|
534
|
+
for (const gate of currentResults.gates) {
|
|
535
|
+
const prevGate = prev.gates?.find(g => g.gate === gate.gate);
|
|
536
|
+
if (!prevGate) continue;
|
|
537
|
+
|
|
538
|
+
if (gate.errorCount !== undefined && prevGate.errorCount !== undefined) {
|
|
539
|
+
const delta = gate.errorCount - prevGate.errorCount;
|
|
540
|
+
if (delta !== 0) {
|
|
541
|
+
deltas.push({
|
|
542
|
+
gate: gate.gate,
|
|
543
|
+
metric: 'errorCount',
|
|
544
|
+
previous: prevGate.errorCount,
|
|
545
|
+
current: gate.errorCount,
|
|
546
|
+
delta,
|
|
547
|
+
improved: delta < 0
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
return deltas;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// ============================================================
|
|
557
|
+
// Main: Run All Gates
|
|
558
|
+
// ============================================================
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Run all Gate 0 checks and return consolidated results.
|
|
562
|
+
* @returns {Object} gate results with score cap
|
|
563
|
+
*/
|
|
564
|
+
function runAllGates() {
|
|
565
|
+
const gates = [];
|
|
566
|
+
|
|
567
|
+
gates.push(checkBuild());
|
|
568
|
+
gates.push(checkTypecheck());
|
|
569
|
+
gates.push(checkLint());
|
|
570
|
+
gates.push(checkLintConfigIntegrity());
|
|
571
|
+
gates.push(checkTests());
|
|
572
|
+
gates.push(checkScriptCompleteness());
|
|
573
|
+
|
|
574
|
+
const cap = calculateScoreCap(gates);
|
|
575
|
+
const framework = detectFramework();
|
|
576
|
+
|
|
577
|
+
// Load previous audit for trend comparison
|
|
578
|
+
let trend = [];
|
|
579
|
+
const lastAuditPath = path.join(PATHS.state, 'last-audit.json');
|
|
580
|
+
const previousAudit = safeJsonParse(lastAuditPath, null);
|
|
581
|
+
if (previousAudit) {
|
|
582
|
+
trend = compareTrend({ gates }, previousAudit);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
return {
|
|
586
|
+
gates,
|
|
587
|
+
cap,
|
|
588
|
+
framework,
|
|
589
|
+
eslintDisables: countEslintDisables(),
|
|
590
|
+
gitHealth: checkGitHealth(),
|
|
591
|
+
envHygiene: checkEnvHygiene(),
|
|
592
|
+
trend,
|
|
593
|
+
timestamp: new Date().toISOString()
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// ============================================================
|
|
598
|
+
// CLI
|
|
599
|
+
// ============================================================
|
|
600
|
+
|
|
601
|
+
function main() {
|
|
602
|
+
const command = process.argv[2] || 'run';
|
|
603
|
+
|
|
604
|
+
switch (command) {
|
|
605
|
+
case 'run': {
|
|
606
|
+
const results = runAllGates();
|
|
607
|
+
console.log(JSON.stringify(results, null, 2));
|
|
608
|
+
break;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
case 'build':
|
|
612
|
+
console.log(JSON.stringify(checkBuild(), null, 2));
|
|
613
|
+
break;
|
|
614
|
+
|
|
615
|
+
case 'typecheck':
|
|
616
|
+
console.log(JSON.stringify(checkTypecheck(), null, 2));
|
|
617
|
+
break;
|
|
618
|
+
|
|
619
|
+
case 'lint':
|
|
620
|
+
console.log(JSON.stringify(checkLint(), null, 2));
|
|
621
|
+
break;
|
|
622
|
+
|
|
623
|
+
case 'lint-config':
|
|
624
|
+
console.log(JSON.stringify(checkLintConfigIntegrity(), null, 2));
|
|
625
|
+
break;
|
|
626
|
+
|
|
627
|
+
case 'tests':
|
|
628
|
+
console.log(JSON.stringify(checkTests(), null, 2));
|
|
629
|
+
break;
|
|
630
|
+
|
|
631
|
+
case 'scripts':
|
|
632
|
+
console.log(JSON.stringify(checkScriptCompleteness(), null, 2));
|
|
633
|
+
break;
|
|
634
|
+
|
|
635
|
+
case 'eslint-disable':
|
|
636
|
+
console.log(JSON.stringify(countEslintDisables(), null, 2));
|
|
637
|
+
break;
|
|
638
|
+
|
|
639
|
+
case 'framework':
|
|
640
|
+
console.log(JSON.stringify(detectFramework(), null, 2));
|
|
641
|
+
break;
|
|
642
|
+
|
|
643
|
+
case 'git-health':
|
|
644
|
+
console.log(JSON.stringify(checkGitHealth(), null, 2));
|
|
645
|
+
break;
|
|
646
|
+
|
|
647
|
+
case 'env-hygiene':
|
|
648
|
+
console.log(JSON.stringify(checkEnvHygiene(), null, 2));
|
|
649
|
+
break;
|
|
650
|
+
|
|
651
|
+
case 'dead-exports': {
|
|
652
|
+
// Delegated to the AI agent — this is a hint for the agent prompt
|
|
653
|
+
console.log('Dead export detection requires AI analysis. Use the audit agent prompt.');
|
|
654
|
+
break;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
case 'dep-health': {
|
|
658
|
+
// Uses existing flow-audit.js outdated + audit
|
|
659
|
+
try {
|
|
660
|
+
const { getOutdatedDeps, getAuditResults } = require('./flow-audit');
|
|
661
|
+
console.log(JSON.stringify({
|
|
662
|
+
outdated: getOutdatedDeps(),
|
|
663
|
+
vulnerabilities: getAuditResults()
|
|
664
|
+
}, null, 2));
|
|
665
|
+
} catch (err) {
|
|
666
|
+
console.log(JSON.stringify({ error: err.message }, null, 2));
|
|
667
|
+
}
|
|
668
|
+
break;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
case 'test-coverage': {
|
|
672
|
+
// Try to run coverage command
|
|
673
|
+
const pkgPath = path.join(PATHS.root, 'package.json');
|
|
674
|
+
const pkg = safeJsonParse(pkgPath, {});
|
|
675
|
+
const scripts = pkg.scripts || {};
|
|
676
|
+
const scriptName = scripts['test:coverage'] ? 'test:coverage' : (scripts['coverage'] ? 'coverage' : null);
|
|
677
|
+
if (scriptName) {
|
|
678
|
+
try {
|
|
679
|
+
const output = execFileSync('npm', ['run', scriptName], {
|
|
680
|
+
cwd: PATHS.root, encoding: 'utf-8', timeout: 120000, stdio: ['pipe', 'pipe', 'pipe']
|
|
681
|
+
});
|
|
682
|
+
console.log(JSON.stringify({ available: true, output: output.substring(0, 3000) }, null, 2));
|
|
683
|
+
} catch (err) {
|
|
684
|
+
console.log(JSON.stringify({ available: true, error: (err.stdout || err.stderr || '').substring(0, 2000) }, null, 2));
|
|
685
|
+
}
|
|
686
|
+
} else {
|
|
687
|
+
// Check test file ratio
|
|
688
|
+
try {
|
|
689
|
+
const testFiles = execFileSync('sh', ['-c', 'find . -name "*.test.*" -o -name "*.spec.*" 2>/dev/null | grep -v node_modules | wc -l'], {
|
|
690
|
+
cwd: PATHS.root, encoding: 'utf-8', timeout: 5000
|
|
691
|
+
}).trim();
|
|
692
|
+
const srcFiles = execFileSync('sh', ['-c', 'find . \\( -name "*.ts" -o -name "*.tsx" -o -name "*.js" -o -name "*.jsx" \\) 2>/dev/null | grep -v node_modules | grep -vE "\\.(test|spec)\\." | wc -l'], {
|
|
693
|
+
cwd: PATHS.root, encoding: 'utf-8', timeout: 5000
|
|
694
|
+
}).trim();
|
|
695
|
+
console.log(JSON.stringify({
|
|
696
|
+
available: false,
|
|
697
|
+
testFiles: parseInt(testFiles, 10) || 0,
|
|
698
|
+
sourceFiles: parseInt(srcFiles, 10) || 0,
|
|
699
|
+
ratio: parseInt(srcFiles, 10) > 0 ? ((parseInt(testFiles, 10) / parseInt(srcFiles, 10)) * 100).toFixed(1) + '%' : '0%'
|
|
700
|
+
}, null, 2));
|
|
701
|
+
} catch (_err) {
|
|
702
|
+
console.log(JSON.stringify({ available: false, testFiles: 0, sourceFiles: 0, ratio: '0%' }, null, 2));
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
break;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
default:
|
|
709
|
+
console.log(`
|
|
710
|
+
Wogi Flow - Audit Gate 0: Pre-Agent Baseline Checks
|
|
711
|
+
|
|
712
|
+
Usage: flow-audit-gates.js <command>
|
|
713
|
+
|
|
714
|
+
Commands:
|
|
715
|
+
run Run all Gate 0 checks (default)
|
|
716
|
+
build Build check only
|
|
717
|
+
typecheck Typecheck only
|
|
718
|
+
lint Lint check only
|
|
719
|
+
lint-config Lint config integrity (downgraded rules)
|
|
720
|
+
tests Test check only
|
|
721
|
+
scripts Package.json script completeness
|
|
722
|
+
eslint-disable Count eslint-disable comments
|
|
723
|
+
framework Auto-detect project framework
|
|
724
|
+
git-health Git history health indicators
|
|
725
|
+
env-hygiene Environment/config hygiene
|
|
726
|
+
dep-health Dependency health (outdated + vulnerabilities)
|
|
727
|
+
test-coverage Test coverage metrics
|
|
728
|
+
dead-exports Dead export detection (requires AI agent)
|
|
729
|
+
`);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// ============================================================
|
|
734
|
+
// Exports
|
|
735
|
+
// ============================================================
|
|
736
|
+
|
|
737
|
+
module.exports = {
|
|
738
|
+
// Gate 0 checks
|
|
739
|
+
checkBuild,
|
|
740
|
+
checkTypecheck,
|
|
741
|
+
checkLint,
|
|
742
|
+
checkLintConfigIntegrity,
|
|
743
|
+
checkTests,
|
|
744
|
+
checkScriptCompleteness,
|
|
745
|
+
|
|
746
|
+
// Extended checks
|
|
747
|
+
countEslintDisables,
|
|
748
|
+
detectFramework,
|
|
749
|
+
checkGitHealth,
|
|
750
|
+
checkEnvHygiene,
|
|
751
|
+
|
|
752
|
+
// Score
|
|
753
|
+
calculateScoreCap,
|
|
754
|
+
scoreToGrade,
|
|
755
|
+
GRADE_VALUES,
|
|
756
|
+
|
|
757
|
+
// Trend
|
|
758
|
+
compareTrend,
|
|
759
|
+
|
|
760
|
+
// Main
|
|
761
|
+
runAllGates
|
|
762
|
+
};
|
|
763
|
+
|
|
764
|
+
if (require.main === module) {
|
|
765
|
+
main();
|
|
766
|
+
}
|