forge-workflow 0.0.9 → 0.0.10
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/dev.md +2 -2
- package/.claude/commands/plan.md +2 -2
- package/.claude/commands/ship.md +2 -2
- package/.claude/commands/status.md +4 -4
- package/.cline/workflows/dev.md +2 -2
- package/.cline/workflows/plan.md +2 -2
- package/.cline/workflows/ship.md +2 -2
- package/.cline/workflows/status.md +4 -4
- package/.codex/skills/dev/SKILL.md +2 -2
- package/.codex/skills/plan/SKILL.md +2 -2
- package/.codex/skills/ship/SKILL.md +2 -2
- package/.codex/skills/status/SKILL.md +4 -4
- package/.cursor/commands/dev.md +2 -2
- package/.cursor/commands/plan.md +2 -2
- package/.cursor/commands/ship.md +2 -2
- package/.cursor/commands/status.md +4 -4
- package/.github/prompts/dev.prompt.md +2 -2
- package/.github/prompts/plan.prompt.md +2 -2
- package/.github/prompts/ship.prompt.md +2 -2
- package/.github/prompts/status.prompt.md +4 -4
- package/.github/workflows/beads-to-github.yml +43 -10
- package/.github/workflows/github-to-beads.yml +10 -7
- package/.kilocode/workflows/dev.md +2 -2
- package/.kilocode/workflows/plan.md +2 -2
- package/.kilocode/workflows/ship.md +2 -2
- package/.kilocode/workflows/status.md +4 -4
- package/.opencode/commands/dev.md +2 -2
- package/.opencode/commands/plan.md +2 -2
- package/.opencode/commands/ship.md +2 -2
- package/.opencode/commands/status.md +4 -4
- package/.roo/commands/dev.md +2 -2
- package/.roo/commands/plan.md +2 -2
- package/.roo/commands/ship.md +2 -2
- package/.roo/commands/status.md +4 -4
- package/AGENTS.md +1 -0
- package/CLAUDE.md +12 -0
- package/bin/forge.js +10 -5
- package/docs/BEADS_GITHUB_SYNC.md +26 -0
- package/docs/TOOLCHAIN.md +130 -148
- package/lib/beads-bootstrap.js +225 -0
- package/lib/beads-health-check.js +55 -10
- package/lib/beads-setup.js +104 -28
- package/lib/beads-sync-scaffold.js +11 -6
- package/lib/commands/_issue.js +11 -1
- package/lib/commands/issues.js +49 -0
- package/lib/commands/recommend.js +22 -1
- package/lib/commands/setup.js +16 -10
- package/lib/commands/status.js +181 -0
- package/lib/commands/team.js +11 -1
- package/lib/commands/test.js +37 -2
- package/lib/commands/validate.js +14 -8
- package/lib/commands/worktree.js +27 -54
- package/lib/dep-guard/keyword-ripple.js +184 -0
- package/lib/detect-worktree.js +9 -10
- package/lib/forge-issues.js +326 -0
- package/lib/issue-sync/authority.js +100 -0
- package/lib/issue-sync/github-pull.js +184 -0
- package/lib/issue-sync/import-primitives.js +98 -0
- package/lib/issue-sync/legacy-link-bridge.js +436 -0
- package/lib/issue-sync/link-store.js +292 -0
- package/lib/issue-sync/project-github.js +123 -0
- package/lib/issue-sync/reconcile.js +195 -0
- package/lib/issue-sync/schema.js +126 -0
- package/lib/lefthook-check.js +5 -2
- package/lib/project-memory.js +564 -0
- package/lib/runtime-health.js +100 -12
- package/lib/smart-status/conflicts.js +205 -0
- package/lib/smart-status/scoring.js +177 -0
- package/lib/status/beads-snapshot.js +102 -0
- package/lib/status/presenter.js +65 -0
- package/lib/workflow/enforce-stage.js +3 -1
- package/lib/workflow/state-manager.js +164 -8
- package/package.json +12 -4
- package/scripts/beads-context.sh +124 -5
- package/scripts/beads-context.test.js +21 -4
- package/scripts/beads-migrate-to-dolt.sh +7 -0
- package/scripts/beads-upgrade-smoke.sh +263 -0
- package/scripts/behavioral-judge.sh +115 -11
- package/scripts/benchmark.js +349 -63
- package/scripts/dep-guard-analyze.js +52 -17
- package/scripts/dep-guard-keyword-ripple.js +29 -0
- package/scripts/dep-guard-render-review.js +86 -0
- package/scripts/dep-guard.sh +45 -232
- package/scripts/forge-team/lib/sync-github.sh +160 -28
- package/scripts/forge-team/tests/sync-github.test.sh +195 -58
- package/scripts/github-beads-sync/index.mjs +122 -98
- package/scripts/github-beads-sync/mapping.mjs +54 -0
- package/scripts/github-beads-sync/reverse-sync-cli.mjs +2 -2
- package/scripts/github-beads-sync/reverse-sync.mjs +31 -7
- package/scripts/lib/beads-migrate-to-dolt.mjs +503 -0
- package/scripts/preflight.sh +181 -0
- package/scripts/smart-status-score.js +31 -0
- package/scripts/smart-status-sessions.js +51 -0
- package/scripts/smart-status.sh +74 -329
- package/scripts/sync-agentic-workflow.js +48 -0
- package/scripts/test-ci-shard.js +244 -0
- package/scripts/test-dashboard.js +188 -52
- package/scripts/test-full-suite.js +186 -0
- package/scripts/test-profile.js +278 -0
- package/scripts/test.js +219 -28
- package/scripts/validate.js +143 -0
- package/scripts/validate.sh +18 -1
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
|
|
6
|
+
const rootDir = path.join(__dirname, '..');
|
|
7
|
+
const sourceDir = path.join(rootDir, '.github', 'agentic-workflows');
|
|
8
|
+
const targetDir = path.join(rootDir, '.github', 'workflows');
|
|
9
|
+
|
|
10
|
+
function syncFile(fileName, checkOnly) {
|
|
11
|
+
const sourcePath = path.join(sourceDir, fileName);
|
|
12
|
+
const targetPath = path.join(targetDir, fileName);
|
|
13
|
+
const source = fs.readFileSync(sourcePath, 'utf8');
|
|
14
|
+
const target = fs.existsSync(targetPath) ? fs.readFileSync(targetPath, 'utf8') : null;
|
|
15
|
+
|
|
16
|
+
if (source === target) {
|
|
17
|
+
return { changed: false, fileName };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (checkOnly) {
|
|
21
|
+
return { changed: true, fileName };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
|
25
|
+
fs.writeFileSync(targetPath, source);
|
|
26
|
+
return { changed: true, fileName };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function main() {
|
|
30
|
+
const checkOnly = process.argv.includes('--check');
|
|
31
|
+
const fileNames = fs.readdirSync(sourceDir).filter((file) => file === 'behavioral-test.md' || file === 'behavioral-test.lock.yml');
|
|
32
|
+
const results = fileNames.map((fileName) => syncFile(fileName, checkOnly));
|
|
33
|
+
const changed = results.filter((result) => result.changed);
|
|
34
|
+
|
|
35
|
+
if (checkOnly && changed.length > 0) {
|
|
36
|
+
console.error(`Agentic workflow files out of sync: ${changed.map((result) => result.fileName).join(', ')}`);
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (changed.length === 0) {
|
|
41
|
+
console.log('Agentic workflow files already in sync');
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
console.log(`Synced agentic workflow files: ${changed.map((result) => result.fileName).join(', ')}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
main();
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const { spawnSync } = require('node:child_process');
|
|
6
|
+
|
|
7
|
+
const rootDir = path.join(__dirname, '..');
|
|
8
|
+
const reportDir = path.join(rootDir, 'test-results');
|
|
9
|
+
const PROFILE_MAX_AGE_MS = 14 * 24 * 60 * 60 * 1000;
|
|
10
|
+
|
|
11
|
+
const DEFAULT_SMOKE_FILES = [
|
|
12
|
+
'test/cli-flags.test.js',
|
|
13
|
+
'test/commands/test.test.js',
|
|
14
|
+
'test/ci-workflow.test.js',
|
|
15
|
+
'test/detect-agent.test.js',
|
|
16
|
+
'test/scripts/test-runner.test.js',
|
|
17
|
+
'test/workflow-profiles.test.js',
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
function parseArgs(argv) {
|
|
21
|
+
const args = { label: 'unit', mode: 'shard' };
|
|
22
|
+
|
|
23
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
24
|
+
const current = argv[index];
|
|
25
|
+
const next = argv[index + 1];
|
|
26
|
+
|
|
27
|
+
if (current === '--mode') args.mode = next;
|
|
28
|
+
if (current === '--label') args.label = next;
|
|
29
|
+
if (current === '--shard-index') args.shardIndex = Number.parseInt(next, 10);
|
|
30
|
+
if (current === '--shard-total') args.shardTotal = Number.parseInt(next, 10);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return args;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function getUnitTestRoots() {
|
|
37
|
+
const roots = [path.join(rootDir, 'test')];
|
|
38
|
+
const packagesDir = path.join(rootDir, 'packages');
|
|
39
|
+
if (!fs.existsSync(packagesDir)) {
|
|
40
|
+
return roots.filter((dir) => fs.existsSync(dir));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
for (const entry of fs.readdirSync(packagesDir, { withFileTypes: true })) {
|
|
44
|
+
if (!entry.isDirectory()) continue;
|
|
45
|
+
const testRoot = path.join(packagesDir, entry.name, 'test');
|
|
46
|
+
if (fs.existsSync(testRoot)) {
|
|
47
|
+
roots.push(testRoot);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return roots;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function walkTests(dir) {
|
|
55
|
+
const results = [];
|
|
56
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
57
|
+
const absolute = path.join(dir, entry.name);
|
|
58
|
+
if (entry.isDirectory()) {
|
|
59
|
+
if (absolute === path.join(rootDir, 'test', 'e2e')) continue;
|
|
60
|
+
results.push(...walkTests(absolute));
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (!entry.name.endsWith('.test.js') && !entry.name.endsWith('.spec.js')) continue;
|
|
65
|
+
results.push(path.relative(rootDir, absolute).replace(/\\/g, '/'));
|
|
66
|
+
}
|
|
67
|
+
return results;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function listAllUnitTests() {
|
|
71
|
+
return getUnitTestRoots()
|
|
72
|
+
.flatMap((dir) => walkTests(dir))
|
|
73
|
+
.sort((left, right) => left.localeCompare(right));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function ensureFilesExist(files) {
|
|
77
|
+
return files.filter((file) => fs.existsSync(path.join(rootDir, file)));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function normalizePath(file) {
|
|
81
|
+
return (file || '').replace(/\\/g, '/').replace(/\/+/g, '/').replace(/^\.\//, '');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function selectModuloShard(files, shardIndex, shardTotal) {
|
|
85
|
+
if (!Number.isInteger(shardIndex) || !Number.isInteger(shardTotal) || shardTotal < 1) {
|
|
86
|
+
throw new Error(`Invalid shard args: index=${shardIndex} total=${shardTotal}`);
|
|
87
|
+
}
|
|
88
|
+
return files.filter((_, index) => index % shardTotal === shardIndex);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function listProfileFiles(dir) {
|
|
92
|
+
if (!fs.existsSync(dir)) return [];
|
|
93
|
+
return fs.readdirSync(dir)
|
|
94
|
+
.filter((entry) => entry.endsWith('.profile.json'))
|
|
95
|
+
.map((entry) => path.join(dir, entry));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function readNewestProfile(dir, { now = Date.now(), maxAgeMs = PROFILE_MAX_AGE_MS } = {}) {
|
|
99
|
+
const candidates = [];
|
|
100
|
+
for (const file of listProfileFiles(dir)) {
|
|
101
|
+
try {
|
|
102
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
103
|
+
const timestamp = Date.parse(parsed.timestamp || '');
|
|
104
|
+
if (!Number.isFinite(timestamp)) continue;
|
|
105
|
+
if ((now - timestamp) > maxAgeMs) continue;
|
|
106
|
+
candidates.push({ file, profile: parsed, timestamp });
|
|
107
|
+
} catch (_error) {
|
|
108
|
+
// Ignore invalid profile files and fall back to deterministic sharding.
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
candidates.sort((left, right) => right.timestamp - left.timestamp || left.file.localeCompare(right.file));
|
|
113
|
+
return candidates[0]?.profile || null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function createDurationMap(profile) {
|
|
117
|
+
const durationMap = new Map();
|
|
118
|
+
const entries = Array.isArray(profile?.allFileDurations) && profile.allFileDurations.length > 0
|
|
119
|
+
? profile.allFileDurations
|
|
120
|
+
: profile?.slowestFiles || [];
|
|
121
|
+
for (const entry of entries) {
|
|
122
|
+
if (!entry || typeof entry.file !== 'string') continue;
|
|
123
|
+
durationMap.set(normalizePath(entry.file), Number(entry.durationMs) || 0);
|
|
124
|
+
}
|
|
125
|
+
return durationMap;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function selectRuntimeBalancedShard(files, shardIndex, shardTotal, durationMap) {
|
|
129
|
+
if (!Number.isInteger(shardIndex) || !Number.isInteger(shardTotal) || shardTotal < 1) {
|
|
130
|
+
throw new Error(`Invalid shard args: index=${shardIndex} total=${shardTotal}`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const shards = Array.from({ length: shardTotal }, (_, index) => ({
|
|
134
|
+
files: [],
|
|
135
|
+
index,
|
|
136
|
+
totalDurationMs: 0,
|
|
137
|
+
}));
|
|
138
|
+
const weightedFiles = files
|
|
139
|
+
.map((file) => ({
|
|
140
|
+
durationMs: durationMap.get(normalizePath(file)) || 0,
|
|
141
|
+
file,
|
|
142
|
+
}))
|
|
143
|
+
.sort((left, right) => right.durationMs - left.durationMs || left.file.localeCompare(right.file));
|
|
144
|
+
|
|
145
|
+
for (const entry of weightedFiles) {
|
|
146
|
+
const targetShard = shards
|
|
147
|
+
.slice()
|
|
148
|
+
.sort((left, right) => left.totalDurationMs - right.totalDurationMs
|
|
149
|
+
|| left.files.length - right.files.length
|
|
150
|
+
|| left.index - right.index)[0];
|
|
151
|
+
targetShard.files.push(entry.file);
|
|
152
|
+
targetShard.totalDurationMs += entry.durationMs;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return shards[shardIndex].files.sort((left, right) => left.localeCompare(right));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function selectShard(files, shardIndex, shardTotal, durationMap = new Map()) {
|
|
159
|
+
if (durationMap.size === 0) {
|
|
160
|
+
return selectModuloShard(files, shardIndex, shardTotal);
|
|
161
|
+
}
|
|
162
|
+
return selectRuntimeBalancedShard(files, shardIndex, shardTotal, durationMap);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function getShardPlan(args, { allUnitTests, durationMap }) {
|
|
166
|
+
if (args.mode === 'smoke') {
|
|
167
|
+
return {
|
|
168
|
+
files: ensureFilesExist(DEFAULT_SMOKE_FILES),
|
|
169
|
+
source: 'smoke',
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const effectiveDurationMap = durationMap || createDurationMap(readNewestProfile(reportDir));
|
|
174
|
+
return {
|
|
175
|
+
files: selectShard(allUnitTests, args.shardIndex, args.shardTotal, effectiveDurationMap),
|
|
176
|
+
source: effectiveDurationMap && effectiveDurationMap.size > 0 ? 'runtime-balanced' : 'modulo',
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function runTests(label, files) {
|
|
181
|
+
fs.mkdirSync(reportDir, { recursive: true });
|
|
182
|
+
const junitPath = path.join(reportDir, `${label}.xml`);
|
|
183
|
+
const bunCommand = process.env.BUN_EXE || 'bun';
|
|
184
|
+
const result = spawnSync(bunCommand, [
|
|
185
|
+
'test',
|
|
186
|
+
'--reporter=junit',
|
|
187
|
+
'--reporter-outfile',
|
|
188
|
+
junitPath,
|
|
189
|
+
...files,
|
|
190
|
+
], {
|
|
191
|
+
cwd: rootDir,
|
|
192
|
+
stdio: 'inherit',
|
|
193
|
+
shell: process.platform === 'win32',
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
if (result.error) {
|
|
197
|
+
throw result.error;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return result.status ?? 1;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function main(argv = process.argv.slice(2)) {
|
|
204
|
+
const args = parseArgs(argv);
|
|
205
|
+
const allUnitTests = listAllUnitTests();
|
|
206
|
+
const plan = getShardPlan(args, { allUnitTests });
|
|
207
|
+
const files = plan.files;
|
|
208
|
+
|
|
209
|
+
if (files.length === 0) {
|
|
210
|
+
console.log(`No test files selected for ${args.label}`);
|
|
211
|
+
return 0;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
console.log(`Selected ${files.length} test file(s) for ${args.label} via ${plan.source}`);
|
|
215
|
+
for (const file of files) {
|
|
216
|
+
console.log(` - ${file}`);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return runTests(args.label, files);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (require.main === module) {
|
|
223
|
+
process.exit(main());
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
module.exports = {
|
|
227
|
+
DEFAULT_SMOKE_FILES,
|
|
228
|
+
PROFILE_MAX_AGE_MS,
|
|
229
|
+
createDurationMap,
|
|
230
|
+
ensureFilesExist,
|
|
231
|
+
getShardPlan,
|
|
232
|
+
listProfileFiles,
|
|
233
|
+
main,
|
|
234
|
+
normalizePath,
|
|
235
|
+
parseArgs,
|
|
236
|
+
readNewestProfile,
|
|
237
|
+
runTests,
|
|
238
|
+
getUnitTestRoots,
|
|
239
|
+
listAllUnitTests,
|
|
240
|
+
selectModuloShard,
|
|
241
|
+
selectRuntimeBalancedShard,
|
|
242
|
+
selectShard,
|
|
243
|
+
walkTests,
|
|
244
|
+
};
|
|
@@ -4,35 +4,59 @@
|
|
|
4
4
|
* Test Quality Dashboard — collects test metrics into a JSON summary.
|
|
5
5
|
*
|
|
6
6
|
* Usage:
|
|
7
|
-
* node scripts/test-dashboard.js
|
|
8
|
-
* node scripts/test-dashboard.js --json
|
|
7
|
+
* node scripts/test-dashboard.js
|
|
8
|
+
* node scripts/test-dashboard.js --json
|
|
9
|
+
* node scripts/test-dashboard.js --profiles-dir test-results/profiles
|
|
9
10
|
*/
|
|
10
11
|
|
|
11
12
|
const fs = require('node:fs');
|
|
12
13
|
const path = require('node:path');
|
|
13
|
-
const { execFileSync } = require('node:child_process');
|
|
14
14
|
|
|
15
15
|
const rootDir = path.join(__dirname, '..');
|
|
16
16
|
|
|
17
|
+
function parseArgs(argv) {
|
|
18
|
+
const args = {
|
|
19
|
+
json: argv.includes('--json'),
|
|
20
|
+
profilesDir: path.join(rootDir, 'test-results'),
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
24
|
+
if (argv[index] === '--profiles-dir') {
|
|
25
|
+
args.profilesDir = path.resolve(rootDir, argv[index + 1]);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return args;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function walk(dir, extension) {
|
|
33
|
+
if (!fs.existsSync(dir)) return [];
|
|
34
|
+
const results = [];
|
|
35
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
36
|
+
const absolute = path.join(dir, entry.name);
|
|
37
|
+
if (entry.isDirectory()) {
|
|
38
|
+
results.push(...walk(absolute, extension));
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (absolute.endsWith(extension)) {
|
|
42
|
+
results.push(absolute);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return results;
|
|
46
|
+
}
|
|
47
|
+
|
|
17
48
|
function getTestCount() {
|
|
18
|
-
// Count test cases by scanning test files for test() calls
|
|
19
|
-
// This avoids running the full test suite (which would be slow/recursive)
|
|
20
49
|
const testDirs = [
|
|
21
50
|
path.join(rootDir, 'test'),
|
|
22
51
|
path.join(rootDir, 'test-env'),
|
|
23
|
-
path.join(rootDir, 'packages', 'skills', 'test')
|
|
52
|
+
path.join(rootDir, 'packages', 'skills', 'test'),
|
|
24
53
|
];
|
|
25
54
|
let count = 0;
|
|
26
55
|
for (const dir of testDirs) {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const
|
|
31
|
-
if (!filePath.endsWith('.test.js') && !filePath.endsWith('.spec.js')) continue;
|
|
32
|
-
if (!fs.statSync(filePath).isFile()) continue;
|
|
33
|
-
const content = fs.readFileSync(filePath, 'utf-8');
|
|
34
|
-
// Count test() and it() calls (excluding test.skip)
|
|
35
|
-
const testMatches = content.match(/\btest\s*\(/g) || [];
|
|
56
|
+
for (const file of walk(dir, '.js')) {
|
|
57
|
+
if (!file.endsWith('.test.js') && !file.endsWith('.spec.js')) continue;
|
|
58
|
+
const content = fs.readFileSync(file, 'utf-8');
|
|
59
|
+
const testMatches = content.match(/\btest(?:\.skip|\.todo|\.if|\.skipIf)?\s*\(/g) || [];
|
|
36
60
|
const itMatches = content.match(/\bit\s*\(/g) || [];
|
|
37
61
|
const skipMatches = content.match(/\btest\.skip\s*\(/g) || [];
|
|
38
62
|
count += testMatches.length + itMatches.length - skipMatches.length;
|
|
@@ -46,24 +70,19 @@ function getCoverageThreshold() {
|
|
|
46
70
|
return pkg.c8 ? pkg.c8.lines : 0;
|
|
47
71
|
}
|
|
48
72
|
|
|
49
|
-
function getEslintWarnings() {
|
|
50
|
-
// We enforce --max-warnings 0 in pre-push hooks (lefthook.yml),
|
|
51
|
-
// so this is always 0 when code passes CI. Report 0 to avoid
|
|
52
|
-
// slow ESLint execution during dashboard generation.
|
|
53
|
-
return 0;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
73
|
function getSkippedTestCount() {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
74
|
+
const testDirs = [
|
|
75
|
+
path.join(rootDir, 'test'),
|
|
76
|
+
path.join(rootDir, 'test-env'),
|
|
77
|
+
path.join(rootDir, 'packages', 'skills', 'test'),
|
|
78
|
+
];
|
|
79
|
+
let skipped = 0;
|
|
80
|
+
for (const file of testDirs.flatMap((dir) => walk(dir, '.js'))) {
|
|
81
|
+
if (!file.endsWith('.test.js') && !file.endsWith('.spec.js')) continue;
|
|
82
|
+
const content = fs.readFileSync(file, 'utf8');
|
|
83
|
+
skipped += (content.match(/\btest\.skip\s*\(/g) || []).length;
|
|
66
84
|
}
|
|
85
|
+
return skipped;
|
|
67
86
|
}
|
|
68
87
|
|
|
69
88
|
function getMutationScore() {
|
|
@@ -71,37 +90,137 @@ function getMutationScore() {
|
|
|
71
90
|
if (!fs.existsSync(reportPath)) return null;
|
|
72
91
|
try {
|
|
73
92
|
const report = JSON.parse(fs.readFileSync(reportPath, 'utf-8'));
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
total++;
|
|
82
|
-
if (mutant.status === 'Killed') killed++;
|
|
83
|
-
}
|
|
93
|
+
const files = Object.values(report.files || {});
|
|
94
|
+
let killed = 0;
|
|
95
|
+
let total = 0;
|
|
96
|
+
for (const file of files) {
|
|
97
|
+
for (const mutant of (file.mutants || [])) {
|
|
98
|
+
total += 1;
|
|
99
|
+
if (mutant.status === 'Killed') killed += 1;
|
|
84
100
|
}
|
|
85
|
-
return total > 0 ? Math.round((killed / total) * 100) : null;
|
|
86
101
|
}
|
|
102
|
+
return total > 0 ? Math.round((killed / total) * 100) : null;
|
|
103
|
+
} catch (_error) {
|
|
87
104
|
return null;
|
|
88
|
-
} catch (_e) {
|
|
89
|
-
return null;
|
|
90
105
|
}
|
|
91
106
|
}
|
|
92
107
|
|
|
108
|
+
function getProfileMetrics(profilesDir) {
|
|
109
|
+
const files = walk(profilesDir, '.json').filter((file) => file.endsWith('.profile.json'));
|
|
110
|
+
if (files.length === 0) {
|
|
111
|
+
return {
|
|
112
|
+
suiteDurationMs: 0,
|
|
113
|
+
slowestFiles: [],
|
|
114
|
+
timedOutFiles: [],
|
|
115
|
+
integrationSkipped: true,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const slowest = new Map();
|
|
120
|
+
const timedOut = new Set();
|
|
121
|
+
let suiteDurationMs = 0;
|
|
122
|
+
let integrationSkipped = false;
|
|
123
|
+
|
|
124
|
+
for (const file of files) {
|
|
125
|
+
let profile;
|
|
126
|
+
try {
|
|
127
|
+
profile = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
128
|
+
} catch (error) {
|
|
129
|
+
console.warn(`Skipping malformed profile ${path.relative(rootDir, file)}: ${error.message}`);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (!profile || typeof profile !== 'object') {
|
|
134
|
+
console.warn(`Skipping invalid profile ${path.relative(rootDir, file)}: expected object`);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
suiteDurationMs += profile.suiteDurationMs || 0;
|
|
139
|
+
integrationSkipped = integrationSkipped || Boolean(profile.integrationSkipped);
|
|
140
|
+
|
|
141
|
+
const entries = Array.isArray(profile.allFileDurations) && profile.allFileDurations.length > 0
|
|
142
|
+
? profile.allFileDurations
|
|
143
|
+
: Array.isArray(profile.slowestFiles) ? profile.slowestFiles : [];
|
|
144
|
+
for (const entry of entries) {
|
|
145
|
+
if (!entry || typeof entry !== 'object' || typeof entry.file !== 'string') {
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
slowest.set(entry.file, Math.max(slowest.get(entry.file) || 0, entry.durationMs || 0));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
for (const timedOutFile of Array.isArray(profile.timedOutFiles) ? profile.timedOutFiles : []) {
|
|
152
|
+
if (typeof timedOutFile !== 'string' || !timedOutFile) {
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
timedOut.add(timedOutFile);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
suiteDurationMs,
|
|
161
|
+
slowestFiles: Array.from(slowest.entries())
|
|
162
|
+
.map(([file, durationMs]) => ({ file, durationMs }))
|
|
163
|
+
.sort((left, right) => right.durationMs - left.durationMs)
|
|
164
|
+
.slice(0, 10),
|
|
165
|
+
timedOutFiles: Array.from(timedOut).sort((left, right) => left.localeCompare(right)),
|
|
166
|
+
integrationSkipped,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function getBenchmarkMetrics(profilesDir) {
|
|
171
|
+
const files = walk(profilesDir, '.json').filter((file) => path.basename(file) === 'benchmark-results.json');
|
|
172
|
+
if (files.length === 0) {
|
|
173
|
+
return {
|
|
174
|
+
groupCount: 0,
|
|
175
|
+
groups: [],
|
|
176
|
+
slowestGroup: null,
|
|
177
|
+
totalMedianMs: 0,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
const benchmark = JSON.parse(fs.readFileSync(files.sort((left, right) => right.localeCompare(left))[0], 'utf8'));
|
|
183
|
+
const groups = Array.isArray(benchmark.groups)
|
|
184
|
+
? benchmark.groups.map((group) => ({
|
|
185
|
+
groupId: group.groupId,
|
|
186
|
+
groupLabel: group.groupLabel,
|
|
187
|
+
medianMs: group.medianMs || 0,
|
|
188
|
+
}))
|
|
189
|
+
: [];
|
|
190
|
+
return {
|
|
191
|
+
groupCount: groups.length,
|
|
192
|
+
groups,
|
|
193
|
+
slowestGroup: benchmark.slowestGroup || null,
|
|
194
|
+
totalMedianMs: benchmark.totalMedianMs || groups.reduce((sum, group) => sum + group.medianMs, 0),
|
|
195
|
+
};
|
|
196
|
+
} catch (_error) {
|
|
197
|
+
return {
|
|
198
|
+
groupCount: 0,
|
|
199
|
+
groups: [],
|
|
200
|
+
slowestGroup: null,
|
|
201
|
+
totalMedianMs: 0,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const args = parseArgs(process.argv.slice(2));
|
|
207
|
+
const profileMetrics = getProfileMetrics(args.profilesDir);
|
|
208
|
+
const benchmarkMetrics = getBenchmarkMetrics(args.profilesDir);
|
|
93
209
|
const dashboard = {
|
|
210
|
+
benchmarks: benchmarkMetrics,
|
|
94
211
|
testCount: getTestCount(),
|
|
95
212
|
coverageThreshold: getCoverageThreshold(),
|
|
96
|
-
eslintWarnings:
|
|
213
|
+
eslintWarnings: 0,
|
|
97
214
|
skippedTests: getSkippedTestCount(),
|
|
98
215
|
mutationScore: getMutationScore(),
|
|
99
|
-
|
|
216
|
+
suiteDurationMs: profileMetrics.suiteDurationMs,
|
|
217
|
+
slowestFiles: profileMetrics.slowestFiles,
|
|
218
|
+
timedOutFiles: profileMetrics.timedOutFiles,
|
|
219
|
+
integrationSkipped: profileMetrics.integrationSkipped,
|
|
220
|
+
timestamp: new Date().toISOString(),
|
|
100
221
|
};
|
|
101
222
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
if (jsonMode) {
|
|
223
|
+
if (args.json) {
|
|
105
224
|
process.stdout.write(JSON.stringify(dashboard));
|
|
106
225
|
} else {
|
|
107
226
|
console.log('\n Test Quality Dashboard');
|
|
@@ -110,14 +229,31 @@ if (jsonMode) {
|
|
|
110
229
|
console.log(` Coverage threshold: ${dashboard.coverageThreshold}%`);
|
|
111
230
|
console.log(` ESLint warnings: ${dashboard.eslintWarnings}`);
|
|
112
231
|
console.log(` Skipped tests: ${dashboard.skippedTests}`);
|
|
113
|
-
console.log(`
|
|
232
|
+
console.log(` Suite duration: ${dashboard.suiteDurationMs}ms`);
|
|
233
|
+
console.log(` Timed out files: ${dashboard.timedOutFiles.length}`);
|
|
234
|
+
console.log(` Integration skipped:${dashboard.integrationSkipped ? ' yes' : ' no'}`);
|
|
235
|
+
console.log(` Mutation score: ${dashboard.mutationScore !== null ? `${dashboard.mutationScore}%` : 'N/A (run test:mutation first)'}`);
|
|
236
|
+
if (dashboard.benchmarks.groupCount > 0) {
|
|
237
|
+
console.log(` Benchmarks: ${dashboard.benchmarks.groupCount} groups (${dashboard.benchmarks.totalMedianMs}ms median total)`);
|
|
238
|
+
}
|
|
114
239
|
console.log(` Generated: ${dashboard.timestamp}`);
|
|
240
|
+
if (dashboard.slowestFiles.length > 0) {
|
|
241
|
+
console.log('\n Slowest files:');
|
|
242
|
+
for (const entry of dashboard.slowestFiles) {
|
|
243
|
+
console.log(` - ${entry.file}: ${entry.durationMs}ms`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
if (dashboard.benchmarks.groupCount > 0) {
|
|
247
|
+
console.log('\n Benchmark medians:');
|
|
248
|
+
for (const entry of dashboard.benchmarks.groups) {
|
|
249
|
+
console.log(` - ${entry.groupLabel}: ${entry.medianMs}ms`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
115
252
|
console.log('');
|
|
116
253
|
|
|
117
|
-
// Write results file
|
|
118
254
|
const outputPath = path.join(rootDir, 'test-dashboard.json');
|
|
119
255
|
fs.writeFileSync(outputPath, JSON.stringify(dashboard, null, 2));
|
|
120
|
-
console.log(
|
|
256
|
+
console.log(' Results saved to: test-dashboard.json\n');
|
|
121
257
|
}
|
|
122
258
|
|
|
123
259
|
module.exports = dashboard;
|