fix(FN-7560): stop release-auth gate flagging tasks that disclaim releasing

The release-authorization classifier substring-matched release signals
(notably `scripts/release.mjs`) even inside disclaimer clauses that
explicitly say the task performs NO release/publish. AI-authored specs
routinely append such disclaimers, so revert/undo/UI tasks (FN-7525,
FN-7554, FN-7556) were parked in awaiting-release-authorization with no
in-band exit — their non-user sources (agent_heartbeat/api) make the
authorization marker inert.

classifyReleaseTask now strips negated release-disclaimer clauses before
signal matching. Genuine "run pnpm release"/"publish @runfusion/fusion"
intent lives in a non-negated clause and still trips the gate. Tests
cover all three real repro shapes plus every documented signal in both
its negated (not release-class) and actionable (still release-class) form.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-05 09:25:51 -07:00
parent 196abb5af9
commit b471aece6a
3 changed files with 119 additions and 2 deletions

View File

@@ -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.

View File

@@ -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");

View File

@@ -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)) {