@webpieces/ai-hook-rules 0.4.636 → 0.4.637
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/README.md +11 -4
- package/package.json +2 -2
- package/src/adapters/hook-core.js +4 -3
- package/src/adapters/hook-core.js.map +1 -1
- package/src/bin/setup.js +72 -6
- package/src/bin/setup.js.map +1 -1
- package/src/core/custom-rule-adapter.js +3 -1
- package/src/core/custom-rule-adapter.js.map +1 -1
- package/src/core/decision-log.d.ts +38 -5
- package/src/core/decision-log.js +45 -6
- package/src/core/decision-log.js.map +1 -1
- package/src/core/l1-doc.js +6 -3
- package/src/core/l1-doc.js.map +1 -1
- package/src/core/l2-doc.d.ts +7 -0
- package/src/core/l2-doc.js +274 -0
- package/src/core/l2-doc.js.map +1 -0
- package/src/core/l2-rows.d.ts +97 -0
- package/src/core/l2-rows.js +229 -0
- package/src/core/l2-rows.js.map +1 -0
- package/src/core/load-rules.d.ts +8 -2
- package/src/core/load-rules.js +51 -31
- package/src/core/load-rules.js.map +1 -1
- package/src/core/main-sync-refresh.d.ts +8 -1
- package/src/core/main-sync-refresh.js +9 -2
- package/src/core/main-sync-refresh.js.map +1 -1
- package/src/core/main-sync-timeout.d.ts +32 -0
- package/src/core/main-sync-timeout.js +93 -0
- package/src/core/main-sync-timeout.js.map +1 -0
- package/src/core/rules/branch-creation-guard.js +1 -1
- package/src/core/rules/branch-creation-guard.js.map +1 -1
- package/src/core/rules/catch-error-pattern.js +1 -1
- package/src/core/rules/catch-error-pattern.js.map +1 -1
- package/src/core/rules/feature-branch-guard.d.ts +3 -3
- package/src/core/rules/feature-branch-guard.js +17 -4
- package/src/core/rules/feature-branch-guard.js.map +1 -1
- package/src/core/rules/index.d.ts +15 -1
- package/src/core/rules/index.js +18 -10
- package/src/core/rules/index.js.map +1 -1
- package/src/core/rules/match-rule.js +4 -1
- package/src/core/rules/match-rule.js.map +1 -1
- package/src/core/rules/max-file-lines.js +1 -1
- package/src/core/rules/max-file-lines.js.map +1 -1
- package/src/core/rules/merge-in-progress-guard.d.ts +9 -3
- package/src/core/rules/merge-in-progress-guard.js +9 -4
- package/src/core/rules/merge-in-progress-guard.js.map +1 -1
- package/src/core/rules/merged-branch-bash-guard.d.ts +3 -3
- package/src/core/rules/merged-branch-bash-guard.js +17 -6
- package/src/core/rules/merged-branch-bash-guard.js.map +1 -1
- package/src/core/rules/no-any-unknown.js +1 -1
- package/src/core/rules/no-any-unknown.js.map +1 -1
- package/src/core/rules/no-custom-css.js +1 -1
- package/src/core/rules/no-custom-css.js.map +1 -1
- package/src/core/rules/no-destructure.js +1 -1
- package/src/core/rules/no-destructure.js.map +1 -1
- package/src/core/rules/no-implicit-any.js +1 -1
- package/src/core/rules/no-implicit-any.js.map +1 -1
- package/src/core/rules/no-js-files.js +1 -1
- package/src/core/rules/no-js-files.js.map +1 -1
- package/src/core/rules/no-process-exit-outside-main.js +1 -1
- package/src/core/rules/no-process-exit-outside-main.js.map +1 -1
- package/src/core/rules/no-symbol-di-tokens.js +1 -1
- package/src/core/rules/no-symbol-di-tokens.js.map +1 -1
- package/src/core/rules/no-unmanaged-exceptions.js +1 -1
- package/src/core/rules/no-unmanaged-exceptions.js.map +1 -1
- package/src/core/rules/pr-creation-or-push-guard.d.ts +10 -3
- package/src/core/rules/pr-creation-or-push-guard.js +10 -4
- package/src/core/rules/pr-creation-or-push-guard.js.map +1 -1
- package/src/core/rules/pr-merge-guard.d.ts +3 -3
- package/src/core/rules/pr-merge-guard.js +2 -1
- package/src/core/rules/pr-merge-guard.js.map +1 -1
- package/src/core/rules/read-stale-guard.d.ts +3 -3
- package/src/core/rules/read-stale-guard.js +15 -6
- package/src/core/rules/read-stale-guard.js.map +1 -1
- package/src/core/rules/redirect-how-to-merge-main.d.ts +3 -3
- package/src/core/rules/redirect-how-to-merge-main.js +1 -1
- package/src/core/rules/redirect-how-to-merge-main.js.map +1 -1
- package/src/core/rules/require-return-type.js +1 -1
- package/src/core/rules/require-return-type.js.map +1 -1
- package/src/core/rules/stale-main-bash-guard.d.ts +3 -3
- package/src/core/rules/stale-main-bash-guard.js +5 -4
- package/src/core/rules/stale-main-bash-guard.js.map +1 -1
- package/src/core/rules/throw-cause-required.js +1 -1
- package/src/core/rules/throw-cause-required.js.map +1 -1
- package/src/core/rules/validate-ts-in-src.js +1 -1
- package/src/core/rules/validate-ts-in-src.js.map +1 -1
- package/src/core/rules/whole-repo-build-guard.js +6 -2
- package/src/core/rules/whole-repo-build-guard.js.map +1 -1
- package/src/core/runner.js +41 -22
- package/src/core/runner.js.map +1 -1
- package/src/core/sync-main.d.ts +9 -0
- package/src/core/sync-main.js +17 -1
- package/src/core/sync-main.js.map +1 -1
- package/src/core/types.d.ts +11 -0
- package/src/core/types.js.map +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"main-sync-refresh.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/main-sync-refresh.ts"],"names":[],"mappings":";;
|
|
1
|
+
{"version":3,"file":"main-sync-refresh.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/main-sync-refresh.ts"],"names":[],"mappings":";;AAaA,4EAEC;AAUD,sCAEC;AA+BD,wDAmCC;;AA7FD,iDAAsC;AACtC,+CAAyB;AACzB,mDAA6B;AAE7B,yCAAqC;AACrC,mDAAsF;AACtF,6CAAyD;AAEzD,qGAAqG;AACrG,yEAAyE;AACzE,IAAI,gBAAgB,GAAG,KAAK,CAAC;AAE7B,8GAA8G;AAC9G,SAAgB,gCAAgC;IAC5C,gBAAgB,GAAG,KAAK,CAAC;AAC7B,CAAC;AAED;;;;;;GAMG;AACH,mHAAmH;AACnH,SAAgB,aAAa,CAAC,SAAiB,EAAE,aAAqB,EAAE,kBAA0B,EAAE,QAAwB;IACxH,OAAO,CAAC,SAAS,EAAE,aAAa,EAAE,MAAM,CAAC,kBAAkB,CAAC,EAAE,QAAQ,CAAC,SAAS,EAAE,QAAQ,CAAC,OAAO,EAAE,QAAQ,CAAC,IAAI,CAAC,CAAC;AACvH,CAAC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,mOAAmO;AACnO,SAAgB,sBAAsB,CAAC,aAAqB,EAAE,kBAA0B;IACpF,gGAAgG;IAChG,mGAAmG;IACnG,mGAAmG;IACnG,2FAA2F;IAC3F,wFAAwF;IACxF,IAAI,gBAAgB;QAAE,OAAO;IAC7B,gBAAgB,GAAG,IAAI,CAAC;IAExB,8DAA8D;IAC9D,IAAI,CAAC;QACD,MAAM,SAAS,GAAG,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,cAAc,CAAC,CAAC;QACvD,2FAA2F;QAC3F,IAAA,4BAAY,EAAC,aAAa,EAAE,IAAI,4BAAY,CAAC,eAAe,EAAE,OAAO,CAAC,GAAG,EAAE,GAAG,EAAE,aAAa,SAAS,EAAE,CAAC,CAAC,CAAC;QAE3G,6FAA6F;QAC7F,mFAAmF;QACnF,MAAM,KAAK,GAAG,EAAE,CAAC,QAAQ,CAAC,IAAA,uCAAuB,EAAC,aAAa,CAAC,EAAE,GAAG,CAAC,CAAC;QACvE,MAAM,KAAK,GAAG,IAAA,qBAAK,EAAC,OAAO,CAAC,QAAQ,EAAE,aAAa,CAAC,SAAS,EAAE,aAAa,EAAE,kBAAkB,EAAE,sBAAS,CAAC,QAAQ,EAAE,CAAC,EAAE;YACrH,QAAQ,EAAE,IAAI;YACd,KAAK,EAAE,CAAC,QAAQ,EAAE,KAAK,EAAE,KAAK,CAAC;SAClC,CAAC,CAAC;QACH,8FAA8F;QAC9F,wFAAwF;QACxF,KAAK,CAAC,IAAI,CAAC,OAAO,EAAE,CAAC,GAAU,EAAQ,EAAE;YACrC,IAAA,4BAAY,EAAC,aAAa,EAAE,IAAI,4BAAY,CAAC,OAAO,EAAE,KAAK,CAAC,GAAG,IAAI,CAAC,CAAC,EAAE,GAAG,EAAE,iBAAiB,GAAG,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC;QACjH,CAAC,CAAC,CAAC;QACH,KAAK,CAAC,KAAK,EAAE,CAAC;QACd,mFAAmF;QACnF,EAAE,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC;IACxB,CAAC;IAAC,OAAO,GAAY,EAAE,CAAC;QACpB,MAAM,KAAK,GAAG,IAAA,kBAAO,EAAC,GAAG,CAAC,CAAC;QAC3B,KAAK,KAAK,CAAC;QACX,0EAA0E;IAC9E,CAAC;AACL,CAAC","sourcesContent":["import { spawn } from 'child_process';\nimport * as fs from 'fs';\nimport * as path from 'path';\n\nimport { toError } from './to-error';\nimport { logSyncEvent, SyncLogEvent, refresherChildStdioPath } from './main-sync-log';\nimport { logStream, StreamIdentity } from './log-stream';\n\n// Per-process latch for the spawn below. A hook process handles exactly one tool call, so this makes\n// the refresher at-most-once per tool call. Exported reset is test-only.\nlet alreadyTriggered = false;\n\n// webpieces-disable no-function-outside-class -- test-only latch reset, matching this module's function shape\nexport function resetMainSyncRefreshLatchForTest(): void {\n alreadyTriggered = false;\n}\n\n/**\n * The detached child's argv, spawner side. Positions here are offset by two from the child's\n * `process.argv` (node + script path), so this array's 3/4/5 are the child's 4/5/6 — which is what\n * `sync-main.ts:spawnerIdentity` reads. The two are pinned against each other by ONE test, because a\n * silent disagreement about these positions IS the bug being fixed: the child previously received no\n * identity at all and wrote every line to the shared `unknown-coordinator-hook-` stream.\n */\n// webpieces-disable no-function-outside-class -- argv builder for this module's spawn, matching its function shape\nexport function refresherArgv(refresher: string, workspaceRoot: string, hangTimeoutMinutes: number, identity: StreamIdentity): string[] {\n return [refresher, workspaceRoot, String(hangTimeoutMinutes), identity.sessionId, identity.agentId, identity.hook];\n}\n\n/**\n * Fire-and-forget spawn of the detached refresher (sync-main.js in this same dir — spawned by path,\n * not a bin). The child outlives this hook process (`detached` + `unref`), does the slow\n * merged-PR/fetch/merge-base/overlap work, and writes the cache the feature-branch-guard reads on\n * the NEXT call. This is the first detached spawn in the codebase — every existing hook is synchronous.\n *\n * Observability: we log SPAWN_ATTEMPT here and the child logs START/FINISH/ERROR, all to\n * `.webpieces/logs/async-refresh/<writer>.log` (LogStream names every writer). The child's\n * stdout/stderr are redirected to a sibling file (not\n * /dev/null) so a crash before the child's own logging is still captured. If the async-refresh stream shows\n * SPAWN_ATTEMPT but never START, the detached child was killed before it ran.\n *\n * `hangTimeoutMinutes` IS REQUIRED, deliberately. It used to default to DEFAULT_HANG_TIMEOUT_MINUTES,\n * and that default is the mechanism by which three of the four configured values could never reach a\n * spawn: two callers ran before any guard and simply omitted the argument, and the at-most-once latch\n * below then meant the guard's own call — the one carrying the configured value — was a no-op. An\n * omitted argument that silently means \"the default\" is unreadable at the call site and impossible to\n * grep. Required makes every caller say which value it is passing, and makes forgetting a compile error.\n *\n * THE IDENTITY GOES ON ARGV, and that is what makes the sentence above true. The child is a separate\n * node process whose `logStream` starts unidentified, so it used to write every line to the shared\n * `unknown-coordinator-hook.log` while this parent wrote SPAWN_ATTEMPT to its own\n * prefixed stream. One cycle, two files — the SPAWN_ATTEMPT-without-START check then read as a\n * failure on every cycle even when the child ran perfectly — and every agent's child appending to\n * that one shared path is exactly the multi-writer tearing LogStream exists to remove. Passing\n * session/agent/hook through (positional argv, alongside the root and timeout the child already\n * takes) puts the whole cycle back in ONE file.\n */\n// webpieces-disable no-function-outside-class -- the module's one entry point, beside refresherArgv; a detached fire-and-forget spawn has no state to inject and must stay callable from a tree too broken to build a DI container\nexport function triggerMainSyncRefresh(workspaceRoot: string, hangTimeoutMinutes: number): void {\n // ONE refresher per hook process. Several call sites fire this on a single tool call — the Read\n // fast path in hook-core AND read-stale-guard's own check(), for one — which is why the log showed\n // two SPAWN_ATTEMPTs ~20ms apart from the same pid on every cycle. The loser only ever reached the\n // lock and exited, so the second child was pure waste (and one more `git fetch` racing the\n // agent's). The child's lock still guards against refreshers from OTHER hook processes.\n if (alreadyTriggered) return;\n alreadyTriggered = true;\n\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const refresher = path.join(__dirname, 'sync-main.js');\n // SPAWN_ATTEMPT first — this also creates .webpieces/logs so the stderr fd below can open.\n logSyncEvent(workspaceRoot, new SyncLogEvent('SPAWN_ATTEMPT', process.pid, '-', `refresher=${refresher}`));\n\n // Redirect the detached child's stdout+stderr to a file (not /dev/null) so an uncaught crash\n // before the child's own logging — e.g. a module-load failure — is still captured.\n const errFd = fs.openSync(refresherChildStdioPath(workspaceRoot), 'a');\n const child = spawn(process.execPath, refresherArgv(refresher, workspaceRoot, hangTimeoutMinutes, logStream.identity()), {\n detached: true,\n stdio: ['ignore', errFd, errFd],\n });\n // spawn errors (e.g. ENOENT) arrive asynchronously; record one if it fires. The hook may exit\n // before this handler runs, but on POSIX a successful exec has already happened by now.\n child.once('error', (err: Error): void => {\n logSyncEvent(workspaceRoot, new SyncLogEvent('ERROR', child.pid ?? -1, '-', `spawn failed: ${err.message}`));\n });\n child.unref();\n // The child has its own dup'd copy of the fd after spawn; close the parent's copy.\n fs.closeSync(errFd);\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n // Spawning the background refresh must never block or fail the tool call.\n }\n}\n"]}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { WebpiecesRulesConfig, BranchStateGuardConfig } from '@webpieces/rules-config';
|
|
2
|
+
import type { Rule } from './types';
|
|
3
|
+
/**
|
|
4
|
+
* The value on a branch-state ENTRY, or the default — for the four guard classes, which are handed
|
|
5
|
+
* their own typed config and never see the whole file.
|
|
6
|
+
*
|
|
7
|
+
* The four used to spell this inline as `this.config.hangTimeoutMinutes ?? DEFAULT_HANG_TIMEOUT_MINUTES`,
|
|
8
|
+
* which is a fourth copy of the resolution and exactly the shape that let the four DECLARATIONS drift
|
|
9
|
+
* apart in the first place. One reader, one fallback.
|
|
10
|
+
*/
|
|
11
|
+
export declare function hangTimeoutOf(config: BranchStateGuardConfig): number;
|
|
12
|
+
/** The configured value for a caller that has ALREADY loaded the whole config. */
|
|
13
|
+
export declare function branchStateHangTimeout(config: WebpiecesRulesConfig): number;
|
|
14
|
+
/**
|
|
15
|
+
* The configured value for a caller that has NOT loaded the config — the hook adapter's two pre-rule
|
|
16
|
+
* refresh triggers (the webpieces.config.json edit bypass, and the Read fast path).
|
|
17
|
+
*
|
|
18
|
+
* Fails back to the default on an unloadable config: warming a cache must never throw on the tool path.
|
|
19
|
+
*/
|
|
20
|
+
export declare function branchStateHangTimeoutFor(cwd: string): number;
|
|
21
|
+
/** Test-only: clears the per-process memo above. */
|
|
22
|
+
export declare function resetHangTimeoutCacheForTest(): void;
|
|
23
|
+
/**
|
|
24
|
+
* Fire-and-forget the detached refresher when the branch-state policy is loaded and active, so the
|
|
25
|
+
* cache stays fresh as the AI works. The guard rules themselves also trigger it; this covers the Bash
|
|
26
|
+
* path so the cache is warm on every command.
|
|
27
|
+
*
|
|
28
|
+
* Keyed on `feature-branch-guard` — the rule NAME, not the config key. Any of the four branch-state
|
|
29
|
+
* classes would do (they share one entry, so they are on or off together); this one is asked because it
|
|
30
|
+
* is the class whose cache freshness matters on the very next Write.
|
|
31
|
+
*/
|
|
32
|
+
export declare function maybeRefreshMainSync(rules: readonly Rule[], workspaceRoot: string, hangTimeoutMinutes: number): void;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.hangTimeoutOf = hangTimeoutOf;
|
|
4
|
+
exports.branchStateHangTimeout = branchStateHangTimeout;
|
|
5
|
+
exports.branchStateHangTimeoutFor = branchStateHangTimeoutFor;
|
|
6
|
+
exports.resetHangTimeoutCacheForTest = resetHangTimeoutCacheForTest;
|
|
7
|
+
exports.maybeRefreshMainSync = maybeRefreshMainSync;
|
|
8
|
+
const rules_config_1 = require("@webpieces/rules-config");
|
|
9
|
+
const to_error_1 = require("./to-error");
|
|
10
|
+
const main_sync_refresh_1 = require("./main-sync-refresh");
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// THE ONE `hangTimeoutMinutes`, and every caller that has to read it.
|
|
13
|
+
//
|
|
14
|
+
// There is ONE detached refresher writing ONE cache (.webpieces/main-sync-status.json), so there is one
|
|
15
|
+
// knob: `hookGuards.branch-state-guard.hangTimeoutMinutes`, which tunes the stale-lock reclaim window.
|
|
16
|
+
//
|
|
17
|
+
// It used to be declared FOUR times — once per branch-state guard class — and read four times, and
|
|
18
|
+
// three of those four values were structurally unable to take effect. Two things conspired:
|
|
19
|
+
// `triggerMainSyncRefresh` latches at most one spawn per hook process, and two callers ran BEFORE any
|
|
20
|
+
// guard's own call while passing the hardcoded default. So on the Bash path and the Read path (the two
|
|
21
|
+
// hottest) the default always won the latch and the configured value never reached a spawn.
|
|
22
|
+
//
|
|
23
|
+
// Collapsing four declarations into one only fixes that if EVERY caller reads the one. This module is
|
|
24
|
+
// where they do, which is also why it is a module rather than three helpers in runner.ts: the callers
|
|
25
|
+
// live in two files (the runner and the hook adapter) and a knob honoured in one of them is the bug
|
|
26
|
+
// again, one release later.
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
/**
|
|
29
|
+
* The value on a branch-state ENTRY, or the default — for the four guard classes, which are handed
|
|
30
|
+
* their own typed config and never see the whole file.
|
|
31
|
+
*
|
|
32
|
+
* The four used to spell this inline as `this.config.hangTimeoutMinutes ?? DEFAULT_HANG_TIMEOUT_MINUTES`,
|
|
33
|
+
* which is a fourth copy of the resolution and exactly the shape that let the four DECLARATIONS drift
|
|
34
|
+
* apart in the first place. One reader, one fallback.
|
|
35
|
+
*/
|
|
36
|
+
// webpieces-disable no-function-outside-class -- module-scope config accessor, matching the shape of the runner helpers it was extracted from
|
|
37
|
+
function hangTimeoutOf(config) {
|
|
38
|
+
const configured = config.hangTimeoutMinutes;
|
|
39
|
+
return typeof configured === 'number' ? configured : rules_config_1.DEFAULT_HANG_TIMEOUT_MINUTES;
|
|
40
|
+
}
|
|
41
|
+
/** The configured value for a caller that has ALREADY loaded the whole config. */
|
|
42
|
+
// webpieces-disable no-function-outside-class -- module-scope config accessor, matching the shape of the runner helpers it was extracted from
|
|
43
|
+
function branchStateHangTimeout(config) {
|
|
44
|
+
const entry = config[rules_config_1.BRANCH_STATE_GUARD_KEY];
|
|
45
|
+
return entry === undefined ? rules_config_1.DEFAULT_HANG_TIMEOUT_MINUTES : hangTimeoutOf(entry);
|
|
46
|
+
}
|
|
47
|
+
// Memoized per PROCESS, and a hook process handles exactly one tool call — so the adapter's two call
|
|
48
|
+
// sites cost at most one extra config parse between them, never one each.
|
|
49
|
+
let cachedHangTimeout = null;
|
|
50
|
+
/**
|
|
51
|
+
* The configured value for a caller that has NOT loaded the config — the hook adapter's two pre-rule
|
|
52
|
+
* refresh triggers (the webpieces.config.json edit bypass, and the Read fast path).
|
|
53
|
+
*
|
|
54
|
+
* Fails back to the default on an unloadable config: warming a cache must never throw on the tool path.
|
|
55
|
+
*/
|
|
56
|
+
// webpieces-disable no-function-outside-class -- module-scope config accessor, matching the shape of the runner helpers it was extracted from
|
|
57
|
+
function branchStateHangTimeoutFor(cwd) {
|
|
58
|
+
if (cachedHangTimeout !== null)
|
|
59
|
+
return cachedHangTimeout;
|
|
60
|
+
let value = rules_config_1.DEFAULT_HANG_TIMEOUT_MINUTES;
|
|
61
|
+
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- a cache-warming knob must never throw on the tool path
|
|
62
|
+
try {
|
|
63
|
+
value = branchStateHangTimeout((0, rules_config_1.loadAndValidate)(cwd).rulesConfig);
|
|
64
|
+
}
|
|
65
|
+
catch (err) {
|
|
66
|
+
const error = (0, to_error_1.toError)(err);
|
|
67
|
+
void error;
|
|
68
|
+
}
|
|
69
|
+
cachedHangTimeout = value;
|
|
70
|
+
return value;
|
|
71
|
+
}
|
|
72
|
+
/** Test-only: clears the per-process memo above. */
|
|
73
|
+
// webpieces-disable no-function-outside-class -- module-scope test hook beside the memo it clears
|
|
74
|
+
function resetHangTimeoutCacheForTest() {
|
|
75
|
+
cachedHangTimeout = null;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Fire-and-forget the detached refresher when the branch-state policy is loaded and active, so the
|
|
79
|
+
* cache stays fresh as the AI works. The guard rules themselves also trigger it; this covers the Bash
|
|
80
|
+
* path so the cache is warm on every command.
|
|
81
|
+
*
|
|
82
|
+
* Keyed on `feature-branch-guard` — the rule NAME, not the config key. Any of the four branch-state
|
|
83
|
+
* classes would do (they share one entry, so they are on or off together); this one is asked because it
|
|
84
|
+
* is the class whose cache freshness matters on the very next Write.
|
|
85
|
+
*/
|
|
86
|
+
// webpieces-disable no-function-outside-class -- module-scope refresh trigger, matching the shape of the runner helpers it was extracted from
|
|
87
|
+
function maybeRefreshMainSync(rules, workspaceRoot, hangTimeoutMinutes) {
|
|
88
|
+
const guard = rules.find((r) => r.name === 'feature-branch-guard');
|
|
89
|
+
if (guard && guard.shouldRun()) {
|
|
90
|
+
(0, main_sync_refresh_1.triggerMainSyncRefresh)(workspaceRoot, hangTimeoutMinutes);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
//# sourceMappingURL=main-sync-timeout.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"main-sync-timeout.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/main-sync-timeout.ts"],"names":[],"mappings":";;AAoCA,sCAGC;AAID,wDAGC;AAaD,8DAYC;AAID,oEAEC;AAYD,oDAKC;AA9FD,0DAGiC;AAEjC,yCAAqC;AACrC,2DAA6D;AAG7D,8EAA8E;AAC9E,sEAAsE;AACtE,EAAE;AACF,wGAAwG;AACxG,uGAAuG;AACvG,EAAE;AACF,mGAAmG;AACnG,4FAA4F;AAC5F,sGAAsG;AACtG,uGAAuG;AACvG,4FAA4F;AAC5F,EAAE;AACF,sGAAsG;AACtG,sGAAsG;AACtG,oGAAoG;AACpG,4BAA4B;AAC5B,8EAA8E;AAE9E;;;;;;;GAOG;AACH,8IAA8I;AAC9I,SAAgB,aAAa,CAAC,MAA8B;IACxD,MAAM,UAAU,GAAG,MAAM,CAAC,kBAAkB,CAAC;IAC7C,OAAO,OAAO,UAAU,KAAK,QAAQ,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,2CAA4B,CAAC;AACtF,CAAC;AAED,kFAAkF;AAClF,8IAA8I;AAC9I,SAAgB,sBAAsB,CAAC,MAA4B;IAC/D,MAAM,KAAK,GAAG,MAAM,CAAC,qCAAsB,CAAC,CAAC;IAC7C,OAAO,KAAK,KAAK,SAAS,CAAC,CAAC,CAAC,2CAA4B,CAAC,CAAC,CAAC,aAAa,CAAC,KAAK,CAAC,CAAC;AACrF,CAAC;AAED,qGAAqG;AACrG,0EAA0E;AAC1E,IAAI,iBAAiB,GAAkB,IAAI,CAAC;AAE5C;;;;;GAKG;AACH,8IAA8I;AAC9I,SAAgB,yBAAyB,CAAC,GAAW;IACjD,IAAI,iBAAiB,KAAK,IAAI;QAAE,OAAO,iBAAiB,CAAC;IACzD,IAAI,KAAK,GAAG,2CAA4B,CAAC;IACzC,wHAAwH;IACxH,IAAI,CAAC;QACD,KAAK,GAAG,sBAAsB,CAAC,IAAA,8BAAe,EAAC,GAAG,CAAC,CAAC,WAAW,CAAC,CAAC;IACrE,CAAC;IAAC,OAAO,GAAY,EAAE,CAAC;QACpB,MAAM,KAAK,GAAG,IAAA,kBAAO,EAAC,GAAG,CAAC,CAAC;QAC3B,KAAK,KAAK,CAAC;IACf,CAAC;IACD,iBAAiB,GAAG,KAAK,CAAC;IAC1B,OAAO,KAAK,CAAC;AACjB,CAAC;AAED,oDAAoD;AACpD,kGAAkG;AAClG,SAAgB,4BAA4B;IACxC,iBAAiB,GAAG,IAAI,CAAC;AAC7B,CAAC;AAED;;;;;;;;GAQG;AACH,8IAA8I;AAC9I,SAAgB,oBAAoB,CAAC,KAAsB,EAAE,aAAqB,EAAE,kBAA0B;IAC1G,MAAM,KAAK,GAAG,KAAK,CAAC,IAAI,CAAC,CAAC,CAAO,EAAW,EAAE,CAAC,CAAC,CAAC,IAAI,KAAK,sBAAsB,CAAC,CAAC;IAClF,IAAI,KAAK,IAAI,KAAK,CAAC,SAAS,EAAE,EAAE,CAAC;QAC7B,IAAA,0CAAsB,EAAC,aAAa,EAAE,kBAAkB,CAAC,CAAC;IAC9D,CAAC;AACL,CAAC","sourcesContent":["import {\n loadAndValidate, WebpiecesRulesConfig, BranchStateGuardConfig, BRANCH_STATE_GUARD_KEY,\n DEFAULT_HANG_TIMEOUT_MINUTES,\n} from '@webpieces/rules-config';\n\nimport { toError } from './to-error';\nimport { triggerMainSyncRefresh } from './main-sync-refresh';\nimport type { Rule } from './types';\n\n// ---------------------------------------------------------------------------\n// THE ONE `hangTimeoutMinutes`, and every caller that has to read it.\n//\n// There is ONE detached refresher writing ONE cache (.webpieces/main-sync-status.json), so there is one\n// knob: `hookGuards.branch-state-guard.hangTimeoutMinutes`, which tunes the stale-lock reclaim window.\n//\n// It used to be declared FOUR times — once per branch-state guard class — and read four times, and\n// three of those four values were structurally unable to take effect. Two things conspired:\n// `triggerMainSyncRefresh` latches at most one spawn per hook process, and two callers ran BEFORE any\n// guard's own call while passing the hardcoded default. So on the Bash path and the Read path (the two\n// hottest) the default always won the latch and the configured value never reached a spawn.\n//\n// Collapsing four declarations into one only fixes that if EVERY caller reads the one. This module is\n// where they do, which is also why it is a module rather than three helpers in runner.ts: the callers\n// live in two files (the runner and the hook adapter) and a knob honoured in one of them is the bug\n// again, one release later.\n// ---------------------------------------------------------------------------\n\n/**\n * The value on a branch-state ENTRY, or the default — for the four guard classes, which are handed\n * their own typed config and never see the whole file.\n *\n * The four used to spell this inline as `this.config.hangTimeoutMinutes ?? DEFAULT_HANG_TIMEOUT_MINUTES`,\n * which is a fourth copy of the resolution and exactly the shape that let the four DECLARATIONS drift\n * apart in the first place. One reader, one fallback.\n */\n// webpieces-disable no-function-outside-class -- module-scope config accessor, matching the shape of the runner helpers it was extracted from\nexport function hangTimeoutOf(config: BranchStateGuardConfig): number {\n const configured = config.hangTimeoutMinutes;\n return typeof configured === 'number' ? configured : DEFAULT_HANG_TIMEOUT_MINUTES;\n}\n\n/** The configured value for a caller that has ALREADY loaded the whole config. */\n// webpieces-disable no-function-outside-class -- module-scope config accessor, matching the shape of the runner helpers it was extracted from\nexport function branchStateHangTimeout(config: WebpiecesRulesConfig): number {\n const entry = config[BRANCH_STATE_GUARD_KEY];\n return entry === undefined ? DEFAULT_HANG_TIMEOUT_MINUTES : hangTimeoutOf(entry);\n}\n\n// Memoized per PROCESS, and a hook process handles exactly one tool call — so the adapter's two call\n// sites cost at most one extra config parse between them, never one each.\nlet cachedHangTimeout: number | null = null;\n\n/**\n * The configured value for a caller that has NOT loaded the config — the hook adapter's two pre-rule\n * refresh triggers (the webpieces.config.json edit bypass, and the Read fast path).\n *\n * Fails back to the default on an unloadable config: warming a cache must never throw on the tool path.\n */\n// webpieces-disable no-function-outside-class -- module-scope config accessor, matching the shape of the runner helpers it was extracted from\nexport function branchStateHangTimeoutFor(cwd: string): number {\n if (cachedHangTimeout !== null) return cachedHangTimeout;\n let value = DEFAULT_HANG_TIMEOUT_MINUTES;\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- a cache-warming knob must never throw on the tool path\n try {\n value = branchStateHangTimeout(loadAndValidate(cwd).rulesConfig);\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n }\n cachedHangTimeout = value;\n return value;\n}\n\n/** Test-only: clears the per-process memo above. */\n// webpieces-disable no-function-outside-class -- module-scope test hook beside the memo it clears\nexport function resetHangTimeoutCacheForTest(): void {\n cachedHangTimeout = null;\n}\n\n/**\n * Fire-and-forget the detached refresher when the branch-state policy is loaded and active, so the\n * cache stays fresh as the AI works. The guard rules themselves also trigger it; this covers the Bash\n * path so the cache is warm on every command.\n *\n * Keyed on `feature-branch-guard` — the rule NAME, not the config key. Any of the four branch-state\n * classes would do (they share one entry, so they are on or off together); this one is asked because it\n * is the class whose cache freshness matters on the very next Write.\n */\n// webpieces-disable no-function-outside-class -- module-scope refresh trigger, matching the shape of the runner helpers it was extracted from\nexport function maybeRefreshMainSync(rules: readonly Rule[], workspaceRoot: string, hangTimeoutMinutes: number): void {\n const guard = rules.find((r: Rule): boolean => r.name === 'feature-branch-guard');\n if (guard && guard.shouldRun()) {\n triggerMainSyncRefresh(workspaceRoot, hangTimeoutMinutes);\n }\n}\n"]}
|
|
@@ -115,7 +115,7 @@ function checkMainIsUpToDate(ctx, requestedName) {
|
|
|
115
115
|
return [];
|
|
116
116
|
}
|
|
117
117
|
class BranchCreationGuardRule extends rule_base_1.BashRuleBase {
|
|
118
|
-
constructor(config) { super(config, 'branch-creation-guard'); }
|
|
118
|
+
constructor(config) { super(config, 'branch-creation-guard', 'branch-creation-guard'); }
|
|
119
119
|
description = 'Block new-branch and new-worktree creation when main is stale, when branching off a non-main ' +
|
|
120
120
|
'branch, or when the branch/worktree count is at its cap (forces cleanup of dead ones).';
|
|
121
121
|
defaultOptions = {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"branch-creation-guard.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/branch-creation-guard.ts"],"names":[],"mappings":"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","sourcesContent":["import { execSync } from 'child_process';\n\nimport {\n BranchCreationGuardConfig,\n DeletableBranch,\n MergedBranchesCache,\n MergedBranchesService,\n WorktreeService,\n readMainSyncStatus,\n} from '@webpieces/rules-config';\n\nimport type { BashContext, Violation } from '../types';\nimport { Violation as V } from '../types';\nimport { BashRuleBase } from '../rule-base';\nimport { FixHint, Option } from '../fix-hint';\nimport { CapRemedies } from './cap-remedies';\nimport { toError } from '../to-error';\n\n// Defaults used when the rule has no explicit value in webpieces.config.json.\n// branchFormat is a human sentence telling the AI how to name a branch created off main; it is\n// intentionally NOT the sub-branch convention (sub-branches are a separate, human-approved path).\nconst DEFAULT_BRANCH_FORMAT =\n 'Name it {whoami}/<short-feature-description> — lowercase, no version numbers, no sub/ prefix (e.g. dean/upgrade-webpieces)';\nconst DEFAULT_SUB_BRANCH_NAMING = 'feature/<ticket>/<short-description>';\n\n// Hard cap on local feature branches. Enforced at CREATION because that is the one moment cleanup is\n// both cheap and obviously worth it — reaping happens over time, never \"ASAP\".\n//\n// The branch cap counts PARKED branches only — branches not checked out in any worktree. Worktree-held\n// branches are counted by the worktree cap instead. Two budgets, because they are not substitutes: if\n// held branches also spent the branch budget, five worktrees would leave room for zero branches and no\n// branch could ever be created again.\nconst DEFAULT_MAX_LOCAL_BRANCHES = 5;\nconst DEFAULT_MAX_WORKTREES = 5;\n\n// A plausible git ref name. Deliberately NOT `[^\\s-]` — that class matches shell metacharacters, so\n// `git branch | wc -l` (a read-only LISTING, piped) was parsed as \"create a branch named `|`\" and\n// blocked. Cleanup work necessarily reads and deletes branches, so a listing must never trip this.\nconst REF_NAME = String.raw`[A-Za-z0-9][A-Za-z0-9_./-]*`;\n\nconst BRANCH_PATTERNS: RegExp[] = [\n new RegExp(String.raw`git\\s+checkout\\s+-[bB]\\s+(${REF_NAME})`),\n new RegExp(String.raw`git\\s+switch\\s+-[cC]\\s+(${REF_NAME})`),\n new RegExp(String.raw`git\\s+branch\\s+(?!-)(${REF_NAME})`),\n // `git worktree add ../dir -b <name> origin/main` — the form docs/git-workflow.md recommends for\n // starting a feature. It creates a branch just as surely as `checkout -b` does, and until this\n // pattern existed it walked straight past the cap, the reserved-suffix check and the sub-branch\n // check. `(?:\\S+\\s+)*?` absorbs the path and any other flags, so the -b may precede or follow them.\n new RegExp(String.raw`git\\s+worktree\\s+add\\s+(?:\\S+\\s+)*?-[bB]\\s+(${REF_NAME})`),\n];\n\n// ANY worktree creation, with or without -b. The no-`-b` forms (`git worktree add ../dir existing`,\n// `--detach`) create no branch but DO create a worktree, so they spend the worktree budget and must\n// still hit the cap.\nconst WORKTREE_ADD = /git\\s+worktree\\s+add\\b/;\n\n// `git worktree add <path> <existing-branch>` — the checkout-an-existing-branch form. Captures the\n// LAST bare (non-flag) argument, which is the committish; the first bare argument is the path.\n// Flags that take a value (`--reason <s>`, `-b <name>`) are excluded by the caller, which only uses\n// this on commands with no `-b`/`-B` at all.\nconst WORKTREE_ADD_EXISTING = new RegExp(\n String.raw`git\\s+worktree\\s+add\\s+(?:-{1,2}[A-Za-z-]+\\s+)*\\S+\\s+(${REF_NAME})`,\n);\n\n// `git branch <name> <sha>` — RESTORING a branch at an explicit commit, which is exactly the\n// `recover=` command wp-cleanup writes to branch-mutations.log for every branch it reaps.\n//\n// This must be allowed UNCONDITIONALLY, ahead of even the caps. The entire argument for letting the\n// tooling delete branches unattended is that any delete is one logged command away from being undone\n// — so a guard that blocks that command turns a real guarantee into a decorative one. (It did: the\n// generic `git branch <name>` creation pattern matched the restore and refused it, demanding the\n// branch be recreated off origin/main, which is precisely the content the restore is meant to bring\n// back.) A restore also cannot grow the branch list beyond what already existed.\nconst RESTORE_AT_SHA = new RegExp(String.raw`git\\s+branch\\s+${REF_NAME}\\s+[0-9a-f]{7,40}(?:\\W|$)`);\n\n// A trailing `wp<number>` was the old squash-merge generation marker (base → basewp2 → basewp3).\n// The tooling NO LONGER produces it — a sync now lands back on the same feature name — but the suffix\n// stays RESERVED so a human branch can't collide with a leftover `…wpN` still floating in a consumer\n// repo mid-transition. Block it at creation time and steer the name back to the plain feature form.\nconst RESERVED_GENERATION_SUFFIX = /wp\\d+$/;\n\n// A branch-creation command that explicitly bases off origin/main (e.g. `git checkout -b feat\n// origin/main`). This is exactly the fresh-main base the guard wants, and it works from ANY current\n// branch or linked worktree — main need not (and in a worktree cannot) be checked out here. Allowed\n// unconditionally so the recovery messages can safely tell you to run it from a worktree.\n//\n// The trailing check is `\\W|$`, not `\\s|$`: the ALLOW pattern must not be stricter about delimiters\n// than the BLOCK pattern above, or a `git checkout -b x origin/main` that ends at a quote or backtick\n// is seen as a branch creation but NOT as an origin/main one — recognised, then wrongly blocked.\nconst ORIGIN_MAIN_BASE = /git\\s+(?:checkout\\s+-[bB]|switch\\s+-[cC])\\s+\\S+\\s+origin\\/main(?:\\W|$)/;\n\n// The worktree arm of the same allow: `git worktree add ../dir -b <name> origin/main`. Same fresh-main\n// base, same reasoning — and in a worktree it is the ONLY workable base, since `git checkout main`\n// fatals there. Kept separate from ORIGIN_MAIN_BASE because the argument shape differs (a path sits\n// between the subcommand and the flags).\nconst WORKTREE_ORIGIN_MAIN_BASE = /git\\s+worktree\\s+add\\s+(?:\\S+\\s+)*origin\\/main(?:\\W|$)/;\n\nfunction extractBranchName(command: string): string | null {\n for (const pattern of BRANCH_PATTERNS) {\n const m = pattern.exec(command);\n if (m) return m[1];\n }\n return null;\n}\n\nfunction truncate(s: string): string {\n const MAX = 120;\n return s.length <= MAX ? s : s.slice(0, MAX) + '…';\n}\n\nfunction checkMainIsUpToDate(ctx: BashContext, requestedName: string): readonly Violation[] {\n // `--no-write-fetch-head` for the same reason as the background refresher (see\n // MainSyncStatusService.fetchOriginMain): this refresh can run while the agent is mid\n // `git fetch`/`git pull`, and two overlapping writers of the unlocked `.git/FETCH_HEAD` can leave\n // a duplicate for-merge line that makes the agent's `git pull` fatal with \"multiple branches\". We\n // read `origin/main` below, never FETCH_HEAD. On a git too old for the flag, retry the plain form.\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n execSync('git fetch --no-write-fetch-head origin main --quiet', { cwd: ctx.workspaceRoot, encoding: 'utf8' });\n } catch (err: unknown) {\n const error = toError(err);\n const text = error.message.toLowerCase();\n const tooOld = text.includes('unknown option') || text.includes('unknown switch') || text.includes('unrecognized option');\n if (!tooOld) throw error;\n execSync('git fetch origin main --quiet', { cwd: ctx.workspaceRoot, encoding: 'utf8' });\n }\n const countStr = execSync('git rev-list HEAD..origin/main --count', {\n cwd: ctx.workspaceRoot,\n encoding: 'utf8',\n }).trim();\n const count = parseInt(countStr, 10);\n if (count > 0) {\n return [new V(\n 1,\n truncate(ctx.command),\n `Local main is ${count} commit(s) behind origin/main. Run 'git pull origin main' first, then retry creating branch '${requestedName}'.`,\n )];\n }\n return [];\n}\n\nexport class BranchCreationGuardRule extends BashRuleBase<BranchCreationGuardConfig> {\n constructor(config: BranchCreationGuardConfig) { super(config, 'branch-creation-guard'); }\n\n readonly description =\n 'Block new-branch and new-worktree creation when main is stale, when branching off a non-main ' +\n 'branch, or when the branch/worktree count is at its cap (forces cleanup of dead ones).';\n override readonly defaultOptions = {\n subBranchNaming: DEFAULT_SUB_BRANCH_NAMING,\n branchFormat: DEFAULT_BRANCH_FORMAT,\n maxLocalBranches: DEFAULT_MAX_LOCAL_BRANCHES,\n maxWorktrees: DEFAULT_MAX_WORKTREES,\n };\n\n private readonly worktrees = new WorktreeService();\n private readonly mergedBranches = new MergedBranchesService(this.worktrees);\n\n // Set by check() when (and only when) a cap is what blocked, so fixHint can render the reap\n // instructions instead of the branch-naming ones. Same instance-field handoff pr-merge-guard uses.\n // Two fields, because the two caps reap different things and their hints share no wording.\n private capCache: MergedBranchesCache | null = null;\n private worktreeCapCache: MergedBranchesCache | null = null;\n\n // True when the blocked command was a `git worktree add`, so the recovery command we hand back is\n // a worktree command and not a `git checkout -b` the user cannot use here.\n private worktreeAdd = false;\n\n private get branchFormat(): string {\n return this.config.branchFormat ?? DEFAULT_BRANCH_FORMAT;\n }\n\n private get subBranchNaming(): string {\n return this.config.subBranchNaming ?? DEFAULT_SUB_BRANCH_NAMING;\n }\n\n private get maxLocalBranches(): number {\n return this.config.maxLocalBranches ?? DEFAULT_MAX_LOCAL_BRANCHES;\n }\n\n private get maxWorktrees(): number {\n return this.config.maxWorktrees ?? DEFAULT_MAX_WORKTREES;\n }\n\n // The recovery command for \"base this off fresh main\", in the flavour of whatever was blocked.\n private freshMainCommand(name: string): string {\n return this.worktreeAdd\n ? `git fetch origin main && git worktree add ../${name.replace(/\\//g, '-')} -b ${name} origin/main`\n : `git fetch origin main && git checkout -b ${name} origin/main`;\n }\n\n // Mode-aware fix hints. Branches off main follow branchFormat — never the sub-branch\n // convention. The sub-branch affordance only appears under mode 'ON'; 'ON_NO_SUBBRANCHES'\n // hard-blocks it and points instead at the turnOffRuleUntilEpoch escape hatch.\n get fixHint(): FixHint {\n const remedies = new CapRemedies();\n if (this.worktreeCapCache) return remedies.worktreeCap();\n if (this.capCache) return remedies.branchCap();\n\n const create = this.worktreeAdd\n ? 'Create it off fresh main: git fetch origin main && git worktree add ../<dir> -b <name> origin/main'\n : 'Create it off fresh main from anywhere (incl. a worktree): git fetch origin main && git checkout -b <name> origin/main';\n\n const options = [\n new Option(create, true),\n new Option(`Name a branch off main per branch-creation-guard.branchFormat: ${this.branchFormat}`),\n ];\n if (this.config.mode === 'ON_NO_SUBBRANCHES') {\n options.push(new Option(\n 'Sub-branches (branching off another feature branch) are disabled. To temporarily allow one, set ' +\n \"branch-creation-guard.turnOffRuleUntilEpoch to a future epoch in webpieces.config.json\",\n ));\n } else {\n options.push(new Option(\n `If you truly need a stacked sub-branch (requires human approval), name it per branch-creation-guard.subBranchNaming: ${this.subBranchNaming}`,\n ));\n }\n return new FixHint(\n 'Cannot create this branch (main is stale, or branching off a non-main branch).',\n 'Create your branch from an up-to-date main. Pick one:',\n options,\n );\n }\n\n /**\n * Strip the parts of a shell command that are DATA rather than executable commands, so the guard\n * stops reading prose as instructions.\n *\n * This guard regex-scans the raw command string and has no notion of quoting, so\n * `git commit -m \"... git checkout -b foo ...\"` — or any heredoc commit message that mentions a\n * branch command — was parsed as an actual branch creation and blocked. That bit three separate\n * times while building the branch cap, including on the cap's own commit. It matters far more now\n * that the cap check runs BEFORE the origin/main allow: at the cap, a merely-MENTIONED branch\n * command would block your commit.\n *\n * A quoted span whose content has no whitespace is kept verbatim (it is a single token — the name\n * in `git checkout -b \"dean/foo\"`), so quoting a branch name cannot smuggle a creation past the\n * guard. Anything with whitespace inside quotes is prose, and collapses to a space.\n */\n check(ctx: BashContext): readonly Violation[] {\n this.capCache = null;\n this.worktreeCapCache = null;\n // Match against the command with heredoc bodies and prose-in-quotes removed (BashContext\n // computes it for every guard now — this rule's private copy was the original).\n const command = ctx.commandCode;\n const requestedName = extractBranchName(command);\n this.worktreeAdd = WORKTREE_ADD.test(command);\n\n // A worktree add with no -b creates no branch, but it DOES spend the worktree budget, so it must\n // survive this early-out and reach the worktree cap below.\n if (!requestedName && !this.worktreeAdd) return [];\n\n // Restoring a reaped branch at its logged SHA is undo, not creation — always allowed, and\n // checked before the caps so a full branch list can never trap you on the recovery path.\n if (!this.worktreeAdd && RESTORE_AT_SHA.test(command)) return [];\n\n const reserved = this.checkReservedSuffix(ctx, requestedName);\n if (reserved) return [reserved];\n\n const capViolation = this.checkCaps(ctx, requestedName !== null);\n if (capViolation) return [capViolation];\n\n // `git worktree add` of an EXISTING branch (or --detach) creates no branch, so the naming and\n // fresh-main rules below do not apply — but one thing still does: the branch may be DEAD.\n if (!requestedName) return this.checkWorktreeOntoDeadBranch(ctx, command);\n\n // Explicitly basing off origin/main is always allowed — it creates the branch from fresh main\n // regardless of the current branch, and is the ONLY way that also works inside a linked worktree\n // (where `git checkout main` fatals). Reserved-name check above still applies.\n if (ORIGIN_MAIN_BASE.test(command)) return [];\n if (this.worktreeAdd && WORKTREE_ORIGIN_MAIN_BASE.test(command)) return [];\n\n const currentBranch = execSync('git rev-parse --abbrev-ref HEAD', {\n cwd: ctx.workspaceRoot,\n encoding: 'utf8',\n }).trim();\n\n if (currentBranch === 'main') {\n return checkMainIsUpToDate(ctx, requestedName);\n }\n\n // Not on main: creating this branch would stack it on a feature branch (a sub-branch).\n if (this.config.mode === 'ON_NO_SUBBRANCHES') {\n return [new V(\n 1,\n truncate(ctx.command),\n `You are on '${currentBranch}', not main. Create the branch OFF origin/main instead of ` +\n `stacking it on this branch: ${this.freshMainCommand(requestedName)} ` +\n `(works here and inside a worktree). ${this.branchFormat}. ` +\n `You can temporarily turn this off if you truly need a sub-branch by setting ` +\n `branch-creation-guard.turnOffRuleUntilEpoch (a future epoch) in webpieces.config.json.`,\n )];\n }\n\n return [new V(\n 1,\n truncate(ctx.command),\n `You are on '${currentBranch}', not main. Branches must be created from fresh main: ` +\n `${this.freshMainCommand(requestedName)}. ${this.branchFormat}. ` +\n `If you truly need a stacked sub-branch (requires human approval), name it per ` +\n `branch-creation-guard.subBranchNaming ('${this.subBranchNaming}').`,\n )];\n }\n\n /**\n * `git worktree add ../dir <existing-branch>` onto a branch whose PR is ALREADY MERGED.\n *\n * The count caps never catch this: the command creates no branch, and if you are under the\n * worktree cap it sails straight through — materialising a fresh directory full of PRE-MERGE\n * code that the AI will then read, plan from and edit. read-stale-guard blocks the reads and\n * feature-branch-guard blocks the edits once you are in there, but that is a turn wasted per\n * tool call. Refuse at the moment of creation instead, using the SAME merged-PR proof the caps\n * already have precomputed on disk.\n *\n * Fails OPEN exactly like both caps: no cache (fresh clone, no `gh`, refresher hasn't run) or an\n * unparseable command → no opinion.\n */\n private checkWorktreeOntoDeadBranch(ctx: BashContext, command: string): readonly Violation[] {\n if (!this.worktreeAdd) return [];\n\n const match = WORKTREE_ADD_EXISTING.exec(command);\n if (!match) return [];\n const branch = match[1];\n // `origin/main` (and any remote-tracking ref) is the RECOMMENDED base, never a dead branch.\n if (branch.startsWith('origin/')) return [];\n\n const cache = this.mergedBranches.readMergedBranches(ctx.workspaceRoot);\n if (!cache) return [];\n\n const dead = cache.deletable.find((entry: DeletableBranch): boolean => entry.branch === branch);\n if (!dead) return [];\n\n const dir = branch.replace(/\\//g, '-');\n return [new V(\n 1,\n truncate(ctx.command),\n `Branch '${branch}' is dead — ${dead.reason}. A worktree on it would be a directory full of ` +\n `PRE-MERGE code: everything you read there is stale relative to origin/main (read-stale-guard ` +\n `blocks those reads) and every edit is blocked by feature-branch-guard. Base the new worktree ` +\n `on fresh main instead: git fetch origin main && git worktree add ../${dir} -b <new-branch> origin/main`,\n )];\n }\n\n // The reserved `…wpN` generation suffix — see RESERVED_GENERATION_SUFFIX for why it stays blocked\n // even though the tooling no longer produces it.\n private checkReservedSuffix(ctx: BashContext, requestedName: string | null): Violation | null {\n if (!requestedName || !RESERVED_GENERATION_SUFFIX.test(requestedName)) return null;\n return new V(\n 1,\n truncate(ctx.command),\n `Branch name '${requestedName}' ends in 'wp<number>', which is reserved for the ` +\n `squash-merge tool's generation marker (base → basewp2 → basewp3). ` +\n `Rename it to a plain feature branch. ${this.branchFormat}.`,\n );\n }\n\n /**\n * Both budgets, in the order that produces the most useful complaint.\n *\n * Called BEFORE the origin/main allow in check() — `... -b <name> origin/main` is the normal,\n * always-permitted path, so a cap checked after it would never once fire.\n *\n * Worktree cap first: a `git worktree add -b` spends BOTH budgets, and when both are full the\n * worktree is the thing the command was actually trying to make, so it is the thing to talk about.\n */\n private checkCaps(ctx: BashContext, createsBranch: boolean): Violation | null {\n if (this.worktreeAdd) {\n const worktreeViolation = this.checkWorktreeCap(ctx);\n if (worktreeViolation) return worktreeViolation;\n }\n if (createsBranch) return this.checkBranchCap(ctx);\n return null;\n }\n\n /**\n * The cap. Blocks branch #N+1 until already-merged branches are reaped, which is the ONLY thing\n * keeping the local branch list bounded.\n *\n * Fails OPEN when the cache is absent (fresh clone, `gh` unavailable, refresher hasn't run yet):\n * never block on data we don't have. The detached refresher regenerates it within one hook call,\n * so the cap starts enforcing on its own.\n */\n private checkBranchCap(ctx: BashContext): Violation | null {\n // PARKED branches only — a branch checked out in a worktree is the worktree cap's problem, and\n // counting it twice would let five worktrees exhaust the branch budget on their own.\n const held = this.worktrees.heldBranches(ctx.workspaceRoot);\n const parked = this.mergedBranches.localBranches(ctx.workspaceRoot)\n .filter((branch: string): boolean => !held.has(branch));\n const count = parked.length;\n const cap = this.effectiveBranchCap(ctx);\n if (count < cap) return null;\n\n const cache = this.loadReconciledCache(ctx);\n if (!cache) return null;\n\n this.capCache = cache;\n\n // No count of \"how many are dead\" any more. It was read off a cache that is stale BY DESIGN,\n // so every branch of that sentence had to hedge (\"None of them are dead\", \"NOT known right\n // now — the cached verdicts are stale\"), and none of the three variants changed the next\n // move: run pnpm wp-cleanup, which recomputes from scratch. Say the one thing that is true.\n return new V(\n 1,\n truncate(ctx.command),\n `You have ${String(count)} parked local branches (worktree-held ones not counted); the cap ` +\n `(branch-creation-guard.maxLocalBranches) is ${String(this.maxLocalBranches)}. Run pnpm wp-cleanup.`,\n );\n }\n\n /**\n * The cache, with entries for branches/worktrees that no longer exist dropped.\n *\n * Reconciling is the fix for the phantom count: the file is written by a detached refresher and is\n * DELIBERATELY allowed to go stale, so it keeps naming branches deleted minutes ago and worktrees\n * already removed. Quoting it verbatim is how the guard announced \"8 parked local branches … none of\n * them are dead\" over a repo that had ONE, and then blocked a legitimate `git worktree add` on that\n * figure. `reconcile` re-checks existence with instant local git reads; it does NOT re-derive any\n * verdict (that needs the network and belongs in the refresher).\n *\n * Its AGE is no longer read here. The cap messages stopped quoting verdict counts entirely, so\n * there is nothing left for a freshness caveat to qualify — `wp-cleanup` recomputes from scratch.\n */\n private loadReconciledCache(ctx: BashContext): MergedBranchesCache | null {\n const raw = this.mergedBranches.readMergedBranches(ctx.workspaceRoot);\n if (!raw) return null;\n return this.mergedBranches.reconcile(ctx.workspaceRoot, raw);\n }\n\n /**\n * The branch cap, yielding by ONE when the agent is standing on an already-merged branch.\n *\n * Two individually-correct guards were composing into a trap: merged-branch-bash-guard blocks\n * almost all Bash until you get off a merged branch, and the ONLY way off it that guard advertises\n * is creating a fresh branch — which this cap then refused. Every printed exit led to editing\n * webpieces.config.json, and that is what an unsupervised agent did.\n *\n * One over cap, and only while a merged branch is what is pushing you: the branch about to be\n * created replaces a branch that is already dead, so the steady-state count does not grow. The cap\n * still fires on the NEXT creation, so this defers the cleanup by exactly one branch, never skips it.\n *\n * Fails toward the strict cap: no cache, a cache for another branch, or a clean branch → no yield.\n */\n private effectiveBranchCap(ctx: BashContext): number {\n // The cache is branch-keyed, so the branch we are standing on is what we look the entry up by.\n // A branch the last refresh never saw misses (null) → the strict cap, as before.\n const current = this.currentBranchOrNull(ctx.workspaceRoot);\n if (current === null) return this.maxLocalBranches;\n const status = readMainSyncStatus(ctx.workspaceRoot, current);\n if (status === null || !status.branchAlreadyMerged) return this.maxLocalBranches;\n if (status.branch !== current) return this.maxLocalBranches;\n return this.maxLocalBranches + 1;\n }\n\n private currentBranchOrNull(workspaceRoot: string): string | null {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return execSync('git rev-parse --abbrev-ref HEAD', {\n cwd: workspaceRoot, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return null;\n }\n }\n\n /**\n * The worktree cap — the second budget. Same gate, same fail-open rule as the branch cap: a\n * worktree list we cannot classify (no cache on disk) blocks nothing.\n *\n * Counts LINKED worktrees only. The primary clone is not a thing anyone can remove, so charging the\n * budget for it would just silently cost you one worktree.\n */\n private checkWorktreeCap(ctx: BashContext): Violation | null {\n const count = this.worktrees.linkedWorktrees(ctx.workspaceRoot).length;\n if (count < this.maxWorktrees) return null;\n\n const cache = this.loadReconciledCache(ctx);\n if (!cache) return null;\n\n this.worktreeCapCache = cache;\n\n return new V(\n 1,\n truncate(ctx.command),\n `You have ${String(count)} linked worktrees; the cap (branch-creation-guard.maxWorktrees) ` +\n `is ${String(this.maxWorktrees)}. Run pnpm wp-cleanup.`,\n );\n }\n\n}\n"]}
|
|
1
|
+
{"version":3,"file":"branch-creation-guard.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/branch-creation-guard.ts"],"names":[],"mappings":"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","sourcesContent":["import { execSync } from 'child_process';\n\nimport {\n BranchCreationGuardConfig,\n DeletableBranch,\n MergedBranchesCache,\n MergedBranchesService,\n WorktreeService,\n readMainSyncStatus,\n} from '@webpieces/rules-config';\n\nimport type { BashContext, Violation } from '../types';\nimport { Violation as V } from '../types';\nimport { BashRuleBase } from '../rule-base';\nimport { FixHint, Option } from '../fix-hint';\nimport { CapRemedies } from './cap-remedies';\nimport { toError } from '../to-error';\n\n// Defaults used when the rule has no explicit value in webpieces.config.json.\n// branchFormat is a human sentence telling the AI how to name a branch created off main; it is\n// intentionally NOT the sub-branch convention (sub-branches are a separate, human-approved path).\nconst DEFAULT_BRANCH_FORMAT =\n 'Name it {whoami}/<short-feature-description> — lowercase, no version numbers, no sub/ prefix (e.g. dean/upgrade-webpieces)';\nconst DEFAULT_SUB_BRANCH_NAMING = 'feature/<ticket>/<short-description>';\n\n// Hard cap on local feature branches. Enforced at CREATION because that is the one moment cleanup is\n// both cheap and obviously worth it — reaping happens over time, never \"ASAP\".\n//\n// The branch cap counts PARKED branches only — branches not checked out in any worktree. Worktree-held\n// branches are counted by the worktree cap instead. Two budgets, because they are not substitutes: if\n// held branches also spent the branch budget, five worktrees would leave room for zero branches and no\n// branch could ever be created again.\nconst DEFAULT_MAX_LOCAL_BRANCHES = 5;\nconst DEFAULT_MAX_WORKTREES = 5;\n\n// A plausible git ref name. Deliberately NOT `[^\\s-]` — that class matches shell metacharacters, so\n// `git branch | wc -l` (a read-only LISTING, piped) was parsed as \"create a branch named `|`\" and\n// blocked. Cleanup work necessarily reads and deletes branches, so a listing must never trip this.\nconst REF_NAME = String.raw`[A-Za-z0-9][A-Za-z0-9_./-]*`;\n\nconst BRANCH_PATTERNS: RegExp[] = [\n new RegExp(String.raw`git\\s+checkout\\s+-[bB]\\s+(${REF_NAME})`),\n new RegExp(String.raw`git\\s+switch\\s+-[cC]\\s+(${REF_NAME})`),\n new RegExp(String.raw`git\\s+branch\\s+(?!-)(${REF_NAME})`),\n // `git worktree add ../dir -b <name> origin/main` — the form docs/git-workflow.md recommends for\n // starting a feature. It creates a branch just as surely as `checkout -b` does, and until this\n // pattern existed it walked straight past the cap, the reserved-suffix check and the sub-branch\n // check. `(?:\\S+\\s+)*?` absorbs the path and any other flags, so the -b may precede or follow them.\n new RegExp(String.raw`git\\s+worktree\\s+add\\s+(?:\\S+\\s+)*?-[bB]\\s+(${REF_NAME})`),\n];\n\n// ANY worktree creation, with or without -b. The no-`-b` forms (`git worktree add ../dir existing`,\n// `--detach`) create no branch but DO create a worktree, so they spend the worktree budget and must\n// still hit the cap.\nconst WORKTREE_ADD = /git\\s+worktree\\s+add\\b/;\n\n// `git worktree add <path> <existing-branch>` — the checkout-an-existing-branch form. Captures the\n// LAST bare (non-flag) argument, which is the committish; the first bare argument is the path.\n// Flags that take a value (`--reason <s>`, `-b <name>`) are excluded by the caller, which only uses\n// this on commands with no `-b`/`-B` at all.\nconst WORKTREE_ADD_EXISTING = new RegExp(\n String.raw`git\\s+worktree\\s+add\\s+(?:-{1,2}[A-Za-z-]+\\s+)*\\S+\\s+(${REF_NAME})`,\n);\n\n// `git branch <name> <sha>` — RESTORING a branch at an explicit commit, which is exactly the\n// `recover=` command wp-cleanup writes to branch-mutations.log for every branch it reaps.\n//\n// This must be allowed UNCONDITIONALLY, ahead of even the caps. The entire argument for letting the\n// tooling delete branches unattended is that any delete is one logged command away from being undone\n// — so a guard that blocks that command turns a real guarantee into a decorative one. (It did: the\n// generic `git branch <name>` creation pattern matched the restore and refused it, demanding the\n// branch be recreated off origin/main, which is precisely the content the restore is meant to bring\n// back.) A restore also cannot grow the branch list beyond what already existed.\nconst RESTORE_AT_SHA = new RegExp(String.raw`git\\s+branch\\s+${REF_NAME}\\s+[0-9a-f]{7,40}(?:\\W|$)`);\n\n// A trailing `wp<number>` was the old squash-merge generation marker (base → basewp2 → basewp3).\n// The tooling NO LONGER produces it — a sync now lands back on the same feature name — but the suffix\n// stays RESERVED so a human branch can't collide with a leftover `…wpN` still floating in a consumer\n// repo mid-transition. Block it at creation time and steer the name back to the plain feature form.\nconst RESERVED_GENERATION_SUFFIX = /wp\\d+$/;\n\n// A branch-creation command that explicitly bases off origin/main (e.g. `git checkout -b feat\n// origin/main`). This is exactly the fresh-main base the guard wants, and it works from ANY current\n// branch or linked worktree — main need not (and in a worktree cannot) be checked out here. Allowed\n// unconditionally so the recovery messages can safely tell you to run it from a worktree.\n//\n// The trailing check is `\\W|$`, not `\\s|$`: the ALLOW pattern must not be stricter about delimiters\n// than the BLOCK pattern above, or a `git checkout -b x origin/main` that ends at a quote or backtick\n// is seen as a branch creation but NOT as an origin/main one — recognised, then wrongly blocked.\nconst ORIGIN_MAIN_BASE = /git\\s+(?:checkout\\s+-[bB]|switch\\s+-[cC])\\s+\\S+\\s+origin\\/main(?:\\W|$)/;\n\n// The worktree arm of the same allow: `git worktree add ../dir -b <name> origin/main`. Same fresh-main\n// base, same reasoning — and in a worktree it is the ONLY workable base, since `git checkout main`\n// fatals there. Kept separate from ORIGIN_MAIN_BASE because the argument shape differs (a path sits\n// between the subcommand and the flags).\nconst WORKTREE_ORIGIN_MAIN_BASE = /git\\s+worktree\\s+add\\s+(?:\\S+\\s+)*origin\\/main(?:\\W|$)/;\n\nfunction extractBranchName(command: string): string | null {\n for (const pattern of BRANCH_PATTERNS) {\n const m = pattern.exec(command);\n if (m) return m[1];\n }\n return null;\n}\n\nfunction truncate(s: string): string {\n const MAX = 120;\n return s.length <= MAX ? s : s.slice(0, MAX) + '…';\n}\n\nfunction checkMainIsUpToDate(ctx: BashContext, requestedName: string): readonly Violation[] {\n // `--no-write-fetch-head` for the same reason as the background refresher (see\n // MainSyncStatusService.fetchOriginMain): this refresh can run while the agent is mid\n // `git fetch`/`git pull`, and two overlapping writers of the unlocked `.git/FETCH_HEAD` can leave\n // a duplicate for-merge line that makes the agent's `git pull` fatal with \"multiple branches\". We\n // read `origin/main` below, never FETCH_HEAD. On a git too old for the flag, retry the plain form.\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n execSync('git fetch --no-write-fetch-head origin main --quiet', { cwd: ctx.workspaceRoot, encoding: 'utf8' });\n } catch (err: unknown) {\n const error = toError(err);\n const text = error.message.toLowerCase();\n const tooOld = text.includes('unknown option') || text.includes('unknown switch') || text.includes('unrecognized option');\n if (!tooOld) throw error;\n execSync('git fetch origin main --quiet', { cwd: ctx.workspaceRoot, encoding: 'utf8' });\n }\n const countStr = execSync('git rev-list HEAD..origin/main --count', {\n cwd: ctx.workspaceRoot,\n encoding: 'utf8',\n }).trim();\n const count = parseInt(countStr, 10);\n if (count > 0) {\n return [new V(\n 1,\n truncate(ctx.command),\n `Local main is ${count} commit(s) behind origin/main. Run 'git pull origin main' first, then retry creating branch '${requestedName}'.`,\n )];\n }\n return [];\n}\n\nexport class BranchCreationGuardRule extends BashRuleBase<BranchCreationGuardConfig> {\n constructor(config: BranchCreationGuardConfig) { super(config, 'branch-creation-guard', 'branch-creation-guard'); }\n\n readonly description =\n 'Block new-branch and new-worktree creation when main is stale, when branching off a non-main ' +\n 'branch, or when the branch/worktree count is at its cap (forces cleanup of dead ones).';\n override readonly defaultOptions = {\n subBranchNaming: DEFAULT_SUB_BRANCH_NAMING,\n branchFormat: DEFAULT_BRANCH_FORMAT,\n maxLocalBranches: DEFAULT_MAX_LOCAL_BRANCHES,\n maxWorktrees: DEFAULT_MAX_WORKTREES,\n };\n\n private readonly worktrees = new WorktreeService();\n private readonly mergedBranches = new MergedBranchesService(this.worktrees);\n\n // Set by check() when (and only when) a cap is what blocked, so fixHint can render the reap\n // instructions instead of the branch-naming ones. Same instance-field handoff pr-merge-guard uses.\n // Two fields, because the two caps reap different things and their hints share no wording.\n private capCache: MergedBranchesCache | null = null;\n private worktreeCapCache: MergedBranchesCache | null = null;\n\n // True when the blocked command was a `git worktree add`, so the recovery command we hand back is\n // a worktree command and not a `git checkout -b` the user cannot use here.\n private worktreeAdd = false;\n\n private get branchFormat(): string {\n return this.config.branchFormat ?? DEFAULT_BRANCH_FORMAT;\n }\n\n private get subBranchNaming(): string {\n return this.config.subBranchNaming ?? DEFAULT_SUB_BRANCH_NAMING;\n }\n\n private get maxLocalBranches(): number {\n return this.config.maxLocalBranches ?? DEFAULT_MAX_LOCAL_BRANCHES;\n }\n\n private get maxWorktrees(): number {\n return this.config.maxWorktrees ?? DEFAULT_MAX_WORKTREES;\n }\n\n // The recovery command for \"base this off fresh main\", in the flavour of whatever was blocked.\n private freshMainCommand(name: string): string {\n return this.worktreeAdd\n ? `git fetch origin main && git worktree add ../${name.replace(/\\//g, '-')} -b ${name} origin/main`\n : `git fetch origin main && git checkout -b ${name} origin/main`;\n }\n\n // Mode-aware fix hints. Branches off main follow branchFormat — never the sub-branch\n // convention. The sub-branch affordance only appears under mode 'ON'; 'ON_NO_SUBBRANCHES'\n // hard-blocks it and points instead at the turnOffRuleUntilEpoch escape hatch.\n get fixHint(): FixHint {\n const remedies = new CapRemedies();\n if (this.worktreeCapCache) return remedies.worktreeCap();\n if (this.capCache) return remedies.branchCap();\n\n const create = this.worktreeAdd\n ? 'Create it off fresh main: git fetch origin main && git worktree add ../<dir> -b <name> origin/main'\n : 'Create it off fresh main from anywhere (incl. a worktree): git fetch origin main && git checkout -b <name> origin/main';\n\n const options = [\n new Option(create, true),\n new Option(`Name a branch off main per branch-creation-guard.branchFormat: ${this.branchFormat}`),\n ];\n if (this.config.mode === 'ON_NO_SUBBRANCHES') {\n options.push(new Option(\n 'Sub-branches (branching off another feature branch) are disabled. To temporarily allow one, set ' +\n \"branch-creation-guard.turnOffRuleUntilEpoch to a future epoch in webpieces.config.json\",\n ));\n } else {\n options.push(new Option(\n `If you truly need a stacked sub-branch (requires human approval), name it per branch-creation-guard.subBranchNaming: ${this.subBranchNaming}`,\n ));\n }\n return new FixHint(\n 'Cannot create this branch (main is stale, or branching off a non-main branch).',\n 'Create your branch from an up-to-date main. Pick one:',\n options,\n );\n }\n\n /**\n * Strip the parts of a shell command that are DATA rather than executable commands, so the guard\n * stops reading prose as instructions.\n *\n * This guard regex-scans the raw command string and has no notion of quoting, so\n * `git commit -m \"... git checkout -b foo ...\"` — or any heredoc commit message that mentions a\n * branch command — was parsed as an actual branch creation and blocked. That bit three separate\n * times while building the branch cap, including on the cap's own commit. It matters far more now\n * that the cap check runs BEFORE the origin/main allow: at the cap, a merely-MENTIONED branch\n * command would block your commit.\n *\n * A quoted span whose content has no whitespace is kept verbatim (it is a single token — the name\n * in `git checkout -b \"dean/foo\"`), so quoting a branch name cannot smuggle a creation past the\n * guard. Anything with whitespace inside quotes is prose, and collapses to a space.\n */\n check(ctx: BashContext): readonly Violation[] {\n this.capCache = null;\n this.worktreeCapCache = null;\n // Match against the command with heredoc bodies and prose-in-quotes removed (BashContext\n // computes it for every guard now — this rule's private copy was the original).\n const command = ctx.commandCode;\n const requestedName = extractBranchName(command);\n this.worktreeAdd = WORKTREE_ADD.test(command);\n\n // A worktree add with no -b creates no branch, but it DOES spend the worktree budget, so it must\n // survive this early-out and reach the worktree cap below.\n if (!requestedName && !this.worktreeAdd) return [];\n\n // Restoring a reaped branch at its logged SHA is undo, not creation — always allowed, and\n // checked before the caps so a full branch list can never trap you on the recovery path.\n if (!this.worktreeAdd && RESTORE_AT_SHA.test(command)) return [];\n\n const reserved = this.checkReservedSuffix(ctx, requestedName);\n if (reserved) return [reserved];\n\n const capViolation = this.checkCaps(ctx, requestedName !== null);\n if (capViolation) return [capViolation];\n\n // `git worktree add` of an EXISTING branch (or --detach) creates no branch, so the naming and\n // fresh-main rules below do not apply — but one thing still does: the branch may be DEAD.\n if (!requestedName) return this.checkWorktreeOntoDeadBranch(ctx, command);\n\n // Explicitly basing off origin/main is always allowed — it creates the branch from fresh main\n // regardless of the current branch, and is the ONLY way that also works inside a linked worktree\n // (where `git checkout main` fatals). Reserved-name check above still applies.\n if (ORIGIN_MAIN_BASE.test(command)) return [];\n if (this.worktreeAdd && WORKTREE_ORIGIN_MAIN_BASE.test(command)) return [];\n\n const currentBranch = execSync('git rev-parse --abbrev-ref HEAD', {\n cwd: ctx.workspaceRoot,\n encoding: 'utf8',\n }).trim();\n\n if (currentBranch === 'main') {\n return checkMainIsUpToDate(ctx, requestedName);\n }\n\n // Not on main: creating this branch would stack it on a feature branch (a sub-branch).\n if (this.config.mode === 'ON_NO_SUBBRANCHES') {\n return [new V(\n 1,\n truncate(ctx.command),\n `You are on '${currentBranch}', not main. Create the branch OFF origin/main instead of ` +\n `stacking it on this branch: ${this.freshMainCommand(requestedName)} ` +\n `(works here and inside a worktree). ${this.branchFormat}. ` +\n `You can temporarily turn this off if you truly need a sub-branch by setting ` +\n `branch-creation-guard.turnOffRuleUntilEpoch (a future epoch) in webpieces.config.json.`,\n )];\n }\n\n return [new V(\n 1,\n truncate(ctx.command),\n `You are on '${currentBranch}', not main. Branches must be created from fresh main: ` +\n `${this.freshMainCommand(requestedName)}. ${this.branchFormat}. ` +\n `If you truly need a stacked sub-branch (requires human approval), name it per ` +\n `branch-creation-guard.subBranchNaming ('${this.subBranchNaming}').`,\n )];\n }\n\n /**\n * `git worktree add ../dir <existing-branch>` onto a branch whose PR is ALREADY MERGED.\n *\n * The count caps never catch this: the command creates no branch, and if you are under the\n * worktree cap it sails straight through — materialising a fresh directory full of PRE-MERGE\n * code that the AI will then read, plan from and edit. read-stale-guard blocks the reads and\n * feature-branch-guard blocks the edits once you are in there, but that is a turn wasted per\n * tool call. Refuse at the moment of creation instead, using the SAME merged-PR proof the caps\n * already have precomputed on disk.\n *\n * Fails OPEN exactly like both caps: no cache (fresh clone, no `gh`, refresher hasn't run) or an\n * unparseable command → no opinion.\n */\n private checkWorktreeOntoDeadBranch(ctx: BashContext, command: string): readonly Violation[] {\n if (!this.worktreeAdd) return [];\n\n const match = WORKTREE_ADD_EXISTING.exec(command);\n if (!match) return [];\n const branch = match[1];\n // `origin/main` (and any remote-tracking ref) is the RECOMMENDED base, never a dead branch.\n if (branch.startsWith('origin/')) return [];\n\n const cache = this.mergedBranches.readMergedBranches(ctx.workspaceRoot);\n if (!cache) return [];\n\n const dead = cache.deletable.find((entry: DeletableBranch): boolean => entry.branch === branch);\n if (!dead) return [];\n\n const dir = branch.replace(/\\//g, '-');\n return [new V(\n 1,\n truncate(ctx.command),\n `Branch '${branch}' is dead — ${dead.reason}. A worktree on it would be a directory full of ` +\n `PRE-MERGE code: everything you read there is stale relative to origin/main (read-stale-guard ` +\n `blocks those reads) and every edit is blocked by feature-branch-guard. Base the new worktree ` +\n `on fresh main instead: git fetch origin main && git worktree add ../${dir} -b <new-branch> origin/main`,\n )];\n }\n\n // The reserved `…wpN` generation suffix — see RESERVED_GENERATION_SUFFIX for why it stays blocked\n // even though the tooling no longer produces it.\n private checkReservedSuffix(ctx: BashContext, requestedName: string | null): Violation | null {\n if (!requestedName || !RESERVED_GENERATION_SUFFIX.test(requestedName)) return null;\n return new V(\n 1,\n truncate(ctx.command),\n `Branch name '${requestedName}' ends in 'wp<number>', which is reserved for the ` +\n `squash-merge tool's generation marker (base → basewp2 → basewp3). ` +\n `Rename it to a plain feature branch. ${this.branchFormat}.`,\n );\n }\n\n /**\n * Both budgets, in the order that produces the most useful complaint.\n *\n * Called BEFORE the origin/main allow in check() — `... -b <name> origin/main` is the normal,\n * always-permitted path, so a cap checked after it would never once fire.\n *\n * Worktree cap first: a `git worktree add -b` spends BOTH budgets, and when both are full the\n * worktree is the thing the command was actually trying to make, so it is the thing to talk about.\n */\n private checkCaps(ctx: BashContext, createsBranch: boolean): Violation | null {\n if (this.worktreeAdd) {\n const worktreeViolation = this.checkWorktreeCap(ctx);\n if (worktreeViolation) return worktreeViolation;\n }\n if (createsBranch) return this.checkBranchCap(ctx);\n return null;\n }\n\n /**\n * The cap. Blocks branch #N+1 until already-merged branches are reaped, which is the ONLY thing\n * keeping the local branch list bounded.\n *\n * Fails OPEN when the cache is absent (fresh clone, `gh` unavailable, refresher hasn't run yet):\n * never block on data we don't have. The detached refresher regenerates it within one hook call,\n * so the cap starts enforcing on its own.\n */\n private checkBranchCap(ctx: BashContext): Violation | null {\n // PARKED branches only — a branch checked out in a worktree is the worktree cap's problem, and\n // counting it twice would let five worktrees exhaust the branch budget on their own.\n const held = this.worktrees.heldBranches(ctx.workspaceRoot);\n const parked = this.mergedBranches.localBranches(ctx.workspaceRoot)\n .filter((branch: string): boolean => !held.has(branch));\n const count = parked.length;\n const cap = this.effectiveBranchCap(ctx);\n if (count < cap) return null;\n\n const cache = this.loadReconciledCache(ctx);\n if (!cache) return null;\n\n this.capCache = cache;\n\n // No count of \"how many are dead\" any more. It was read off a cache that is stale BY DESIGN,\n // so every branch of that sentence had to hedge (\"None of them are dead\", \"NOT known right\n // now — the cached verdicts are stale\"), and none of the three variants changed the next\n // move: run pnpm wp-cleanup, which recomputes from scratch. Say the one thing that is true.\n return new V(\n 1,\n truncate(ctx.command),\n `You have ${String(count)} parked local branches (worktree-held ones not counted); the cap ` +\n `(branch-creation-guard.maxLocalBranches) is ${String(this.maxLocalBranches)}. Run pnpm wp-cleanup.`,\n );\n }\n\n /**\n * The cache, with entries for branches/worktrees that no longer exist dropped.\n *\n * Reconciling is the fix for the phantom count: the file is written by a detached refresher and is\n * DELIBERATELY allowed to go stale, so it keeps naming branches deleted minutes ago and worktrees\n * already removed. Quoting it verbatim is how the guard announced \"8 parked local branches … none of\n * them are dead\" over a repo that had ONE, and then blocked a legitimate `git worktree add` on that\n * figure. `reconcile` re-checks existence with instant local git reads; it does NOT re-derive any\n * verdict (that needs the network and belongs in the refresher).\n *\n * Its AGE is no longer read here. The cap messages stopped quoting verdict counts entirely, so\n * there is nothing left for a freshness caveat to qualify — `wp-cleanup` recomputes from scratch.\n */\n private loadReconciledCache(ctx: BashContext): MergedBranchesCache | null {\n const raw = this.mergedBranches.readMergedBranches(ctx.workspaceRoot);\n if (!raw) return null;\n return this.mergedBranches.reconcile(ctx.workspaceRoot, raw);\n }\n\n /**\n * The branch cap, yielding by ONE when the agent is standing on an already-merged branch.\n *\n * Two individually-correct guards were composing into a trap: merged-branch-bash-guard blocks\n * almost all Bash until you get off a merged branch, and the ONLY way off it that guard advertises\n * is creating a fresh branch — which this cap then refused. Every printed exit led to editing\n * webpieces.config.json, and that is what an unsupervised agent did.\n *\n * One over cap, and only while a merged branch is what is pushing you: the branch about to be\n * created replaces a branch that is already dead, so the steady-state count does not grow. The cap\n * still fires on the NEXT creation, so this defers the cleanup by exactly one branch, never skips it.\n *\n * Fails toward the strict cap: no cache, a cache for another branch, or a clean branch → no yield.\n */\n private effectiveBranchCap(ctx: BashContext): number {\n // The cache is branch-keyed, so the branch we are standing on is what we look the entry up by.\n // A branch the last refresh never saw misses (null) → the strict cap, as before.\n const current = this.currentBranchOrNull(ctx.workspaceRoot);\n if (current === null) return this.maxLocalBranches;\n const status = readMainSyncStatus(ctx.workspaceRoot, current);\n if (status === null || !status.branchAlreadyMerged) return this.maxLocalBranches;\n if (status.branch !== current) return this.maxLocalBranches;\n return this.maxLocalBranches + 1;\n }\n\n private currentBranchOrNull(workspaceRoot: string): string | null {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return execSync('git rev-parse --abbrev-ref HEAD', {\n cwd: workspaceRoot, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return null;\n }\n }\n\n /**\n * The worktree cap — the second budget. Same gate, same fail-open rule as the branch cap: a\n * worktree list we cannot classify (no cache on disk) blocks nothing.\n *\n * Counts LINKED worktrees only. The primary clone is not a thing anyone can remove, so charging the\n * budget for it would just silently cost you one worktree.\n */\n private checkWorktreeCap(ctx: BashContext): Violation | null {\n const count = this.worktrees.linkedWorktrees(ctx.workspaceRoot).length;\n if (count < this.maxWorktrees) return null;\n\n const cache = this.loadReconciledCache(ctx);\n if (!cache) return null;\n\n this.worktreeCapCache = cache;\n\n return new V(\n 1,\n truncate(ctx.command),\n `You have ${String(count)} linked worktrees; the cap (branch-creation-guard.maxWorktrees) ` +\n `is ${String(this.maxWorktrees)}. Run pnpm wp-cleanup.`,\n );\n }\n\n}\n"]}
|
|
@@ -32,7 +32,7 @@ function findToErrorStatement(lines, startIndex) {
|
|
|
32
32
|
return 'end-of-content';
|
|
33
33
|
}
|
|
34
34
|
class CatchErrorPatternRule extends rule_base_1.EditRuleBase {
|
|
35
|
-
constructor(config) { super(config, 'catch-error-pattern'); }
|
|
35
|
+
constructor(config) { super(config, 'catch-error-pattern', 'catch-error-pattern'); }
|
|
36
36
|
description = 'Catch blocks must use: catch (err: unknown) { const error = toError(err); }'; // webpieces-disable catch-error-pattern -- example text in a description string
|
|
37
37
|
files = ['**/*.ts', '**/*.tsx'];
|
|
38
38
|
get fixHint() {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"catch-error-pattern.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/catch-error-pattern.ts"],"names":[],"mappings":"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","sourcesContent":["import { CatchErrorPatternConfig, RULE_NAMES } from '@webpieces/rules-config';\n\nimport type { EditContext, Violation } from '../types';\nimport { Violation as V } from '../types';\nimport { EditRuleBase } from '../rule-base';\nimport { FixHint, Option, DisableEscape } from '../fix-hint';\nimport { writeTemplateIfMissing } from '../instruct-ai-writer';\n\n/**\n * Matches a catch clause opening: } catch (paramName: typeAnnotation) {\n * Captures: group 1 = param name, group 2 = type annotation (if present)\n */\nconst CATCH_PATTERN = /\\bcatch\\s*\\(\\s*(\\w+)(?:\\s*:\\s*(\\w+))?\\s*\\)/;\n\n/**\n * Matches the required toError first statement (with or without comment-out).\n * Group 1 = variable name, group 2 = param passed to toError\n */\nconst TO_ERROR_PATTERN = /^\\s*(?:\\/\\/\\s*)?const\\s+(\\w+)\\s*=\\s*toError\\(\\s*(\\w+)\\s*\\)\\s*;?\\s*$/;\n\ninterface ToErrorMatch {\n varName: string;\n paramName: string;\n lineIndex: number;\n}\n\nfunction findToErrorStatement(lines: readonly string[], startIndex: number): ToErrorMatch | 'not-found' | 'end-of-content' {\n for (let j = startIndex; j < lines.length; j += 1) {\n const line = lines[j].trim();\n if (line === '' || line === '{') continue;\n\n const match = TO_ERROR_PATTERN.exec(line);\n if (match) {\n return { varName: match[1], paramName: match[2], lineIndex: j };\n }\n // First non-blank line is not a toError call\n return 'not-found';\n }\n // Ran off the end of the edit content — can't validate further\n return 'end-of-content';\n}\n\nexport class CatchErrorPatternRule extends EditRuleBase<CatchErrorPatternConfig> {\n constructor(config: CatchErrorPatternConfig) { super(config, 'catch-error-pattern'); }\n\n readonly description = 'Catch blocks must use: catch (err: unknown) { const error = toError(err); }'; // webpieces-disable catch-error-pattern -- example text in a description string\n override readonly files = ['**/*.ts', '**/*.tsx'];\n get fixHint(): FixHint {\n return new FixHint(\n 'Catch block does not follow the toError(err) pattern.',\n 'Name the catch parameter err (err2/err3 when nested), then pick one:',\n [\n new Option('Add as the first statement in the catch block: const error = toError(err);', true),\n new Option('To explicitly ignore the error: //const error = toError(err);'),\n ],\n new DisableEscape(this.config.disableAllowed ?? true, '// webpieces-disable catch-error-pattern -- <reason>'), // webpieces-disable catch-error-pattern -- example text in a hint string\n );\n }\n\n check(ctx: EditContext): readonly Violation[] {\n const disableAllowed = this.config.disableAllowed ?? true;\n const violations: V[] = [];\n const lines = ctx.strippedLines;\n\n for (let i = 0; i < lines.length; i += 1) {\n const stripped = lines[i];\n const catchMatch = CATCH_PATTERN.exec(stripped);\n if (!catchMatch) continue;\n\n const lineNum = i + 1;\n if (disableAllowed && ctx.isLineDisabled(lineNum, RULE_NAMES.CATCH_ERROR_PATTERN)) continue;\n\n const actualParam = catchMatch[1];\n const typeAnnotation = catchMatch[2];\n\n // Determine expected names from suffix on the actual param (err, err2, err3...)\n const suffixMatch = actualParam.match(/^err(\\d*)$/);\n const suffix = suffixMatch ? suffixMatch[1] : '';\n const expectedParam = 'err' + suffix;\n const expectedVar = 'error' + suffix;\n\n // Check parameter name\n if (actualParam !== expectedParam) {\n violations.push(new V(\n lineNum,\n ctx.lines[i].trim(),\n `Catch parameter must be named \"${expectedParam}\" (or \"err2\", \"err3\" for nested catches), got \"${actualParam}\"`,\n ));\n }\n\n // Check type annotation is unknown\n if (typeAnnotation !== 'unknown') {\n const msg = typeAnnotation\n ? `Catch parameter must be typed as \"unknown\": catch (${expectedParam}: unknown), got \"${typeAnnotation}\"`\n : `Catch parameter must be typed as \"unknown\": catch (${expectedParam}: unknown)`;\n violations.push(new V(lineNum, ctx.lines[i].trim(), msg));\n }\n\n // Find next non-blank line after the catch opening to check for toError\n const toErrorResult = findToErrorStatement(lines, i + 1);\n if (toErrorResult === 'not-found') {\n violations.push(new V(\n lineNum,\n ctx.lines[i].trim(),\n `Catch block must call toError(${actualParam}) as first statement: const ${expectedVar} = toError(${actualParam}); or //const ${expectedVar} = toError(${actualParam});`,\n ));\n } else if (toErrorResult !== 'end-of-content') {\n // Validate variable name and param match\n if (toErrorResult.varName !== expectedVar) {\n const toErrorLineNum = toErrorResult.lineIndex + 1;\n violations.push(new V(\n toErrorLineNum,\n ctx.lines[toErrorResult.lineIndex].trim(),\n `Error variable must be named \"${expectedVar}\", got \"${toErrorResult.varName}\"`,\n ));\n }\n if (toErrorResult.paramName !== actualParam) {\n const toErrorLineNum = toErrorResult.lineIndex + 1;\n violations.push(new V(\n toErrorLineNum,\n ctx.lines[toErrorResult.lineIndex].trim(),\n `toError() must be called with \"${actualParam}\", got \"${toErrorResult.paramName}\"`,\n ));\n }\n }\n }\n if (violations.length > 0) writeTemplateIfMissing(ctx.workspaceRoot, 'webpieces.exceptions.md');\n return violations;\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"catch-error-pattern.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/catch-error-pattern.ts"],"names":[],"mappings":"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","sourcesContent":["import { CatchErrorPatternConfig, RULE_NAMES } from '@webpieces/rules-config';\n\nimport type { EditContext, Violation } from '../types';\nimport { Violation as V } from '../types';\nimport { EditRuleBase } from '../rule-base';\nimport { FixHint, Option, DisableEscape } from '../fix-hint';\nimport { writeTemplateIfMissing } from '../instruct-ai-writer';\n\n/**\n * Matches a catch clause opening: } catch (paramName: typeAnnotation) {\n * Captures: group 1 = param name, group 2 = type annotation (if present)\n */\nconst CATCH_PATTERN = /\\bcatch\\s*\\(\\s*(\\w+)(?:\\s*:\\s*(\\w+))?\\s*\\)/;\n\n/**\n * Matches the required toError first statement (with or without comment-out).\n * Group 1 = variable name, group 2 = param passed to toError\n */\nconst TO_ERROR_PATTERN = /^\\s*(?:\\/\\/\\s*)?const\\s+(\\w+)\\s*=\\s*toError\\(\\s*(\\w+)\\s*\\)\\s*;?\\s*$/;\n\ninterface ToErrorMatch {\n varName: string;\n paramName: string;\n lineIndex: number;\n}\n\nfunction findToErrorStatement(lines: readonly string[], startIndex: number): ToErrorMatch | 'not-found' | 'end-of-content' {\n for (let j = startIndex; j < lines.length; j += 1) {\n const line = lines[j].trim();\n if (line === '' || line === '{') continue;\n\n const match = TO_ERROR_PATTERN.exec(line);\n if (match) {\n return { varName: match[1], paramName: match[2], lineIndex: j };\n }\n // First non-blank line is not a toError call\n return 'not-found';\n }\n // Ran off the end of the edit content — can't validate further\n return 'end-of-content';\n}\n\nexport class CatchErrorPatternRule extends EditRuleBase<CatchErrorPatternConfig> {\n constructor(config: CatchErrorPatternConfig) { super(config, 'catch-error-pattern', 'catch-error-pattern'); }\n\n readonly description = 'Catch blocks must use: catch (err: unknown) { const error = toError(err); }'; // webpieces-disable catch-error-pattern -- example text in a description string\n override readonly files = ['**/*.ts', '**/*.tsx'];\n get fixHint(): FixHint {\n return new FixHint(\n 'Catch block does not follow the toError(err) pattern.',\n 'Name the catch parameter err (err2/err3 when nested), then pick one:',\n [\n new Option('Add as the first statement in the catch block: const error = toError(err);', true),\n new Option('To explicitly ignore the error: //const error = toError(err);'),\n ],\n new DisableEscape(this.config.disableAllowed ?? true, '// webpieces-disable catch-error-pattern -- <reason>'), // webpieces-disable catch-error-pattern -- example text in a hint string\n );\n }\n\n check(ctx: EditContext): readonly Violation[] {\n const disableAllowed = this.config.disableAllowed ?? true;\n const violations: V[] = [];\n const lines = ctx.strippedLines;\n\n for (let i = 0; i < lines.length; i += 1) {\n const stripped = lines[i];\n const catchMatch = CATCH_PATTERN.exec(stripped);\n if (!catchMatch) continue;\n\n const lineNum = i + 1;\n if (disableAllowed && ctx.isLineDisabled(lineNum, RULE_NAMES.CATCH_ERROR_PATTERN)) continue;\n\n const actualParam = catchMatch[1];\n const typeAnnotation = catchMatch[2];\n\n // Determine expected names from suffix on the actual param (err, err2, err3...)\n const suffixMatch = actualParam.match(/^err(\\d*)$/);\n const suffix = suffixMatch ? suffixMatch[1] : '';\n const expectedParam = 'err' + suffix;\n const expectedVar = 'error' + suffix;\n\n // Check parameter name\n if (actualParam !== expectedParam) {\n violations.push(new V(\n lineNum,\n ctx.lines[i].trim(),\n `Catch parameter must be named \"${expectedParam}\" (or \"err2\", \"err3\" for nested catches), got \"${actualParam}\"`,\n ));\n }\n\n // Check type annotation is unknown\n if (typeAnnotation !== 'unknown') {\n const msg = typeAnnotation\n ? `Catch parameter must be typed as \"unknown\": catch (${expectedParam}: unknown), got \"${typeAnnotation}\"`\n : `Catch parameter must be typed as \"unknown\": catch (${expectedParam}: unknown)`;\n violations.push(new V(lineNum, ctx.lines[i].trim(), msg));\n }\n\n // Find next non-blank line after the catch opening to check for toError\n const toErrorResult = findToErrorStatement(lines, i + 1);\n if (toErrorResult === 'not-found') {\n violations.push(new V(\n lineNum,\n ctx.lines[i].trim(),\n `Catch block must call toError(${actualParam}) as first statement: const ${expectedVar} = toError(${actualParam}); or //const ${expectedVar} = toError(${actualParam});`,\n ));\n } else if (toErrorResult !== 'end-of-content') {\n // Validate variable name and param match\n if (toErrorResult.varName !== expectedVar) {\n const toErrorLineNum = toErrorResult.lineIndex + 1;\n violations.push(new V(\n toErrorLineNum,\n ctx.lines[toErrorResult.lineIndex].trim(),\n `Error variable must be named \"${expectedVar}\", got \"${toErrorResult.varName}\"`,\n ));\n }\n if (toErrorResult.paramName !== actualParam) {\n const toErrorLineNum = toErrorResult.lineIndex + 1;\n violations.push(new V(\n toErrorLineNum,\n ctx.lines[toErrorResult.lineIndex].trim(),\n `toError() must be called with \"${actualParam}\", got \"${toErrorResult.paramName}\"`,\n ));\n }\n }\n }\n if (violations.length > 0) writeTemplateIfMissing(ctx.workspaceRoot, 'webpieces.exceptions.md');\n return violations;\n }\n}\n"]}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { BranchStateGuardConfig } from '@webpieces/rules-config';
|
|
2
2
|
import type { FileContext, Violation } from '../types';
|
|
3
3
|
import { FileRuleBase } from '../rule-base';
|
|
4
4
|
import { FixHint } from '../fix-hint';
|
|
@@ -15,8 +15,8 @@ import { FixHint } from '../fix-hint';
|
|
|
15
15
|
* hookGuard); file-scoped, so only Write/Edit/MultiEdit are guarded — Bash passes through so the AI
|
|
16
16
|
* can still run `pnpm wp-start-upsert-pr` and the rest of the recovery flow.
|
|
17
17
|
*/
|
|
18
|
-
export declare class FeatureBranchGuardRule extends FileRuleBase<
|
|
19
|
-
constructor(config:
|
|
18
|
+
export declare class FeatureBranchGuardRule extends FileRuleBase<BranchStateGuardConfig> {
|
|
19
|
+
constructor(config: BranchStateGuardConfig);
|
|
20
20
|
readonly description = "Block edits unless you are on a proper feature branch (not main, not already-merged, forked, in sync with main).";
|
|
21
21
|
readonly files: string[];
|
|
22
22
|
readonly defaultOptions: {
|
|
@@ -8,6 +8,7 @@ const rule_base_1 = require("../rule-base");
|
|
|
8
8
|
const fix_hint_1 = require("../fix-hint");
|
|
9
9
|
const to_error_1 = require("../to-error");
|
|
10
10
|
const main_sync_refresh_1 = require("../main-sync-refresh");
|
|
11
|
+
const main_sync_timeout_1 = require("../main-sync-timeout");
|
|
11
12
|
const decision_log_1 = require("../decision-log");
|
|
12
13
|
const l0_fault_codes_1 = require("../l0-fault-codes");
|
|
13
14
|
const merged_branch_message_1 = require("./merged-branch-message");
|
|
@@ -26,7 +27,9 @@ const tree_recovery_1 = require("./tree-recovery");
|
|
|
26
27
|
* can still run `pnpm wp-start-upsert-pr` and the rest of the recovery flow.
|
|
27
28
|
*/
|
|
28
29
|
class FeatureBranchGuardRule extends rule_base_1.FileRuleBase {
|
|
29
|
-
|
|
30
|
+
// NAME is this class's operator identity (every `rule=` in the log, every deny header);
|
|
31
|
+
// CONFIG KEY is the one branch-state policy entry all four of these guards read. See AbstractRule.
|
|
32
|
+
constructor(config) { super(config, 'feature-branch-guard', rules_config_1.BRANCH_STATE_GUARD_KEY); }
|
|
30
33
|
description = 'Block edits unless you are on a proper feature branch (not main, not already-merged, forked, in sync with main).';
|
|
31
34
|
files = ['**/*'];
|
|
32
35
|
defaultOptions = {
|
|
@@ -36,7 +39,7 @@ class FeatureBranchGuardRule extends rule_base_1.FileRuleBase {
|
|
|
36
39
|
fixHint = new fix_hint_1.FixHint('You are not on a clean, up-to-date feature branch.', 'You must be on a clean, up-to-date feature branch to edit code. Pick one:', [
|
|
37
40
|
new fix_hint_1.Option('On main → create a feature branch. Already merged → branch off fresh main.', true),
|
|
38
41
|
new fix_hint_1.Option('main moved/conflicts, NO PR yet → `pnpm wp-start-update` (merge), `/wp-merge` (resolve), `pnpm wp-finish-update`. An OPEN PR? then you MUST use `pnpm wp-start-upsert-pr` → `/wp-merge` → `pnpm wp-finish-upsert-pr` (the merge rewrites the branch, so the PR must be re-pointed in the same run). Never mix a start from one pair with a finish from the other.'),
|
|
39
|
-
new fix_hint_1.Option('Disable in webpieces.config.json under
|
|
42
|
+
new fix_hint_1.Option('Disable in webpieces.config.json under hookGuards → branch-state-guard (mode OFF) if intentional — that one key governs the Write, Read and Bash halves of this policy together.'),
|
|
40
43
|
]);
|
|
41
44
|
check(ctx) {
|
|
42
45
|
// Only files inside the workspace root — guard has no jurisdiction, nothing worth logging.
|
|
@@ -51,7 +54,7 @@ class FeatureBranchGuardRule extends rule_base_1.FileRuleBase {
|
|
|
51
54
|
return this.block(ctx, branch, 'on-main', this.onMainMessage());
|
|
52
55
|
}
|
|
53
56
|
// Keep the cache warm for the next call. Detached; never blocks this edit.
|
|
54
|
-
(0, main_sync_refresh_1.triggerMainSyncRefresh)(ctx.workspaceRoot, this.config
|
|
57
|
+
(0, main_sync_refresh_1.triggerMainSyncRefresh)(ctx.workspaceRoot, (0, main_sync_timeout_1.hangTimeoutOf)(this.config));
|
|
55
58
|
const status = (0, rules_config_1.readMainSyncStatus)(ctx.workspaceRoot, branch);
|
|
56
59
|
// No cache yet (first edit of the session) → allow; the refresh we just spawned populates it
|
|
57
60
|
// for the next call. Fail-open: never block on missing data.
|
|
@@ -78,6 +81,16 @@ class FeatureBranchGuardRule extends rule_base_1.FileRuleBase {
|
|
|
78
81
|
if (status.conflict) {
|
|
79
82
|
return this.block(ctx, branch, 'main-moved-conflict', this.conflictMessage(status.conflictFiles, status.openPr), cache);
|
|
80
83
|
}
|
|
84
|
+
// NOT-MERGED, or NOT-ASKED? `branchAlreadyMerged: false` is produced both by "this branch has
|
|
85
|
+
// no merged PR" and by "the forge could not be reached" (`gh` missing, unauthenticated,
|
|
86
|
+
// rate-limited, offline). Same allow either way — never block on data you could not establish
|
|
87
|
+
// — but the LOG must not call the second one an approval, or the trail cannot tell a policy
|
|
88
|
+
// that is protecting something from one that is quietly standing down.
|
|
89
|
+
// Reached only when the branch is NOT merged, HAS a fork point and does NOT conflict. The
|
|
90
|
+
// last two are pure git; only the first depends on the forge, which is why an unreachable
|
|
91
|
+
// forge downgrades this to an abstention rather than leaving it a clean approval.
|
|
92
|
+
if (!status.forgeReachable)
|
|
93
|
+
return this.failOpen(ctx, branch, 'no-forge', cache);
|
|
81
94
|
return this.allow(ctx, branch, 'clean-feature-branch', cache);
|
|
82
95
|
}
|
|
83
96
|
// One-line summary of the async-written cache that drove this decision, for the SYNC log — so a
|
|
@@ -112,7 +125,7 @@ class FeatureBranchGuardRule extends rule_base_1.FileRuleBase {
|
|
|
112
125
|
return [new types_1.Violation(1, ctx.relativePath, message)];
|
|
113
126
|
}
|
|
114
127
|
logDecision(ctx, branch, verdict, reason, cache) {
|
|
115
|
-
(0, decision_log_1.logGuardDecision)(ctx.workspaceRoot, new decision_log_1.GuardDecision('feature-branch-guard', ctx.tool, ctx.relativePath, branch ?? 'unknown', verdict, reason, cache, l0_fault_codes_1.L0_FAULT_NONE, decision_log_1.
|
|
128
|
+
(0, decision_log_1.logGuardDecision)(ctx.workspaceRoot, new decision_log_1.GuardDecision('feature-branch-guard', ctx.tool, ctx.relativePath, branch ?? 'unknown', verdict, reason, cache, l0_fault_codes_1.L0_FAULT_NONE, (0, decision_log_1.matrixL2Row)(reason)));
|
|
116
129
|
}
|
|
117
130
|
currentBranch(workspaceRoot) {
|
|
118
131
|
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions
|