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.
- package/AGENTS.md +14 -7
- package/CHANGELOG.md +43 -1
- package/README.md +6 -2
- package/bin/forge-cmd.js +20 -0
- package/bin/forge.js +16 -374
- package/docs/INDEX.md +1 -1
- package/docs/guides/BEADS_GITHUB_SYNC.md +2 -31
- package/docs/guides/MIGRATION.md +4 -4
- package/docs/guides/SETUP.md +16 -16
- package/docs/reference/COMMANDS.md +8 -5
- package/docs/reference/INSIGHTS_RECAP.md +9 -20
- package/docs/reference/RELEASE.md +5 -3
- package/docs/reference/TOOLCHAIN.md +8 -0
- package/docs/reference/protected-state-surfaces.md +4 -4
- package/docs/reference/shepherd.md +54 -25
- package/lefthook.yml +12 -0
- package/lib/activation/ensure-forge-home.js +33 -15
- package/lib/adapters/pr-state-adapter.js +344 -142
- package/lib/audit-evidence.js +71 -110
- package/lib/capped-jsonl-log.js +236 -0
- package/lib/commands/_registry.js +2 -2
- package/lib/commands/clean.js +196 -32
- package/lib/commands/dev.js +4 -33
- package/lib/commands/hooks.js +223 -25
- package/lib/commands/insights.js +8 -3
- package/lib/commands/merge.js +600 -40
- package/lib/commands/pr.js +1 -1
- package/lib/commands/preflight.js +11 -2
- package/lib/commands/prime.js +21 -8
- package/lib/commands/push.js +41 -51
- package/lib/commands/recall.js +60 -16
- package/lib/commands/recap.js +6 -1
- package/lib/commands/release.js +17 -2
- package/lib/commands/setup.js +191 -94
- package/lib/commands/shepherd.js +13 -1
- package/lib/commands/ship.js +22 -23
- package/lib/commands/skill.js +119 -11
- package/lib/commands/status.js +17 -1
- package/lib/commands/test.js +24 -34
- package/lib/commands/worktree.js +220 -42
- package/lib/core/runtime-graph.js +1 -1
- package/lib/doc-assertions.js +297 -0
- package/lib/existing-tdd-gate.js +253 -0
- package/lib/forge-context.js +1 -4
- package/lib/forge-issues.js +56 -32
- package/lib/git-defaults.js +56 -0
- package/lib/harness-capability-matrix.js +3 -3
- package/lib/hook-renderer.js +93 -4
- package/lib/insights.js +96 -80
- package/lib/kernel/backing-issue.js +14 -2
- package/lib/kernel/broker.js +16 -0
- package/lib/kernel/cli-broker-factory.js +12 -1
- package/lib/kernel/close-on-merge.js +154 -0
- package/lib/kernel/fs-class.js +42 -25
- package/lib/kernel/sqlite-driver.js +153 -29
- package/lib/lefthook-wiring.js +21 -1
- package/lib/memory/router.js +16 -1
- package/lib/memory-digest.js +47 -15
- package/lib/memory-recall-events.js +145 -0
- package/lib/memory-recall.js +71 -10
- package/lib/merge-rules.js +8 -4
- package/lib/npm-publish-workflow.js +272 -0
- package/lib/orientation.js +68 -43
- package/lib/plugin-catalog.js +14 -4
- package/lib/pr-bundle.js +5 -6
- package/lib/pr-monitor/journal.js +18 -2
- package/lib/pr-monitor/reconcile-executor.js +224 -41
- package/lib/pr-monitor/render-summary.js +196 -0
- package/lib/pr-monitor/shepherd-lease.js +10 -1
- package/lib/pr-monitor/watch-lifecycle.js +13 -1
- package/lib/pr-pull.js +33 -14
- package/lib/pr-shepherd.js +34 -8
- package/lib/preflight/gates.js +65 -18
- package/lib/preflight/runner.js +5 -0
- package/lib/project-memory.js +33 -1
- package/lib/protected-state-authority.js +305 -0
- package/lib/protected-state-surfaces.js +64 -44
- package/lib/release-readiness.js +51 -4
- package/lib/shell-utils.js +1 -1
- package/lib/skills-sync.js +6 -3
- package/lib/smart-merge.js +28 -4
- package/lib/symlink-utils.js +74 -26
- package/lib/upgrade-safety.js +39 -0
- package/lib/using-forge.js +19 -6
- package/package.json +6 -7
- package/scripts/doc-asserting-tests.js +158 -0
- package/scripts/lib/behavioral-eval-runner.js +310 -0
- package/scripts/lib/behavioral-eval-runtime.js +456 -0
- package/scripts/lib/eval-evidence.js +328 -0
- package/scripts/lib/eval-runner.js +81 -41
- package/scripts/lib/immutable-eval-corpus.js +309 -0
- package/scripts/lib/promotion-evidence-loader.js +94 -0
- package/scripts/lib/promotion-scorecard.js +314 -0
- package/scripts/npm-release-receipt.js +134 -0
- package/scripts/process-tree.js +761 -0
- package/scripts/protected-state-check.js +47 -22
- package/scripts/run-command-eval.js +29 -1
- package/scripts/sync-d20-audit.js +172 -0
- package/scripts/test-full-suite.js +249 -37
- package/scripts/test.js +176 -43
- package/skills/review/SKILL.md +4 -11
- package/skills/review/evals/scorecard.json +3 -3
- package/skills/rollback/SKILL.md +4 -11
- package/skills/rollback/evals/scorecard.json +3 -3
- package/skills/shepherd/SKILL.md +20 -14
- package/skills/shepherd/evals/scorecard.json +2 -2
- package/skills/ship/SKILL.md +4 -12
- package/skills/ship/evals/scorecard.json +3 -3
- package/skills/worktree/SKILL.md +6 -1
- package/skills/worktree/evals/scorecard.json +2 -2
- package/lib/beads-setup.js +0 -538
- package/lib/beads-sync-scaffold.js +0 -189
- package/lib/pat-setup.js +0 -207
- package/lib/pr-monitor/render-sticky.js +0 -206
- package/lib/pr-monitor/upsert-sticky.js +0 -169
- package/scripts/beads-context.sh +0 -577
- package/scripts/beads-migrate-to-dolt.sh +0 -7
- package/scripts/beads-upgrade-smoke.sh +0 -284
- package/scripts/lib/beads-migrate-to-dolt.mjs +0 -503
package/lib/symlink-utils.js
CHANGED
|
@@ -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
|
-
//
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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 '';
|
package/lib/upgrade-safety.js
CHANGED
|
@@ -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,
|
package/lib/using-forge.js
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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.
|
|
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": "^
|
|
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.
|
|
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.
|
|
123
|
+
"fast-uri": "^3.1.4",
|
|
126
124
|
"flatted": "3.4.2",
|
|
127
125
|
"minimatch": "10.2.4",
|
|
128
|
-
"brace-expansion": "5.0.
|
|
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 };
|