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,
|
||||
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");
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user