@webpieces/rules-config 0.4.635 → 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/package.json +1 -1
- package/src/WebpiecesRulesConfig.d.ts +4 -8
- package/src/WebpiecesRulesConfig.js +5 -6
- package/src/WebpiecesRulesConfig.js.map +1 -1
- package/src/abstract-rule.d.ts +21 -2
- package/src/abstract-rule.js +22 -2
- package/src/abstract-rule.js.map +1 -1
- package/src/default-rules.js +15 -9
- package/src/default-rules.js.map +1 -1
- package/src/index.d.ts +4 -3
- package/src/index.js +11 -13
- package/src/index.js.map +1 -1
- package/src/load-config.d.ts +0 -1
- package/src/load-config.js +13 -18
- package/src/load-config.js.map +1 -1
- package/src/main-sync-file.d.ts +27 -1
- package/src/main-sync-file.js +34 -3
- package/src/main-sync-file.js.map +1 -1
- package/src/main-sync-guard-configs.d.ts +22 -22
- package/src/main-sync-guard-configs.js +27 -72
- package/src/main-sync-guard-configs.js.map +1 -1
- package/src/main-sync-status.d.ts +2 -0
- package/src/main-sync-status.js +18 -2
- package/src/main-sync-status.js.map +1 -1
- package/src/retired-config-keys.js +73 -6
- package/src/retired-config-keys.js.map +1 -1
- package/src/rule-configs.d.ts +31 -16
- package/src/rule-configs.js +32 -33
- package/src/rule-configs.js.map +1 -1
- package/src/rule-schemas.d.ts +11 -0
- package/src/rule-schemas.js +26 -10
- package/src/rule-schemas.js.map +1 -1
- package/src/sections.d.ts +43 -2
- package/src/sections.js +50 -18
- package/src/sections.js.map +1 -1
- package/src/validate-config.js +10 -0
- package/src/validate-config.js.map +1 -1
package/src/load-config.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"load-config.js","sourceRoot":"","sources":["../../../../../packages/tooling/rules-config/src/load-config.ts"],"names":[],"mappings":"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","sourcesContent":["import * as path from 'path';\nimport { injectable, bindingScopeValues } from 'inversify';\n\nimport { buildCommandsConfig, CommandsConfig } from './commands-config';\nimport { formatConfigErrorsBanner } from './config-error-banner';\nimport { ConfigFile } from './config-file';\nimport { defaultRules } from './default-rules';\nimport { ExcludePaths } from './exclude-hook-paths';\nimport { InformAiError } from './inform-ai-error';\nimport { PrGateConfig } from './pr-gate-config';\nimport { ResolvedConfig, ResolvedRuleConfig, RuleOptions } from './types';\nimport { validateCommandsSection } from './commands-section-validators';\nimport { validateTopLevelKeys } from './config-key-rules';\nimport { validateExcludePaths, validateMatchRulesSection, validateSectionPlacement, validateWebpiecesConfig } from './validate-config';\nimport { MatchRuleConfig } from './match-rules-config';\nimport { WebpiecesRulesConfig } from './WebpiecesRulesConfig';\n\n/**\n * Everything a consumer might need from webpieces.config.json, produced from ONE parse + ONE\n * validation pass. Data-only (per CLAUDE.md, classes for data).\n */\nexport class LoadedConfig {\n // eslint-disable-next-line @typescript-eslint/max-params\n constructor(\n readonly resolved: ResolvedConfig,\n readonly rulesConfig: WebpiecesRulesConfig,\n readonly commands: CommandsConfig,\n readonly prGate: PrGateConfig,\n readonly excludePaths: ExcludePaths,\n readonly matchRules: readonly MatchRuleConfig[],\n readonly configPath: string | null,\n ) {}\n}\n\n// Renamed rules used to be normalized here — a DEPRECATED_RULE_ALIASES table rewrote the old key to its\n// canonical name BEFORE validation, so no validator ever saw it and no consumer was ever told. That is why\n// the aliases could never be removed: the old names kept working forever. The renames now live in\n// RETIRED_CONFIG_KEYS as hard errors naming the new key. See retired-config-keys.ts for the policy.\n\n/**\n * The single load+validate entry point for ALL consumers (ai-hook-rules, code-rules,\n * nx-webpieces-rules, pr-gate scripts). `@injectable(bindingScopeValues.Singleton)` + injects {@link ConfigFile} so it appears\n * in the rules-config DI design.\n */\n@injectable(bindingScopeValues.Singleton)\nexport class ConfigLoader {\n constructor(private readonly configFile: ConfigFile) {}\n\n /**\n * Reads webpieces.config.json once, validates BOTH the `rules` map and the top-level `pr-gate`\n * block, and throws one InformAiError listing every error. When no config file is found it returns\n * lenient empties/defaults (matching prior no-file behavior).\n */\n // webpieces-disable max-lines-new-methods -- the single load+validate pass is one cohesive method\n loadAndValidate(cwd: string): LoadedConfig {\n const configPath = this.configFile.findConfigFile(cwd);\n if (!configPath) {\n const emptyCommands = buildCommandsConfig(undefined);\n return new LoadedConfig(\n new ResolvedConfig(new Map(), new Set(), [], null),\n new WebpiecesRulesConfig(),\n emptyCommands,\n emptyCommands.prGate,\n new ExcludePaths([]),\n [],\n null,\n );\n }\n\n const consumerConfig = this.configFile.readRawConfig(configPath);\n const rulesSection = consumerConfig.rules || {};\n const hookGuardsSection = consumerConfig.hookGuards || {};\n // Read only so validateCommandsSection can REJECT it. There is no fallback to it — a config\n // carrying the retired top-level block never loads, so reading it as a value would be dead code.\n const legacyPrGate = consumerConfig['pr-gate'];\n\n // rules + hookGuards are validated/loaded as one flat name→config map (the runtime dispatches\n // by each rule's own `scope`). Placement is enforced separately.\n const overrideRules = { ...rulesSection, ...hookGuardsSection };\n\n const rulesDir = consumerConfig.rulesDir ?? [];\n\n // The repo root (dir holding webpieces.config.json) lets checklists[].docs existence be checked.\n const repoRoot = path.dirname(configPath);\n const errors = [\n // webpieces-disable no-any-unknown -- the raw parsed config is opaque; only key names are read\n ...validateTopLevelKeys(consumerConfig as unknown as Record<string, unknown>),\n ...validateWebpiecesConfig(overrideRules, rulesDir.length > 0),\n ...validateSectionPlacement(rulesSection, hookGuardsSection),\n ...validateCommandsSection(consumerConfig.commands, legacyPrGate, repoRoot),\n ...validateExcludePaths(consumerConfig.excludePaths),\n ...validateMatchRulesSection(consumerConfig['match-rules']),\n ];\n if (errors.length > 0) {\n throw new InformAiError(this.formatConfigErrorsBanner(errors));\n }\n\n const commands = buildCommandsConfig(consumerConfig.commands);\n this.applyCommandDefaults(overrideRules, commands);\n\n const userConfiguredRuleNames = new Set(Object.keys(overrideRules));\n const mergedRules = new Map<string, ResolvedRuleConfig>();\n const allRuleNames = new Set([\n ...Object.keys(defaultRules),\n ...Object.keys(overrideRules),\n ]);\n for (const name of allRuleNames) {\n mergedRules.set(name, this.mergeRule(defaultRules[name], overrideRules[name]));\n }\n const resolved = new ResolvedConfig(mergedRules, userConfiguredRuleNames, rulesDir, configPath);\n\n const rulesConfig = this.buildWebpiecesRulesConfig(overrideRules, rulesDir);\n const excludePaths = this.parseExcludePaths(consumerConfig.excludePaths);\n const matchRules = this.parseMatchRules(consumerConfig['match-rules']);\n\n return new LoadedConfig(resolved, rulesConfig, commands, commands.prGate, excludePaths, matchRules, configPath);\n }\n\n // Inject the canonical command strings (from the `commands` section) as the DEFAULT for the guards\n // that surface them in their fix hints. Only fills a gap — an explicit per-guard override wins.\n private applyCommandDefaults(\n // webpieces-disable no-any-unknown -- opaque merged rule/guard map\n rules: Record<string, Record<string, unknown>>,\n commands: CommandsConfig,\n ): void {\n const prCreation = rules['pr-creation-or-push-guard'];\n if (prCreation && prCreation['upsertPrCommand'] === undefined) {\n prCreation['upsertPrCommand'] = commands.upsertPr;\n }\n const mergeInProgress = rules['merge-in-progress-guard'];\n if (mergeInProgress && mergeInProgress['mergeCompleteCommand'] === undefined) {\n mergeInProgress['mergeCompleteCommand'] = commands.mergeComplete;\n }\n // whole-repo-build-guard is NOT injected here — it has no rules/hookGuards entry at all. It reads\n // the same commands.pr-gate.buildCommand, handed to it directly by ai-hook-rules' runner off\n // LoadedConfig.prGate. See home-config.ts for why that guard is home-config-only.\n }\n\n // webpieces-disable no-any-unknown -- merging opaque option bags from config JSON\n private mergeRule(\n // webpieces-disable no-any-unknown -- opaque option bag\n baseRule: Record<string, unknown> | undefined,\n // webpieces-disable no-any-unknown -- opaque option bag\n overrideRule: Record<string, unknown> | undefined,\n ): ResolvedRuleConfig {\n if (!baseRule && !overrideRule) return new ResolvedRuleConfig({ mode: 'OFF' });\n if (!baseRule) return new ResolvedRuleConfig(overrideRule! as RuleOptions);\n if (!overrideRule) return new ResolvedRuleConfig(baseRule as RuleOptions);\n\n // webpieces-disable no-any-unknown -- building merged option bag\n const merged: Record<string, unknown> = {};\n for (const key of Object.keys(baseRule)) merged[key] = baseRule[key];\n for (const key of Object.keys(overrideRule)) merged[key] = overrideRule[key];\n return new ResolvedRuleConfig(merged as RuleOptions);\n }\n\n // Parse the (already-validated) raw excludePaths block into the typed ExcludePaths. The ONLY accepted\n // shape is a bare string[]; the retired two-list object cannot reach here, because validateExcludePaths\n // has already failed the load with the migration instruction.\n // webpieces-disable no-any-unknown -- `raw` is opaque consumer JSON until narrowed here\n private parseExcludePaths(raw: unknown): ExcludePaths {\n if (!Array.isArray(raw)) return new ExcludePaths([]);\n return new ExcludePaths(raw.filter(p => typeof p === 'string'));\n }\n\n // Parse the (already-validated) raw match-rules array into typed MatchRuleConfig[]. The entries use\n // the canonical field names (turnOffRuleUntilEpoch / turnOffRuleWhileOnBranch) that the match-rules\n // engine reads directly, so no normalization is needed.\n // webpieces-disable no-any-unknown -- validated array; each entry cast to the typed MatchRuleConfig\n private parseMatchRules(raw: unknown): MatchRuleConfig[] {\n if (!Array.isArray(raw)) return [];\n return raw as MatchRuleConfig[];\n }\n\n private buildWebpiecesRulesConfig(\n // webpieces-disable no-any-unknown -- JSON values are opaque until assigned to typed fields\n rawRules: Record<string, Record<string, unknown>>,\n rulesDir: string[],\n ): WebpiecesRulesConfig {\n const typed = new WebpiecesRulesConfig();\n for (const key of Object.keys(rawRules)) {\n // webpieces-disable no-any-unknown -- dynamic key assignment to typed class\n (typed as Record<string, unknown>)[key] = rawRules[key];\n }\n typed.rulesDir = rulesDir;\n return typed;\n }\n\n // Assemble the validation-failure banner. Every error here came from validating this ONE file, so\n // the cure is always \"edit it\" — see config-error-banner.ts for why nothing else belongs in it.\n private formatConfigErrorsBanner(errors: string[]): string {\n return formatConfigErrorsBanner(errors);\n }\n}\n\n// Temporary migration delegator — consumers migrate to injecting ConfigLoader over follow-up PRs,\n// then this free function is removed. The logic now lives in the injected ConfigLoader class.\nconst configLoaderSvc = new ConfigLoader(new ConfigFile());\n\n// webpieces-disable no-function-outside-class -- temporary back-compat delegator to ConfigLoader; removed once all 118 consumers inject it\nexport function loadAndValidate(cwd: string): LoadedConfig {\n return configLoaderSvc.loadAndValidate(cwd);\n}\n"]}
|
|
1
|
+
{"version":3,"file":"load-config.js","sourceRoot":"","sources":["../../../../../packages/tooling/rules-config/src/load-config.ts"],"names":[],"mappings":"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","sourcesContent":["import * as path from 'path';\nimport { injectable, bindingScopeValues } from 'inversify';\n\nimport { buildCommandsConfig, CommandsConfig } from './commands-config';\nimport { formatConfigErrorsBanner } from './config-error-banner';\nimport { ConfigFile } from './config-file';\nimport { defaultRules } from './default-rules';\nimport { ExcludePaths } from './exclude-hook-paths';\nimport { InformAiError } from './inform-ai-error';\nimport { PrGateConfig } from './pr-gate-config';\nimport { ResolvedConfig, ResolvedRuleConfig, RuleOptions } from './types';\nimport { validateCommandsSection } from './commands-section-validators';\nimport { validateTopLevelKeys } from './config-key-rules';\nimport { validateExcludePaths, validateMatchRulesSection, validateSectionPlacement, validateWebpiecesConfig } from './validate-config';\nimport { MatchRuleConfig } from './match-rules-config';\nimport { WebpiecesRulesConfig } from './WebpiecesRulesConfig';\n\n/**\n * Everything a consumer might need from webpieces.config.json, produced from ONE parse + ONE\n * validation pass. Data-only (per CLAUDE.md, classes for data).\n */\nexport class LoadedConfig {\n // eslint-disable-next-line @typescript-eslint/max-params\n constructor(\n readonly resolved: ResolvedConfig,\n readonly rulesConfig: WebpiecesRulesConfig,\n readonly commands: CommandsConfig,\n readonly prGate: PrGateConfig,\n readonly excludePaths: ExcludePaths,\n readonly matchRules: readonly MatchRuleConfig[],\n readonly configPath: string | null,\n ) {}\n}\n\n// Renamed rules used to be normalized here — a DEPRECATED_RULE_ALIASES table rewrote the old key to its\n// canonical name BEFORE validation, so no validator ever saw it and no consumer was ever told. That is why\n// the aliases could never be removed: the old names kept working forever. The renames now live in\n// RETIRED_CONFIG_KEYS as hard errors naming the new key. See retired-config-keys.ts for the policy.\n\n/**\n * The single load+validate entry point for ALL consumers (ai-hook-rules, code-rules,\n * nx-webpieces-rules, pr-gate scripts). `@injectable(bindingScopeValues.Singleton)` + injects {@link ConfigFile} so it appears\n * in the rules-config DI design.\n */\n@injectable(bindingScopeValues.Singleton)\nexport class ConfigLoader {\n constructor(private readonly configFile: ConfigFile) {}\n\n /**\n * Reads webpieces.config.json once, validates BOTH the `rules` map and the top-level `pr-gate`\n * block, and throws one InformAiError listing every error. When no config file is found it returns\n * lenient empties/defaults (matching prior no-file behavior).\n */\n // webpieces-disable max-lines-new-methods -- the single load+validate pass is one cohesive method\n loadAndValidate(cwd: string): LoadedConfig {\n const configPath = this.configFile.findConfigFile(cwd);\n if (!configPath) {\n const emptyCommands = buildCommandsConfig(undefined);\n return new LoadedConfig(\n new ResolvedConfig(new Map(), new Set(), [], null),\n new WebpiecesRulesConfig(),\n emptyCommands,\n emptyCommands.prGate,\n new ExcludePaths([]),\n [],\n null,\n );\n }\n\n const consumerConfig = this.configFile.readRawConfig(configPath);\n const rulesSection = consumerConfig.rules || {};\n const hookGuardsSection = consumerConfig.hookGuards || {};\n // Read only so validateCommandsSection can REJECT it. There is no fallback to it — a config\n // carrying the retired top-level block never loads, so reading it as a value would be dead code.\n const legacyPrGate = consumerConfig['pr-gate'];\n\n // rules + hookGuards are validated/loaded as one flat name→config map (the runtime dispatches\n // by each rule's own `scope`). Placement is enforced separately.\n const overrideRules = { ...rulesSection, ...hookGuardsSection };\n\n const rulesDir = consumerConfig.rulesDir ?? [];\n\n // The repo root (dir holding webpieces.config.json) lets checklists[].docs existence be checked.\n const repoRoot = path.dirname(configPath);\n const errors = [\n // webpieces-disable no-any-unknown -- the raw parsed config is opaque; only key names are read\n ...validateTopLevelKeys(consumerConfig as unknown as Record<string, unknown>),\n ...validateWebpiecesConfig(overrideRules, rulesDir.length > 0),\n ...validateSectionPlacement(rulesSection, hookGuardsSection),\n ...validateCommandsSection(consumerConfig.commands, legacyPrGate, repoRoot),\n ...validateExcludePaths(consumerConfig.excludePaths),\n ...validateMatchRulesSection(consumerConfig['match-rules']),\n ];\n if (errors.length > 0) {\n throw new InformAiError(this.formatConfigErrorsBanner(errors));\n }\n\n const commands = buildCommandsConfig(consumerConfig.commands);\n\n const userConfiguredRuleNames = new Set(Object.keys(overrideRules));\n const mergedRules = new Map<string, ResolvedRuleConfig>();\n const allRuleNames = new Set([\n ...Object.keys(defaultRules),\n ...Object.keys(overrideRules),\n ]);\n for (const name of allRuleNames) {\n mergedRules.set(name, this.mergeRule(defaultRules[name], overrideRules[name]));\n }\n const resolved = new ResolvedConfig(mergedRules, userConfiguredRuleNames, rulesDir, configPath);\n\n const rulesConfig = this.buildWebpiecesRulesConfig(overrideRules, rulesDir);\n const excludePaths = this.parseExcludePaths(consumerConfig.excludePaths);\n const matchRules = this.parseMatchRules(consumerConfig['match-rules']);\n\n return new LoadedConfig(resolved, rulesConfig, commands, commands.prGate, excludePaths, matchRules, configPath);\n }\n\n // There is deliberately NO applyCommandDefaults here any more.\n //\n // It used to WRITE `commands.guardHints` values into two guard entries under the literal keys\n // 'pr-creation-or-push-guard' / 'merge-in-progress-guard', as a default beneath the guards' own\n // `upsertPrCommand` / `mergeCompleteCommand` fields. Two defects in one method: those per-guard\n // fields were a second spelling that BEAT the commands section (so the \"one place\" promise was\n // false), and the injection was keyed on guard-name literals — a key rename that missed it did NOT\n // fail the build, the lookup simply missed and both guards quietly printed their local defaults.\n //\n // Both fields are now deleted (see PrLifecycleGuardConfig and RETIRED_FIELD_HINTS), and the\n // resolved `CommandsConfig` strings are handed to the two rules at CONSTRUCTION — a compile-time\n // wire that cannot silently miss. whole-repo-build-guard was already fed that way off\n // LoadedConfig.prGate; this makes the guard-hint strings match.\n\n // webpieces-disable no-any-unknown -- merging opaque option bags from config JSON\n private mergeRule(\n // webpieces-disable no-any-unknown -- opaque option bag\n baseRule: Record<string, unknown> | undefined,\n // webpieces-disable no-any-unknown -- opaque option bag\n overrideRule: Record<string, unknown> | undefined,\n ): ResolvedRuleConfig {\n if (!baseRule && !overrideRule) return new ResolvedRuleConfig({ mode: 'OFF' });\n if (!baseRule) return new ResolvedRuleConfig(overrideRule! as RuleOptions);\n if (!overrideRule) return new ResolvedRuleConfig(baseRule as RuleOptions);\n\n // webpieces-disable no-any-unknown -- building merged option bag\n const merged: Record<string, unknown> = {};\n for (const key of Object.keys(baseRule)) merged[key] = baseRule[key];\n for (const key of Object.keys(overrideRule)) merged[key] = overrideRule[key];\n return new ResolvedRuleConfig(merged as RuleOptions);\n }\n\n // Parse the (already-validated) raw excludePaths block into the typed ExcludePaths. The ONLY accepted\n // shape is a bare string[]; the retired two-list object cannot reach here, because validateExcludePaths\n // has already failed the load with the migration instruction.\n // webpieces-disable no-any-unknown -- `raw` is opaque consumer JSON until narrowed here\n private parseExcludePaths(raw: unknown): ExcludePaths {\n if (!Array.isArray(raw)) return new ExcludePaths([]);\n return new ExcludePaths(raw.filter(p => typeof p === 'string'));\n }\n\n // Parse the (already-validated) raw match-rules array into typed MatchRuleConfig[]. The entries use\n // the canonical field names (turnOffRuleUntilEpoch / turnOffRuleWhileOnBranch) that the match-rules\n // engine reads directly, so no normalization is needed.\n // webpieces-disable no-any-unknown -- validated array; each entry cast to the typed MatchRuleConfig\n private parseMatchRules(raw: unknown): MatchRuleConfig[] {\n if (!Array.isArray(raw)) return [];\n return raw as MatchRuleConfig[];\n }\n\n private buildWebpiecesRulesConfig(\n // webpieces-disable no-any-unknown -- JSON values are opaque until assigned to typed fields\n rawRules: Record<string, Record<string, unknown>>,\n rulesDir: string[],\n ): WebpiecesRulesConfig {\n const typed = new WebpiecesRulesConfig();\n for (const key of Object.keys(rawRules)) {\n // webpieces-disable no-any-unknown -- dynamic key assignment to typed class\n (typed as Record<string, unknown>)[key] = rawRules[key];\n }\n typed.rulesDir = rulesDir;\n return typed;\n }\n\n // Assemble the validation-failure banner. Every error here came from validating this ONE file, so\n // the cure is always \"edit it\" — see config-error-banner.ts for why nothing else belongs in it.\n private formatConfigErrorsBanner(errors: string[]): string {\n return formatConfigErrorsBanner(errors);\n }\n}\n\n// Temporary migration delegator — consumers migrate to injecting ConfigLoader over follow-up PRs,\n// then this free function is removed. The logic now lives in the injected ConfigLoader class.\nconst configLoaderSvc = new ConfigLoader(new ConfigFile());\n\n// webpieces-disable no-function-outside-class -- temporary back-compat delegator to ConfigLoader; removed once all 118 consumers inject it\nexport function loadAndValidate(cwd: string): LoadedConfig {\n return configLoaderSvc.loadAndValidate(cwd);\n}\n"]}
|
package/src/main-sync-file.d.ts
CHANGED
|
@@ -28,6 +28,19 @@ export declare class MainSyncStatus {
|
|
|
28
28
|
timestamp: string;
|
|
29
29
|
openPr: string;
|
|
30
30
|
localMain: string;
|
|
31
|
+
/**
|
|
32
|
+
* Did the forge answer when this entry was computed? See PullRequestIndex.forgeReachable.
|
|
33
|
+
*
|
|
34
|
+
* `branchAlreadyMerged: false` is produced BOTH by "this branch has no merged PR" and by "we could
|
|
35
|
+
* not ask", and the three guards that act on the merged state took the ordinary ALLOW path in both.
|
|
36
|
+
* Carrying the distinction into the cache is what lets them say ALLOW_FAIL_OPEN / `no-forge`
|
|
37
|
+
* instead — the typed verdict exists precisely so abstentions are countable.
|
|
38
|
+
*
|
|
39
|
+
* Defaults TRUE, matching `openPr`/`localMain` as a non-positional field: an entry written before
|
|
40
|
+
* this field existed (or hand-edited) is read as "the forge answered", which preserves today's
|
|
41
|
+
* verdicts exactly rather than retro-labelling old caches as abstentions.
|
|
42
|
+
*/
|
|
43
|
+
forgeReachable: boolean;
|
|
31
44
|
constructor(branch: string, branchAlreadyMerged: boolean, mergedPr: string, hasForkPoint: boolean, forkPoint: string | null, originMain: string, featureHead: string, conflict: boolean, conflictFiles: string[], timestamp: string);
|
|
32
45
|
}
|
|
33
46
|
export declare class MainSyncStatusFile {
|
|
@@ -46,7 +59,20 @@ export declare class MainSyncStatusFile {
|
|
|
46
59
|
export declare class PullRequestIndex {
|
|
47
60
|
merged: Record<string, string>;
|
|
48
61
|
open: Record<string, string>;
|
|
49
|
-
|
|
62
|
+
/**
|
|
63
|
+
* Did the forge actually ANSWER?
|
|
64
|
+
*
|
|
65
|
+
* `mergedFor()` returning '' has always meant two completely different things — "this branch has no
|
|
66
|
+
* merged PR" and "we could not ask" (`gh` missing, unauthenticated, rate-limited, offline, or the
|
|
67
|
+
* response unparseable). Both produced the same empty index, so from the decision log you could not
|
|
68
|
+
* tell whether the merged-branch policy was PROTECTING anything or quietly abstaining. This flag is
|
|
69
|
+
* the difference, and it is what lets the guards emit ALLOW_FAIL_OPEN instead of a plain ALLOW.
|
|
70
|
+
*
|
|
71
|
+
* REQUIRED, not defaulted: an index built on a failure path must say so out loud, and a default of
|
|
72
|
+
* `true` would make "we asked and got an answer" the thing you get by forgetting.
|
|
73
|
+
*/
|
|
74
|
+
forgeReachable: boolean;
|
|
75
|
+
constructor(merged: Record<string, string>, open: Record<string, string>, forgeReachable: boolean);
|
|
50
76
|
mergedFor(branch: string): string;
|
|
51
77
|
openFor(branch: string): string;
|
|
52
78
|
}
|
package/src/main-sync-file.js
CHANGED
|
@@ -42,6 +42,19 @@ class MainSyncStatus {
|
|
|
42
42
|
// could not be read. Paired with `originMain`, this is what tells the read-stale-guard whether a
|
|
43
43
|
// checked-out `main` is behind its remote. Defaulted field for the same reason as `openPr`.
|
|
44
44
|
localMain = '';
|
|
45
|
+
/**
|
|
46
|
+
* Did the forge answer when this entry was computed? See PullRequestIndex.forgeReachable.
|
|
47
|
+
*
|
|
48
|
+
* `branchAlreadyMerged: false` is produced BOTH by "this branch has no merged PR" and by "we could
|
|
49
|
+
* not ask", and the three guards that act on the merged state took the ordinary ALLOW path in both.
|
|
50
|
+
* Carrying the distinction into the cache is what lets them say ALLOW_FAIL_OPEN / `no-forge`
|
|
51
|
+
* instead — the typed verdict exists precisely so abstentions are countable.
|
|
52
|
+
*
|
|
53
|
+
* Defaults TRUE, matching `openPr`/`localMain` as a non-positional field: an entry written before
|
|
54
|
+
* this field existed (or hand-edited) is read as "the forge answered", which preserves today's
|
|
55
|
+
* verdicts exactly rather than retro-labelling old caches as abstentions.
|
|
56
|
+
*/
|
|
57
|
+
forgeReachable = true;
|
|
45
58
|
constructor(branch, branchAlreadyMerged, mergedPr, hasForkPoint, forkPoint, originMain, featureHead, conflict, conflictFiles, timestamp) {
|
|
46
59
|
this.branch = branch;
|
|
47
60
|
this.branchAlreadyMerged = branchAlreadyMerged;
|
|
@@ -80,9 +93,23 @@ class PullRequestIndex {
|
|
|
80
93
|
// branch -> PR number, as a string ('' means "no such PR"), for MERGED and OPEN respectively.
|
|
81
94
|
merged;
|
|
82
95
|
open;
|
|
83
|
-
|
|
96
|
+
/**
|
|
97
|
+
* Did the forge actually ANSWER?
|
|
98
|
+
*
|
|
99
|
+
* `mergedFor()` returning '' has always meant two completely different things — "this branch has no
|
|
100
|
+
* merged PR" and "we could not ask" (`gh` missing, unauthenticated, rate-limited, offline, or the
|
|
101
|
+
* response unparseable). Both produced the same empty index, so from the decision log you could not
|
|
102
|
+
* tell whether the merged-branch policy was PROTECTING anything or quietly abstaining. This flag is
|
|
103
|
+
* the difference, and it is what lets the guards emit ALLOW_FAIL_OPEN instead of a plain ALLOW.
|
|
104
|
+
*
|
|
105
|
+
* REQUIRED, not defaulted: an index built on a failure path must say so out loud, and a default of
|
|
106
|
+
* `true` would make "we asked and got an answer" the thing you get by forgetting.
|
|
107
|
+
*/
|
|
108
|
+
forgeReachable;
|
|
109
|
+
constructor(merged, open, forgeReachable) {
|
|
84
110
|
this.merged = merged;
|
|
85
111
|
this.open = open;
|
|
112
|
+
this.forgeReachable = forgeReachable;
|
|
86
113
|
}
|
|
87
114
|
// A merged PR for this branch, or '' — the same value the old per-branch `gh` call produced.
|
|
88
115
|
mergedFor(branch) {
|
|
@@ -183,8 +210,10 @@ let MainSyncFileStore = class MainSyncFileStore {
|
|
|
183
210
|
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions
|
|
184
211
|
try {
|
|
185
212
|
const parsed = JSON.parse(json);
|
|
213
|
+
// Parsed but not an array: `gh` answered with something we do not understand, which is
|
|
214
|
+
// not the same as it answering "no PRs". Unreachable, so the guards abstain rather than allow.
|
|
186
215
|
if (!Array.isArray(parsed))
|
|
187
|
-
return new PullRequestIndex(merged, open);
|
|
216
|
+
return new PullRequestIndex(merged, open, false);
|
|
188
217
|
for (const row of parsed) {
|
|
189
218
|
if (row !== null && typeof row === 'object')
|
|
190
219
|
this.indexOne(row, merged, open);
|
|
@@ -193,8 +222,9 @@ let MainSyncFileStore = class MainSyncFileStore {
|
|
|
193
222
|
catch (err) {
|
|
194
223
|
const error = (0, to_error_1.toError)(err);
|
|
195
224
|
void error;
|
|
225
|
+
return new PullRequestIndex(merged, open, false);
|
|
196
226
|
}
|
|
197
|
-
return new PullRequestIndex(merged, open);
|
|
227
|
+
return new PullRequestIndex(merged, open, true);
|
|
198
228
|
}
|
|
199
229
|
indexOne(row, merged, open) {
|
|
200
230
|
const branch = row.headRefName ?? '';
|
|
@@ -232,6 +262,7 @@ let MainSyncFileStore = class MainSyncFileStore {
|
|
|
232
262
|
const status = new MainSyncStatus(raw.branch ?? '', raw.branchAlreadyMerged ?? false, raw.mergedPr ?? '', raw.hasForkPoint ?? true, raw.forkPoint ?? null, raw.originMain ?? '', raw.featureHead ?? '', raw.conflict ?? false, raw.conflictFiles ?? [], raw.timestamp ?? '');
|
|
233
263
|
status.openPr = raw.openPr ?? '';
|
|
234
264
|
status.localMain = raw.localMain ?? '';
|
|
265
|
+
status.forgeReachable = raw.forgeReachable ?? true;
|
|
235
266
|
return status;
|
|
236
267
|
}
|
|
237
268
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"main-sync-file.js","sourceRoot":"","sources":["../../../../../packages/tooling/rules-config/src/main-sync-file.ts"],"names":[],"mappings":"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","sourcesContent":["import * as fs from 'fs';\nimport { injectable, bindingScopeValues } from 'inversify';\n\nimport { AtomicFile } from './atomic-file';\nimport { toError } from './to-error';\n\n/**\n * The ON-DISK SHAPE of `<primary>/.webpieces/main-sync-status.json`, and nothing else.\n *\n * WHY this is its own module rather than more of main-sync-status.ts: that file already owns the lock\n * state machine plus the whole slow git/gh computation, and adding the map shape to it pushed it past\n * the 700-line cap. The split is along a real seam — everything here is pure parse/serialise with no\n * process spawning, so it is also the cheap half to test.\n *\n * WHY the file is a MAP of branch -> status (v2) rather than one status (v1): the cache is SHARED by\n * every worktree of the repo (one `.git`, one `origin/main`, one single-flight refresher), but a v1\n * file described exactly ONE branch — whichever tree won the lock. Every guard fails open when the\n * cached branch is not the branch it is judging, so with N worktrees at most one worktree's guards\n * were armed and the rest abstained, thrashing as the lock changed hands. Keeping the payload per\n * branch identical and only changing the LOOKUP means no guard's field access changed.\n */\n\n// Bumped when the shape changes. v1 = a single bare MainSyncStatus at the top level.\nexport const MAIN_SYNC_STATUS_VERSION = 2;\n\n// Data-only (per CLAUDE.md, classes for data). The per-branch payload — unchanged from v1.\nexport class MainSyncStatus {\n branch: string;\n branchAlreadyMerged: boolean;\n mergedPr: string;\n hasForkPoint: boolean;\n forkPoint: string | null;\n originMain: string;\n featureHead: string;\n conflict: boolean;\n conflictFiles: string[];\n timestamp: string;\n // An OPEN (not merged) PR tracking this branch, if any — '' = none or not-yet-known. Advisory.\n // Kept OUT of the positional constructor (a defaulted field) so existing call sites don't churn.\n openPr: string = '';\n // The LOCAL refs/heads/main hash — '' = main does not exist locally (fresh clone / worktree) or\n // could not be read. Paired with `originMain`, this is what tells the read-stale-guard whether a\n // checked-out `main` is behind its remote. Defaulted field for the same reason as `openPr`.\n localMain: string = '';\n\n constructor(\n branch: string,\n branchAlreadyMerged: boolean,\n mergedPr: string,\n hasForkPoint: boolean,\n forkPoint: string | null,\n originMain: string,\n featureHead: string,\n conflict: boolean,\n conflictFiles: string[],\n timestamp: string,\n ) {\n this.branch = branch;\n this.branchAlreadyMerged = branchAlreadyMerged;\n this.mergedPr = mergedPr;\n this.hasForkPoint = hasForkPoint;\n this.forkPoint = forkPoint;\n this.originMain = originMain;\n this.featureHead = featureHead;\n this.conflict = conflict;\n this.conflictFiles = conflictFiles;\n this.timestamp = timestamp;\n }\n}\n\n// Data-only. The whole document: every branch the last refresh could see, written as one unit.\nexport class MainSyncStatusFile {\n version: number;\n // When the map as a whole was written. Each entry also carries its own timestamp.\n timestamp: string;\n branches: Record<string, MainSyncStatus>;\n\n constructor(version: number, timestamp: string, branches: Record<string, MainSyncStatus>) {\n this.version = version;\n this.timestamp = timestamp;\n this.branches = branches;\n }\n}\n\n/**\n * The repo's pull requests indexed by head branch — ONE `gh pr list` for the whole refresh.\n *\n * WHY it exists: the per-branch status needs a merged-PR and an open-PR answer, and the old code asked\n * `gh` twice PER BRANCH. Recording N branches per refresh would have meant 2N network round trips and\n * made the refresher scale with worktree count. One repo-wide query indexed locally is 1 call for any N.\n */\nexport class PullRequestIndex {\n // branch -> PR number, as a string ('' means \"no such PR\"), for MERGED and OPEN respectively.\n merged: Record<string, string>;\n open: Record<string, string>;\n\n constructor(merged: Record<string, string>, open: Record<string, string>) {\n this.merged = merged;\n this.open = open;\n }\n\n // A merged PR for this branch, or '' — the same value the old per-branch `gh` call produced.\n mergedFor(branch: string): string {\n if (branch === '' || branch === 'main') return '';\n return hasOwn(this.merged, branch) ? (this.merged[branch] ?? '') : '';\n }\n\n openFor(branch: string): string {\n if (branch === '' || branch === 'main') return '';\n return hasOwn(this.open, branch) ? (this.open[branch] ?? '') : '';\n }\n}\n\n// Raw JSON shapes for the cast at the parse boundary.\ninterface RawStatus {\n branch?: string;\n branchAlreadyMerged?: boolean;\n mergedPr?: string;\n hasForkPoint?: boolean;\n forkPoint?: string | null;\n originMain?: string;\n featureHead?: string;\n conflict?: boolean;\n conflictFiles?: string[];\n timestamp?: string;\n openPr?: string;\n localMain?: string;\n}\n\n// v2 has `branches`; a v1 document instead has the RawStatus fields at the top level.\ninterface RawFile extends RawStatus {\n version?: number;\n branches?: Record<string, RawStatus>;\n}\n\n// One row of `gh pr list --json number,headRefName,state`.\ninterface RawPullRequest {\n number?: number;\n headRefName?: string;\n state?: string;\n}\n\n// Own-property lookup only: a branch literally named `constructor` or `toString` must not resolve to\n// something off Object.prototype and be mistaken for a cached status.\n// webpieces-disable no-function-outside-class -- one-line own-property predicate used by the classes in this module\nfunction hasOwn(record: object, key: string): boolean {\n return Object.prototype.hasOwnProperty.call(record, key);\n}\n\n/**\n * Reads and writes the branch-keyed status document. Every read fails SOFT to null (or to an empty\n * map) — the guards above this treat null as `no-sync-cache (fail-open)`, so a missing, truncated or\n * malformed file must never throw and must never block an edit.\n */\n@injectable(bindingScopeValues.Singleton)\nexport class MainSyncFileStore {\n constructor(private readonly atomicFile: AtomicFile = new AtomicFile()) {}\n\n /**\n * Parse the document at `statusPath`, adapting a v1 file on the way. Returns null when there is\n * nothing readable there — missing file, unreadable file, or JSON that does not parse.\n */\n readFile(statusPath: string): MainSyncStatusFile | null {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n if (!fs.existsSync(statusPath)) return null;\n // The cast is the trust boundary: every field of RawFile is optional and every read of it\n // below is defaulted, so a document of any other shape degrades to an empty map.\n const parsed = JSON.parse(fs.readFileSync(statusPath, 'utf8')) as RawFile | null;\n if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return null;\n return this.adapt(parsed);\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return null;\n }\n }\n\n /**\n * ATOMIC write (temp file + `rename()` in the same directory). The refresher writes this while\n * guards on the blocking path read it, from other worktrees; a plain `writeFileSync` truncates\n * first, so a reader landing in that window gets a torn document. Writing the map WHOLE also means\n * a reader never sees half of one refresh's branches and half of the previous refresh's.\n */\n writeFile(statusPath: string, file: MainSyncStatusFile): void {\n this.atomicFile.writeJsonAtomic(statusPath, file);\n }\n\n /**\n * READ-MODIFY-WRITE a single branch's entry, leaving every sibling entry untouched.\n *\n * This is what any writer that is NOT the single-flight refresher must use. `stampCleanMainSyncStatus`\n * (pr-gate's post-merge stamp) runs without the lock and knows about exactly one branch; if it wrote\n * a whole document it would silently erase every other worktree's entry and disarm their guards\n * until the next refresh.\n */\n mergeBranch(statusPath: string, status: MainSyncStatus): void {\n const existing = this.readFile(statusPath);\n const branches: Record<string, MainSyncStatus> = existing === null ? {} : existing.branches;\n branches[status.branch] = status;\n this.writeFile(statusPath, new MainSyncStatusFile(\n MAIN_SYNC_STATUS_VERSION, new Date().toISOString(), branches));\n }\n\n // The entry for one branch, or null when this refresh never saw that branch.\n branchStatus(file: MainSyncStatusFile | null, branch: string): MainSyncStatus | null {\n if (file === null) return null;\n // Defensive: readFile always produces a map, but this method is public and sits on the\n // blocking path — a caller handing it a raw document must get null, not a throw.\n if (file.branches === null || file.branches === undefined) return null;\n if (!hasOwn(file.branches, branch)) return null;\n return file.branches[branch] ?? null;\n }\n\n /**\n * Index `gh pr list --state all --json number,headRefName,state` output by head branch.\n *\n * Newest-first is gh's order, so the FIRST row for a branch wins — matching the old per-branch\n * `--jq .[0].number`. Anything unparseable yields an empty index, i.e. \"no PR\", which is the same\n * answer the old code produced when `gh` was missing or offline: fail-open.\n */\n indexPullRequests(json: string): PullRequestIndex {\n const merged: Record<string, string> = {};\n const open: Record<string, string> = {};\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const parsed = JSON.parse(json) as RawPullRequest[] | null;\n if (!Array.isArray(parsed)) return new PullRequestIndex(merged, open);\n for (const row of parsed) {\n if (row !== null && typeof row === 'object') this.indexOne(row, merged, open);\n }\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n }\n return new PullRequestIndex(merged, open);\n }\n\n private indexOne(row: RawPullRequest, merged: Record<string, string>, open: Record<string, string>): void {\n const branch = row.headRefName ?? '';\n const number = typeof row.number === 'number' ? String(row.number) : '';\n if (branch === '' || number === '') return;\n const state = (row.state ?? '').toUpperCase();\n // First row wins per branch+state — gh lists newest first, matching the old `.[0].number`.\n if (state === 'MERGED' && !hasOwn(merged, branch)) merged[branch] = number;\n if (state === 'OPEN' && !hasOwn(open, branch)) open[branch] = number;\n }\n\n // v2 documents pass through; a v1 document becomes a one-entry map keyed by its own `branch`.\n private adapt(raw: RawFile): MainSyncStatusFile {\n const branches: Record<string, MainSyncStatus> = {};\n const rawBranches = raw.branches;\n if (rawBranches !== undefined && rawBranches !== null && typeof rawBranches === 'object') {\n for (const [name, entry] of Object.entries(rawBranches)) {\n if (entry !== null && typeof entry === 'object') branches[name] = this.toStatus(entry);\n }\n return new MainSyncStatusFile(raw.version ?? MAIN_SYNC_STATUS_VERSION, raw.timestamp ?? '', branches);\n }\n // v1: one bare status at the top level. Key it by the branch it was computed for, so the\n // guard for THAT branch stays armed across the upgrade and every other branch simply misses\n // (null → `no-sync-cache (fail-open)`), which is what it did under v1 anyway.\n if (typeof raw.branch === 'string' && raw.branch !== '') branches[raw.branch] = this.toStatus(raw);\n return new MainSyncStatusFile(1, raw.timestamp ?? '', branches);\n }\n\n // Every field defaulted: a truncated or hand-edited entry degrades to a benign status rather\n // than to a throw. `hasForkPoint` defaults TRUE because false is the BLOCKING value.\n private toStatus(raw: RawStatus): MainSyncStatus {\n const status = new MainSyncStatus(\n raw.branch ?? '',\n raw.branchAlreadyMerged ?? false,\n raw.mergedPr ?? '',\n raw.hasForkPoint ?? true,\n raw.forkPoint ?? null,\n raw.originMain ?? '',\n raw.featureHead ?? '',\n raw.conflict ?? false,\n raw.conflictFiles ?? [],\n raw.timestamp ?? '',\n );\n status.openPr = raw.openPr ?? '';\n status.localMain = raw.localMain ?? '';\n return status;\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"main-sync-file.js","sourceRoot":"","sources":["../../../../../packages/tooling/rules-config/src/main-sync-file.ts"],"names":[],"mappings":"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","sourcesContent":["import * as fs from 'fs';\nimport { injectable, bindingScopeValues } from 'inversify';\n\nimport { AtomicFile } from './atomic-file';\nimport { toError } from './to-error';\n\n/**\n * The ON-DISK SHAPE of `<primary>/.webpieces/main-sync-status.json`, and nothing else.\n *\n * WHY this is its own module rather than more of main-sync-status.ts: that file already owns the lock\n * state machine plus the whole slow git/gh computation, and adding the map shape to it pushed it past\n * the 700-line cap. The split is along a real seam — everything here is pure parse/serialise with no\n * process spawning, so it is also the cheap half to test.\n *\n * WHY the file is a MAP of branch -> status (v2) rather than one status (v1): the cache is SHARED by\n * every worktree of the repo (one `.git`, one `origin/main`, one single-flight refresher), but a v1\n * file described exactly ONE branch — whichever tree won the lock. Every guard fails open when the\n * cached branch is not the branch it is judging, so with N worktrees at most one worktree's guards\n * were armed and the rest abstained, thrashing as the lock changed hands. Keeping the payload per\n * branch identical and only changing the LOOKUP means no guard's field access changed.\n */\n\n// Bumped when the shape changes. v1 = a single bare MainSyncStatus at the top level.\nexport const MAIN_SYNC_STATUS_VERSION = 2;\n\n// Data-only (per CLAUDE.md, classes for data). The per-branch payload — unchanged from v1.\nexport class MainSyncStatus {\n branch: string;\n branchAlreadyMerged: boolean;\n mergedPr: string;\n hasForkPoint: boolean;\n forkPoint: string | null;\n originMain: string;\n featureHead: string;\n conflict: boolean;\n conflictFiles: string[];\n timestamp: string;\n // An OPEN (not merged) PR tracking this branch, if any — '' = none or not-yet-known. Advisory.\n // Kept OUT of the positional constructor (a defaulted field) so existing call sites don't churn.\n openPr: string = '';\n // The LOCAL refs/heads/main hash — '' = main does not exist locally (fresh clone / worktree) or\n // could not be read. Paired with `originMain`, this is what tells the read-stale-guard whether a\n // checked-out `main` is behind its remote. Defaulted field for the same reason as `openPr`.\n localMain: string = '';\n /**\n * Did the forge answer when this entry was computed? See PullRequestIndex.forgeReachable.\n *\n * `branchAlreadyMerged: false` is produced BOTH by \"this branch has no merged PR\" and by \"we could\n * not ask\", and the three guards that act on the merged state took the ordinary ALLOW path in both.\n * Carrying the distinction into the cache is what lets them say ALLOW_FAIL_OPEN / `no-forge`\n * instead — the typed verdict exists precisely so abstentions are countable.\n *\n * Defaults TRUE, matching `openPr`/`localMain` as a non-positional field: an entry written before\n * this field existed (or hand-edited) is read as \"the forge answered\", which preserves today's\n * verdicts exactly rather than retro-labelling old caches as abstentions.\n */\n forgeReachable: boolean = true;\n\n constructor(\n branch: string,\n branchAlreadyMerged: boolean,\n mergedPr: string,\n hasForkPoint: boolean,\n forkPoint: string | null,\n originMain: string,\n featureHead: string,\n conflict: boolean,\n conflictFiles: string[],\n timestamp: string,\n ) {\n this.branch = branch;\n this.branchAlreadyMerged = branchAlreadyMerged;\n this.mergedPr = mergedPr;\n this.hasForkPoint = hasForkPoint;\n this.forkPoint = forkPoint;\n this.originMain = originMain;\n this.featureHead = featureHead;\n this.conflict = conflict;\n this.conflictFiles = conflictFiles;\n this.timestamp = timestamp;\n }\n}\n\n// Data-only. The whole document: every branch the last refresh could see, written as one unit.\nexport class MainSyncStatusFile {\n version: number;\n // When the map as a whole was written. Each entry also carries its own timestamp.\n timestamp: string;\n branches: Record<string, MainSyncStatus>;\n\n constructor(version: number, timestamp: string, branches: Record<string, MainSyncStatus>) {\n this.version = version;\n this.timestamp = timestamp;\n this.branches = branches;\n }\n}\n\n/**\n * The repo's pull requests indexed by head branch — ONE `gh pr list` for the whole refresh.\n *\n * WHY it exists: the per-branch status needs a merged-PR and an open-PR answer, and the old code asked\n * `gh` twice PER BRANCH. Recording N branches per refresh would have meant 2N network round trips and\n * made the refresher scale with worktree count. One repo-wide query indexed locally is 1 call for any N.\n */\nexport class PullRequestIndex {\n // branch -> PR number, as a string ('' means \"no such PR\"), for MERGED and OPEN respectively.\n merged: Record<string, string>;\n open: Record<string, string>;\n /**\n * Did the forge actually ANSWER?\n *\n * `mergedFor()` returning '' has always meant two completely different things — \"this branch has no\n * merged PR\" and \"we could not ask\" (`gh` missing, unauthenticated, rate-limited, offline, or the\n * response unparseable). Both produced the same empty index, so from the decision log you could not\n * tell whether the merged-branch policy was PROTECTING anything or quietly abstaining. This flag is\n * the difference, and it is what lets the guards emit ALLOW_FAIL_OPEN instead of a plain ALLOW.\n *\n * REQUIRED, not defaulted: an index built on a failure path must say so out loud, and a default of\n * `true` would make \"we asked and got an answer\" the thing you get by forgetting.\n */\n forgeReachable: boolean;\n\n constructor(merged: Record<string, string>, open: Record<string, string>, forgeReachable: boolean) {\n this.merged = merged;\n this.open = open;\n this.forgeReachable = forgeReachable;\n }\n\n // A merged PR for this branch, or '' — the same value the old per-branch `gh` call produced.\n mergedFor(branch: string): string {\n if (branch === '' || branch === 'main') return '';\n return hasOwn(this.merged, branch) ? (this.merged[branch] ?? '') : '';\n }\n\n openFor(branch: string): string {\n if (branch === '' || branch === 'main') return '';\n return hasOwn(this.open, branch) ? (this.open[branch] ?? '') : '';\n }\n}\n\n// Raw JSON shapes for the cast at the parse boundary.\ninterface RawStatus {\n branch?: string;\n branchAlreadyMerged?: boolean;\n mergedPr?: string;\n hasForkPoint?: boolean;\n forkPoint?: string | null;\n originMain?: string;\n featureHead?: string;\n conflict?: boolean;\n conflictFiles?: string[];\n timestamp?: string;\n openPr?: string;\n localMain?: string;\n forgeReachable?: boolean;\n}\n\n// v2 has `branches`; a v1 document instead has the RawStatus fields at the top level.\ninterface RawFile extends RawStatus {\n version?: number;\n branches?: Record<string, RawStatus>;\n}\n\n// One row of `gh pr list --json number,headRefName,state`.\ninterface RawPullRequest {\n number?: number;\n headRefName?: string;\n state?: string;\n}\n\n// Own-property lookup only: a branch literally named `constructor` or `toString` must not resolve to\n// something off Object.prototype and be mistaken for a cached status.\n// webpieces-disable no-function-outside-class -- one-line own-property predicate used by the classes in this module\nfunction hasOwn(record: object, key: string): boolean {\n return Object.prototype.hasOwnProperty.call(record, key);\n}\n\n/**\n * Reads and writes the branch-keyed status document. Every read fails SOFT to null (or to an empty\n * map) — the guards above this treat null as `no-sync-cache (fail-open)`, so a missing, truncated or\n * malformed file must never throw and must never block an edit.\n */\n@injectable(bindingScopeValues.Singleton)\nexport class MainSyncFileStore {\n constructor(private readonly atomicFile: AtomicFile = new AtomicFile()) {}\n\n /**\n * Parse the document at `statusPath`, adapting a v1 file on the way. Returns null when there is\n * nothing readable there — missing file, unreadable file, or JSON that does not parse.\n */\n readFile(statusPath: string): MainSyncStatusFile | null {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n if (!fs.existsSync(statusPath)) return null;\n // The cast is the trust boundary: every field of RawFile is optional and every read of it\n // below is defaulted, so a document of any other shape degrades to an empty map.\n const parsed = JSON.parse(fs.readFileSync(statusPath, 'utf8')) as RawFile | null;\n if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return null;\n return this.adapt(parsed);\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return null;\n }\n }\n\n /**\n * ATOMIC write (temp file + `rename()` in the same directory). The refresher writes this while\n * guards on the blocking path read it, from other worktrees; a plain `writeFileSync` truncates\n * first, so a reader landing in that window gets a torn document. Writing the map WHOLE also means\n * a reader never sees half of one refresh's branches and half of the previous refresh's.\n */\n writeFile(statusPath: string, file: MainSyncStatusFile): void {\n this.atomicFile.writeJsonAtomic(statusPath, file);\n }\n\n /**\n * READ-MODIFY-WRITE a single branch's entry, leaving every sibling entry untouched.\n *\n * This is what any writer that is NOT the single-flight refresher must use. `stampCleanMainSyncStatus`\n * (pr-gate's post-merge stamp) runs without the lock and knows about exactly one branch; if it wrote\n * a whole document it would silently erase every other worktree's entry and disarm their guards\n * until the next refresh.\n */\n mergeBranch(statusPath: string, status: MainSyncStatus): void {\n const existing = this.readFile(statusPath);\n const branches: Record<string, MainSyncStatus> = existing === null ? {} : existing.branches;\n branches[status.branch] = status;\n this.writeFile(statusPath, new MainSyncStatusFile(\n MAIN_SYNC_STATUS_VERSION, new Date().toISOString(), branches));\n }\n\n // The entry for one branch, or null when this refresh never saw that branch.\n branchStatus(file: MainSyncStatusFile | null, branch: string): MainSyncStatus | null {\n if (file === null) return null;\n // Defensive: readFile always produces a map, but this method is public and sits on the\n // blocking path — a caller handing it a raw document must get null, not a throw.\n if (file.branches === null || file.branches === undefined) return null;\n if (!hasOwn(file.branches, branch)) return null;\n return file.branches[branch] ?? null;\n }\n\n /**\n * Index `gh pr list --state all --json number,headRefName,state` output by head branch.\n *\n * Newest-first is gh's order, so the FIRST row for a branch wins — matching the old per-branch\n * `--jq .[0].number`. Anything unparseable yields an empty index, i.e. \"no PR\", which is the same\n * answer the old code produced when `gh` was missing or offline: fail-open.\n */\n indexPullRequests(json: string): PullRequestIndex {\n const merged: Record<string, string> = {};\n const open: Record<string, string> = {};\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const parsed = JSON.parse(json) as RawPullRequest[] | null;\n // Parsed but not an array: `gh` answered with something we do not understand, which is\n // not the same as it answering \"no PRs\". Unreachable, so the guards abstain rather than allow.\n if (!Array.isArray(parsed)) return new PullRequestIndex(merged, open, false);\n for (const row of parsed) {\n if (row !== null && typeof row === 'object') this.indexOne(row, merged, open);\n }\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return new PullRequestIndex(merged, open, false);\n }\n return new PullRequestIndex(merged, open, true);\n }\n\n private indexOne(row: RawPullRequest, merged: Record<string, string>, open: Record<string, string>): void {\n const branch = row.headRefName ?? '';\n const number = typeof row.number === 'number' ? String(row.number) : '';\n if (branch === '' || number === '') return;\n const state = (row.state ?? '').toUpperCase();\n // First row wins per branch+state — gh lists newest first, matching the old `.[0].number`.\n if (state === 'MERGED' && !hasOwn(merged, branch)) merged[branch] = number;\n if (state === 'OPEN' && !hasOwn(open, branch)) open[branch] = number;\n }\n\n // v2 documents pass through; a v1 document becomes a one-entry map keyed by its own `branch`.\n private adapt(raw: RawFile): MainSyncStatusFile {\n const branches: Record<string, MainSyncStatus> = {};\n const rawBranches = raw.branches;\n if (rawBranches !== undefined && rawBranches !== null && typeof rawBranches === 'object') {\n for (const [name, entry] of Object.entries(rawBranches)) {\n if (entry !== null && typeof entry === 'object') branches[name] = this.toStatus(entry);\n }\n return new MainSyncStatusFile(raw.version ?? MAIN_SYNC_STATUS_VERSION, raw.timestamp ?? '', branches);\n }\n // v1: one bare status at the top level. Key it by the branch it was computed for, so the\n // guard for THAT branch stays armed across the upgrade and every other branch simply misses\n // (null → `no-sync-cache (fail-open)`), which is what it did under v1 anyway.\n if (typeof raw.branch === 'string' && raw.branch !== '') branches[raw.branch] = this.toStatus(raw);\n return new MainSyncStatusFile(1, raw.timestamp ?? '', branches);\n }\n\n // Every field defaulted: a truncated or hand-edited entry degrades to a benign status rather\n // than to a throw. `hasForkPoint` defaults TRUE because false is the BLOCKING value.\n private toStatus(raw: RawStatus): MainSyncStatus {\n const status = new MainSyncStatus(\n raw.branch ?? '',\n raw.branchAlreadyMerged ?? false,\n raw.mergedPr ?? '',\n raw.hasForkPoint ?? true,\n raw.forkPoint ?? null,\n raw.originMain ?? '',\n raw.featureHead ?? '',\n raw.conflict ?? false,\n raw.conflictFiles ?? [],\n raw.timestamp ?? '',\n );\n status.openPr = raw.openPr ?? '';\n status.localMain = raw.localMain ?? '';\n status.forgeReachable = raw.forgeReachable ?? true;\n return status;\n }\n}\n"]}
|
|
@@ -1,36 +1,36 @@
|
|
|
1
1
|
import { SchemaShape } from './field-def';
|
|
2
2
|
import { BaseRuleConfig, OnOffMode } from './rule-configs';
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* `branch-state-guard` — ONE key, ONE policy: *may I work here, and is what I read current?*
|
|
5
|
+
*
|
|
6
|
+
* Four CLASSES implement it, because the tool wiring genuinely differs (a Read names one file, a Bash
|
|
7
|
+
* command is opaque, and the two Bash halves are not De Morgan duals of each other — see
|
|
8
|
+
* guards/L2-branch-state.md, "Deliberate divergence"):
|
|
6
9
|
*
|
|
7
10
|
* | on stale `main` (state A) | on an already-merged branch (state B)
|
|
8
11
|
* Write/Edit | feature-branch-guard | feature-branch-guard
|
|
9
12
|
* Read | read-stale-guard | read-stale-guard
|
|
10
13
|
* Bash | stale-main-bash-guard | merged-branch-bash-guard
|
|
11
14
|
*
|
|
12
|
-
*
|
|
13
|
-
* `
|
|
14
|
-
*
|
|
15
|
+
* Those four class NAMES are unchanged and still appear in every decision-log line as `rule=`, so
|
|
16
|
+
* `grep rule=stale-main-bash-guard` over `.webpieces/logs/L2-decisions` keeps working. What merged is
|
|
17
|
+
* the SWITCH: all four read this one config entry (`AbstractRule.configKey`).
|
|
18
|
+
*
|
|
19
|
+
* ## Why one key and not four
|
|
20
|
+
*
|
|
21
|
+
* Four keys made **half a policy representable**: `read-stale-guard: OFF` beside
|
|
22
|
+
* `merged-branch-bash-guard: ON` is "read the file, yes; `cat` the same file, no" — the same
|
|
23
|
+
* information, opposite verdicts. Nobody chose that; it was reachable because the config had a knob
|
|
24
|
+
* per class instead of a knob per decision. One key makes it unconstructible.
|
|
25
|
+
*
|
|
26
|
+
* And `hangTimeoutMinutes` was declared four times, read four times, and fed ONE shared refresher for
|
|
27
|
+
* ONE shared cache — with a per-process latch (main-sync-refresh.ts) plus config-blind callers ahead of
|
|
28
|
+
* the guards, so at most one of the four values could ever reach a spawn. Four knobs, one honoured; now
|
|
29
|
+
* one knob, honoured everywhere (runner + hook-core route through this entry too).
|
|
15
30
|
*/
|
|
16
|
-
export declare class
|
|
31
|
+
export declare class BranchStateGuardConfig extends BaseRuleConfig {
|
|
17
32
|
mode?: OnOffMode;
|
|
18
33
|
branchNamingConvention?: string;
|
|
19
34
|
hangTimeoutMinutes?: number;
|
|
20
|
-
static readonly SCHEMA: SchemaShape<
|
|
21
|
-
}
|
|
22
|
-
export declare class ReadStaleGuardConfig extends BaseRuleConfig {
|
|
23
|
-
mode?: OnOffMode;
|
|
24
|
-
hangTimeoutMinutes?: number;
|
|
25
|
-
static readonly SCHEMA: SchemaShape<ReadStaleGuardConfig>;
|
|
26
|
-
}
|
|
27
|
-
export declare class MergedBranchBashGuardConfig extends BaseRuleConfig {
|
|
28
|
-
mode?: OnOffMode;
|
|
29
|
-
hangTimeoutMinutes?: number;
|
|
30
|
-
static readonly SCHEMA: SchemaShape<MergedBranchBashGuardConfig>;
|
|
31
|
-
}
|
|
32
|
-
export declare class StaleMainBashGuardConfig extends BaseRuleConfig {
|
|
33
|
-
mode?: OnOffMode;
|
|
34
|
-
hangTimeoutMinutes?: number;
|
|
35
|
-
static readonly SCHEMA: SchemaShape<StaleMainBashGuardConfig>;
|
|
35
|
+
static readonly SCHEMA: SchemaShape<BranchStateGuardConfig>;
|
|
36
36
|
}
|
|
@@ -1,30 +1,42 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.
|
|
3
|
+
exports.BranchStateGuardConfig = void 0;
|
|
4
4
|
const field_def_1 = require("./field-def");
|
|
5
5
|
const rule_configs_1 = require("./rule-configs");
|
|
6
6
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
7
|
+
* `branch-state-guard` — ONE key, ONE policy: *may I work here, and is what I read current?*
|
|
8
|
+
*
|
|
9
|
+
* Four CLASSES implement it, because the tool wiring genuinely differs (a Read names one file, a Bash
|
|
10
|
+
* command is opaque, and the two Bash halves are not De Morgan duals of each other — see
|
|
11
|
+
* guards/L2-branch-state.md, "Deliberate divergence"):
|
|
9
12
|
*
|
|
10
13
|
* | on stale `main` (state A) | on an already-merged branch (state B)
|
|
11
14
|
* Write/Edit | feature-branch-guard | feature-branch-guard
|
|
12
15
|
* Read | read-stale-guard | read-stale-guard
|
|
13
16
|
* Bash | stale-main-bash-guard | merged-branch-bash-guard
|
|
14
17
|
*
|
|
15
|
-
*
|
|
16
|
-
* `
|
|
17
|
-
*
|
|
18
|
+
* Those four class NAMES are unchanged and still appear in every decision-log line as `rule=`, so
|
|
19
|
+
* `grep rule=stale-main-bash-guard` over `.webpieces/logs/L2-decisions` keeps working. What merged is
|
|
20
|
+
* the SWITCH: all four read this one config entry (`AbstractRule.configKey`).
|
|
21
|
+
*
|
|
22
|
+
* ## Why one key and not four
|
|
23
|
+
*
|
|
24
|
+
* Four keys made **half a policy representable**: `read-stale-guard: OFF` beside
|
|
25
|
+
* `merged-branch-bash-guard: ON` is "read the file, yes; `cat` the same file, no" — the same
|
|
26
|
+
* information, opposite verdicts. Nobody chose that; it was reachable because the config had a knob
|
|
27
|
+
* per class instead of a knob per decision. One key makes it unconstructible.
|
|
28
|
+
*
|
|
29
|
+
* And `hangTimeoutMinutes` was declared four times, read four times, and fed ONE shared refresher for
|
|
30
|
+
* ONE shared cache — with a per-process latch (main-sync-refresh.ts) plus config-blind callers ahead of
|
|
31
|
+
* the guards, so at most one of the four values could ever reach a spawn. Four knobs, one honoured; now
|
|
32
|
+
* one knob, honoured everywhere (runner + hook-core route through this entry too).
|
|
18
33
|
*/
|
|
19
|
-
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
// - no fork point with origin/main → block, squash onto a new branch
|
|
23
|
-
// - origin/main moved & touches your files → block, merge main first
|
|
24
|
-
// branchNamingConvention is surfaced in the on-main message; hangTimeoutMinutes tunes the detached
|
|
25
|
-
// refresher's stale-lock reclaim window.
|
|
26
|
-
class FeatureBranchGuardConfig extends rule_configs_1.BaseRuleConfig {
|
|
34
|
+
class BranchStateGuardConfig extends rule_configs_1.BaseRuleConfig {
|
|
35
|
+
// Surfaced verbatim in the on-main block's message, so a project's own convention is what the agent
|
|
36
|
+
// is told to follow. Consumed by FeatureBranchGuardRule.onMainMessage.
|
|
27
37
|
branchNamingConvention;
|
|
38
|
+
// Tunes the DETACHED refresher's stale-lock reclaim window (main-sync.lock.json). One value for one
|
|
39
|
+
// refresher writing one cache — which is what it always physically was.
|
|
28
40
|
hangTimeoutMinutes;
|
|
29
41
|
static SCHEMA = {
|
|
30
42
|
mode: new field_def_1.FieldDef('string', rule_configs_1.ON_OFF_MODES),
|
|
@@ -33,62 +45,5 @@ class FeatureBranchGuardConfig extends rule_configs_1.BaseRuleConfig {
|
|
|
33
45
|
...rule_configs_1.BASE_RULE_SCHEMA,
|
|
34
46
|
};
|
|
35
47
|
}
|
|
36
|
-
exports.
|
|
37
|
-
// Blocks Read while the checked-out branch is stale to read FROM — either main is behind origin/main,
|
|
38
|
-
// or the feature branch's PR is already merged (a pre-merge snapshot). Both mean the AI reads stale
|
|
39
|
-
// FILES, which is the actual damage. Deliberately scoped to Read only: every cure (`git pull origin
|
|
40
|
-
// main`, `git checkout -b <new> origin/main`, `pnpm install`, any upgrade) is a Bash command, and Bash
|
|
41
|
-
// is never touched by this guard, so there is no allowlist to maintain and no way to wedge.
|
|
42
|
-
// (Named main-stale-guard through 0.4.x; the old config key still loads via DEPRECATED_RULE_ALIASES.)
|
|
43
|
-
// hangTimeoutMinutes tunes the detached refresher's stale-lock reclaim window (as on
|
|
44
|
-
// feature-branch-guard, whose cache this guard shares).
|
|
45
|
-
class ReadStaleGuardConfig extends rule_configs_1.BaseRuleConfig {
|
|
46
|
-
hangTimeoutMinutes;
|
|
47
|
-
static SCHEMA = {
|
|
48
|
-
mode: new field_def_1.FieldDef('string', rule_configs_1.ON_OFF_MODES),
|
|
49
|
-
hangTimeoutMinutes: field_def_1.FieldDef.optional('number'),
|
|
50
|
-
...rule_configs_1.BASE_RULE_SCHEMA,
|
|
51
|
-
};
|
|
52
|
-
}
|
|
53
|
-
exports.ReadStaleGuardConfig = ReadStaleGuardConfig;
|
|
54
|
-
// The BASH-side half of the merged-branch protection. feature-branch-guard blocks Write/Edit and
|
|
55
|
-
// read-stale-guard blocks the Read tool when the checked-out branch's PR is already merged — but
|
|
56
|
-
// NOTHING blocked ordinary Bash, so an agent that only ran shell (booting servers, `cat`-ing files,
|
|
57
|
-
// git) sailed through a whole session on a merged branch (the incident this guard closes). It is a
|
|
58
|
-
// bash-scope hookGuard that DEFAULT-DENIES Bash on a merged branch, allowlisting only the recovery /
|
|
59
|
-
// cleanup / read-only-inspection commands that get the agent OFF the branch. Shares the same
|
|
60
|
-
// precomputed cache (main-sync-status.json / branchAlreadyMerged) the two file guards read.
|
|
61
|
-
// hangTimeoutMinutes tunes the detached refresher's stale-lock reclaim window, as on those guards.
|
|
62
|
-
class MergedBranchBashGuardConfig extends rule_configs_1.BaseRuleConfig {
|
|
63
|
-
hangTimeoutMinutes;
|
|
64
|
-
static SCHEMA = {
|
|
65
|
-
mode: new field_def_1.FieldDef('string', rule_configs_1.ON_OFF_MODES),
|
|
66
|
-
hangTimeoutMinutes: field_def_1.FieldDef.optional('number'),
|
|
67
|
-
...rule_configs_1.BASE_RULE_SCHEMA,
|
|
68
|
-
};
|
|
69
|
-
}
|
|
70
|
-
exports.MergedBranchBashGuardConfig = MergedBranchBashGuardConfig;
|
|
71
|
-
// The BASH-side half of the STALE-MAIN protection (read-stale-guard's State A). read-stale-guard
|
|
72
|
-
// blocks the Read tool when local main is behind origin/main, but deliberately never looks at Bash —
|
|
73
|
-
// so `cat`/`grep`/`ls` walked the same stale tree through the side door, for a whole session, while
|
|
74
|
-
// the logs read "handled" (the incident this guard closes: a main 18 commits behind, an agent
|
|
75
|
-
// describing a workflow set missing a workflow that existed upstream). Unlike merged-branch-bash-guard
|
|
76
|
-
// this one does NOT default-deny Bash: builds, tests, installs, the `git pull` cure and all git
|
|
77
|
-
// METADATA stay open, and only CONTENT reads of workspace files are blocked. Shares the same
|
|
78
|
-
// precomputed cache (main-sync-status.json → localMain vs originMain) the file guards read;
|
|
79
|
-
// hangTimeoutMinutes tunes the detached refresher's stale-lock reclaim window, as on those guards.
|
|
80
|
-
//
|
|
81
|
-
// It also carries the PREVENTIVE half of the same protection — blocking a bare `git checkout main`
|
|
82
|
-
// unless the pull rides along in the same command — under this one key rather than a second guard,
|
|
83
|
-
// because it is the same failure being stopped one step earlier and one on/off switch should govern
|
|
84
|
-
// both. That half needs no cache and no timeout, so the config shape is unchanged.
|
|
85
|
-
class StaleMainBashGuardConfig extends rule_configs_1.BaseRuleConfig {
|
|
86
|
-
hangTimeoutMinutes;
|
|
87
|
-
static SCHEMA = {
|
|
88
|
-
mode: new field_def_1.FieldDef('string', rule_configs_1.ON_OFF_MODES),
|
|
89
|
-
hangTimeoutMinutes: field_def_1.FieldDef.optional('number'),
|
|
90
|
-
...rule_configs_1.BASE_RULE_SCHEMA,
|
|
91
|
-
};
|
|
92
|
-
}
|
|
93
|
-
exports.StaleMainBashGuardConfig = StaleMainBashGuardConfig;
|
|
48
|
+
exports.BranchStateGuardConfig = BranchStateGuardConfig;
|
|
94
49
|
//# sourceMappingURL=main-sync-guard-configs.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"main-sync-guard-configs.js","sourceRoot":"","sources":["../../../../../packages/tooling/rules-config/src/main-sync-guard-configs.ts"],"names":[],"mappings":";;;AAAA,2CAAoD;AACpD,iDAA2F;AAE3F
|
|
1
|
+
{"version":3,"file":"main-sync-guard-configs.js","sourceRoot":"","sources":["../../../../../packages/tooling/rules-config/src/main-sync-guard-configs.ts"],"names":[],"mappings":";;;AAAA,2CAAoD;AACpD,iDAA2F;AAE3F;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,MAAa,sBAAuB,SAAQ,6BAAc;IAEtD,oGAAoG;IACpG,uEAAuE;IACvE,sBAAsB,CAAU;IAChC,oGAAoG;IACpG,wEAAwE;IACxE,kBAAkB,CAAU;IAE5B,MAAM,CAAU,MAAM,GAAwC;QAC1D,IAAI,EAAE,IAAI,oBAAQ,CAAC,QAAQ,EAAE,2BAAY,CAAC;QAC1C,sBAAsB,EAAE,oBAAQ,CAAC,QAAQ,CAAC,QAAQ,CAAC;QACnD,kBAAkB,EAAE,oBAAQ,CAAC,QAAQ,CAAC,QAAQ,CAAC;QAC/C,GAAG,+BAAgB;KACtB,CAAC;;AAdN,wDAeC","sourcesContent":["import { FieldDef, SchemaShape } from './field-def';\nimport { BaseRuleConfig, BASE_RULE_SCHEMA, OnOffMode, ON_OFF_MODES } from './rule-configs';\n\n/**\n * `branch-state-guard` — ONE key, ONE policy: *may I work here, and is what I read current?*\n *\n * Four CLASSES implement it, because the tool wiring genuinely differs (a Read names one file, a Bash\n * command is opaque, and the two Bash halves are not De Morgan duals of each other — see\n * guards/L2-branch-state.md, \"Deliberate divergence\"):\n *\n * | on stale `main` (state A) | on an already-merged branch (state B)\n * Write/Edit | feature-branch-guard | feature-branch-guard\n * Read | read-stale-guard | read-stale-guard\n * Bash | stale-main-bash-guard | merged-branch-bash-guard\n *\n * Those four class NAMES are unchanged and still appear in every decision-log line as `rule=`, so\n * `grep rule=stale-main-bash-guard` over `.webpieces/logs/L2-decisions` keeps working. What merged is\n * the SWITCH: all four read this one config entry (`AbstractRule.configKey`).\n *\n * ## Why one key and not four\n *\n * Four keys made **half a policy representable**: `read-stale-guard: OFF` beside\n * `merged-branch-bash-guard: ON` is \"read the file, yes; `cat` the same file, no\" — the same\n * information, opposite verdicts. Nobody chose that; it was reachable because the config had a knob\n * per class instead of a knob per decision. One key makes it unconstructible.\n *\n * And `hangTimeoutMinutes` was declared four times, read four times, and fed ONE shared refresher for\n * ONE shared cache — with a per-process latch (main-sync-refresh.ts) plus config-blind callers ahead of\n * the guards, so at most one of the four values could ever reach a spawn. Four knobs, one honoured; now\n * one knob, honoured everywhere (runner + hook-core route through this entry too).\n */\nexport class BranchStateGuardConfig extends BaseRuleConfig {\n declare mode?: OnOffMode;\n // Surfaced verbatim in the on-main block's message, so a project's own convention is what the agent\n // is told to follow. Consumed by FeatureBranchGuardRule.onMainMessage.\n branchNamingConvention?: string;\n // Tunes the DETACHED refresher's stale-lock reclaim window (main-sync.lock.json). One value for one\n // refresher writing one cache — which is what it always physically was.\n hangTimeoutMinutes?: number;\n\n static readonly SCHEMA: SchemaShape<BranchStateGuardConfig> = {\n mode: new FieldDef('string', ON_OFF_MODES),\n branchNamingConvention: FieldDef.optional('string'),\n hangTimeoutMinutes: FieldDef.optional('number'),\n ...BASE_RULE_SCHEMA,\n };\n}\n"]}
|
|
@@ -127,6 +127,8 @@ export declare class MainSyncStatusService {
|
|
|
127
127
|
* `gh`: both are done once per refresh by the caller.
|
|
128
128
|
*/
|
|
129
129
|
private computeBranchStatus;
|
|
130
|
+
private stampForge;
|
|
131
|
+
private computeBranchStatusInner;
|
|
130
132
|
squashRecoverySteps(currentBranch: string): string[];
|
|
131
133
|
/**
|
|
132
134
|
* Synchronously stamp a clean "up to date with main" status — call right after a successful merge.
|
package/src/main-sync-status.js
CHANGED
|
@@ -287,6 +287,16 @@ let MainSyncStatusService = class MainSyncStatusService {
|
|
|
287
287
|
*/
|
|
288
288
|
// webpieces-disable max-lines-new-methods -- one cohesive slow-path computation
|
|
289
289
|
computeBranchStatus(repoRoot, branch, index) {
|
|
290
|
+
return this.stampForge(this.computeBranchStatusInner(repoRoot, branch, index), index);
|
|
291
|
+
}
|
|
292
|
+
// Every return path of the computation below has to carry the forge flag, and threading it through
|
|
293
|
+
// four constructions is how one of them would end up missing it. One wrapper, one assignment.
|
|
294
|
+
stampForge(status, index) {
|
|
295
|
+
status.forgeReachable = index.forgeReachable;
|
|
296
|
+
return status;
|
|
297
|
+
}
|
|
298
|
+
// webpieces-disable max-lines-new-methods -- one cohesive slow-path computation
|
|
299
|
+
computeBranchStatusInner(repoRoot, branch, index) {
|
|
290
300
|
const mergedPr = index.mergedFor(branch);
|
|
291
301
|
const openPr = index.openFor(branch);
|
|
292
302
|
const head = this.capture(repoRoot, 'git', ['rev-parse', 'HEAD']);
|
|
@@ -457,8 +467,14 @@ let MainSyncStatusService = class MainSyncStatusService {
|
|
|
457
467
|
const result = this.capture(repoRoot, 'gh', [
|
|
458
468
|
'pr', 'list', '--state', 'all', '--json', 'number,headRefName,state', '--limit', '200',
|
|
459
469
|
]);
|
|
460
|
-
|
|
461
|
-
|
|
470
|
+
// `gh` could not be run, or exited non-zero (missing, unauthenticated, rate-limited, offline).
|
|
471
|
+
// An EMPTY index is the same fail-open answer as before — but it is now labelled UNREACHABLE, so
|
|
472
|
+
// the guards can report an abstention rather than an approval. Empty STDOUT on a zero exit is a
|
|
473
|
+
// real answer ("this repo has no PRs"), so only the failure path clears the flag.
|
|
474
|
+
if (!result.ok)
|
|
475
|
+
return new main_sync_file_1.PullRequestIndex({}, {}, false);
|
|
476
|
+
if (result.out === '')
|
|
477
|
+
return new main_sync_file_1.PullRequestIndex({}, {}, true);
|
|
462
478
|
return this.store.indexPullRequests(result.out);
|
|
463
479
|
}
|
|
464
480
|
/**
|