forge-workflow 0.0.8 → 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 (135) hide show
  1. package/.claude/commands/dev.md +2 -2
  2. package/.claude/commands/plan.md +2 -2
  3. package/.claude/commands/premerge.md +2 -2
  4. package/.claude/commands/review.md +5 -2
  5. package/.claude/commands/ship.md +6 -5
  6. package/.claude/commands/status.md +4 -4
  7. package/.claude/rules/greptile-review-process.md +4 -4
  8. package/.cline/workflows/dev.md +2 -2
  9. package/.cline/workflows/plan.md +2 -2
  10. package/.cline/workflows/premerge.md +2 -2
  11. package/.cline/workflows/review.md +5 -2
  12. package/.cline/workflows/ship.md +6 -5
  13. package/.cline/workflows/status.md +4 -4
  14. package/.codex/skills/dev/SKILL.md +2 -2
  15. package/.codex/skills/plan/SKILL.md +2 -2
  16. package/.codex/skills/premerge/SKILL.md +2 -2
  17. package/.codex/skills/review/SKILL.md +5 -2
  18. package/.codex/skills/ship/SKILL.md +6 -5
  19. package/.codex/skills/status/SKILL.md +4 -4
  20. package/.cursor/commands/dev.md +2 -2
  21. package/.cursor/commands/plan.md +2 -2
  22. package/.cursor/commands/premerge.md +2 -2
  23. package/.cursor/commands/review.md +5 -2
  24. package/.cursor/commands/ship.md +6 -5
  25. package/.cursor/commands/status.md +4 -4
  26. package/.github/prompts/dev.prompt.md +2 -2
  27. package/.github/prompts/plan.prompt.md +2 -2
  28. package/.github/prompts/premerge.prompt.md +2 -2
  29. package/.github/prompts/review.prompt.md +5 -2
  30. package/.github/prompts/ship.prompt.md +6 -5
  31. package/.github/prompts/status.prompt.md +4 -4
  32. package/.github/workflows/beads-to-github.yml +44 -11
  33. package/.github/workflows/github-to-beads.yml +11 -8
  34. package/.kilocode/workflows/dev.md +2 -2
  35. package/.kilocode/workflows/plan.md +2 -2
  36. package/.kilocode/workflows/premerge.md +2 -2
  37. package/.kilocode/workflows/review.md +5 -2
  38. package/.kilocode/workflows/ship.md +6 -5
  39. package/.kilocode/workflows/status.md +4 -4
  40. package/.opencode/commands/dev.md +2 -2
  41. package/.opencode/commands/plan.md +2 -2
  42. package/.opencode/commands/premerge.md +2 -2
  43. package/.opencode/commands/review.md +5 -2
  44. package/.opencode/commands/ship.md +6 -5
  45. package/.opencode/commands/status.md +4 -4
  46. package/.roo/commands/dev.md +2 -2
  47. package/.roo/commands/plan.md +2 -2
  48. package/.roo/commands/premerge.md +2 -2
  49. package/.roo/commands/review.md +5 -2
  50. package/.roo/commands/ship.md +6 -5
  51. package/.roo/commands/status.md +4 -4
  52. package/AGENTS.md +10 -9
  53. package/CLAUDE.md +12 -0
  54. package/README.md +12 -6
  55. package/bin/forge.js +24 -8
  56. package/docs/BEADS_GITHUB_SYNC.md +32 -2
  57. package/docs/EXAMPLES.md +22 -22
  58. package/docs/ROADMAP.md +3 -3
  59. package/docs/TOOLCHAIN.md +150 -160
  60. package/lib/agents/codex.plugin.json +3 -0
  61. package/lib/agents-config.js +18 -12
  62. package/lib/beads-bootstrap.js +225 -0
  63. package/lib/beads-health-check.js +55 -10
  64. package/lib/beads-setup.js +104 -28
  65. package/lib/beads-sync-scaffold.js +11 -6
  66. package/lib/codex-skills.js +54 -1
  67. package/lib/commands/_issue.js +11 -1
  68. package/lib/commands/issues.js +49 -0
  69. package/lib/commands/plan.js +5 -2
  70. package/lib/commands/recommend.js +22 -1
  71. package/lib/commands/setup.js +247 -27
  72. package/lib/commands/ship.js +188 -5
  73. package/lib/commands/status.js +187 -19
  74. package/lib/commands/team.js +11 -1
  75. package/lib/commands/test.js +125 -25
  76. package/lib/commands/validate.js +232 -9
  77. package/lib/commands/worktree.js +27 -54
  78. package/lib/dep-guard/keyword-ripple.js +184 -0
  79. package/lib/detect-worktree.js +9 -10
  80. package/lib/forge-issues.js +326 -0
  81. package/lib/issue-sync/authority.js +100 -0
  82. package/lib/issue-sync/github-pull.js +184 -0
  83. package/lib/issue-sync/import-primitives.js +98 -0
  84. package/lib/issue-sync/legacy-link-bridge.js +436 -0
  85. package/lib/issue-sync/link-store.js +292 -0
  86. package/lib/issue-sync/project-github.js +123 -0
  87. package/lib/issue-sync/reconcile.js +195 -0
  88. package/lib/issue-sync/schema.js +126 -0
  89. package/lib/lefthook-check.js +5 -2
  90. package/lib/project-memory.js +564 -0
  91. package/lib/runtime-health.js +100 -12
  92. package/lib/setup-action-log.js +2 -0
  93. package/lib/setup-summary-renderer.js +15 -11
  94. package/lib/smart-status/conflicts.js +205 -0
  95. package/lib/smart-status/scoring.js +177 -0
  96. package/lib/status/beads-snapshot.js +102 -0
  97. package/lib/status/presenter.js +65 -0
  98. package/lib/workflow/enforce-stage.js +14 -8
  99. package/lib/workflow/state-manager.js +349 -0
  100. package/package.json +12 -4
  101. package/scripts/beads-context.sh +124 -5
  102. package/scripts/beads-context.test.js +21 -4
  103. package/scripts/beads-migrate-to-dolt.sh +7 -0
  104. package/scripts/beads-upgrade-smoke.sh +263 -0
  105. package/scripts/behavioral-judge.sh +115 -11
  106. package/scripts/benchmark.js +349 -63
  107. package/scripts/dep-guard-analyze.js +52 -17
  108. package/scripts/dep-guard-keyword-ripple.js +29 -0
  109. package/scripts/dep-guard-render-review.js +86 -0
  110. package/scripts/dep-guard.sh +45 -222
  111. package/scripts/forge-team/lib/hooks.sh +1 -1
  112. package/scripts/forge-team/lib/sync-github.sh +160 -28
  113. package/scripts/forge-team/lib/verify.sh +1 -1
  114. package/scripts/forge-team/lib/workload.sh +56 -27
  115. package/scripts/forge-team/tests/sync-github.test.sh +195 -58
  116. package/scripts/forge-team/tests/workload.test.sh +35 -4
  117. package/scripts/github-beads-sync/index.mjs +122 -98
  118. package/scripts/github-beads-sync/mapping.mjs +54 -0
  119. package/scripts/github-beads-sync/reverse-sync-cli.mjs +2 -2
  120. package/scripts/github-beads-sync/reverse-sync.mjs +31 -7
  121. package/scripts/github-beads-sync/run-bd.mjs +4 -2
  122. package/scripts/lib/beads-migrate-to-dolt.mjs +503 -0
  123. package/scripts/preflight.sh +181 -0
  124. package/scripts/smart-status-score.js +31 -0
  125. package/scripts/smart-status-sessions.js +51 -0
  126. package/scripts/smart-status.sh +84 -330
  127. package/scripts/sync-agentic-workflow.js +48 -0
  128. package/scripts/sync-utils.sh +39 -0
  129. package/scripts/test-ci-shard.js +244 -0
  130. package/scripts/test-dashboard.js +188 -52
  131. package/scripts/test-full-suite.js +186 -0
  132. package/scripts/test-profile.js +278 -0
  133. package/scripts/test.js +335 -38
  134. package/scripts/validate.js +143 -0
  135. 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();
