forge-workflow 0.1.0-beta.4 → 0.1.0-beta.5

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 (119) hide show
  1. package/AGENTS.md +14 -7
  2. package/CHANGELOG.md +43 -1
  3. package/README.md +6 -2
  4. package/bin/forge-cmd.js +20 -0
  5. package/bin/forge.js +16 -374
  6. package/docs/INDEX.md +1 -1
  7. package/docs/guides/BEADS_GITHUB_SYNC.md +2 -31
  8. package/docs/guides/MIGRATION.md +4 -4
  9. package/docs/guides/SETUP.md +16 -16
  10. package/docs/reference/COMMANDS.md +8 -5
  11. package/docs/reference/INSIGHTS_RECAP.md +9 -20
  12. package/docs/reference/RELEASE.md +5 -3
  13. package/docs/reference/TOOLCHAIN.md +8 -0
  14. package/docs/reference/protected-state-surfaces.md +4 -4
  15. package/docs/reference/shepherd.md +54 -25
  16. package/lefthook.yml +12 -0
  17. package/lib/activation/ensure-forge-home.js +33 -15
  18. package/lib/adapters/pr-state-adapter.js +344 -142
  19. package/lib/audit-evidence.js +71 -110
  20. package/lib/capped-jsonl-log.js +236 -0
  21. package/lib/commands/_registry.js +2 -2
  22. package/lib/commands/clean.js +196 -32
  23. package/lib/commands/dev.js +4 -33
  24. package/lib/commands/hooks.js +223 -25
  25. package/lib/commands/insights.js +8 -3
  26. package/lib/commands/merge.js +600 -40
  27. package/lib/commands/pr.js +1 -1
  28. package/lib/commands/preflight.js +11 -2
  29. package/lib/commands/prime.js +21 -8
  30. package/lib/commands/push.js +41 -51
  31. package/lib/commands/recall.js +60 -16
  32. package/lib/commands/recap.js +6 -1
  33. package/lib/commands/release.js +17 -2
  34. package/lib/commands/setup.js +191 -94
  35. package/lib/commands/shepherd.js +13 -1
  36. package/lib/commands/ship.js +22 -23
  37. package/lib/commands/skill.js +119 -11
  38. package/lib/commands/status.js +17 -1
  39. package/lib/commands/test.js +24 -34
  40. package/lib/commands/worktree.js +220 -42
  41. package/lib/core/runtime-graph.js +1 -1
  42. package/lib/doc-assertions.js +297 -0
  43. package/lib/existing-tdd-gate.js +253 -0
  44. package/lib/forge-context.js +1 -4
  45. package/lib/forge-issues.js +56 -32
  46. package/lib/git-defaults.js +56 -0
  47. package/lib/harness-capability-matrix.js +3 -3
  48. package/lib/hook-renderer.js +93 -4
  49. package/lib/insights.js +96 -80
  50. package/lib/kernel/backing-issue.js +14 -2
  51. package/lib/kernel/broker.js +16 -0
  52. package/lib/kernel/cli-broker-factory.js +12 -1
  53. package/lib/kernel/close-on-merge.js +154 -0
  54. package/lib/kernel/fs-class.js +42 -25
  55. package/lib/kernel/sqlite-driver.js +153 -29
  56. package/lib/lefthook-wiring.js +21 -1
  57. package/lib/memory/router.js +16 -1
  58. package/lib/memory-digest.js +47 -15
  59. package/lib/memory-recall-events.js +145 -0
  60. package/lib/memory-recall.js +71 -10
  61. package/lib/merge-rules.js +8 -4
  62. package/lib/npm-publish-workflow.js +272 -0
  63. package/lib/orientation.js +68 -43
  64. package/lib/plugin-catalog.js +14 -4
  65. package/lib/pr-bundle.js +5 -6
  66. package/lib/pr-monitor/journal.js +18 -2
  67. package/lib/pr-monitor/reconcile-executor.js +224 -41
  68. package/lib/pr-monitor/render-summary.js +196 -0
  69. package/lib/pr-monitor/shepherd-lease.js +10 -1
  70. package/lib/pr-monitor/watch-lifecycle.js +13 -1
  71. package/lib/pr-pull.js +33 -14
  72. package/lib/pr-shepherd.js +34 -8
  73. package/lib/preflight/gates.js +65 -18
  74. package/lib/preflight/runner.js +5 -0
  75. package/lib/project-memory.js +33 -1
  76. package/lib/protected-state-authority.js +305 -0
  77. package/lib/protected-state-surfaces.js +64 -44
  78. package/lib/release-readiness.js +51 -4
  79. package/lib/shell-utils.js +1 -1
  80. package/lib/skills-sync.js +6 -3
  81. package/lib/smart-merge.js +28 -4
  82. package/lib/symlink-utils.js +74 -26
  83. package/lib/upgrade-safety.js +39 -0
  84. package/lib/using-forge.js +19 -6
  85. package/package.json +6 -7
  86. package/scripts/doc-asserting-tests.js +158 -0
  87. package/scripts/lib/behavioral-eval-runner.js +310 -0
  88. package/scripts/lib/behavioral-eval-runtime.js +456 -0
  89. package/scripts/lib/eval-evidence.js +328 -0
  90. package/scripts/lib/eval-runner.js +81 -41
  91. package/scripts/lib/immutable-eval-corpus.js +309 -0
  92. package/scripts/lib/promotion-evidence-loader.js +94 -0
  93. package/scripts/lib/promotion-scorecard.js +314 -0
  94. package/scripts/npm-release-receipt.js +134 -0
  95. package/scripts/process-tree.js +761 -0
  96. package/scripts/protected-state-check.js +47 -22
  97. package/scripts/run-command-eval.js +29 -1
  98. package/scripts/sync-d20-audit.js +172 -0
  99. package/scripts/test-full-suite.js +249 -37
  100. package/scripts/test.js +176 -43
  101. package/skills/review/SKILL.md +4 -11
  102. package/skills/review/evals/scorecard.json +3 -3
  103. package/skills/rollback/SKILL.md +4 -11
  104. package/skills/rollback/evals/scorecard.json +3 -3
  105. package/skills/shepherd/SKILL.md +20 -14
  106. package/skills/shepherd/evals/scorecard.json +2 -2
  107. package/skills/ship/SKILL.md +4 -12
  108. package/skills/ship/evals/scorecard.json +3 -3
  109. package/skills/worktree/SKILL.md +6 -1
  110. package/skills/worktree/evals/scorecard.json +2 -2
  111. package/lib/beads-setup.js +0 -538
  112. package/lib/beads-sync-scaffold.js +0 -189
  113. package/lib/pat-setup.js +0 -207
  114. package/lib/pr-monitor/render-sticky.js +0 -206
  115. package/lib/pr-monitor/upsert-sticky.js +0 -169
  116. package/scripts/beads-context.sh +0 -577
  117. package/scripts/beads-migrate-to-dolt.sh +0 -7
  118. package/scripts/beads-upgrade-smoke.sh +0 -284
  119. package/scripts/lib/beads-migrate-to-dolt.mjs +0 -503
