@event4u/agent-config 14.14.1 → 14.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.augment-plugin/marketplace.json +3 -3
- package/.augment-plugin/plugin.json +1 -1
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +30 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/cli-delegate/cmd_conformance.js +512 -16
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +1 -1
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +1 -1
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +1 -1
- package/dist/hooks/dispatch.js +515 -19
- package/dist/mcp/registry-manifest.json +1 -1
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +518 -17
- package/package.json +1 -1
- package/src/config/gate-violation-baselines.json +2 -2
- package/src/config/rule-enforcement-baseline.json +0 -1
- package/src/scripts/_lib/release_highlights.ts +63 -7
- package/src/scripts/git_authorization_hook.ts +476 -6
- package/src/scripts/hooks/block_unauthorized_git.ts +238 -9
- package/src/scripts/hooks/merge_impact.ts +207 -0
- package/src/scripts/release.ts +5 -17
- package/src/scripts/release_drill.ts +26 -7
- package/src/scripts/release_env.ts +31 -0
- package/src/scripts/release_publication.ts +67 -1
|
@@ -61,12 +61,16 @@ import path from "node:path";
|
|
|
61
61
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
62
62
|
|
|
63
63
|
import { readHookStdin } from "./hook_stdin.js";
|
|
64
|
+
import { analyseMergeImpact, describeImpact } from "./merge_impact.js";
|
|
64
65
|
import { atomic_write_json } from "./state_io.js";
|
|
65
66
|
import {
|
|
67
|
+
consumeGrantTarget,
|
|
66
68
|
ledgerFileFor,
|
|
67
69
|
pendingFileFor,
|
|
70
|
+
readGrants,
|
|
68
71
|
STATE_FILE,
|
|
69
72
|
type GitOp,
|
|
73
|
+
type MergeGrant,
|
|
70
74
|
} from "../git_authorization_hook.js";
|
|
71
75
|
|
|
72
76
|
const EXIT_ALLOW = 0;
|
|
@@ -95,6 +99,31 @@ export const BLOCK_OPS: ReadonlySet<GitOp> = new Set<GitOp>([
|
|
|
95
99
|
// Enabling auto-merge commits the outcome to a condition the agent does not
|
|
96
100
|
// control, so it is blocked on the same terms as the merge it schedules.
|
|
97
101
|
"pr-merge-auto",
|
|
102
|
+
|
|
103
|
+
// ── Added 2026-09-03. Every one is already never-autonomous in the
|
|
104
|
+
// `non-destructive-by-default` prose; the measurement that drove it is in the
|
|
105
|
+
// `GitOp` union's own note — 17 of 25 borderline operations classified as
|
|
106
|
+
// NOTHING, including three worse than anything the guard already caught.
|
|
107
|
+
/** The undo that is more destructive than the act: lockfiles break. */
|
|
108
|
+
"unpublish",
|
|
109
|
+
/** Metadata only, and every install on earth starts warning. */
|
|
110
|
+
"deprecate",
|
|
111
|
+
/** Replaces bytes consumers already hold a checksum for. */
|
|
112
|
+
"release-asset",
|
|
113
|
+
/** Deletes the guard rather than the data — everything after is unguarded. */
|
|
114
|
+
"protection",
|
|
115
|
+
/** No data lost; the gate that protects everything downstream is off. */
|
|
116
|
+
"workflow-toggle",
|
|
117
|
+
/** Reach outside the repository, and mostly one-way. */
|
|
118
|
+
"repo-lifecycle",
|
|
119
|
+
/** No code moves, and an armed auto-merge is released. */
|
|
120
|
+
"review-approve",
|
|
121
|
+
/** Discards commits that landed after the last fetch, on a shared ref. */
|
|
122
|
+
"force-push",
|
|
123
|
+
/** Destroys uncommitted work belonging to a session this one cannot see. */
|
|
124
|
+
"worktree-remove",
|
|
125
|
+
/** `-x` is the flag that takes `.env` and local certificates. */
|
|
126
|
+
"clean-ignored",
|
|
98
127
|
]);
|
|
99
128
|
|
|
100
129
|
/** Recoverable operations — warned without a this-turn authorization. */
|
|
@@ -103,6 +132,22 @@ export const WARN_OPS: ReadonlySet<GitOp> = new Set<GitOp>([
|
|
|
103
132
|
"push",
|
|
104
133
|
"pr-create",
|
|
105
134
|
"branch",
|
|
135
|
+
|
|
136
|
+
// ── Added 2026-09-03, deliberately at warn rather than block.
|
|
137
|
+
//
|
|
138
|
+
// Each is destructive in some reading, and each is also routine enough that
|
|
139
|
+
// blocking it would be the friction that gets the whole guard routed around —
|
|
140
|
+
// the failure mode this estate has already recorded twice. `reset-hard` is the
|
|
141
|
+
// sharpest case and stays here for a decidable reason: the Hard Floor's
|
|
142
|
+
// qualifier is "past unpushed work", and the command text cannot say whether
|
|
143
|
+
// the work was pushed.
|
|
144
|
+
"tag-force",
|
|
145
|
+
"rebase",
|
|
146
|
+
"reset-hard",
|
|
147
|
+
"clean",
|
|
148
|
+
"stash-drop",
|
|
149
|
+
"branch-delete",
|
|
150
|
+
"close",
|
|
106
151
|
]);
|
|
107
152
|
|
|
108
153
|
/**
|
|
@@ -191,6 +236,74 @@ const COMMAND_OPS: ReadonlyArray<{ op: GitOp; re: RegExp }> = [
|
|
|
191
236
|
op: "pr-create",
|
|
192
237
|
re: new RegExp(`${P}gh\\s+pr\\s+create\\b|${ghApiWrite("\\/pulls\\b").source}`, "i"),
|
|
193
238
|
},
|
|
239
|
+
// ── Added 2026-09-03. Placement inside this table is as load-bearing as the
|
|
240
|
+
// patterns: the loop takes the MOST SEVERE match per segment, but within one
|
|
241
|
+
// op the first pattern wins, so anything more specific than a plain `git push`
|
|
242
|
+
// has to sit above it.
|
|
243
|
+
|
|
244
|
+
// `npm unpublish` is NOT reached by the publish pattern — `\bpublish\b` needs
|
|
245
|
+
// a word boundary that `unpublish` does not provide between `n` and `p`. That
|
|
246
|
+
// is why it classified as nothing for as long as it did.
|
|
247
|
+
{ op: "unpublish", re: new RegExp(`${P}(npm|pnpm|yarn)\\s+${G}unpublish\\b`, "i") },
|
|
248
|
+
{ op: "deprecate", re: new RegExp(`${P}(npm|pnpm|yarn)\\s+${G}deprecate\\b`, "i") },
|
|
249
|
+
{
|
|
250
|
+
op: "release-asset",
|
|
251
|
+
re: new RegExp(`${P}gh\\s+release\\s+(upload|edit|delete)\\b`, "i"),
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
op: "protection",
|
|
255
|
+
re: new RegExp(
|
|
256
|
+
`${P}gh\\s+api\\b(?=[^\\n]*(?:protection|rulesets?)\\b)(?=[^\\n]*(?:-X|--method)\\s+(?:DELETE|PUT|PATCH|POST)\\b)|${P}gh\\s+ruleset\\s+delete\\b`,
|
|
257
|
+
"i",
|
|
258
|
+
),
|
|
259
|
+
},
|
|
260
|
+
{
|
|
261
|
+
op: "workflow-toggle",
|
|
262
|
+
re: new RegExp(`${P}gh\\s+workflow\\s+(disable|enable)\\b`, "i"),
|
|
263
|
+
},
|
|
264
|
+
{
|
|
265
|
+
op: "repo-lifecycle",
|
|
266
|
+
re: new RegExp(
|
|
267
|
+
`${P}gh\\s+repo\\s+(archive|delete|unarchive)\\b|${P}gh\\s+repo\\s+edit\\b(?=[^\\n]*--visibility\\b)`,
|
|
268
|
+
"i",
|
|
269
|
+
),
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
op: "review-approve",
|
|
273
|
+
re: new RegExp(`${P}gh\\s+pr\\s+review\\b(?=[^\\n]*--approve\\b)`, "i"),
|
|
274
|
+
},
|
|
275
|
+
// Above `push`, and both spellings: `--force` and `--force-with-lease`. The
|
|
276
|
+
// "safe force" is in the same class here because what it is safe against is a
|
|
277
|
+
// stale local view, not a collaborator who pushed after your fetch.
|
|
278
|
+
{
|
|
279
|
+
op: "force-push",
|
|
280
|
+
re: new RegExp(`${P}git\\s+${G}push\\b[^\\n;|&]*(--force\\b|--force-with-lease\\b|\\s-f\\b)`, "i"),
|
|
281
|
+
},
|
|
282
|
+
{
|
|
283
|
+
op: "worktree-remove",
|
|
284
|
+
re: new RegExp(`${P}git\\s+worktree\\s+remove\\b[^\\n;|&]*(--force\\b|\\s-f\\b)`, "i"),
|
|
285
|
+
},
|
|
286
|
+
// `-x` (or `-X`) anywhere in the flag cluster is what reaches ignored files.
|
|
287
|
+
{
|
|
288
|
+
op: "clean-ignored",
|
|
289
|
+
re: new RegExp(`${P}git\\s+clean\\b[^\\n;|&]*-[A-Za-z]*x`, "i"),
|
|
290
|
+
},
|
|
291
|
+
{ op: "tag-force", re: new RegExp(`${P}git\\s+tag\\b[^\\n;|&]*(-f\\b|--force\\b)`, "i") },
|
|
292
|
+
{ op: "rebase", re: new RegExp(`${P}git\\s+${G}rebase\\b`, "i") },
|
|
293
|
+
{ op: "reset-hard", re: new RegExp(`${P}git\\s+${G}reset\\b[^\\n;|&]*--hard\\b`, "i") },
|
|
294
|
+
{ op: "clean", re: new RegExp(`${P}git\\s+clean\\b`, "i") },
|
|
295
|
+
{ op: "stash-drop", re: new RegExp(`${P}git\\s+stash\\s+(drop|clear)\\b`, "i") },
|
|
296
|
+
// Remote deletion and the local `-D` are one op: both remove a ref, and the
|
|
297
|
+
// remote form additionally closes any open PR pointing at it.
|
|
298
|
+
{
|
|
299
|
+
op: "branch-delete",
|
|
300
|
+
re: new RegExp(
|
|
301
|
+
`${P}git\\s+branch\\b[^\\n;|&]*(-D\\b|--delete\\b)|${P}git\\s+${G}push\\b[^\\n;|&]*(--delete\\b|\\s:\\S)`,
|
|
302
|
+
"i",
|
|
303
|
+
),
|
|
304
|
+
},
|
|
305
|
+
{ op: "close", re: new RegExp(`${P}gh\\s+(pr|issue)\\s+close\\b`, "i") },
|
|
306
|
+
|
|
194
307
|
{ op: "push", re: new RegExp(`${P}git\\s+${G}push\\b`, "i") },
|
|
195
308
|
{ op: "commit", re: new RegExp(`${P}git\\s+${G}commit\\b`, "i") },
|
|
196
309
|
{ op: "branch", re: new RegExp(`${P}git\\s+${G}(checkout\\s+-b|switch\\s+-c)\\b`, "i") },
|
|
@@ -503,6 +616,55 @@ export function commandOp(command: string): GitOp | null {
|
|
|
503
616
|
return found;
|
|
504
617
|
}
|
|
505
618
|
|
|
619
|
+
/**
|
|
620
|
+
* The pull request a merge command acts on, or `null` when the command does not
|
|
621
|
+
* say.
|
|
622
|
+
*
|
|
623
|
+
* `gh pr merge` with no number merges whatever the current branch's PR is — the
|
|
624
|
+
* command text identifies no object, so no grant can match it and the
|
|
625
|
+
* clock-bound path applies unchanged. That is the fail-closed direction: an
|
|
626
|
+
* unnamed target never consumes a grant minted for a named one.
|
|
627
|
+
*/
|
|
628
|
+
export function mergeTargetOf(command: string): number | null {
|
|
629
|
+
const gh = /\bgh\s+pr\s+merge\s+(?:-{1,2}\S+\s+)*?(\d{1,7})\b/i.exec(command);
|
|
630
|
+
if (gh) {
|
|
631
|
+
return Number.parseInt(gh[1] as string, 10);
|
|
632
|
+
}
|
|
633
|
+
// The same operation by its REST spelling. `commandOp` already classifies
|
|
634
|
+
// `gh api -X PUT repos/o/r/pulls/1499/merge` as `pr-merge`, so a reader that
|
|
635
|
+
// missed it here would silently fall back to the clock.
|
|
636
|
+
const api = /\/pulls\/(\d{1,7})\/merge\b/i.exec(command);
|
|
637
|
+
return api ? Number.parseInt(api[1] as string, 10) : null;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Does a standing grant cover this exact merge?
|
|
642
|
+
*
|
|
643
|
+
* Three conditions, all necessary: the grant is for this op, the command named
|
|
644
|
+
* a target, and that target sits in the grant's frozen set unspent.
|
|
645
|
+
*
|
|
646
|
+
* No clock is consulted here, and that is the whole of ADR-252. `authorized`
|
|
647
|
+
* answers "did the user name this operation recently"; a grant answers "did the
|
|
648
|
+
* user name this operation AND this pull request". The second question does not
|
|
649
|
+
* decay with time, because the object it names cannot drift into a different
|
|
650
|
+
* object — a force-push changes the SHA, but the grant is spent on first use, so
|
|
651
|
+
* a merge that already happened can never be replayed against a moved head.
|
|
652
|
+
*/
|
|
653
|
+
export function grantCovers(
|
|
654
|
+
grants: MergeGrant[],
|
|
655
|
+
op: GitOp,
|
|
656
|
+
target: number | null,
|
|
657
|
+
): MergeGrant | null {
|
|
658
|
+
if (op !== "pr-merge" || target === null) {
|
|
659
|
+
return null;
|
|
660
|
+
}
|
|
661
|
+
return (
|
|
662
|
+
grants.find(
|
|
663
|
+
(g) => g.op === "pr-merge" && g.targets.includes(target) && !g.consumed.includes(target),
|
|
664
|
+
) ?? null
|
|
665
|
+
);
|
|
666
|
+
}
|
|
667
|
+
|
|
506
668
|
/**
|
|
507
669
|
* A ledger older than this is not "this turn" under any reading.
|
|
508
670
|
*
|
|
@@ -558,7 +720,15 @@ function _loadLedger(
|
|
|
558
720
|
consumer_root: string,
|
|
559
721
|
session_id: string,
|
|
560
722
|
now: number,
|
|
561
|
-
): { authorized: Set<GitOp>; present: boolean } {
|
|
723
|
+
): { authorized: Set<GitOp>; present: boolean; grants: MergeGrant[] } {
|
|
724
|
+
// Grants are read on their OWN path, and the separation is load-bearing.
|
|
725
|
+
// `_readLedgerFile` below discards the entire ledger once `detected_at` is
|
|
726
|
+
// older than `LEDGER_MAX_AGE_MS`, so folding grants into its return value
|
|
727
|
+
// would let the clock silently kill them too — the change would ship, look
|
|
728
|
+
// correct, and do nothing after thirty minutes, with no test going red.
|
|
729
|
+
// `readGrants` performs the session check and no age check, which is exactly
|
|
730
|
+
// the contract: object identity, not recency (ADR-252).
|
|
731
|
+
const grants = readGrants(consumer_root, session_id);
|
|
562
732
|
// This session's own ledger first; the flat legacy file only as a fallback,
|
|
563
733
|
// so a ledger written by an older build still counts. Reading the flat file
|
|
564
734
|
// FIRST was the defect: any concurrent session in the repo overwrites it, and
|
|
@@ -570,10 +740,10 @@ function _loadLedger(
|
|
|
570
740
|
for (const rel of candidates) {
|
|
571
741
|
const found = _readLedgerFile(path.join(consumer_root, rel), session_id, now);
|
|
572
742
|
if (found !== null) {
|
|
573
|
-
return found;
|
|
743
|
+
return { ...found, grants };
|
|
574
744
|
}
|
|
575
745
|
}
|
|
576
|
-
return { authorized: new Set<GitOp>(), present: false };
|
|
746
|
+
return { authorized: new Set<GitOp>(), present: false, grants };
|
|
577
747
|
}
|
|
578
748
|
|
|
579
749
|
/**
|
|
@@ -622,11 +792,21 @@ export interface Decision {
|
|
|
622
792
|
* `git_authorization_hook.PENDING_FILE` for why that record exists at all.
|
|
623
793
|
*/
|
|
624
794
|
blockedOp?: GitOp;
|
|
795
|
+
/**
|
|
796
|
+
* The grant target this decision spent, when a standing grant is what allowed
|
|
797
|
+
* it.
|
|
798
|
+
*
|
|
799
|
+
* `decide` stays pure; `run` turns this into the ledger write that marks the
|
|
800
|
+
* target used. Single use is the property that keeps a grant from being a
|
|
801
|
+
* standing licence: the user authorized merging PR #1499, once.
|
|
802
|
+
*/
|
|
803
|
+
consumedTarget?: number;
|
|
625
804
|
}
|
|
626
805
|
|
|
627
806
|
export function decide(
|
|
628
807
|
command: string | null,
|
|
629
|
-
ledger: { authorized: Set<GitOp>; present: boolean },
|
|
808
|
+
ledger: { authorized: Set<GitOp>; present: boolean; grants?: MergeGrant[] },
|
|
809
|
+
impact?: string,
|
|
630
810
|
): Decision {
|
|
631
811
|
if (command === null) {
|
|
632
812
|
return { exit: EXIT_ALLOW, stdout: "", stderr: "" };
|
|
@@ -636,13 +816,30 @@ export function decide(
|
|
|
636
816
|
return { exit: EXIT_ALLOW, stdout: "", stderr: "" };
|
|
637
817
|
}
|
|
638
818
|
|
|
819
|
+
// A standing grant is the SECOND way an operation can be authorized, and it
|
|
820
|
+
// is strictly narrower than the first rather than an escape from it. Only
|
|
821
|
+
// `pr-merge` can hold one, the command must name its pull request, and that
|
|
822
|
+
// number must sit unspent in a set the user's own sentence froze. An unnamed
|
|
823
|
+
// target matches nothing and a spent target matches nothing, so every path
|
|
824
|
+
// that is not exactly "the user named this PR and it has not been merged yet"
|
|
825
|
+
// falls through to the clock-bound decision below unchanged (ADR-252).
|
|
826
|
+
const target = mergeTargetOf(command);
|
|
827
|
+
if (grantCovers(ledger.grants ?? [], op, target) && target !== null) {
|
|
828
|
+
return { exit: EXIT_ALLOW, stdout: "", stderr: "", consumedTarget: target };
|
|
829
|
+
}
|
|
830
|
+
|
|
639
831
|
const shown = command.length > 120 ? `${command.slice(0, 117)}…` : command;
|
|
640
832
|
|
|
641
833
|
if (BLOCK_OPS.has(op)) {
|
|
834
|
+
// STAGE 2. The refusal stands either way — what the scan changes is whether
|
|
835
|
+
// the user has to go read the diff to answer it. `impact` is injected by
|
|
836
|
+
// `run`, never computed here, so `decide` stays pure and testable without a
|
|
837
|
+
// repository.
|
|
838
|
+
const scan = op === "pr-merge" && target !== null && impact ? ` ${impact}` : "";
|
|
642
839
|
const reason =
|
|
643
840
|
`Blocked: \`${op}\` with no authorization in this turn's prompt. ` +
|
|
644
841
|
`This operation is irreversible and non-destructive-by-default already declares it ` +
|
|
645
|
-
`never-autonomous. Command: ${shown}
|
|
842
|
+
`never-autonomous. Command: ${shown}.${scan} ` +
|
|
646
843
|
`Ask the user, in one numbered-options block, and run it only after they answer this turn.`;
|
|
647
844
|
return { exit: EXIT_BLOCK, stdout: "", stderr: `${reason}\n`, blockedOp: op };
|
|
648
845
|
}
|
|
@@ -675,10 +872,42 @@ export function run(stdin_text: string, options: { consumer_root: string }): num
|
|
|
675
872
|
}
|
|
676
873
|
}
|
|
677
874
|
const session_id = typeof envelope["session_id"] === "string" ? envelope["session_id"] : "";
|
|
678
|
-
const
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
875
|
+
const command = extractCommand(envelope);
|
|
876
|
+
const ledger = _loadLedger(options.consumer_root, session_id, Date.now());
|
|
877
|
+
|
|
878
|
+
// STAGE 2 runs only on the path where it can change something: a merge that is
|
|
879
|
+
// ABOUT to be refused. Not on an allowed merge (nothing to tell the user), not
|
|
880
|
+
// on an op whose target the command does not name, and never on publish or
|
|
881
|
+
// release — a scan costs a diff read, and spending one where it cannot change
|
|
882
|
+
// the outcome is latency in the user's critical path for nothing.
|
|
883
|
+
let impact: string | undefined;
|
|
884
|
+
const stage1 = command === null ? null : commandOp(command);
|
|
885
|
+
const stage2Target = command === null ? null : mergeTargetOf(command);
|
|
886
|
+
if (
|
|
887
|
+
stage1 === "pr-merge" &&
|
|
888
|
+
stage2Target !== null &&
|
|
889
|
+
!ledger.authorized.has("pr-merge") &&
|
|
890
|
+
!grantCovers(ledger.grants ?? [], "pr-merge", stage2Target)
|
|
891
|
+
) {
|
|
892
|
+
try {
|
|
893
|
+
impact = describeImpact(
|
|
894
|
+
stage2Target,
|
|
895
|
+
analyseMergeImpact(stage2Target, { cwd: options.consumer_root }),
|
|
896
|
+
);
|
|
897
|
+
} catch {
|
|
898
|
+
// The scan is an enrichment, never a gate. A refusal without it is the
|
|
899
|
+
// pre-stage-2 message, which is still correct.
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
const decision = decide(command, ledger, impact);
|
|
904
|
+
if (decision.consumedTarget !== undefined) {
|
|
905
|
+
// Spend the target at the moment the merge is actually let through, never
|
|
906
|
+
// when the grant was minted. A grant the run never reached stays whole, and
|
|
907
|
+
// a pull request that merged can never be replayed — which is what makes a
|
|
908
|
+
// force-push back to an already-merged head harmless here.
|
|
909
|
+
consumeGrantTarget(options.consumer_root, session_id, decision.consumedTarget);
|
|
910
|
+
}
|
|
682
911
|
if (decision.blockedOp) {
|
|
683
912
|
// Record WHAT was refused, so the user's answer to the question this
|
|
684
913
|
// refusal just told the agent to ask can authorize that one operation.
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stage-2 impact analysis for a merge the guard is about to refuse.
|
|
3
|
+
*
|
|
4
|
+
* STAGE 1 asks "is this operation in the blocked set?" — a question about the
|
|
5
|
+
* command's shape, answered by `commandOp`. STAGE 2 asks "what would it
|
|
6
|
+
* actually do?" — a question about the diff, answered here.
|
|
7
|
+
*
|
|
8
|
+
* WHAT THIS DOES NOT DO, stated first because the opposite is the tempting
|
|
9
|
+
* reading: it never downgrades a refusal into an allow. Both council seats
|
|
10
|
+
* (2026-09-03, anthropic + codex-default, deep) rejected exactly that, and the
|
|
11
|
+
* argument is not about diff parsing being imperfect — it is that a purely
|
|
12
|
+
* additive diff can still fire a production deploy, close issues, hit outbound
|
|
13
|
+
* webhooks, and land against a base that moved since the review. Diff content
|
|
14
|
+
* is one axis; the destination is the other, and nothing readable from a patch
|
|
15
|
+
* tells you what merging into that branch triggers.
|
|
16
|
+
*
|
|
17
|
+
* So the verdict enriches the REFUSAL. A block that says "this PR adds two
|
|
18
|
+
* files and touches no migration, no exported symbol and no version — say
|
|
19
|
+
* `merge #1499` to authorize" is a question the user can answer in four words,
|
|
20
|
+
* including out loud. A block that says "refused" makes them go read the diff
|
|
21
|
+
* themselves. Same gate, and the difference is entirely in whether the human
|
|
22
|
+
* can act on it.
|
|
23
|
+
*
|
|
24
|
+
* THREE-VALUED ON PURPOSE. `undecidable` is a real answer and the most common
|
|
25
|
+
* one offline: no ref, no network, an unparsable patch. Collapsing it into
|
|
26
|
+
* `additive` would be the failure this module exists to avoid, so every unknown
|
|
27
|
+
* resolves toward "ask".
|
|
28
|
+
*/
|
|
29
|
+
import { execFileSync } from "node:child_process";
|
|
30
|
+
|
|
31
|
+
export type ImpactVerdict = "additive" | "destructive" | "undecidable";
|
|
32
|
+
|
|
33
|
+
export interface MergeImpact {
|
|
34
|
+
verdict: ImpactVerdict;
|
|
35
|
+
/** Human-readable markers behind a `destructive` verdict, in diff order. */
|
|
36
|
+
markers: string[];
|
|
37
|
+
/** Why the analysis could not run, when the verdict is `undecidable`. */
|
|
38
|
+
reason?: string;
|
|
39
|
+
/** Files the diff touches, for the one-line summary in a refusal. */
|
|
40
|
+
filesChanged?: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Destructive markers, each decidable from the patch text alone.
|
|
45
|
+
*
|
|
46
|
+
* DELIBERATELY A PROXY SET, NOT A BREAKING-CHANGE ORACLE. "Does this break a
|
|
47
|
+
* consumer" is undecidable in general; these are the shapes that are cheap to
|
|
48
|
+
* see and expensive to miss. A marker firing means "a human should look", never
|
|
49
|
+
* "this is certainly breaking" — and the absence of every marker means "nothing
|
|
50
|
+
* cheap to see fired", never "this is certainly safe". That asymmetry is why an
|
|
51
|
+
* `additive` verdict still refuses.
|
|
52
|
+
*/
|
|
53
|
+
const DESTRUCTIVE_MARKERS: ReadonlyArray<{ label: string; re: RegExp }> = [
|
|
54
|
+
// Schema removal. Matched on ADDED lines only — a migration that *removes* a
|
|
55
|
+
// dropColumn call is the opposite of destructive, and matching the raw patch
|
|
56
|
+
// would score it the same way.
|
|
57
|
+
{ label: "migration drops a column or table", re: /^\+.*\b(dropColumn|dropTable|dropIfExists|removeColumn|drop_column|drop_table)\b/im },
|
|
58
|
+
{ label: "raw DROP or TRUNCATE in a migration", re: /^\+.*\b(DROP\s+(TABLE|COLUMN|INDEX|CONSTRAINT)|TRUNCATE\s+TABLE)\b/im },
|
|
59
|
+
// The author said so themselves. Conventional Commits marks a breaking change
|
|
60
|
+
// with a `!` before the colon or a body trailer.
|
|
61
|
+
{ label: "author marked a BREAKING CHANGE", re: /^\+.*BREAKING[ -]CHANGE/m },
|
|
62
|
+
// A removed export is the shape most likely to break an importer.
|
|
63
|
+
{ label: "an exported symbol was removed", re: /^-\s*export\s+(default\s+|const\s+|function\s+|class\s+|interface\s+|type\s+|async\s+)/m },
|
|
64
|
+
{ label: "a public PHP or Python symbol was removed", re: /^-\s*(public\s+function|def\s+[a-z_]+\s*\()/m },
|
|
65
|
+
// Route removal breaks callers that never imported anything.
|
|
66
|
+
{ label: "a route or endpoint was removed", re: /^-.*\b(Route::(get|post|put|patch|delete)|@(Get|Post|Put|Patch|Delete)Mapping|app\.(get|post|put|patch|delete)\s*\()/m },
|
|
67
|
+
{ label: "a whole file was deleted", re: /^deleted file mode /m },
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
/** A major-version bump in a manifest, which is the author declaring a break. */
|
|
71
|
+
const MAJOR_BUMP =
|
|
72
|
+
/^-\s*"version"\s*:\s*"(\d+)\.[^"]*"[\s\S]{0,200}?^\+\s*"version"\s*:\s*"(\d+)\./m;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Classify a unified diff.
|
|
76
|
+
*
|
|
77
|
+
* Pure — the caller supplies the patch, so this is testable without a network,
|
|
78
|
+
* a repository, or a fixture PR.
|
|
79
|
+
*/
|
|
80
|
+
export function classifyDiff(patch: string): MergeImpact {
|
|
81
|
+
if (patch.trim() === "") {
|
|
82
|
+
return { verdict: "undecidable", markers: [], reason: "empty diff" };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const markers: string[] = [];
|
|
86
|
+
for (const { label, re } of DESTRUCTIVE_MARKERS) {
|
|
87
|
+
if (re.test(patch)) {
|
|
88
|
+
markers.push(label);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const bump = MAJOR_BUMP.exec(patch);
|
|
93
|
+
if (bump && bump[1] !== bump[2]) {
|
|
94
|
+
markers.push(`major version bump ${bump[1]}.x to ${bump[2]}.x`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const filesChanged = (patch.match(/^diff --git /gm) ?? []).length;
|
|
98
|
+
|
|
99
|
+
if (markers.length > 0) {
|
|
100
|
+
return { verdict: "destructive", markers, filesChanged };
|
|
101
|
+
}
|
|
102
|
+
return { verdict: "additive", markers: [], filesChanged };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** How the patch was obtained, so a refusal can say which source answered. */
|
|
106
|
+
export type PatchSource = "local" | "gh" | "none";
|
|
107
|
+
|
|
108
|
+
export interface PatchResult {
|
|
109
|
+
patch: string;
|
|
110
|
+
source: PatchSource;
|
|
111
|
+
reason?: string;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Fetch the diff for a pull request, local first.
|
|
116
|
+
*
|
|
117
|
+
* The ordering is not a preference, it is a cost decision. A local
|
|
118
|
+
* `git diff` against the merge base costs milliseconds and works offline; a
|
|
119
|
+
* `gh pr diff` costs a network round trip on a `pre_tool_use` hook, which is in
|
|
120
|
+
* the user's critical path. Local answers whenever the ref happens to be
|
|
121
|
+
* fetched, which in a drain run is almost always, because the run just synced
|
|
122
|
+
* the branch it is about to merge.
|
|
123
|
+
*
|
|
124
|
+
* Every failure returns `none` rather than throwing. The caller turns that into
|
|
125
|
+
* `undecidable`, which asks the user — a hook that crashed would be worse than
|
|
126
|
+
* a hook that admitted it did not know.
|
|
127
|
+
*/
|
|
128
|
+
export function fetchPatch(
|
|
129
|
+
pr: number,
|
|
130
|
+
opts: { cwd?: string; allowNetwork?: boolean; exec?: typeof execFileSync } = {},
|
|
131
|
+
): PatchResult {
|
|
132
|
+
const run = opts.exec ?? execFileSync;
|
|
133
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
134
|
+
const capture = (file: string, args: string[]): string =>
|
|
135
|
+
run(file, args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 15_000 }) as unknown as string;
|
|
136
|
+
|
|
137
|
+
// A fetched PR ref is what a synced drain run leaves behind.
|
|
138
|
+
for (const ref of [`refs/pull/${pr}/head`, `pull/${pr}/head`]) {
|
|
139
|
+
try {
|
|
140
|
+
const base = capture("git", ["merge-base", "HEAD", ref]).trim();
|
|
141
|
+
if (base) {
|
|
142
|
+
const patch = capture("git", ["diff", `${base}...${ref}`]);
|
|
143
|
+
if (patch.trim() !== "") {
|
|
144
|
+
return { patch, source: "local" };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
} catch {
|
|
148
|
+
/* try the next spelling, then the network */
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (opts.allowNetwork === false) {
|
|
153
|
+
return { patch: "", source: "none", reason: "no local ref and network reads disabled" };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
const patch = capture("gh", ["pr", "diff", String(pr), "--patch"]);
|
|
158
|
+
if (patch.trim() !== "") {
|
|
159
|
+
return { patch, source: "gh" };
|
|
160
|
+
}
|
|
161
|
+
return { patch: "", source: "none", reason: "gh returned an empty diff" };
|
|
162
|
+
} catch {
|
|
163
|
+
return { patch: "", source: "none", reason: "no local ref, and gh pr diff failed" };
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Analyse a pull request end to end. `undecidable` whenever the patch is not readable. */
|
|
168
|
+
export function analyseMergeImpact(
|
|
169
|
+
pr: number,
|
|
170
|
+
opts: Parameters<typeof fetchPatch>[1] = {},
|
|
171
|
+
): MergeImpact {
|
|
172
|
+
const got = fetchPatch(pr, opts);
|
|
173
|
+
if (got.source === "none") {
|
|
174
|
+
return { verdict: "undecidable", markers: [], ...(got.reason ? { reason: got.reason } : {}) };
|
|
175
|
+
}
|
|
176
|
+
return classifyDiff(got.patch);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* One line for a refusal message.
|
|
181
|
+
*
|
|
182
|
+
* Phrased so the user can answer it out loud, which is the point of the whole
|
|
183
|
+
* stage: the shortest reply that unblocks the operation is quoted back to them.
|
|
184
|
+
*/
|
|
185
|
+
export function describeImpact(pr: number, impact: MergeImpact): string {
|
|
186
|
+
const say = `Say "merge #${pr}" to authorize it.`;
|
|
187
|
+
switch (impact.verdict) {
|
|
188
|
+
case "additive":
|
|
189
|
+
return (
|
|
190
|
+
`Impact scan: PR #${pr} touches ${impact.filesChanged ?? 0} file(s) and trips no ` +
|
|
191
|
+
`destructive marker — no schema drop, no removed export or route, no major bump, ` +
|
|
192
|
+
`no BREAKING CHANGE. Additive as far as the diff can show, which is not the same ` +
|
|
193
|
+
`as safe to merge: the base has moved since review and merging may trigger a ` +
|
|
194
|
+
`deploy. ${say}`
|
|
195
|
+
);
|
|
196
|
+
case "destructive":
|
|
197
|
+
return (
|
|
198
|
+
`Impact scan: PR #${pr} is NOT additive — ${impact.markers.join("; ")}. ` +
|
|
199
|
+
`Read the diff before authorizing. ${say}`
|
|
200
|
+
);
|
|
201
|
+
default:
|
|
202
|
+
return (
|
|
203
|
+
`Impact scan: could not read the diff for PR #${pr} (${impact.reason ?? "unknown"}), ` +
|
|
204
|
+
`so nothing is known about what it changes. ${say}`
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
}
|
package/src/scripts/release.ts
CHANGED
|
@@ -160,6 +160,8 @@ import {
|
|
|
160
160
|
SystemExitError,
|
|
161
161
|
set_augment_manifest_version,
|
|
162
162
|
set_marketplace_version,
|
|
163
|
+
_rstrip,
|
|
164
|
+
_splitlines,
|
|
163
165
|
} from './release_env.js';
|
|
164
166
|
import type { RunResult } from './release_publication.js';
|
|
165
167
|
// Re-export surface for the six names tests import from `release.js` and that
|
|
@@ -192,6 +194,7 @@ import {
|
|
|
192
194
|
_target_from_branch,
|
|
193
195
|
create_and_push_annotated_tag,
|
|
194
196
|
guard_publication,
|
|
197
|
+
guard_release_curation,
|
|
195
198
|
die,
|
|
196
199
|
gh,
|
|
197
200
|
git,
|
|
@@ -744,22 +747,6 @@ function prepend_changelog(p: string, entry: string): void {
|
|
|
744
747
|
}
|
|
745
748
|
|
|
746
749
|
/** Mirror of Python `str.splitlines()` (no trailing empty for a final newline). */
|
|
747
|
-
function _splitlines(text: string): string[] {
|
|
748
|
-
if (text === '') {
|
|
749
|
-
return [];
|
|
750
|
-
}
|
|
751
|
-
const parts = text.split(/\r\n|\r|\n/);
|
|
752
|
-
if (parts.length > 0 && parts[parts.length - 1] === '') {
|
|
753
|
-
parts.pop();
|
|
754
|
-
}
|
|
755
|
-
return parts;
|
|
756
|
-
}
|
|
757
|
-
|
|
758
|
-
/** Mirror of Python `str.rstrip()` (trailing whitespace). */
|
|
759
|
-
function _rstrip(text: string): string {
|
|
760
|
-
return text.replace(/\s+$/u, '');
|
|
761
|
-
}
|
|
762
|
-
|
|
763
750
|
// ─── file mutations ───────────────────────────────────────────────────────────
|
|
764
751
|
|
|
765
752
|
/** Update the top-level `version` field; preserve 4-space indentation. */
|
|
@@ -1167,7 +1154,6 @@ function _step(n: number, total: number, msg: string): void {
|
|
|
1167
1154
|
process.stdout.write(`[${n}/${total}] ${msg}\n`);
|
|
1168
1155
|
}
|
|
1169
1156
|
|
|
1170
|
-
|
|
1171
1157
|
function execute(
|
|
1172
1158
|
plan: Plan,
|
|
1173
1159
|
opts: { wait_for_checks: boolean; dry_run: boolean; resume?: boolean; ci?: boolean },
|
|
@@ -1276,6 +1262,8 @@ function execute(
|
|
|
1276
1262
|
run(['task', 'release-prepare']);
|
|
1277
1263
|
}
|
|
1278
1264
|
|
|
1265
|
+
guard_release_curation(plan.target, pr_merged);
|
|
1266
|
+
|
|
1279
1267
|
// ─── 3. commit ──────────────────────────────────────────────────────────
|
|
1280
1268
|
if (pr_merged) {
|
|
1281
1269
|
_step(3, total, 'PR already merged — skip commit');
|
|
@@ -390,22 +390,41 @@ function _count(world: FakeWorld, needle: string): number {
|
|
|
390
390
|
}
|
|
391
391
|
|
|
392
392
|
const SCENARIOS: Record<string, Scenario> = {
|
|
393
|
-
'marker-refuses-before-
|
|
393
|
+
'marker-refuses-before-commit': {
|
|
394
394
|
summary:
|
|
395
|
-
'step
|
|
395
|
+
'step 3: a draft marker refuses BEFORE the release commit — no local release state, and therefore no remote state either',
|
|
396
396
|
config: { changelog_file: markedChangelogFixture(current_version()) },
|
|
397
397
|
expect_success: false,
|
|
398
398
|
verify: (w, err) => {
|
|
399
399
|
const f: string[] = [];
|
|
400
400
|
_expect(
|
|
401
|
-
(err ?? '').includes(
|
|
401
|
+
(err ?? '').includes('release highlights are still the generator'),
|
|
402
402
|
`died for the wrong reason: ${err ?? '(no error)'}`,
|
|
403
403
|
f,
|
|
404
404
|
);
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
405
|
+
_expect(
|
|
406
|
+
!w.calls.some((c) => c.startsWith('git commit')),
|
|
407
|
+
'a release commit was made after the refusal',
|
|
408
|
+
f,
|
|
409
|
+
);
|
|
410
|
+
// RENAMED from `marker-refuses-before-branch-push` when
|
|
411
|
+
// `guard_release_curation` moved the refusal one step earlier.
|
|
412
|
+
// Every negative assertion below is UNCHANGED and still
|
|
413
|
+
// load-bearing — each is a remote side effect the 14.14.0 run
|
|
414
|
+
// actually produced: a pushed branch, an open PR, a check watch on
|
|
415
|
+
// a check that could not pass. They now hold more strongly, since
|
|
416
|
+
// the run stops before the commit rather than after it, and the
|
|
417
|
+
// no-commit assertion above is the property that is new.
|
|
418
|
+
//
|
|
419
|
+
// What this scenario no longer reaches, said plainly rather than
|
|
420
|
+
// left for a reader to infer: `guard_release_branch_push`. On the
|
|
421
|
+
// non-merged path it is now unreachable in ONE run, because a
|
|
422
|
+
// section marked at step 4 was already marked at step 3. It stays
|
|
423
|
+
// as defence-in-depth for a section that changes BETWEEN the two —
|
|
424
|
+
// a hand edit mid-run, or a resume over an altered changelog — and
|
|
425
|
+
// its own behaviour stays pinned by the unit tests in
|
|
426
|
+
// `tests/scripts/release_push_failure_masking.test.ts`, which is
|
|
427
|
+
// where that coverage moved to rather than being dropped.
|
|
409
428
|
_expect(
|
|
410
429
|
!w.calls.includes(`git push -u origin ${w.branch}`),
|
|
411
430
|
'the branch was pushed after the refusal',
|