diff --git a/packages/loopover-miner/lib/deny-hooks.d.ts b/packages/loopover-miner/lib/deny-hooks.d.ts index 402304a2bb..1b940a827f 100644 --- a/packages/loopover-miner/lib/deny-hooks.d.ts +++ b/packages/loopover-miner/lib/deny-hooks.d.ts @@ -1,28 +1 @@ -export type DenyRule = { - /** Tool-name glob (`*` = any within a segment, `**` across segments) or an exact tool name. */ - matcher: string; - /** Optional glob tested against every path-shaped string in the tool-call input. */ - pathPattern?: string; - /** Optional substrings that must ALL appear in one string-shaped input field (e.g. a shell command). */ - inputIncludesAll?: string[]; - /** Optional pattern that must match a whole whitespace-separated token (quotes stripped) of one - * string-shaped input field — for flag-shaped needles where a substring test would false-positive - * on an unrelated longer flag (e.g. `-f` vs. `--follow-tags`). */ - inputTokenPattern?: RegExp; - /** Human-readable reason surfaced when this rule blocks a call. */ - reason: string; -}; - -export type DenyVerdict = { - allowed: boolean; - blockedBy?: DenyRule; -}; - -export type ProposedToolCall = { - name: string; - input: Record; -}; - -export const DEFAULT_DENY_RULES: DenyRule[]; - -export function evaluateDenyHooks(toolCall: ProposedToolCall, rules?: DenyRule[]): DenyVerdict; +export { DEFAULT_DENY_RULES, evaluateDenyHooks, type DenyRule, type DenyVerdict, type ProposedToolCall, } from "@loopover/engine"; diff --git a/packages/loopover-miner/lib/deny-hooks.js b/packages/loopover-miner/lib/deny-hooks.js index af2875c48e..9612109588 100644 --- a/packages/loopover-miner/lib/deny-hooks.js +++ b/packages/loopover-miner/lib/deny-hooks.js @@ -1,6 +1,7 @@ // PreToolUse-style deny-hook primitives (#2295). Now a thin re-export of the engine's pure, deterministic deny // evaluator: the whole implementation moved into `@loopover/engine` (packages/loopover-engine/src/miner/ // deny-hooks.ts) by #5667 so the review stack and the miner share one copy. No behavior change — the evaluator is -// pure (no IO, no globals, no Date/random). See deny-hooks.d.ts for the type contract (DenyRule/DenyVerdict/ -// ProposedToolCall), which still declares the same shapes the engine module now implements. -export { DEFAULT_DENY_RULES, evaluateDenyHooks } from "@loopover/engine"; +// pure (no IO, no globals, no Date/random). Types (DenyRule/DenyVerdict/ProposedToolCall) come from the same +// engine module so the miner package's public contract stays identical after the TypeScript migration. +export { DEFAULT_DENY_RULES, evaluateDenyHooks, } from "@loopover/engine"; +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiZGVueS1ob29rcy5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbImRlbnktaG9va3MudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEsK0dBQStHO0FBQy9HLHlHQUF5RztBQUN6RyxrSEFBa0g7QUFDbEgsNkdBQTZHO0FBQzdHLHVHQUF1RztBQUV2RyxPQUFPLEVBQ0wsa0JBQWtCLEVBQ2xCLGlCQUFpQixHQUlsQixNQUFNLGtCQUFrQixDQUFDIn0= \ No newline at end of file diff --git a/packages/loopover-miner/lib/deny-hooks.ts b/packages/loopover-miner/lib/deny-hooks.ts new file mode 100644 index 0000000000..872a177478 --- /dev/null +++ b/packages/loopover-miner/lib/deny-hooks.ts @@ -0,0 +1,13 @@ +// PreToolUse-style deny-hook primitives (#2295). Now a thin re-export of the engine's pure, deterministic deny +// evaluator: the whole implementation moved into `@loopover/engine` (packages/loopover-engine/src/miner/ +// deny-hooks.ts) by #5667 so the review stack and the miner share one copy. No behavior change — the evaluator is +// pure (no IO, no globals, no Date/random). Types (DenyRule/DenyVerdict/ProposedToolCall) come from the same +// engine module so the miner package's public contract stays identical after the TypeScript migration. + +export { + DEFAULT_DENY_RULES, + evaluateDenyHooks, + type DenyRule, + type DenyVerdict, + type ProposedToolCall, +} from "@loopover/engine"; diff --git a/packages/loopover-miner/lib/pr-outcome.d.ts b/packages/loopover-miner/lib/pr-outcome.d.ts index 4fe170faae..81e7d723bd 100644 --- a/packages/loopover-miner/lib/pr-outcome.d.ts +++ b/packages/loopover-miner/lib/pr-outcome.d.ts @@ -1,41 +1,60 @@ import type { AppendEventInput, LedgerEntry } from "./event-ledger.js"; - -export const MINER_PR_OUTCOME_EVENT: "pr_outcome"; -export const MINER_PR_OUTCOME_DECISIONS: readonly ["merged", "closed"]; - -export type MinerPrOutcomeDecision = "merged" | "closed"; - -export interface NormalizedPrOutcomePayload { - prNumber: number; - decision: MinerPrOutcomeDecision; - closedAt: string | null; - reason: string | null; -} - -export interface PrOutcomeInput { - repoFullName?: unknown; - prNumber?: unknown; - decision?: unknown; - closedAt?: unknown; - reason?: unknown; -} - -export interface RecordPrOutcomeOptions { - /** Optional at the type level so a caller can pass an unusable ledger to exercise the fail-closed guard; the - * writer throws `invalid_event_ledger` at runtime when this is absent or lacks `appendEvent`. Reuses the - * real EventLedger#appendEvent signature so a genuine EventLedger (not just a same-shaped stub) type-checks. */ - eventLedger?: { appendEvent(event: AppendEventInput): LedgerEntry }; -} - -export interface PrOutcomeLedgerReader { - readEvents(filter?: { since?: number; repoFullName?: string }): unknown[]; -} - -export function normalizePrOutcomePayload(payload: unknown): NormalizedPrOutcomePayload | null; - -export function recordPrOutcomeSnapshot(input: PrOutcomeInput, options?: RecordPrOutcomeOptions): unknown; - -export function readPrOutcomes( - eventLedger: PrOutcomeLedgerReader, - filter?: { since?: number; repoFullName?: string }, -): Map; +/** Event-ledger vocabulary for a miner-local PR outcome. */ +export declare const MINER_PR_OUTCOME_EVENT: "pr_outcome"; +/** The terminal decisions a miner records for one of its own PRs. */ +export declare const MINER_PR_OUTCOME_DECISIONS: readonly ["merged", "closed"]; +export type MinerPrOutcomeDecision = (typeof MINER_PR_OUTCOME_DECISIONS)[number]; +export type NormalizedPrOutcomePayload = { + prNumber: number; + decision: MinerPrOutcomeDecision; + closedAt: string | null; + reason: string | null; +}; +export type PrOutcomeInput = { + repoFullName?: unknown; + prNumber?: unknown; + decision?: unknown; + closedAt?: unknown; + reason?: unknown; +}; +export type RecordPrOutcomeOptions = { + /** Optional at the type level so a caller can pass an unusable ledger to exercise the fail-closed guard; the + * writer throws `invalid_event_ledger` at runtime when this is absent or lacks `appendEvent`. Reuses the + * real EventLedger#appendEvent signature so a genuine EventLedger (not just a same-shaped stub) type-checks. */ + eventLedger?: { + appendEvent(event: AppendEventInput): LedgerEntry; + }; +}; +export type PrOutcomeLedgerReader = { + readEvents(filter?: { + since?: number; + repoFullName?: string; + }): unknown[]; +}; +/** + * Validate + normalize a PR-outcome payload; returns `null` on any malformed shape (mirrors manage-status.js's + * `normalizeManageUpdatePayload`, so a bad row can neither be written nor read back). A `closed` decision may carry + * a reason bucket drawn from {@link REJECTION_REASONS} (shared with the rejection-state-machine sibling); a `merged` + * decision — or an unrecognized reason — normalizes the reason to `null` (a merged PR has no rejection reason). + */ +export declare function normalizePrOutcomePayload(payload: unknown): NormalizedPrOutcomePayload | null; +/** + * Thin writer over an INJECTED event ledger (same dependency-injection shape as manage-poll.js's + * `recordManagePollSnapshot`, so it's unit-testable without a real ledger file). Appends one + * {@link MINER_PR_OUTCOME_EVENT} scoped to the repo and returns the appended entry. Fail-soft on a malformed + * snapshot: a missing repo or an invalid payload returns `null` rather than throwing (an unusable ledger is the + * only hard error, since that is a programmer wiring mistake). + */ +export declare function recordPrOutcomeSnapshot(input: PrOutcomeInput, options?: RecordPrOutcomeOptions): unknown; +/** + * Reconstruct the latest outcome per repo/PR from the ledger's ascending append-only event stream (mirrors + * manage-status.js's `indexLatestManageUpdates`). Reads via the injected ledger's `readEvents(filter)` and reduces + * the pure result — a later event for the same repo/PR supersedes an earlier one. Returns a `Map` keyed by + * `repoFullName:prNumber`. + */ +export declare function readPrOutcomes(eventLedger: PrOutcomeLedgerReader | null | undefined, filter?: { + since?: number; + repoFullName?: string; +}): Map; diff --git a/packages/loopover-miner/lib/pr-outcome.js b/packages/loopover-miner/lib/pr-outcome.js index dce82bec6c..a4ec93d12f 100644 --- a/packages/loopover-miner/lib/pr-outcome.js +++ b/packages/loopover-miner/lib/pr-outcome.js @@ -8,25 +8,21 @@ // loopover SERVER recording ground truth for every contributor. THIS is a laptop-mode miner's local record of // its own PRs (it may have no webhook relay at all): same concept name, different codebase layer, no shared code. // The distinct `MINER_PR_OUTCOME_EVENT` local constant keeps the two from being conflated. - import { REJECTION_REASONS } from "./rejection-templates.js"; - /** Event-ledger vocabulary for a miner-local PR outcome. */ export const MINER_PR_OUTCOME_EVENT = "pr_outcome"; - /** The terminal decisions a miner records for one of its own PRs. */ export const MINER_PR_OUTCOME_DECISIONS = Object.freeze(["merged", "closed"]); - const decisionSet = new Set(MINER_PR_OUTCOME_DECISIONS); const reasonSet = new Set(REJECTION_REASONS); - function optionalString(value) { - if (value === undefined || value === null) return null; - if (typeof value !== "string") return null; - const trimmed = value.trim(); - return trimmed || null; + if (value === undefined || value === null) + return null; + if (typeof value !== "string") + return null; + const trimmed = value.trim(); + return trimmed || null; } - /** * Validate + normalize a PR-outcome payload; returns `null` on any malformed shape (mirrors manage-status.js's * `normalizeManageUpdatePayload`, so a bad row can neither be written nor read back). A `closed` decision may carry @@ -34,20 +30,23 @@ function optionalString(value) { * decision — or an unrecognized reason — normalizes the reason to `null` (a merged PR has no rejection reason). */ export function normalizePrOutcomePayload(payload) { - if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null; - if (!Number.isInteger(payload.prNumber) || payload.prNumber <= 0) return null; - const decision = optionalString(payload.decision); - if (!decision || !decisionSet.has(decision)) return null; - const reasonRaw = optionalString(payload.reason); - const reason = decision === "closed" && reasonRaw !== null && reasonSet.has(reasonRaw) ? reasonRaw : null; - return { - prNumber: payload.prNumber, - decision, - closedAt: optionalString(payload.closedAt), - reason, - }; + if (!payload || typeof payload !== "object" || Array.isArray(payload)) + return null; + const record = payload; + if (!Number.isInteger(record.prNumber) || record.prNumber <= 0) + return null; + const decision = optionalString(record.decision); + if (!decision || !decisionSet.has(decision)) + return null; + const reasonRaw = optionalString(record.reason); + const reason = decision === "closed" && reasonRaw !== null && reasonSet.has(reasonRaw) ? reasonRaw : null; + return { + prNumber: record.prNumber, + decision: decision, + closedAt: optionalString(record.closedAt), + reason, + }; } - /** * Thin writer over an INJECTED event ledger (same dependency-injection shape as manage-poll.js's * `recordManagePollSnapshot`, so it's unit-testable without a real ledger file). Appends one @@ -56,20 +55,22 @@ export function normalizePrOutcomePayload(payload) { * only hard error, since that is a programmer wiring mistake). */ export function recordPrOutcomeSnapshot(input, options = {}) { - const eventLedger = options.eventLedger; - if (!eventLedger || typeof eventLedger.appendEvent !== "function") throw new Error("invalid_event_ledger"); - const repoFullName = typeof input?.repoFullName === "string" ? input.repoFullName.trim() : ""; - if (!repoFullName) return null; - const payload = normalizePrOutcomePayload({ - prNumber: input?.prNumber, - decision: input?.decision, - closedAt: input?.closedAt, - reason: input?.reason, - }); - if (!payload) return null; - return eventLedger.appendEvent({ type: MINER_PR_OUTCOME_EVENT, repoFullName, payload }); + const eventLedger = options.eventLedger; + if (!eventLedger || typeof eventLedger.appendEvent !== "function") + throw new Error("invalid_event_ledger"); + const repoFullName = typeof input.repoFullName === "string" ? input.repoFullName.trim() : ""; + if (!repoFullName) + return null; + const payload = normalizePrOutcomePayload({ + prNumber: input.prNumber, + decision: input.decision, + closedAt: input.closedAt, + reason: input.reason, + }); + if (!payload) + return null; + return eventLedger.appendEvent({ type: MINER_PR_OUTCOME_EVENT, repoFullName, payload }); } - /** * Reconstruct the latest outcome per repo/PR from the ledger's ascending append-only event stream (mirrors * manage-status.js's `indexLatestManageUpdates`). Reads via the injected ledger's `readEvents(filter)` and reduces @@ -77,21 +78,28 @@ export function recordPrOutcomeSnapshot(input, options = {}) { * `repoFullName:prNumber`. */ export function readPrOutcomes(eventLedger, filter = {}) { - const events = eventLedger && typeof eventLedger.readEvents === "function" ? eventLedger.readEvents(filter) : []; - const latest = new Map(); - for (const event of Array.isArray(events) ? events : []) { - if (event?.type !== MINER_PR_OUTCOME_EVENT) continue; - if (typeof event.repoFullName !== "string" || !event.repoFullName.trim()) continue; - const normalized = normalizePrOutcomePayload(event.payload); - if (!normalized) continue; - // Re-key on every event so Map iteration order tracks most-recently-UPDATED last, not first-seen (#7222). A - // bare Map.set() on an existing key updates the value but leaves the key frozen at its original position, so a - // later outcome for the same PR (e.g. closed-without-merge, then reopened + merged) stayed at its old slot -- - // breaking recency-ordered consumers like loop-reentry.js's countConsecutiveDisengagements. Deleting first - // moves the freshly-updated entry to the end, matching this reducer's own "a later event supersedes" contract. - const key = `${event.repoFullName}:${normalized.prNumber}`; - latest.delete(key); - latest.set(key, { ...normalized, repoFullName: event.repoFullName }); - } - return latest; + const events = eventLedger && typeof eventLedger.readEvents === "function" ? eventLedger.readEvents(filter) : []; + const latest = new Map(); + for (const event of Array.isArray(events) ? events : []) { + if (!event || typeof event !== "object") + continue; + const row = event; + if (row.type !== MINER_PR_OUTCOME_EVENT) + continue; + if (typeof row.repoFullName !== "string" || !row.repoFullName.trim()) + continue; + const normalized = normalizePrOutcomePayload(row.payload); + if (!normalized) + continue; + // Re-key on every event so Map iteration order tracks most-recently-UPDATED last, not first-seen (#7222). A + // bare Map.set() on an existing key updates the value but leaves the key frozen at its original position, so a + // later outcome for the same PR (e.g. closed-without-merge, then reopened + merged) stayed at its old slot -- + // breaking recency-ordered consumers like loop-reentry.js's countConsecutiveDisengagements. Deleting first + // moves the freshly-updated entry to the end, matching this reducer's own "a later event supersedes" contract. + const key = `${row.repoFullName}:${normalized.prNumber}`; + latest.delete(key); + latest.set(key, { ...normalized, repoFullName: row.repoFullName }); + } + return latest; } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoicHItb3V0Y29tZS5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbInByLW91dGNvbWUudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEsaUhBQWlIO0FBQ2pILDBHQUEwRztBQUMxRyxpSEFBaUg7QUFDakgsOEJBQThCO0FBQzlCLEVBQUU7QUFDRiw2R0FBNkc7QUFDN0csK0dBQStHO0FBQy9HLDhHQUE4RztBQUM5RyxrSEFBa0g7QUFDbEgsMkZBQTJGO0FBRTNGLE9BQU8sRUFBRSxpQkFBaUIsRUFBRSxNQUFNLDBCQUEwQixDQUFDO0FBRzdELDREQUE0RDtBQUM1RCxNQUFNLENBQUMsTUFBTSxzQkFBc0IsR0FBRyxZQUFxQixDQUFDO0FBRTVELHFFQUFxRTtBQUNyRSxNQUFNLENBQUMsTUFBTSwwQkFBMEIsR0FBRyxNQUFNLENBQUMsTUFBTSxDQUFDLENBQUMsUUFBUSxFQUFFLFFBQVEsQ0FBVSxDQUFDLENBQUM7QUE4QnZGLE1BQU0sV0FBVyxHQUFHLElBQUksR0FBRyxDQUFTLDBCQUEwQixDQUFDLENBQUM7QUFDaEUsTUFBTSxTQUFTLEdBQUcsSUFBSSxHQUFHLENBQVMsaUJBQWlCLENBQUMsQ0FBQztBQUVyRCxTQUFTLGNBQWMsQ0FBQyxLQUFjO0lBQ3BDLElBQUksS0FBSyxLQUFLLFNBQVMsSUFBSSxLQUFLLEtBQUssSUFBSTtRQUFFLE9BQU8sSUFBSSxDQUFDO0lBQ3ZELElBQUksT0FBTyxLQUFLLEtBQUssUUFBUTtRQUFFLE9BQU8sSUFBSSxDQUFDO0lBQzNDLE1BQU0sT0FBTyxHQUFHLEtBQUssQ0FBQyxJQUFJLEVBQUUsQ0FBQztJQUM3QixPQUFPLE9BQU8sSUFBSSxJQUFJLENBQUM7QUFDekIsQ0FBQztBQUVEOzs7OztHQUtHO0FBQ0gsTUFBTSxVQUFVLHlCQUF5QixDQUFDLE9BQWdCO0lBQ3hELElBQUksQ0FBQyxPQUFPLElBQUksT0FBTyxPQUFPLEtBQUssUUFBUSxJQUFJLEtBQUssQ0FBQyxPQUFPLENBQUMsT0FBTyxDQUFDO1FB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 \ No newline at end of file diff --git a/packages/loopover-miner/lib/pr-outcome.ts b/packages/loopover-miner/lib/pr-outcome.ts new file mode 100644 index 0000000000..274df98712 --- /dev/null +++ b/packages/loopover-miner/lib/pr-outcome.ts @@ -0,0 +1,132 @@ +// Miner-local PR-outcome record (#4274). The miner's OWN local record of the outcomes of its OWN PRs — merged or +// closed — written to the miner's local SQLite via the generic append-only event-ledger.js, mirroring how +// manage-status.js layers a specific typed event (MANAGE_PR_UPDATE_EVENT + a payload normalizer + a thin writer) +// on top of that same ledger. +// +// DISTINCT from the server-side `pr_outcome` concept: src/review/outcomes-wire.ts's `recordPrOutcome` writes +// `pr_outcome` rows to the HOSTED backend's D1 audit tables from the GitHub App's webhook stream — that is the +// loopover SERVER recording ground truth for every contributor. THIS is a laptop-mode miner's local record of +// its own PRs (it may have no webhook relay at all): same concept name, different codebase layer, no shared code. +// The distinct `MINER_PR_OUTCOME_EVENT` local constant keeps the two from being conflated. + +import { REJECTION_REASONS } from "./rejection-templates.js"; +import type { AppendEventInput, LedgerEntry } from "./event-ledger.js"; + +/** Event-ledger vocabulary for a miner-local PR outcome. */ +export const MINER_PR_OUTCOME_EVENT = "pr_outcome" as const; + +/** The terminal decisions a miner records for one of its own PRs. */ +export const MINER_PR_OUTCOME_DECISIONS = Object.freeze(["merged", "closed"] as const); + +export type MinerPrOutcomeDecision = (typeof MINER_PR_OUTCOME_DECISIONS)[number]; + +export type NormalizedPrOutcomePayload = { + prNumber: number; + decision: MinerPrOutcomeDecision; + closedAt: string | null; + reason: string | null; +}; + +export type PrOutcomeInput = { + repoFullName?: unknown; + prNumber?: unknown; + decision?: unknown; + closedAt?: unknown; + reason?: unknown; +}; + +export type RecordPrOutcomeOptions = { + /** Optional at the type level so a caller can pass an unusable ledger to exercise the fail-closed guard; the + * writer throws `invalid_event_ledger` at runtime when this is absent or lacks `appendEvent`. Reuses the + * real EventLedger#appendEvent signature so a genuine EventLedger (not just a same-shaped stub) type-checks. */ + eventLedger?: { appendEvent(event: AppendEventInput): LedgerEntry }; +}; + +export type PrOutcomeLedgerReader = { + readEvents(filter?: { since?: number; repoFullName?: string }): unknown[]; +}; + +const decisionSet = new Set(MINER_PR_OUTCOME_DECISIONS); +const reasonSet = new Set(REJECTION_REASONS); + +function optionalString(value: unknown): string | null { + if (value === undefined || value === null) return null; + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed || null; +} + +/** + * Validate + normalize a PR-outcome payload; returns `null` on any malformed shape (mirrors manage-status.js's + * `normalizeManageUpdatePayload`, so a bad row can neither be written nor read back). A `closed` decision may carry + * a reason bucket drawn from {@link REJECTION_REASONS} (shared with the rejection-state-machine sibling); a `merged` + * decision — or an unrecognized reason — normalizes the reason to `null` (a merged PR has no rejection reason). + */ +export function normalizePrOutcomePayload(payload: unknown): NormalizedPrOutcomePayload | null { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null; + const record = payload as Record; + if (!Number.isInteger(record.prNumber) || (record.prNumber as number) <= 0) return null; + const decision = optionalString(record.decision); + if (!decision || !decisionSet.has(decision)) return null; + const reasonRaw = optionalString(record.reason); + const reason = decision === "closed" && reasonRaw !== null && reasonSet.has(reasonRaw) ? reasonRaw : null; + return { + prNumber: record.prNumber as number, + decision: decision as MinerPrOutcomeDecision, + closedAt: optionalString(record.closedAt), + reason, + }; +} + +/** + * Thin writer over an INJECTED event ledger (same dependency-injection shape as manage-poll.js's + * `recordManagePollSnapshot`, so it's unit-testable without a real ledger file). Appends one + * {@link MINER_PR_OUTCOME_EVENT} scoped to the repo and returns the appended entry. Fail-soft on a malformed + * snapshot: a missing repo or an invalid payload returns `null` rather than throwing (an unusable ledger is the + * only hard error, since that is a programmer wiring mistake). + */ +export function recordPrOutcomeSnapshot(input: PrOutcomeInput, options: RecordPrOutcomeOptions = {}): unknown { + const eventLedger = options.eventLedger; + if (!eventLedger || typeof eventLedger.appendEvent !== "function") throw new Error("invalid_event_ledger"); + const repoFullName = typeof input.repoFullName === "string" ? input.repoFullName.trim() : ""; + if (!repoFullName) return null; + const payload = normalizePrOutcomePayload({ + prNumber: input.prNumber, + decision: input.decision, + closedAt: input.closedAt, + reason: input.reason, + }); + if (!payload) return null; + return eventLedger.appendEvent({ type: MINER_PR_OUTCOME_EVENT, repoFullName, payload }); +} + +/** + * Reconstruct the latest outcome per repo/PR from the ledger's ascending append-only event stream (mirrors + * manage-status.js's `indexLatestManageUpdates`). Reads via the injected ledger's `readEvents(filter)` and reduces + * the pure result — a later event for the same repo/PR supersedes an earlier one. Returns a `Map` keyed by + * `repoFullName:prNumber`. + */ +export function readPrOutcomes( + eventLedger: PrOutcomeLedgerReader | null | undefined, + filter: { since?: number; repoFullName?: string } = {}, +): Map { + const events = eventLedger && typeof eventLedger.readEvents === "function" ? eventLedger.readEvents(filter) : []; + const latest = new Map(); + for (const event of Array.isArray(events) ? events : []) { + if (!event || typeof event !== "object") continue; + const row = event as { type?: unknown; repoFullName?: unknown; payload?: unknown }; + if (row.type !== MINER_PR_OUTCOME_EVENT) continue; + if (typeof row.repoFullName !== "string" || !row.repoFullName.trim()) continue; + const normalized = normalizePrOutcomePayload(row.payload); + if (!normalized) continue; + // Re-key on every event so Map iteration order tracks most-recently-UPDATED last, not first-seen (#7222). A + // bare Map.set() on an existing key updates the value but leaves the key frozen at its original position, so a + // later outcome for the same PR (e.g. closed-without-merge, then reopened + merged) stayed at its old slot -- + // breaking recency-ordered consumers like loop-reentry.js's countConsecutiveDisengagements. Deleting first + // moves the freshly-updated entry to the end, matching this reducer's own "a later event supersedes" contract. + const key = `${row.repoFullName}:${normalized.prNumber}`; + latest.delete(key); + latest.set(key, { ...normalized, repoFullName: row.repoFullName }); + } + return latest; +} diff --git a/test/unit/miner-deny-hooks.test.ts b/test/unit/miner-deny-hooks.test.ts index fce17a0cb1..f0b1cbc9c5 100644 --- a/test/unit/miner-deny-hooks.test.ts +++ b/test/unit/miner-deny-hooks.test.ts @@ -110,3 +110,11 @@ describe("evaluateDenyHooks — rule composition and allow paths", () => { expect(evaluateDenyHooks({ name: "Read", input: { count: 3, nested: { file: "src/x.ts" } } }).allowed).toBe(true); }); }); + +describe("deny-hooks TypeScript re-export surface (#7313 batch 4.5)", () => { + it("keeps the miner package entry as a thin engine re-export after .ts conversion", () => { + expect(typeof evaluateDenyHooks).toBe("function"); + expect(Array.isArray(DEFAULT_DENY_RULES)).toBe(true); + expect(DEFAULT_DENY_RULES.length).toBeGreaterThan(0); + }); +}); diff --git a/test/unit/miner-pr-outcome.test.ts b/test/unit/miner-pr-outcome.test.ts index 36960d7100..5ae4cf293d 100644 --- a/test/unit/miner-pr-outcome.test.ts +++ b/test/unit/miner-pr-outcome.test.ts @@ -105,4 +105,15 @@ describe("readPrOutcomes (#4274)", () => { expect(readPrOutcomes({} as never).size).toBe(0); expect(readPrOutcomes({ readEvents: () => null } as never).size).toBe(0); }); + + it("skips non-object events in the stream without throwing (#7313 TypeScript conversion)", () => { + const ledger = mockLedger(); + ledger._events.push( + null as never, + "noise" as never, + 42 as never, + { type: MINER_PR_OUTCOME_EVENT, repoFullName: "acme/widgets", payload: { prNumber: 8, decision: "merged" } }, + ); + expect([...readPrOutcomes(ledger).keys()]).toEqual(["acme/widgets:8"]); + }); });