@@ -18,6 +18,40 @@ const path = require('node:path');
18
18
  const HEADER_COMMENT =
19
19
  '<!-- This file is a copy of AGENTS.md. Keep in sync manually or use: bunx forge setup --symlink -->';
20
20
 
21
+ const SYMLINK_FALLBACK_ERRORS = new Set(['EACCES', 'ENOSYS', 'ENOTSUP', 'EOPNOTSUPP', 'EPERM']);
22
+
23
+ function inspectExistingDestination(target, linkPath) {
24
+ let stat;
25
+ try {
26
+ stat = fs.lstatSync(linkPath);
27
+ } catch (err) {
28
+ if (err.code === 'ENOENT') return null;
29
+ throw err;
30
+ }
31
+
32
+ if (stat.isSymbolicLink()) {
33
+ let linkedTarget;
34
+ try {
35
+ linkedTarget = fs.readlinkSync(linkPath);
36
+ } catch (err) {
37
+ if (err.code === 'ENOENT') return null;
38
+ throw err;
39
+ }
40
+ if (path.resolve(path.dirname(linkPath), linkedTarget) === path.resolve(target)) {
41
+ return 'linked';
42
+ }
43
+ }
44
+ if (stat.isDirectory()) {
45
+ console.warn(` Warning: Skipped ${linkPath} because it is a directory. Remove it manually and re-run setup.`);
46
+ return '';
47
+ }
48
+ if (stat.isFile() && fs.readFileSync(linkPath, 'utf8').trim() === '@AGENTS.md') {
49
+ return 'existing-import';
50
+ }
51
+ console.warn(` Warning: Skipped ${linkPath} because an existing destination must be preserved.`);
52
+ return '';
53
+ }
54
+
21
55
  /**
22
56
  * Create a symlink from `linkPath` pointing to `target`.
23
57
  * If symlink creation fails (e.g., EPERM on Windows without admin),
@@ -28,7 +62,7 @@ const HEADER_COMMENT =
28
62
  * @param {string} linkPath - Absolute path for the symlink/copy (e.g., CLAUDE.md)
29
63
  * @param {Object} [options={}] - Options
30
64
  * @param {boolean} [options.symlinkOnly=false] - When true, skip copy fallback (--symlink flag)
31
- * @returns {'linked'|'copied'|''} Result indicator
65
+ * @returns {'linked'|'copied'|'existing-import'|''} Result indicator
32
66
  */
