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.
Files changed (102) hide show
  1. package/.claude/commands/dev.md +2 -2
  2. package/.claude/commands/plan.md +2 -2
  3. package/.claude/commands/ship.md +2 -2
  4. package/.claude/commands/status.md +4 -4
  5. package/.cline/workflows/dev.md +2 -2
  6. package/.cline/workflows/plan.md +2 -2
  7. package/.cline/workflows/ship.md +2 -2
  8. package/.cline/workflows/status.md +4 -4
  9. package/.codex/skills/dev/SKILL.md +2 -2
  10. package/.codex/skills/plan/SKILL.md +2 -2
  11. package/.codex/skills/ship/SKILL.md +2 -2
  12. package/.codex/skills/status/SKILL.md +4 -4
  13. package/.cursor/commands/dev.md +2 -2
  14. package/.cursor/commands/plan.md +2 -2
  15. package/.cursor/commands/ship.md +2 -2
  16. package/.cursor/commands/status.md +4 -4
  17. package/.github/prompts/dev.prompt.md +2 -2
  18. package/.github/prompts/plan.prompt.md +2 -2
  19. package/.github/prompts/ship.prompt.md +2 -2
  20. package/.github/prompts/status.prompt.md +4 -4
  21. package/.github/workflows/beads-to-github.yml +43 -10
  22. package/.github/workflows/github-to-beads.yml +10 -7
  23. package/.kilocode/workflows/dev.md +2 -2
  24. package/.kilocode/workflows/plan.md +2 -2
  25. package/.kilocode/workflows/ship.md +2 -2
  26. package/.kilocode/workflows/status.md +4 -4
  27. package/.opencode/commands/dev.md +2 -2
  28. package/.opencode/commands/plan.md +2 -2
  29. package/.opencode/commands/ship.md +2 -2
  30. package/.opencode/commands/status.md +4 -4
  31. package/.roo/commands/dev.md +2 -2
  32. package/.roo/commands/plan.md +2 -2
  33. package/.roo/commands/ship.md +2 -2
  34. package/.roo/commands/status.md +4 -4
  35. package/AGENTS.md +1 -0
  36. package/CLAUDE.md +12 -0
  37. package/bin/forge.js +10 -5
  38. package/docs/BEADS_GITHUB_SYNC.md +26 -0
  39. package/docs/TOOLCHAIN.md +130 -148
  40. package/lib/beads-bootstrap.js +225 -0
  41. package/lib/beads-health-check.js +55 -10
  42. package/lib/beads-setup.js +104 -28
  43. package/lib/beads-sync-scaffold.js +11 -6
  44. package/lib/commands/_issue.js +11 -1
  45. package/lib/commands/issues.js +49 -0
  46. package/lib/commands/recommend.js +22 -1
  47. package/lib/commands/setup.js +16 -10
  48. package/lib/commands/status.js +181 -0
  49. package/lib/commands/team.js +11 -1
  50. package/lib/commands/test.js +37 -2
  51. package/lib/commands/validate.js +14 -8
  52. package/lib/commands/worktree.js +27 -54
  53. package/lib/dep-guard/keyword-ripple.js +184 -0
  54. package/lib/detect-worktree.js +9 -10
  55. package/lib/forge-issues.js +326 -0
  56. package/lib/issue-sync/authority.js +100 -0
  57. package/lib/issue-sync/github-pull.js +184 -0
  58. package/lib/issue-sync/import-primitives.js +98 -0
  59. package/lib/issue-sync/legacy-link-bridge.js +436 -0
  60. package/lib/issue-sync/link-store.js +292 -0
  61. package/lib/issue-sync/project-github.js +123 -0
  62. package/lib/issue-sync/reconcile.js +195 -0
  63. package/lib/issue-sync/schema.js +126 -0
  64. package/lib/lefthook-check.js +5 -2
  65. package/lib/project-memory.js +564 -0
  66. package/lib/runtime-health.js +100 -12
  67. package/lib/smart-status/conflicts.js +205 -0
  68. package/lib/smart-status/scoring.js +177 -0
  69. package/lib/status/beads-snapshot.js +102 -0
  70. package/lib/status/presenter.js +65 -0
  71. package/lib/workflow/enforce-stage.js +3 -1
  72. package/lib/workflow/state-manager.js +164 -8
  73. package/package.json +12 -4
  74. package/scripts/beads-context.sh +124 -5
  75. package/scripts/beads-context.test.js +21 -4
  76. package/scripts/beads-migrate-to-dolt.sh +7 -0
  77. package/scripts/beads-upgrade-smoke.sh +263 -0
  78. package/scripts/behavioral-judge.sh +115 -11
  79. package/scripts/benchmark.js +349 -63
  80. package/scripts/dep-guard-analyze.js +52 -17
  81. package/scripts/dep-guard-keyword-ripple.js +29 -0
  82. package/scripts/dep-guard-render-review.js +86 -0
  83. package/scripts/dep-guard.sh +45 -232
  84. package/scripts/forge-team/lib/sync-github.sh +160 -28
  85. package/scripts/forge-team/tests/sync-github.test.sh +195 -58
  86. package/scripts/github-beads-sync/index.mjs +122 -98
  87. package/scripts/github-beads-sync/mapping.mjs +54 -0
  88. package/scripts/github-beads-sync/reverse-sync-cli.mjs +2 -2
  89. package/scripts/github-beads-sync/reverse-sync.mjs +31 -7
  90. package/scripts/lib/beads-migrate-to-dolt.mjs +503 -0
  91. package/scripts/preflight.sh +181 -0
  92. package/scripts/smart-status-score.js +31 -0
  93. package/scripts/smart-status-sessions.js +51 -0
  94. package/scripts/smart-status.sh +74 -329
  95. package/scripts/sync-agentic-workflow.js +48 -0
  96. package/scripts/test-ci-shard.js +244 -0
  97. package/scripts/test-dashboard.js +188 -52
  98. package/scripts/test-full-suite.js +186 -0
  99. package/scripts/test-profile.js +278 -0
  100. package/scripts/test.js +219 -28
  101. package/scripts/validate.js +143 -0
  102. 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 # Human-readable output
