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
|
@@ -37,7 +37,9 @@ const crypto = require('node:crypto');
|
|
|
37
37
|
const { resolveGitCommonDir } = require('../kernel/broker');
|
|
38
38
|
|
|
39
39
|
/** A wedged owner whose heartbeat is older than this (ms) is reclaimable. */
|
|
40
|
-
|
|
40
|
+
// Must exceed the longest synchronous daemon read (`gh pr list`, 30s) with
|
|
41
|
+
// enough margin that a blocked event loop cannot be reclaimed mid-read.
|
|
42
|
+
const STALE_MS = 90000;
|
|
41
43
|
const LOCK_FILE_MODE = 0o600;
|
|
42
44
|
|
|
43
45
|
/**
|
|
@@ -229,6 +231,12 @@ function release(projectRoot, { gitCommonDir, token, pid = process.pid } = {}) {
|
|
|
229
231
|
}
|
|
230
232
|
}
|
|
231
233
|
|
|
234
|
+
/** True only while this exact pid+token still owns the shared lease. */
|
|
235
|
+
function owns(projectRoot, { gitCommonDir, token, pid = process.pid } = {}) {
|
|
236
|
+
const held = readLock(lockFilePath(projectRoot, { gitCommonDir }));
|
|
237
|
+
return Boolean(held && ownsLock(held, { token, pid }));
|
|
238
|
+
}
|
|
239
|
+
|
|
232
240
|
module.exports = {
|
|
233
241
|
STALE_MS,
|
|
234
242
|
lockFilePath,
|
|
@@ -240,4 +248,5 @@ module.exports = {
|
|
|
240
248
|
stopHeartbeat,
|
|
241
249
|
updateWatchers,
|
|
242
250
|
release,
|
|
251
|
+
owns,
|
|
243
252
|
};
|
|
@@ -20,6 +20,7 @@ const path = require('node:path');
|
|
|
20
20
|
const { spawn, execFileSync } = require('node:child_process');
|
|
21
21
|
|
|
22
22
|
const journal = require('./journal');
|
|
23
|
+
const brokerMod = require('../kernel/broker');
|
|
23
24
|
|
|
24
25
|
/** Absolute path to the forge CLI entrypoint (this file is lib/pr-monitor/). */
|
|
25
26
|
function forgeBin() {
|
|
@@ -66,7 +67,18 @@ function startPrWatcherDetached(opts = {}) {
|
|
|
66
67
|
|
|
67
68
|
const slug = resolveSlug({ cwd, exec: opts.exec });
|
|
68
69
|
if (slug) {
|
|
69
|
-
|
|
70
|
+
let gitCommonDir = opts.gitCommonDir;
|
|
71
|
+
if (!gitCommonDir) {
|
|
72
|
+
try {
|
|
73
|
+
const resolveGitCommonDir = opts.resolveGitCommonDir || brokerMod.resolveGitCommonDir;
|
|
74
|
+
gitCommonDir = resolveGitCommonDir(cwd, { warn: () => {} });
|
|
75
|
+
} catch {
|
|
76
|
+
/* unavailable common-dir keeps the legacy per-root journal fallback */
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const dir = journalMod.journalDir({
|
|
80
|
+
root: cwd, gitCommonDir, repo: slug, pr: prNumber,
|
|
81
|
+
});
|
|
70
82
|
if (journalMod.watcherRunning(dir)) return { started: false, reason: 'already-running' };
|
|
71
83
|
}
|
|
72
84
|
|
package/lib/pr-pull.js
CHANGED
|
@@ -43,6 +43,11 @@ const LOG_FETCH_MULTIPLIER = 3;
|
|
|
43
43
|
// re-review or a late reviewer comment inside the window means the PR is still
|
|
44
44
|
// in flight. Mirrors the merge-rules `settle_min` idea (default 10 minutes).
|
|
45
45
|
const DEFAULT_SETTLE_WINDOW_MS = 600000;
|
|
46
|
+
const FULL_HEAD_SHA = /^[0-9a-f]{40}$/i;
|
|
47
|
+
|
|
48
|
+
function normalizeHeadSha(value) {
|
|
49
|
+
return typeof value === 'string' && FULL_HEAD_SHA.test(value) ? value.toLowerCase() : null;
|
|
50
|
+
}
|
|
46
51
|
|
|
47
52
|
/**
|
|
48
53
|
* Bot logins whose review threads ARE actionable fixes (their comments tell you
|
|
@@ -320,7 +325,7 @@ function isSkipped(check) {
|
|
|
320
325
|
/** A check still in flight (not green, not failed) — e.g. IN_PROGRESS/QUEUED or
|
|
321
326
|
* a status context with no conclusion yet. */
|
|
322
327
|
function isPending(check) {
|
|
323
|
-
return !isGreen(check) && !isFailed(check);
|
|
328
|
+
return !isSkipped(check) && !isGreen(check) && !isFailed(check);
|
|
324
329
|
}
|
|
325
330
|
|
|
326
331
|
/**
|
|
@@ -865,12 +870,14 @@ function buildVerdictContext(input) {
|
|
|
865
870
|
/** Rank 1: UNKNOWN (fail-closed) — unreadable input/required set, missing head
|
|
866
871
|
* oid, or a torn read (head moved during the gather). */
|
|
867
872
|
function rankUnknown(v) {
|
|
868
|
-
const
|
|
873
|
+
const startValid = normalizeHeadSha(v.headOidStart) !== null;
|
|
874
|
+
const endValid = normalizeHeadSha(v.headOidEnd) !== null;
|
|
875
|
+
const torn = startValid && endValid && v.headOidStart !== v.headOidEnd;
|
|
869
876
|
v.evidence.tornRead = torn;
|
|
870
877
|
if (v.requiredClass.unreadable && !v.evidence.unreadable.includes('requiredChecks')) {
|
|
871
878
|
v.evidence.unreadable.push('requiredChecks');
|
|
872
879
|
}
|
|
873
|
-
if (!
|
|
880
|
+
if ((!startValid || !endValid) && !v.evidence.unreadable.includes('headOid')) {
|
|
874
881
|
v.evidence.unreadable.push('headOid');
|
|
875
882
|
}
|
|
876
883
|
return (v.evidence.unreadable.length > 0 || torn) ? 'UNKNOWN' : null;
|
|
@@ -1139,10 +1146,13 @@ async function gatherPrSnapshot(ctx) {
|
|
|
1139
1146
|
|
|
1140
1147
|
// readState is the one REQUIRED read — if it throws, the whole gather fails.
|
|
1141
1148
|
const state = await adapter.readState(pr);
|
|
1149
|
+
const headOidStart = normalizeHeadSha(state.headSha);
|
|
1150
|
+
if (!headOidStart) unreadable.push('headOid');
|
|
1151
|
+
if (state.providerEvidenceReadable === false) unreadable.push('providerEvidence');
|
|
1152
|
+
|
|
1142
1153
|
const requiredSet = await safeRead(
|
|
1143
1154
|
'requiredChecks',
|
|
1144
|
-
// `pr`
|
|
1145
|
-
// protection is unreadable (the Actions token can't read protection).
|
|
1155
|
+
// `pr` is included for diagnostic rollup evidence when protection is unreadable.
|
|
1146
1156
|
() => adapter.readRequiredChecks({ owner, repo, base, pr }),
|
|
1147
1157
|
{ degraded, fallback: null },
|
|
1148
1158
|
);
|
|
@@ -1169,16 +1179,26 @@ async function gatherPrSnapshot(ctx) {
|
|
|
1169
1179
|
// Branch divergence (behind base → needs an update/rebase).
|
|
1170
1180
|
const div = await safeRead(
|
|
1171
1181
|
'divergence',
|
|
1172
|
-
() => callIfPresent(
|
|
1173
|
-
|
|
1182
|
+
() => callIfPresent(
|
|
1183
|
+
adapter,
|
|
1184
|
+
'readDivergence',
|
|
1185
|
+
{ baseRef: ctx.baseRef, cwd, headRef: state.headSha },
|
|
1186
|
+
{ behind: 0 },
|
|
1187
|
+
),
|
|
1188
|
+
{ degraded, unreadable, fallback: { behind: 0 } },
|
|
1174
1189
|
);
|
|
1175
1190
|
const behind = div.behind || 0;
|
|
1176
1191
|
|
|
1177
1192
|
// Predicted merge conflicts (which files) — optional adapter capability.
|
|
1178
1193
|
const conflicts = await safeRead(
|
|
1179
1194
|
'conflicts',
|
|
1180
|
-
() => callIfPresent(
|
|
1181
|
-
|
|
1195
|
+
() => callIfPresent(
|
|
1196
|
+
adapter,
|
|
1197
|
+
'detectConflicts',
|
|
1198
|
+
{ baseRef: ctx.baseRef, cwd, headRef: state.headSha },
|
|
1199
|
+
null,
|
|
1200
|
+
),
|
|
1201
|
+
{ degraded, unreadable, fallback: null },
|
|
1182
1202
|
);
|
|
1183
1203
|
|
|
1184
1204
|
// Bot STATUS COMMENTS (Sonar/Vercel/Netlify/Codecov quality-gate + deployment
|
|
@@ -1214,7 +1234,8 @@ async function gatherPrSnapshot(ctx) {
|
|
|
1214
1234
|
const headPushKnown = headPushTimeMs != null;
|
|
1215
1235
|
// Torn-read guard: re-read the head oid at the END of the gather.
|
|
1216
1236
|
const endState = await safeRead('headEnd', () => adapter.readState(pr), { degraded, unreadable });
|
|
1217
|
-
const headOidEnd = endState
|
|
1237
|
+
const headOidEnd = normalizeHeadSha(endState && endState.headSha);
|
|
1238
|
+
if (!headOidEnd && !unreadable.includes('headOid')) unreadable.push('headOid');
|
|
1218
1239
|
|
|
1219
1240
|
// Actionable NON-HUMAN direct comments (mechanism-detected, suppression-list
|
|
1220
1241
|
// filtered) and an AUTHOR-AGNOSTIC unresolved-thread count — both independent
|
|
@@ -1224,7 +1245,7 @@ async function gatherPrSnapshot(ctx) {
|
|
|
1224
1245
|
const unresolvedThreadCount = countUnresolvedThreads(threads);
|
|
1225
1246
|
|
|
1226
1247
|
const { verdict, evidence } = computeVerdict({
|
|
1227
|
-
headOidStart
|
|
1248
|
+
headOidStart,
|
|
1228
1249
|
headOidEnd,
|
|
1229
1250
|
mergeStateStatus: state.mergeStateStatus,
|
|
1230
1251
|
mergeable: state.mergeable,
|
|
@@ -1245,9 +1266,7 @@ async function gatherPrSnapshot(ctx) {
|
|
|
1245
1266
|
unreadable,
|
|
1246
1267
|
});
|
|
1247
1268
|
|
|
1248
|
-
// Record
|
|
1249
|
-
// `rollup` | null) so the source is visible in the verdict evidence — the
|
|
1250
|
-
// rollup source is the CI path where branch protection is unreadable.
|
|
1269
|
+
// Record the authoritative required-check source (`protection` or null).
|
|
1251
1270
|
evidence.requiredSource = adapter.lastRequiredSource || null;
|
|
1252
1271
|
|
|
1253
1272
|
return {
|
package/lib/pr-shepherd.js
CHANGED
|
@@ -32,6 +32,7 @@ const { fenceUntrusted } = require('./untrusted-content');
|
|
|
32
32
|
|
|
33
33
|
/** Non-erroring terminal states a pass can settle into. */
|
|
34
34
|
const TERMINAL_STATES = ['MERGE_READY', 'ESCALATE', 'PENDING', 'MERGED', 'CLOSED', 'NEEDS_REVIEW'];
|
|
35
|
+
const MERGE_READY_PROVIDER_STATES = new Set(['CLEAN', 'HAS_HOOKS', 'UNSTABLE']);
|
|
35
36
|
|
|
36
37
|
// Review threads are classified BY MECHANISM, not by a bot-name list: a GitHub
|
|
37
38
|
// review THREAD is opened by a reviewer (a human OR any bot) and stays open until
|
|
@@ -72,9 +73,11 @@ function actionableComments(threads, _self) {
|
|
|
72
73
|
);
|
|
73
74
|
}
|
|
74
75
|
|
|
75
|
-
const SUCCESS_CONCLUSIONS = new Set(['SUCCESS'
|
|
76
|
+
const SUCCESS_CONCLUSIONS = new Set(['SUCCESS']);
|
|
76
77
|
|
|
77
78
|
function isGreen(check) {
|
|
79
|
+
if (!Object.prototype.hasOwnProperty.call(check || {}, 'status')
|
|
80
|
+
|| String(check.status || '').toUpperCase() !== 'COMPLETED') return false;
|
|
78
81
|
const c = String(check.conclusion || '').toUpperCase();
|
|
79
82
|
return SUCCESS_CONCLUSIONS.has(c);
|
|
80
83
|
}
|
|
@@ -388,13 +391,18 @@ async function runShepherdPass(ctx) {
|
|
|
388
391
|
|
|
389
392
|
// Every read goes through the auth guard so 401/403-scope/rate-limit map to
|
|
390
393
|
// the documented PENDING/HARD_STOP states instead of escaping as a generic
|
|
391
|
-
// failure.
|
|
394
|
+
// failure. Other read errors are surfaced below as ESCALATE results.
|
|
392
395
|
|
|
393
396
|
// --- Read PR/CI state FIRST so a merged/closed PR is detected as terminal
|
|
394
397
|
// even when the branch-protection (required-checks) read would fail with an
|
|
395
398
|
// auth/scope error — the scheduler must always get the terminal signal for a
|
|
396
399
|
// landed/closed PR. ---
|
|
397
|
-
|
|
400
|
+
let stateRead;
|
|
401
|
+
try {
|
|
402
|
+
stateRead = await guardAuth(() => adapter.readState(pr), actions);
|
|
403
|
+
} catch (err) {
|
|
404
|
+
return result('ESCALATE', { actions, reason: `PR provider state is unreadable: ${err.message}` });
|
|
405
|
+
}
|
|
398
406
|
if (stateRead.outcome) return stateRead.outcome;
|
|
399
407
|
const startState = stateRead.value;
|
|
400
408
|
const startSha = startState.headSha;
|
|
@@ -402,13 +410,24 @@ async function runShepherdPass(ctx) {
|
|
|
402
410
|
// --- Lifecycle: a merged/closed PR is terminal. ---
|
|
403
411
|
const lifecycle = lifecycleOutcome(startState.state, actions);
|
|
404
412
|
if (lifecycle) return lifecycle;
|
|
413
|
+
if (startState.providerEvidenceReadable === false) {
|
|
414
|
+
return result('ESCALATE', {
|
|
415
|
+
actions,
|
|
416
|
+
reason: 'PR lifecycle, draft, head, or check-rollup evidence is malformed or incomplete.',
|
|
417
|
+
});
|
|
418
|
+
}
|
|
405
419
|
|
|
406
420
|
// --- Required-checks set (only matters for non-terminal PRs); this is where
|
|
407
421
|
// auth/scope fails fast. ---
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
422
|
+
let requiredRead;
|
|
423
|
+
try {
|
|
424
|
+
requiredRead = await guardAuth(
|
|
425
|
+
() => adapter.readRequiredChecks({ owner, repo, base }),
|
|
426
|
+
actions,
|
|
427
|
+
);
|
|
428
|
+
} catch (err) {
|
|
429
|
+
return result('ESCALATE', { actions, reason: `Required-check policy is unreadable: ${err.message}` });
|
|
430
|
+
}
|
|
412
431
|
if (requiredRead.outcome) return requiredRead.outcome;
|
|
413
432
|
const required = requiredRead.value;
|
|
414
433
|
|
|
@@ -422,7 +441,7 @@ async function runShepherdPass(ctx) {
|
|
|
422
441
|
}
|
|
423
442
|
|
|
424
443
|
const divergenceRead = await guardAuth(
|
|
425
|
-
() => adapter.readDivergence({ baseRef, cwd }),
|
|
444
|
+
() => adapter.readDivergence({ baseRef, cwd, headRef: startSha }),
|
|
426
445
|
actions,
|
|
427
446
|
);
|
|
428
447
|
if (divergenceRead.outcome) return divergenceRead.outcome;
|
|
@@ -472,6 +491,13 @@ async function runShepherdPass(ctx) {
|
|
|
472
491
|
|
|
473
492
|
// --- Terminal: all required green + not behind → merge-ready handoff. ---
|
|
474
493
|
if (allRequiredGreen) {
|
|
494
|
+
const providerMergeState = String(startState.mergeStateStatus || '').toUpperCase();
|
|
495
|
+
if (!MERGE_READY_PROVIDER_STATES.has(providerMergeState)) {
|
|
496
|
+
return result('ESCALATE', {
|
|
497
|
+
actions,
|
|
498
|
+
reason: `Provider merge state ${providerMergeState || 'UNKNOWN'} does not authorize merge readiness.`,
|
|
499
|
+
});
|
|
500
|
+
}
|
|
475
501
|
return result('MERGE_READY', {
|
|
476
502
|
actions,
|
|
477
503
|
reason: 'All required checks are green and the branch is up to date. Handing off to the human to merge in the GitHub UI — the shepherd never merges.',
|
package/lib/preflight/gates.js
CHANGED
|
@@ -17,10 +17,11 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
const path = require('node:path');
|
|
20
|
+
const crypto = require('node:crypto');
|
|
20
21
|
const { spawnSync } = require('node:child_process');
|
|
21
|
-
const { execFileSync } = require('node:child_process');
|
|
22
22
|
const nodeFs = require('node:fs');
|
|
23
|
-
const {
|
|
23
|
+
const { getTestCandidatesForChangedFile } = require('../commands/test');
|
|
24
|
+
const { selectDocAssertingTests } = require('../doc-assertions');
|
|
24
25
|
|
|
25
26
|
const IS_WINDOWS = process.platform === 'win32';
|
|
26
27
|
|
|
@@ -37,6 +38,36 @@ function statusSummary(result, okMsg, failMsg) {
|
|
|
37
38
|
: { ok: false, summary: failMsg };
|
|
38
39
|
}
|
|
39
40
|
|
|
41
|
+
function compareStringsByCodePoint(left, right) {
|
|
42
|
+
if (left < right) return -1;
|
|
43
|
+
if (left > right) return 1;
|
|
44
|
+
return 0;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function normalizeChangedFiles(files) {
|
|
48
|
+
if (!Array.isArray(files)) return Object.freeze([]);
|
|
49
|
+
return Object.freeze([...new Set(
|
|
50
|
+
files.map((file) => String(file).replaceAll('\\', '/')).filter((file) => file !== ''),
|
|
51
|
+
)].sort(compareStringsByCodePoint));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function fingerprintChangedFiles(files) {
|
|
55
|
+
return crypto.createHash('sha256').update(JSON.stringify(files)).digest('hex');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function selectAffectedTests(projectRoot, changedFiles, fs = nodeFs) {
|
|
59
|
+
const tests = new Set();
|
|
60
|
+
for (const file of changedFiles) {
|
|
61
|
+
for (const candidate of getTestCandidatesForChangedFile(file)) {
|
|
62
|
+
if (fs.existsSync(path.join(projectRoot, candidate))) tests.add(candidate);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
for (const candidate of selectDocAssertingTests(changedFiles, projectRoot, fs)) {
|
|
66
|
+
tests.add(candidate);
|
|
67
|
+
}
|
|
68
|
+
return [...tests].sort(compareStringsByCodePoint);
|
|
69
|
+
}
|
|
70
|
+
|
|
40
71
|
/**
|
|
41
72
|
* Gate 1 — ESLint on the change blast radius (or the whole tree with --all).
|
|
42
73
|
* `files === null` means "lint everything" (`eslint .`).
|
|
@@ -152,38 +183,50 @@ function runSonar(files, { projectRoot, spawn = spawnSync } = {}) {
|
|
|
152
183
|
*/
|
|
153
184
|
function runAffectedTests({
|
|
154
185
|
projectRoot,
|
|
155
|
-
changedFiles
|
|
186
|
+
changedFiles,
|
|
156
187
|
spawn = spawnSync,
|
|
157
188
|
resolveTests,
|
|
158
189
|
} = {}) {
|
|
190
|
+
const inputs = Array.isArray(changedFiles) && Object.isFrozen(changedFiles)
|
|
191
|
+
? changedFiles
|
|
192
|
+
: normalizeChangedFiles(changedFiles);
|
|
193
|
+
const inputFingerprint = fingerprintChangedFiles(inputs);
|
|
159
194
|
const resolver = typeof resolveTests === 'function'
|
|
160
195
|
? resolveTests
|
|
161
|
-
|
|
162
|
-
// otherwise a git error would masquerade as "no affected tests" (fast-lane
|
|
163
|
-
// green), a fail-OPEN. The catch below turns that throw into a closed gate.
|
|
164
|
-
: () => getAffectedTestFiles(projectRoot, execFileSync, nodeFs, { strict: true });
|
|
196
|
+
: (files) => selectAffectedTests(projectRoot, files);
|
|
165
197
|
let targets;
|
|
166
198
|
try {
|
|
167
|
-
targets = resolver() || [];
|
|
199
|
+
targets = resolver(inputs) || [];
|
|
168
200
|
} catch (err) {
|
|
169
201
|
// A resolver ERROR must NOT masquerade as "no affected tests" (green).
|
|
170
202
|
// We could not determine what to run, so fail closed — never a vacuous pass.
|
|
171
203
|
const reason = err && err.message ? err.message : String(err);
|
|
172
|
-
return {
|
|
204
|
+
return {
|
|
205
|
+
ok: false,
|
|
206
|
+
summary: `affected-test resolution failed — fail-closed (${reason})`,
|
|
207
|
+
inputFingerprint,
|
|
208
|
+
};
|
|
173
209
|
}
|
|
174
210
|
if (targets.length === 0) {
|
|
175
|
-
return {
|
|
211
|
+
return {
|
|
212
|
+
ok: true,
|
|
213
|
+
summary: 'no affected tests resolved (fast lane)',
|
|
214
|
+
inputFingerprint,
|
|
215
|
+
};
|
|
176
216
|
}
|
|
177
217
|
const result = spawn(
|
|
178
218
|
'bun',
|
|
179
219
|
['test', '--timeout', '15000', ...targets],
|
|
180
220
|
{ cwd: projectRoot, stdio: 'inherit', shell: IS_WINDOWS },
|
|
181
221
|
);
|
|
182
|
-
return
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
222
|
+
return {
|
|
223
|
+
...statusSummary(
|
|
224
|
+
result,
|
|
225
|
+
`${targets.length} affected test file(s) passed`,
|
|
226
|
+
'affected tests failed',
|
|
227
|
+
),
|
|
228
|
+
inputFingerprint,
|
|
229
|
+
};
|
|
187
230
|
}
|
|
188
231
|
|
|
189
232
|
/**
|
|
@@ -198,15 +241,18 @@ function runAffectedTests({
|
|
|
198
241
|
* @returns {{ name: string, run: () => Promise<{ok:boolean, summary?:string}> }[]}
|
|
199
242
|
*/
|
|
200
243
|
function buildGates({ projectRoot, changedFiles = [], runAll = false, deps = {} }) {
|
|
244
|
+
const normalizedChangedFiles = normalizeChangedFiles(changedFiles);
|
|
245
|
+
const inputFingerprint = fingerprintChangedFiles(normalizedChangedFiles);
|
|
201
246
|
const eslint = deps.eslint || ((files) => runEslint(files, { projectRoot }));
|
|
202
247
|
const structural = deps.structural || (() => runStructural({ projectRoot }));
|
|
203
248
|
const sonar = deps.sonar || ((files) => runSonar(files, { projectRoot }));
|
|
204
|
-
const affected = deps.affected
|
|
249
|
+
const affected = deps.affected
|
|
250
|
+
|| ((files) => runAffectedTests({ projectRoot, changedFiles: files }));
|
|
205
251
|
|
|
206
252
|
// Under --all, scope BOTH lint and sonar to the whole tree (null). Otherwise
|
|
207
253
|
// sonar would receive changedFiles=[] and report a vacuous "no changed files"
|
|
208
254
|
// pass while lint scanned everything — a fail-open hole on the remedy path.
|
|
209
|
-
const scanTargets = runAll ? null :
|
|
255
|
+
const scanTargets = runAll ? null : normalizedChangedFiles;
|
|
210
256
|
|
|
211
257
|
// Affected-tests maps from the change set, which is empty under --all. Running
|
|
212
258
|
// the WHOLE suite here defeats preflight's fast purpose (and hangs on Windows),
|
|
@@ -216,8 +262,9 @@ function buildGates({ projectRoot, changedFiles = [], runAll = false, deps = {}
|
|
|
216
262
|
ok: true,
|
|
217
263
|
skipped: true,
|
|
218
264
|
summary: 'whole-tree mode (--all): affected-test mapping N/A — run the full suite (CI does)',
|
|
265
|
+
inputFingerprint,
|
|
219
266
|
})
|
|
220
|
-
: async () => affected();
|
|
267
|
+
: async () => affected(normalizedChangedFiles);
|
|
221
268
|
|
|
222
269
|
return [
|
|
223
270
|
{ name: 'lint', run: async () => eslint(scanTargets) },
|
package/lib/preflight/runner.js
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
* @typedef {Object} GateOutcome
|
|
20
20
|
* @property {boolean} ok - Whether the gate passed.
|
|
21
21
|
* @property {string} [summary] - Short human-readable result line.
|
|
22
|
+
* @property {string} [inputFingerprint] - Exact input snapshot tested by the gate.
|
|
22
23
|
*
|
|
23
24
|
* @typedef {Object} Gate
|
|
24
25
|
* @property {string} name
|
|
@@ -30,6 +31,7 @@
|
|
|
30
31
|
* @property {boolean} skipped
|
|
31
32
|
* @property {string} summary
|
|
32
33
|
* @property {number} [durationMs]
|
|
34
|
+
* @property {string} [inputFingerprint]
|
|
33
35
|
*/
|
|
34
36
|
|
|
35
37
|
/**
|
|
@@ -66,6 +68,9 @@ async function executeGate(gate) {
|
|
|
66
68
|
skipped,
|
|
67
69
|
summary: outcome?.summary || '',
|
|
68
70
|
durationMs,
|
|
71
|
+
...(typeof outcome?.inputFingerprint === 'string'
|
|
72
|
+
? { inputFingerprint: outcome.inputFingerprint }
|
|
73
|
+
: {}),
|
|
69
74
|
};
|
|
70
75
|
}
|
|
71
76
|
|
package/lib/project-memory.js
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
3
5
|
const { resolveKernelDatabasePath } = require('./kernel/cli-broker-factory');
|
|
6
|
+
const { resolveGitCommonDir } = require('./kernel/broker');
|
|
4
7
|
const { createBuiltinSQLiteDriver } = require('./kernel/sqlite-driver');
|
|
8
|
+
const { normalizeRecallHit } = require('./memory-recall');
|
|
5
9
|
|
|
6
10
|
// Project memory is a Forge read model persisted in the kernel store (kernel_memories),
|
|
7
11
|
// written DIRECTLY rather than through the issue CAS/guarded-event path. The store seam
|
|
@@ -30,6 +34,24 @@ function resolveStore(projectRoot, options = {}) {
|
|
|
30
34
|
return options.store ?? defaultStore(projectRoot, options);
|
|
31
35
|
}
|
|
32
36
|
|
|
37
|
+
function resolveProjectId(projectRoot, options = {}) {
|
|
38
|
+
const platform = options.platform || process.platform;
|
|
39
|
+
const pathImpl = platform === 'win32' ? path.win32 : path.posix;
|
|
40
|
+
const commonDir = options.gitCommonDir || resolveGitCommonDir(projectRoot, options);
|
|
41
|
+
const absolute = pathImpl.resolve(projectRoot, commonDir);
|
|
42
|
+
const realpath = options.realpath || fs.realpathSync.native;
|
|
43
|
+
let canonical;
|
|
44
|
+
try {
|
|
45
|
+
canonical = realpath(absolute);
|
|
46
|
+
} catch {
|
|
47
|
+
canonical = absolute;
|
|
48
|
+
}
|
|
49
|
+
canonical = canonical.replaceAll('\\', '/');
|
|
50
|
+
return platform === 'win32'
|
|
51
|
+
? canonical.toLowerCase()
|
|
52
|
+
: canonical;
|
|
53
|
+
}
|
|
54
|
+
|
|
33
55
|
function assertEntryObject(entry) {
|
|
34
56
|
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
35
57
|
throw new TypeError('project memory entry must be an object');
|
|
@@ -144,7 +166,16 @@ function searchRanked(projectRoot, query, limit, options = {}) {
|
|
|
144
166
|
// apply a relevance floor. A no-match/empty query returns [] (no recency fallback). The
|
|
145
167
|
// per-turn memory-recall hook uses this to inject nothing unless a note clearly matches.
|
|
146
168
|
function searchRankedScored(projectRoot, query, limit, options = {}) {
|
|
147
|
-
|
|
169
|
+
const projectId = resolveProjectId(projectRoot, options);
|
|
170
|
+
const searchOptions = {
|
|
171
|
+
projectId,
|
|
172
|
+
excludeKeys: options.excludeKeys || [],
|
|
173
|
+
...(options.now ? { now: options.now } : {}),
|
|
174
|
+
...(options.busyTimeoutMs !== undefined ? { busyTimeoutMs: options.busyTimeoutMs } : {}),
|
|
175
|
+
};
|
|
176
|
+
return resolveStore(projectRoot, options)
|
|
177
|
+
.searchMemoriesRankedScored(query, limit, searchOptions)
|
|
178
|
+
.map(hit => normalizeRecallHit(hit, projectId));
|
|
148
179
|
}
|
|
149
180
|
|
|
150
181
|
// Close and forget every cached default store. The CLI process is short-lived (the OS
|
|
@@ -170,5 +201,6 @@ module.exports = {
|
|
|
170
201
|
count,
|
|
171
202
|
searchRanked,
|
|
172
203
|
searchRankedScored,
|
|
204
|
+
resolveProjectId,
|
|
173
205
|
closeAll,
|
|
174
206
|
};
|