33
67
  function createSymlinkOrCopy(target, linkPath, options = {}) {
34
68
  try {
@@ -38,17 +72,9 @@ function createSymlinkOrCopy(target, linkPath, options = {}) {
38
72
  return '';
39
73
  }
40
74
 
41
- // Remove existing file/symlink at linkPath
42
- if (fs.existsSync(linkPath)) {
43
- const stat = fs.lstatSync(linkPath);
44
- if (stat.isDirectory()) {
45
- console.warn(
46
- ` ⚠ Skipped ${linkPath} (a directory exists at this path). Remove it manually and re-run setup.`
47
- );
48
- return '';
49
- }
50
- fs.unlinkSync(linkPath);
51
- }
75
+ // Existing agent files belong to the user. lstat also sees dangling links.
76
+ const existing = inspectExistingDestination(target, linkPath);
77
+ if (existing !== null) return existing;
52
78
 
53
79
  // Ensure parent directory exists
54
80
  const linkDir = path.dirname(linkPath);
@@ -56,22 +82,44 @@ function createSymlinkOrCopy(target, linkPath, options = {}) {
56
82
  fs.mkdirSync(linkDir, { recursive: true });
57
83
  }
58
84
 
59
- // Attempt symlink (relative path for portability)
60
- try {
61
- const relPath = path.relative(linkDir, target);
62
- fs.symlinkSync(relPath, linkPath);
63
- return 'linked';
64
- } catch (_symlinkErr) {
65
- // Expected: symlink creation fails with EPERM on Windows without admin privileges — fall back to copy
66
- if (options.symlinkOnly) {
67
- console.warn(` ⚠ Symlink failed for ${linkPath} (--symlink requires symlink support)`);
68
- return '';
85
+ for (let attempt = 0; attempt < 2; attempt += 1) {
86
+ // Attempt symlink (relative path for portability)
87
+ try {
88
+ const relPath = path.relative(linkDir, target);
89
+ fs.symlinkSync(relPath, linkPath);
90
+ return 'linked';
91
+ } catch (symlinkErr) {
92
+ if (symlinkErr.code === 'EEXIST') {
93
+ const racedExisting = inspectExistingDestination(target, linkPath);
94
+ if (racedExisting !== null) return racedExisting;
95
+ continue;
96
+ }
97
+ if (!SYMLINK_FALLBACK_ERRORS.has(symlinkErr.code)) {
98
+ throw symlinkErr;
99
+ }
100
+ if (options.symlinkOnly) {
101
+ console.warn(` ⚠ Symlink failed for ${linkPath} (--symlink requires symlink support)`);
102
+ return '';
103
+ }
104
+ const content = fs.readFileSync(target, 'utf-8');
105
+ try {
106
+ fs.writeFileSync(linkPath, HEADER_COMMENT + '\n' + content, {
107
+ encoding: 'utf8',
108
+ flag: 'wx',
109
+ });
110
+ return 'copied';
111
+ } catch (copyErr) {
112
+ if (copyErr.code === 'EEXIST') {
113
+ const racedExisting = inspectExistingDestination(target, linkPath);
114
+ if (racedExisting !== null) return racedExisting;
115
+ continue;
116
+ }
117
+ throw copyErr;
118
+ }
69
119
  }
70
- // Fall back to copy with header
71
- const content = fs.readFileSync(target, 'utf-8');
72
- fs.writeFileSync(linkPath, HEADER_COMMENT + '\n' + content, 'utf-8');
73
- return 'copied';
74
120
  }
121
+ console.warn(` ⚠ Could not create ${linkPath} after repeated conflicts; please re-run setup.`);
122
+ return '';
75
123
  } catch (err) {
76
124
  console.error(` ✗ Failed to link/copy ${target} -> ${linkPath}: ${err.message}`);
77
125
  return '';
@@ -8,6 +8,14 @@ const { resolvePatchIntentRecords } = require('./patch-intent');
8
8
  const { verifyForgeLock, readForgeLock } = require('./forge-lock');
9
9
  const { readConfigBackend, resolveIssueBackend } = require('./issue-backend');
10
10
  const { detectBeadsJsonlSource } = require('./beads-detect');
11
+ const {
12
+ FORGE_HOOK_CONTRACT,
13
+ hasForgeClaudeHooks,
14
+ hasMultipleHardLinks,
15
+ HookConfigParseError,
16
+ renderHookConfig,
17
+ } = require('./hook-renderer');
18
+ const { assertNoAncestorSymlinkEscape, assertNoSymlinkEscape } = require('./protected-state-surfaces');
11
19
 
12
20
  function checkStatus(ok) {
13
21
  return ok ? 'pass' : 'fail';
@@ -55,6 +63,31 @@ function buildSelfHealCandidates(projectRoot) {
55
63
  description: 'Create missing Forge audit log file',
56
64
  });
57
65
  }
66
+ const claudeDir = path.join(projectRoot, '.claude');
67
+ if (fs.existsSync(claudeDir)) {
68
+ const claudeSettings = path.join(claudeDir, 'settings.json');
69
+ const unsafePath =
70
+ assertNoAncestorSymlinkEscape(projectRoot, claudeSettings) ||
71
+ assertNoSymlinkEscape(projectRoot, claudeSettings);
72
+ if (unsafePath || hasMultipleHardLinks(claudeSettings)) return candidates;
73
+ const existing = fs.existsSync(claudeSettings) ? fs.readFileSync(claudeSettings, 'utf8') : '';
74
+ try {
75
+ if (!hasForgeClaudeHooks(existing, FORGE_HOOK_CONTRACT)) {
76
+ candidates.push({
77
+ id: 'claude-hooks',
78
+ path: '.claude/settings.json',
79
+ description: 'Merge missing Forge-owned Claude lifecycle hooks',
80
+ });
81
+ }
82
+ } catch (error) {
83
+ if (!(error instanceof HookConfigParseError)) throw error;
84
+ candidates.push({
85
+ id: 'claude-hooks',
86
+ path: '.claude/settings.json',
87
+ description: 'Back up malformed Claude settings before hook repair',
88
+ });
89
+ }
90
+ }
58
91
  return candidates;
59
92
  }
60
93
 
@@ -235,6 +268,12 @@ function applySelfHeal(projectRoot, report) {
235
268
  applied.push({ path: '.forge/log.jsonl' });
236
269
  }
237
270
 
271
+ if (report.selfHealCandidates.some(candidate => candidate.id === 'claude-hooks')) {
272
+ const result = renderHookConfig({ harness: 'claude', targetRoot: projectRoot });
273
+ if (result.wrote) applied.push({ path: '.claude/settings.json' });
274
+ else if (result.backup) applied.push({ path: path.relative(projectRoot, result.backup) });
275
+ }
276
+
238
277
  return {
239
278
  refused: false,
240
279
  applied,
@@ -100,10 +100,10 @@ function applyFrontmatterLine(state, line) {
100
100
  if (state.inDescription) {
101
101
  if (/^\s+\S/.test(line)) {
102
102
  state.descParts.push(line.trim());
103
- } else if (line.trim() !== '') {
104
- state.inDescription = false;
103
+ return;
105
104
  }
106
- return;
105
+ if (line.trim() === '') return;
106
+ state.inDescription = false;
107
107
  }
108
108
  // Detect the `name:` key without a regex: SonarCloud flags every /^name:.../ variant for
109
109
  // super-linear backtracking. startsWith + slice is behavior-identical — the remainder is
@@ -113,6 +113,15 @@ function applyFrontmatterLine(state, line) {
113
113
  if (value) state.name = unquote(value);
114
114
  return;
115
115
  }
116
+ if (line.startsWith('invocation:')) {
117
+ const value = line.slice('invocation:'.length).trim();
118
+ const quote = value[0];
119
+ const hasMatchingQuotes = value.length >= 2
120
+ && (quote === '"' || quote === "'")
121
+ && value.endsWith(quote);
122
+ state.invocation = hasMatchingQuotes ? value.slice(1, -1) : value;
123
+ return;
124
+ }
116
125
  const descMatch = /^description:\s*(.*)$/.exec(line);
117
126
  if (descMatch) {
118
127
  state.inDescription = true;
@@ -124,10 +133,14 @@ function applyFrontmatterLine(state, line) {
124
133
  /** Parse the name and (flattened) description from a SKILL.md frontmatter block. */
125
134
  function parseFrontmatter(raw) {
126
135
  const block = frontmatterBlock(raw);
127
- if (block === null) return { name: null, description: '' };
128
- const state = { name: null, descParts: [], inDescription: false };
136
+ if (block === null) return { name: null, description: '', invocation: 'model' };
137
+ const state = { name: null, descParts: [], inDescription: false, invocation: 'model' };
129
138
  for (const line of block.split(/\r?\n/)) applyFrontmatterLine(state, line);
130
- return { name: state.name, description: state.descParts.join(' ').replace(/\s+/g, ' ').trim() };
139
+ return {
140
+ name: state.name,
141
+ description: state.descParts.join(' ').replace(/\s+/g, ' ').trim(),
142
+ invocation: state.invocation,
143
+ };
131
144
  }
132
145
 
133
146
  /** Read one skill dir into a `{ name, description }` catalog entry, or null when unreadable. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forge-workflow",
3
- "version": "0.1.0-beta.4",
3
+ "version": "0.1.0-beta.5",
4
4
  "description": "Local runtime control plane for AI-assisted engineering workflows, gates, evidence, and all AI agents",
5
5
  "bin": {
6
6
  "forge": "bin/forge.js",
@@ -53,11 +53,11 @@
53
53
  "@eslint/js": "^10.0.1",
54
54
  "@microsoft/eslint-formatter-sarif": "^3.1.0",
55
55
  "@stryker-mutator/core": "^9.6.0",
56
- "c8": "^11.0.0",
56
+ "c8": "^12.0.0",
57
57
  "eslint": "^10.0.2",
58
58
  "eslint-plugin-sonarjs": "^4.1.0",
59
59
  "globals": "^17.3.0",
60
- "js-yaml": "^5.1.0",
60
+ "js-yaml": "^5.2.2",
61
61
  "lefthook": "^2.1.4",
62
62
  "typescript": "7.0.2"
63
63
  },
@@ -96,8 +96,6 @@
96
96
  ".cursor/",
97
97
  "!.cursor/node_modules/",
98
98
  "scripts/",
99
- "!scripts/github-beads-sync/",
100
- "!scripts/github-beads-sync.config.json",
101
99
  "!scripts/**/*.test.js",
102
100
  ".github/PLUGIN_TEMPLATE.json",
103
101
  ".forge/hooks/",
@@ -122,10 +120,11 @@
122
120
  },
123
121
  "overrides": {
124
122
  "eslint": "^10.0.2",
125
- "fast-uri": "^3.1.2",
123
+ "fast-uri": "^3.1.4",
126
124
  "flatted": "3.4.2",
127
125
  "minimatch": "10.2.4",
128
- "brace-expansion": "5.0.6",
126
+ "brace-expansion": "5.0.9",
127
+ "linkify-it": "^5.0.2",
129
128
  "lodash": "4.18.1"
130
129
  },
131
130
  "c8": {
@@ -0,0 +1,158 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * Runs (or lists) the test suites that assert on changed markdown.
6
+ *
7
+ * A markdown-only PR does not match the `paths:` filter in
8
+ * `.github/workflows/test.yml`, so the full matrix never runs and
9
+ * `required-checks-bypass.yml` reports the required "Test Suite" check green.
10
+ * Several suites nevertheless assert on markdown CONTENT, so such a PR merged
11
+ * green and then broke master for the next unrelated code PR — twice (kernel
12
+ * issue 63556816: the README size badge in #307/#310, and the AGENTS.md
13
+ * convention test after #325).
14
+ *
15
+ * This script closes that gap by running exactly the doc-asserting suites for the
16
+ * changed markdown. It shares `lib/doc-assertions.js` with the local push lane
17
+ * (`lib/commands/test.js`), so CI and local selection cannot drift apart.
18
+ *
19
+ * Usage:
20
+ * node scripts/doc-asserting-tests.js [--base <ref>] [--list]
21
+ *
22
+ * --base <ref> Compare against <ref> (default: origin/<default-branch> or HEAD~1).
23
+ * --list Print the selected suites instead of running them.
24
+ */
25
+
26
+ const { execFileSync, spawnSync } = require('node:child_process');
27
+ const fs = require('node:fs');
28
+ const path = require('node:path');
29
+
30
+ const { selectDocAssertingTests } = require('../lib/doc-assertions');
31
+
32
+ const REPO_ROOT = path.resolve(__dirname, '..');
33
+
34
+ /** Wall-clock ceiling for the doc-asserting lane; it is a small, fast subset. */
35
+ const LANE_TIMEOUT_MS = 5 * 60 * 1000;
36
+
37
+ /**
38
+ * Parses the supported command-line flags.
39
+ *
40
+ * @param {string[]} argv Raw arguments (without node/script).
41
+ * @returns {{base: string|null, list: boolean}} Parsed options.
42
+ */
43
+ function parseArgs(argv) {
44
+ const baseIndex = argv.indexOf('--base');
45
+ return {
46
+ base: baseIndex !== -1 ? argv[baseIndex + 1] || null : null,
47
+ list: argv.includes('--list'),
48
+ };
49
+ }
50
+
51
+ /**
52
+ * Resolves the ref the PR should be compared against.
53
+ *
54
+ * @param {string|null} explicitBase Base supplied with `--base`.
55
+ * @returns {string} A git ref usable in `git diff <ref>...HEAD`.
56
+ */
57
+ function resolveBase(explicitBase) {
58
+ if (explicitBase) return explicitBase;
59
+ for (const ref of ['origin/master', 'origin/main']) {
60
+ try {
61
+ execFileSync('git', ['rev-parse', '--verify', ref], { cwd: REPO_ROOT, stdio: 'pipe', timeout: 5000 });
62
+ return ref;
63
+ } catch (_e) { /* intentional: ref not present in this checkout, try next */ } // NOSONAR S2486
64
+ }
65
+ return 'HEAD~1';
66
+ }
67
+
68
+ /**
69
+ * Parses `git diff --name-only` output into repository-relative paths.
70
+ *
71
+ * Split out so the parsing contract (trimmed, no blanks) is testable without a live
72
+ * repository: CI checks out shallow, so a test that reaches for `HEAD~1` passes locally
73
+ * and fails on the runner.
74
+ *
75
+ * @param {string} output Raw `git diff --name-only` stdout.
76
+ * @returns {string[]} Changed file paths.
77
+ */
78
+ function parseChangedFiles(output) {
79
+ return output.split('\n').map((line) => line.trim()).filter(Boolean);
80
+ }
81
+
82
+ /**
83
+ * Lists repository-relative paths changed against the base ref.
84
+ *
85
+ * @param {string} base Base git ref.
86
+ * @returns {string[]} Changed file paths.
87
+ */
88
+ function changedFilesSince(base) {
89
+ const output = execFileSync('git', ['diff', '--name-only', `${base}...HEAD`], {
90
+ cwd: REPO_ROOT,
91
+ encoding: 'utf8',
92
+ timeout: 15000,
93
+ });
94
+ return parseChangedFiles(output);
95
+ }
96
+
97
+ /**
98
+ * Turns a `spawnSync` result into a process exit status.
99
+ *
100
+ * A spawn that never produced a status (`error`, or killed by a signal so `status`
101
+ * is null) MUST report FAILURE. Reporting 0 there would re-create the bug this lane
102
+ * exists to remove: a required check reporting green without running the tests.
103
+ *
104
+ * @param {{status: number|null, error?: Error}} result Result of `spawnSync`.
105
+ * @returns {number} Exit status; non-zero whenever the run did not demonstrably pass.
106
+ */
107
+ function resolveSpawnStatus(result) {
108
+ if (!result || result.error) return 1;
109
+ return result.status ?? 1;
110
+ }
111
+
112
+ function main() {
113
+ const { base: explicitBase, list } = parseArgs(process.argv.slice(2));
114
+ const base = resolveBase(explicitBase);
115
+
116
+ let changedFiles;
117
+ try {
118
+ changedFiles = changedFilesSince(base);
119
+ } catch (error) {
120
+ // Fail closed: if the change set cannot be determined we cannot prove the
121
+ // doc-asserting suites are unaffected.
122
+ console.error(`Could not compute changed files against ${base}: ${error.message}`);
123
+ return 1;
124
+ }
125
+
126
+ const suites = selectDocAssertingTests(changedFiles, REPO_ROOT, fs);
127
+
128
+ if (suites.length === 0) {
129
+ console.log('No changed markdown asserts on by any test suite — nothing to run.');
130
+ return 0;
131
+ }
132
+
133
+ if (list) {
134
+ console.log(suites.join('\n'));
135
+ return 0;
136
+ }
137
+
138
+ console.log(`Running ${suites.length} doc-asserting suite${suites.length === 1 ? '' : 's'} for changed markdown:`);
139
+ for (const suite of suites) console.log(` ${suite}`);
140
+
141
+ const result = spawnSync('bun', ['test', ...suites], {
142
+ cwd: REPO_ROOT,
143
+ stdio: 'inherit',
144
+ timeout: LANE_TIMEOUT_MS,
145
+ shell: process.platform === 'win32',
146
+ });
147
+
148
+ if (result.error) {
149
+ console.error(`Failed to run doc-asserting suites: ${result.error.message}`);
150
+ }
151
+ return resolveSpawnStatus(result);
152
+ }
153
+
154
+ if (require.main === module) {
155
+ process.exit(main());
156
+ }
157
+
158
+ module.exports = { changedFilesSince, parseArgs, parseChangedFiles, resolveBase, resolveSpawnStatus };