@@ -271,16 +271,55 @@ _run_sync() {
271
271
  fi
272
272
  }
273
273
 
274
+ _has_dolt_remote() {
275
+ local remote_name="${1:-origin}"
276
+ local remote_list=""
277
+ local raw_output=""
278
+ local bd_cmd="${BD_CMD:-bd}"
279
+ local remote_status=0
280
+
281
+ if ! command -v "$bd_cmd" >/dev/null 2>&1; then
282
+ return 2
283
+ fi
284
+
285
+ raw_output="$("$bd_cmd" dolt remote list 2>/dev/null)"
286
+ remote_status=$?
287
+ if [[ "$remote_status" -ne 0 ]]; then
288
+ return 2
289
+ fi
290
+ remote_list="$(printf '%s' "$raw_output" | tr -d '\r')"
291
+
292
+ if [[ -z "$remote_list" ]]; then
293
+ return 1
294
+ fi
295
+
296
+ printf '%s\n' "$remote_list" | awk 'NF { print $1 }' | grep -Fqx -- "$remote_name"
297
+ }
298
+
274
299
  # Environment overrides (for testing):
275
300
  # BD_SYNC_CMD — single command to run instead of default pull+push (e.g. "echo mock-sync")
276
301
  # FILE_INDEX_ROOT — root directory for file-index.sh (default: repo_dir)
277
302
  auto_sync() {
278
303
  local repo_dir="${1:-.}"
279
304
  local last_sync_file="$repo_dir/.beads/.last-sync"
305
+ local sync_remote
306
+ sync_remote="$(get_sync_remote "$repo_dir")"
280
307
 
281
308
  # Ensure .beads directory exists
282
309
  mkdir -p "$repo_dir/.beads"
283
310
 
311
+ if _has_dolt_remote "$sync_remote"; then
312
+ :
313
+ else
314
+ local remote_status=$?
315
+ if [[ "$remote_status" -eq 2 ]]; then
316
+ echo "Warning: sync skipped, unable to inspect Beads Dolt remotes (is 'bd' installed and configured?)." >&2
317
+ else
318
+ echo "Warning: sync skipped, Beads Dolt remote '$sync_remote' is not configured (run 'bd dolt remote add $sync_remote <url>')." >&2
319
+ fi
320
+ return 0
321
+ fi
322
+
284
323
  # Run sync — helper function avoids eval while supporting compound default
285
324
  if _run_sync >/dev/null 2>&1; then
286
325
  # Success: record timestamp
@@ -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;