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:
@@ -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.
|
||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
evaluateReleaseAuthorizationGate,
|
evaluateReleaseAuthorizationGate,
|
||||||
isUserAuthoredSource,
|
isUserAuthoredSource,
|
||||||
parseReleaseAuthorizationMarker,
|
parseReleaseAuthorizationMarker,
|
||||||
|
stripNegatedReleaseClauses,
|
||||||
} from "../triage-release-authorization.js";
|
} from "../triage-release-authorization.js";
|
||||||
|
|
||||||
const releasePrompt = `# Task: FN-6469 - Release @runfusion/fusion patch
|
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", () => {
|
it("handles empty and undefined inputs without throwing or flagging", () => {
|
||||||
expect(classifyReleaseTask({})).toEqual({ isReleaseClass: false, signals: [] });
|
expect(classifyReleaseTask({})).toEqual({ isReleaseClass: false, signals: [] });
|
||||||
expect(evaluateReleaseAuthorizationGate({ sourceType: undefined }).action).toBe("allow");
|
expect(evaluateReleaseAuthorizationGate({ sourceType: undefined }).action).toBe("allow");
|
||||||
|
|||||||
@@ -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 },
|
{ 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 {
|
export function isUserAuthoredSource(sourceType: string | null | undefined): boolean {
|
||||||
return typeof sourceType === "string" && USER_AUTHORED_SOURCE_TYPES.has(sourceType);
|
return typeof sourceType === "string" && USER_AUTHORED_SOURCE_TYPES.has(sourceType);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function classifyReleaseTask(input: ReleaseTaskClassificationInput): ReleaseTaskClassification {
|
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)
|
.filter((value): value is string => typeof value === "string" && value.length > 0)
|
||||||
.join("\n\n");
|
.join("\n\n");
|
||||||
|
|
||||||
if (!text.trim()) {
|
if (!rawText.trim()) {
|
||||||
return { isReleaseClass: false, signals: [] };
|
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[] = [];
|
const signals: string[] = [];
|
||||||
for (const { label, pattern } of RELEASE_SIGNAL_PATTERNS) {
|
for (const { label, pattern } of RELEASE_SIGNAL_PATTERNS) {
|
||||||
if (pattern.test(text)) {
|
if (pattern.test(text)) {
|
||||||
|
|||||||
Reference in New Issue
Block a user