diff --git a/.changeset/fn-7560-release-gate-disclaimer-false-positive.md b/.changeset/fn-7560-release-gate-disclaimer-false-positive.md new file mode 100644 index 0000000000..6ddbecef94 --- /dev/null +++ b/.changeset/fn-7560-release-gate-disclaimer-false-positive.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Stop the release-authorization gate from holding tasks that merely disclaim releasing. +category: fix +dev: classifyReleaseTask now strips negated release-disclaimer clauses (e.g. "this task performs no release/publish; releases are owned by scripts/release.mjs") before signal matching in packages/engine/src/triage-release-authorization.ts, so revert/undo/UI specs are no longer false-flagged as release-class. Genuine "run pnpm release"/"publish @runfusion/fusion" intent still trips the gate. diff --git a/packages/engine/src/__tests__/triage-release-authorization.test.ts b/packages/engine/src/__tests__/triage-release-authorization.test.ts index bd7638eaea..8e43a08b23 100644 --- a/packages/engine/src/__tests__/triage-release-authorization.test.ts +++ b/packages/engine/src/__tests__/triage-release-authorization.test.ts @@ -4,6 +4,7 @@ import { evaluateReleaseAuthorizationGate, isUserAuthoredSource, parseReleaseAuthorizationMarker, + stripNegatedReleaseClauses, } from "../triage-release-authorization.js"; const releasePrompt = `# Task: FN-6469 - Release @runfusion/fusion patch @@ -123,6 +124,80 @@ describe("triage release authorization gate", () => { } }); + /* + * FN-7560 regression: release disclaimers must not self-incriminate. + * Symptom: FN-7525/FN-7554/FN-7556 (revert/undo/UI tasks) were parked in + * awaiting-release-authorization solely because their AI-authored specs said + * they perform NO release while naming `scripts/release.mjs` as the owner. + * Surface enumeration below covers every documented signal in both its negated + * (disclaimer → not release-class) and actionable (intent → still release-class) + * form so the invariant holds across all known signal surfaces, not just the repro. + */ + describe("negated release disclaimers are not classified as release-class (FN-7560)", () => { + const disclaimerRepros = [ + // FN-7525 + "This task does not perform any package release or publish (releases are owned by `scripts/release.mjs`).", + // FN-7554 + "This task's delivery is the changeset FILE only — it performs no release/publish (`scripts/release.mjs` owns releases).", + // FN-7556 + "Delivery is the changeset FILE only; this task performs no package release or publish (releases are owned by `scripts/release.mjs`).", + ]; + + for (const promptText of disclaimerRepros) { + it(`does not flag disclaimer: ${promptText.slice(0, 48)}…`, () => { + const classification = classifyReleaseTask({ promptText }); + expect(classification.isReleaseClass, promptText).toBe(false); + expect(classification.signals, promptText).toEqual([]); + }); + } + + it("clears the awaiting-release-authorization hold for the real FN-7525 shape", () => { + const decision = evaluateReleaseAuthorizationGate({ + sourceType: "agent_heartbeat", + title: "Add Revert/Undo affordance to Done and Archived task cards", + promptText: + "## Scope\nThis task does not perform any package release or publish (releases are owned by `scripts/release.mjs`).\n\n## Git Commit Convention\nCommits at step boundaries.", + }); + expect(decision.action).toBe("allow"); + expect(decision.isReleaseClass).toBe(false); + }); + }); + + it("still flags genuine release intent even alongside a disclaimer clause", () => { + // A real release instruction lives in its own non-negated clause and must survive stripping. + const classification = classifyReleaseTask({ + promptText: + "Run pnpm release --yes to publish @runfusion/fusion. This other task performs no release.", + }); + expect(classification.isReleaseClass).toBe(true); + expect(classification.signals).toContain("pnpm release"); + }); + + it("still flags every documented signal when phrased as an actionable instruction", () => { + const actionable = [ + "Run pnpm release --yes now.", + "Execute node scripts/release.mjs to cut the build.", + "Run pnpm changeset publish to ship.", + "Then npm publish the @runfusion/fusion tarball.", + "Run pnpm publish @runfusion/fusion.", + "Publish the package to npm as the final step.", + "Create git tag v1.2.3 for the release.", + "Author a version bump release commit for v1.2.3.", + ]; + for (const promptText of actionable) { + expect(classifyReleaseTask({ promptText }).isReleaseClass, promptText).toBe(true); + } + }); + + it("stripNegatedReleaseClauses drops disclaimer clauses but keeps actionable ones", () => { + const stripped = stripNegatedReleaseClauses( + "Run pnpm release to publish. This task performs no other release; releases are owned by scripts/release.mjs.", + ); + expect(stripped).toMatch(/pnpm release/); + expect(stripped).not.toMatch(/scripts\/release\.mjs/); + expect(stripped).not.toMatch(/performs no/); + }); + it("handles empty and undefined inputs without throwing or flagging", () => { expect(classifyReleaseTask({})).toEqual({ isReleaseClass: false, signals: [] }); expect(evaluateReleaseAuthorizationGate({ sourceType: undefined }).action).toBe("allow"); diff --git a/packages/engine/src/triage-release-authorization.ts b/packages/engine/src/triage-release-authorization.ts index 0027b29fa6..c7e98307f8 100644 --- a/packages/engine/src/triage-release-authorization.ts +++ b/packages/engine/src/triage-release-authorization.ts @@ -42,19 +42,54 @@ const RELEASE_SIGNAL_PATTERNS: ReleaseSignalPattern[] = [ { label: "version-bump release commit", pattern: /\b(?:version\s*bump|bump\s+version|release\s+commit|release\s+version)\b[\s\S]{0,120}\bv\d+\.\d+\.\d+\b|\bv\d+\.\d+\.\d+\b[\s\S]{0,120}\b(?:version\s*bump|bump\s+version|release\s+commit|release\s+version)\b/i }, ]; +/* +FNXC:ReleaseAuthorizationGate 2026-07-05-15:40: +FN-7560: classifyReleaseTask matched a bare mention of a release signal (e.g. `scripts/release.mjs`) even when it appeared inside a disclaimer clause that explicitly states the task does NOT release — "this task performs no release/publish (releases are owned by `scripts/release.mjs`)". AI-authored specs routinely append such disclaimers, so revert/undo/UI tasks (FN-7525, FN-7554, FN-7556) were false-flagged as release-class and parked in awaiting-release-authorization with no in-band exit (their non-user sources make the authorization marker inert). Strip negated release-disclaimer clauses before signal matching so a spec that disclaims releasing does not self-incriminate. Genuine release intent survives because "run pnpm release" / "publish @runfusion/fusion" lives in a non-negated clause and is evaluated normally. +*/ +const RELEASE_NEGATION_PATTERNS: RegExp[] = [ + // "performs no release", "performs no package release/publish" + /\bperforms?\s+no\s+(?:[\w-]+\s+){0,3}?(?:release|publish)/i, + // "does not perform any package release", "will not publish", "doesn't release" + /\b(?:does|do|did|will|would|shall|can|could|should)(?:\s+not|n['’]?t)\b\s+(?:[\w-]+\s+){0,4}?(?:release|publish)/i, + // "no release/publish", "no package/actual release" + /\bno\s+(?:[\w-]+\s+){0,2}?(?:release|publish)\b/i, + // "releases are owned by scripts/release.mjs" — ownership disclaimer, not intent + /\breleases?\s+are\s+owned\s+by\b/i, + // "never release/publish" + /\bnever\s+(?:[\w-]+\s+){0,3}?(?:release|publish)/i, +]; + +/** + * FNXC:ReleaseAuthorizationGate 2026-07-05-15:40: + * Split into clause-sized segments (sentence terminators and line breaks) and + * drop any segment carrying a release-negation cue, keeping segments small so + * removing one disclaimer clause never discards an adjacent genuine release + * instruction. Returns the surviving text for signal matching. + */ +export function stripNegatedReleaseClauses(text: string): string { + return text + .split(/(?<=[.!?;])\s+|\n+/) + .filter((clause) => !RELEASE_NEGATION_PATTERNS.some((pattern) => pattern.test(clause))) + .join("\n"); +} + export function isUserAuthoredSource(sourceType: string | null | undefined): boolean { return typeof sourceType === "string" && USER_AUTHORED_SOURCE_TYPES.has(sourceType); } export function classifyReleaseTask(input: ReleaseTaskClassificationInput): ReleaseTaskClassification { - const text = [input.title, input.description, input.promptText] + const rawText = [input.title, input.description, input.promptText] .filter((value): value is string => typeof value === "string" && value.length > 0) .join("\n\n"); - if (!text.trim()) { + if (!rawText.trim()) { return { isReleaseClass: false, signals: [] }; } + // Evaluate signals only against clauses that are not release disclaimers, so a + // spec that says "this task performs no release" is not flagged as one. + const text = stripNegatedReleaseClauses(rawText); + const signals: string[] = []; for (const { label, pattern } of RELEASE_SIGNAL_PATTERNS) { if (pattern.test(text)) {