8
- * node scripts/test-dashboard.js --json # JSON output (for CI/tests)
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
- if (!fs.existsSync(dir)) continue;
28
- const files = fs.readdirSync(dir, { recursive: true });
29
- for (const file of files) {
30
- const filePath = path.join(dir, String(file));
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
- try {
58
- const output = execFileSync('grep', ['-r', 'test.skip', 'test/', 'test-env/'], {
59
- cwd: rootDir,
60
- encoding: 'utf-8',
61
- stdio: ['pipe', 'pipe', 'pipe']
62
- });
63
- return output.trim().split('\n').filter(Boolean).length;
64
- } catch (_e) {
65
- return 0;
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
- if (report.schemaVersion) {
75
- // Stryker v8+ JSON report format
76
- const files = Object.values(report.files || {});
77
- let killed = 0;
78
- let total = 0;
79
- for (const file of files) {
80
- for (const mutant of (file.mutants || [])) {
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: getEslintWarnings(),
213
+ eslintWarnings: 0,
97
214
  skippedTests: getSkippedTestCount(),
98
215
  mutationScore: getMutationScore(),
99
- timestamp: new Date().toISOString()
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
- const jsonMode = process.argv.includes('--json');
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(` Mutation score: ${dashboard.mutationScore !== null ? dashboard.mutationScore + '%' : 'N/A (run test:mutation first)'}`);
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(` Results saved to: test-dashboard.json\n`);
256
+ console.log(' Results saved to: test-dashboard.json\n');
121
257
  }
122
258
 
123
259
  module.exports = dashboard;