@event4u/agent-config 14.17.0 → 14.18.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 +170 -79
- package/README.md +1 -1
- package/dist/agent-src/commands/pr/merge.md +35 -44
- package/dist/agent-src/skills/accessibility-auditor/SKILL.md +2 -0
- package/dist/agent-src/skills/ai-code-blindspots/SKILL.md +27 -1
- package/dist/agent-src/skills/code-intelligence/SKILL.md +60 -12
- package/dist/agent-src/skills/complexity-first-planning/SKILL.md +27 -0
- package/dist/agent-src/skills/design-review/SKILL.md +2 -0
- package/dist/agent-src/skills/feature-planning/SKILL.md +34 -0
- package/dist/agent-src/skills/git-workflow/SKILL.md +37 -12
- package/dist/agent-src/skills/git-workflow/references/push-closes-its-loop.md +176 -0
- package/dist/agent-src/skills/laravel-mail/SKILL.md +44 -0
- package/dist/agent-src/skills/testing-anti-patterns/SKILL.md +40 -0
- package/dist/agent-src/skills/using-git-worktrees/SKILL.md +12 -0
- package/dist/agent-src/skills/verify-repair-loop/SKILL.md +53 -8
- package/dist/agent-src/skills/worktree-lifecycle/SKILL.md +3 -1
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/cli-delegate/cmd_conformance.js +335 -659
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +15 -15
- 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 +2 -2
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +2 -2
- package/dist/hooks/dispatch.js +959 -1304
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +671 -1002
- package/dist/shared/settingsConsent.js +1 -1
- package/package.json +1 -1
- package/src/config/conformance-claim-baseline.json +9 -0
- package/src/config/gate-violation-baselines.json +3 -3
- package/src/config/release-gate-locality.yml +129 -0
- package/src/scripts/_lib/release_highlights.ts +172 -1
- package/src/scripts/_lib/release_material.ts +66 -0
- package/src/scripts/_lib/repeated_run.ts +77 -0
- package/src/scripts/_lib/tamper_vocabulary.ts +242 -0
- package/src/scripts/_lib/unified_diff.ts +121 -0
- package/src/scripts/check_finding_dispositions.ts +179 -3
- package/src/scripts/check_installed_hooks_fresh.ts +381 -0
- package/src/scripts/check_release_highlights.ts +25 -13
- package/src/scripts/check_suppression_hygiene.ts +11 -0
- package/src/scripts/code_graph/build.ts +230 -33
- package/src/scripts/code_graph/extract.ts +168 -33
- package/src/scripts/code_graph/types.ts +37 -2
- package/src/scripts/code_graph/validate.ts +12 -1
- package/src/scripts/conformance_scan.ts +2 -2
- package/src/scripts/detect_silent_catch.ts +403 -0
- package/src/scripts/detect_verification_tampering.ts +538 -0
- package/src/scripts/git_authorization_hook.ts +153 -17
- package/src/scripts/hook_manifest.json +1 -1
- package/src/scripts/hook_manifest.yaml +33 -19
- package/src/scripts/hooks/block_no_verify.ts +3 -3
- package/src/scripts/hooks/concern_registry.ts +2 -2
- package/src/scripts/hooks/evidence_independence.ts +21 -2
- package/src/scripts/hooks/{block_unauthorized_git.ts → git_command_classifier.ts} +25 -369
- package/src/scripts/hooks/push_settle_hook.ts +250 -0
- package/src/scripts/hooks/ship_diff_volume_hook.ts +1 -1
- package/src/scripts/install-hooks.sh +134 -1
- package/src/scripts/language_mirror_hook.ts +27 -5
- package/src/scripts/release.ts +31 -143
- package/src/scripts/release_drill.ts +71 -1
- package/src/scripts/release_publication.ts +243 -3
- package/src/scripts/release_verify.ts +220 -0
- package/src/scripts/schemas/review-findings.schema.json +1 -1
- package/src/scripts/schemas/skill.schema.json +11 -1
- package/src/scripts/self_review_gate.ts +10 -1
- package/src/shared/settingsConsent.ts +1 -1
|
@@ -12,7 +12,25 @@ PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
|
|
12
12
|
# (where $PROJECT_ROOT/.git is a file, not a dir, and a literal .git/hooks path
|
|
13
13
|
# would break under `set -e`). --git-common-dir points at the main .git even from
|
|
14
14
|
# a worktree; fall back to the literal path only outside a git repo.
|
|
15
|
-
|
|
15
|
+
#
|
|
16
|
+
# AGENT_CONFIG_HOOKS_DIR overrides the destination. It exists so that
|
|
17
|
+
# `check_installed_hooks_fresh` can render what this installer WOULD write into
|
|
18
|
+
# a scratch directory and byte-compare it against what is installed — the only
|
|
19
|
+
# way to answer "is the installed copy stale?" without re-parsing these
|
|
20
|
+
# heredocs, which carry an appended block (post-merge / post-checkout) that no
|
|
21
|
+
# heredoc slice can reproduce. It is a test/inspection seam; a normal install
|
|
22
|
+
# never sets it.
|
|
23
|
+
if [ -n "${AGENT_CONFIG_HOOKS_DIR+set}" ]; then
|
|
24
|
+
# SET-BUT-EMPTY is refused rather than ignored. A caller that meant to
|
|
25
|
+
# redirect an inspection run and passed an empty value would otherwise fall
|
|
26
|
+
# through to the real .git/hooks and INSTALL over it — including over a
|
|
27
|
+
# pre-push that is running the inspection, which truncates it silently.
|
|
28
|
+
if [ -z "$AGENT_CONFIG_HOOKS_DIR" ]; then
|
|
29
|
+
echo "❌ AGENT_CONFIG_HOOKS_DIR is set but empty — refusing to fall back to the real .git/hooks." >&2
|
|
30
|
+
exit 2
|
|
31
|
+
fi
|
|
32
|
+
HOOKS_DIR="$AGENT_CONFIG_HOOKS_DIR"
|
|
33
|
+
elif COMMON_GIT_DIR="$(git -C "$PROJECT_ROOT" rev-parse --git-common-dir 2>/dev/null)"; then
|
|
16
34
|
case "$COMMON_GIT_DIR" in /*) : ;; *) COMMON_GIT_DIR="$PROJECT_ROOT/$COMMON_GIT_DIR" ;; esac
|
|
17
35
|
HOOKS_DIR="$COMMON_GIT_DIR/hooks"
|
|
18
36
|
else
|
|
@@ -20,6 +38,12 @@ else
|
|
|
20
38
|
fi
|
|
21
39
|
|
|
22
40
|
mkdir -p "$HOOKS_DIR"
|
|
41
|
+
# Name the destination. AGENT_CONFIG_HOOKS_DIR is a global override, and a
|
|
42
|
+
# stray non-empty value would otherwise redirect a real `task install-hooks`
|
|
43
|
+
# while it printed six "hook installed" lines that name no path at all — the
|
|
44
|
+
# freshness check would then report every hook missing with nothing pointing at
|
|
45
|
+
# the cause.
|
|
46
|
+
echo "→ installing git hooks into $HOOKS_DIR" >&2
|
|
23
47
|
|
|
24
48
|
cat > "$HOOKS_DIR/pre-push" << 'EOF'
|
|
25
49
|
#!/usr/bin/env bash
|
|
@@ -85,6 +109,96 @@ fi
|
|
|
85
109
|
|
|
86
110
|
fail=0
|
|
87
111
|
|
|
112
|
+
# BASE FRESHNESS — first, and it EXITS rather than setting fail=1.
|
|
113
|
+
#
|
|
114
|
+
# The header above has promised "when this hook refuses for staleness" since the
|
|
115
|
+
# hook was written, and nothing below ever asked. Measured over the 50 PRs
|
|
116
|
+
# preceding 2026-09-04: 25 of them carried at least one `Merge branch 'main'
|
|
117
|
+
# into <branch>` commit (52 such commits in total), and 17 of the 36 PRs whose
|
|
118
|
+
# workflow runs were still readable had at least one FAILED run — the three
|
|
119
|
+
# workflows that failed most (Tests 18, Consistency 16, Rule Backstops 13) are
|
|
120
|
+
# exactly the base-relative ones: ratchets, budgets and generated counts that
|
|
121
|
+
# were green against the base the branch forked from and red against the base it
|
|
122
|
+
# merges into. A branch pushed behind its base buys a CONFLICTING PR, a red run,
|
|
123
|
+
# or both, and the contributor pays for it after the work looks finished.
|
|
124
|
+
#
|
|
125
|
+
# It runs FIRST because everything after it is invalidated by the merge that has
|
|
126
|
+
# to happen anyway: `task consistency` regenerates derived files against the
|
|
127
|
+
# stale base, the merge then re-dirties them, and the ~10s is spent twice. And
|
|
128
|
+
# it EXITS instead of accumulating into `fail`, for the same reason — there is
|
|
129
|
+
# no useful second finding to collect from a tree that is about to move.
|
|
130
|
+
#
|
|
131
|
+
# It never merges. `check_branch_freshness` asks the REMOTE (one `ls-remote`,
|
|
132
|
+
# measured 4.5s) and returns 1 only on a VERIFIED behind state; offline, in CI,
|
|
133
|
+
# on a detached HEAD, or standing on the base itself it returns 0 and says so.
|
|
134
|
+
# So an unreachable network cannot block a push, and a stale local tracking ref
|
|
135
|
+
# cannot fake a green.
|
|
136
|
+
echo "🔍 Base freshness — is this branch behind the base it will merge into?"
|
|
137
|
+
if [ "${AGENT_CONFIG_SKIP_PREPUSH_FRESHNESS:-}" = "1" ]; then
|
|
138
|
+
echo "⏭️ skipped via AGENT_CONFIG_SKIP_PREPUSH_FRESHNESS=1"
|
|
139
|
+
elif [ ! -x ./scripts-run ]; then
|
|
140
|
+
echo "⚠️ ./scripts-run not found — skipping the freshness check for this push."
|
|
141
|
+
elif ! ./scripts-run src/scripts/check_branch_freshness --quiet; then
|
|
142
|
+
echo ""
|
|
143
|
+
echo " Push blocked — the branch is behind its base, and the gates you just"
|
|
144
|
+
echo " passed were answered against a base that no longer exists."
|
|
145
|
+
echo ""
|
|
146
|
+
echo " task push-ready # fetch → integrate the base SET → regenerate"
|
|
147
|
+
echo " # → verify → re-check freshness, then push"
|
|
148
|
+
echo " task push-ready DRY=1 # the same steps, read-only"
|
|
149
|
+
echo ""
|
|
150
|
+
echo " This hook refuses; it never merges. Bypass a genuine WIP push with"
|
|
151
|
+
echo " AGENT_CONFIG_SKIP_PREPUSH_FRESHNESS=1."
|
|
152
|
+
exit 1
|
|
153
|
+
fi
|
|
154
|
+
|
|
155
|
+
# INSTALLED-HOOK FRESHNESS — ADVISORY, and deliberately AFTER base freshness.
|
|
156
|
+
#
|
|
157
|
+
# What it answers: are the hooks in .git/hooks still what THIS checkout's
|
|
158
|
+
# install-hooks.sh would write? Measured 2026-09-05: the installed pre-push here
|
|
159
|
+
# was 146 lines against a 189-line source body, missing the entire base-freshness
|
|
160
|
+
# gate above, merged five days earlier. It read as live in the source, in CI and
|
|
161
|
+
# in the skill documenting it, and did not run here.
|
|
162
|
+
#
|
|
163
|
+
# WHY IT DOES NOT REFUSE. The predicate is "installed == what this checkout
|
|
164
|
+
# renders", and .git/hooks is SHARED by every linked worktree through the common
|
|
165
|
+
# dir. With two worktrees whose installer bodies differ, at most one can satisfy
|
|
166
|
+
# it at a time and each re-install flips which — so a refusal would fire on
|
|
167
|
+
# ordinary parallel work, and the documented skip variable would become routine,
|
|
168
|
+
# which retires the gate rather than enforcing it. An AI council (claude-sonnet-4-5
|
|
169
|
+
# + codex-default, 2026-09-05, 2 of 2 seats) chose advisory unanimously after an
|
|
170
|
+
# independent review raised exactly that. Blocking returns only with a
|
|
171
|
+
# single-referent predicate — per-worktree hook isolation via core.hooksPath, or a
|
|
172
|
+
# branch-independent dispatcher installed once in the common dir.
|
|
173
|
+
#
|
|
174
|
+
# WHY AFTER BASE FRESHNESS. Being behind a base that moved the installer is the
|
|
175
|
+
# most common cause of a mismatch, and the repair for THAT is to merge, not to
|
|
176
|
+
# re-install: re-installing from a behind-base checkout writes the OLDER hook set
|
|
177
|
+
# over the shared directory. Base freshness exits first, so that contributor is
|
|
178
|
+
# told the correct thing and never reaches this notice.
|
|
179
|
+
echo "🔍 Installed-hook freshness — do .git/hooks still match this checkout?"
|
|
180
|
+
if [ "${AGENT_CONFIG_SKIP_PREPUSH_HOOKFRESH:-}" = "1" ]; then
|
|
181
|
+
echo "⏭️ skipped via AGENT_CONFIG_SKIP_PREPUSH_HOOKFRESH=1"
|
|
182
|
+
elif [ ! -x ./scripts-run ] || [ ! -d node_modules ] \
|
|
183
|
+
|| [ ! -f src/scripts/check_installed_hooks_fresh.ts ]; then
|
|
184
|
+
# Three guards, and each has been a real miss. `-d node_modules`: without
|
|
185
|
+
# dependencies ./scripts-run cannot start tsx and the non-zero exit would
|
|
186
|
+
# read as staleness. `-f`: .git/hooks is shared, so this file also runs in a
|
|
187
|
+
# worktree standing on a branch that predates the gate, and calling a script
|
|
188
|
+
# that is not on that checkout would report the wrong thing.
|
|
189
|
+
echo "⏭️ check_installed_hooks_fresh not runnable on this checkout — skipping."
|
|
190
|
+
else
|
|
191
|
+
./scripts-run src/scripts/check_installed_hooks_fresh --quiet
|
|
192
|
+
hookfresh_rc=$?
|
|
193
|
+
# The gate's three exit codes mean three different things, and collapsing
|
|
194
|
+
# them reports a false cause: 0 = match or nothing installed, 1 = mismatch,
|
|
195
|
+
# 2 = it could not render (installer failed, unwritable TMPDIR, usage error).
|
|
196
|
+
if [ "$hookfresh_rc" = "2" ]; then
|
|
197
|
+
echo "⚠️ could not compare the installed hooks (see above) — not a staleness verdict."
|
|
198
|
+
fi
|
|
199
|
+
# Advisory in both cases: nothing here sets fail=1 or exits.
|
|
200
|
+
fi
|
|
201
|
+
|
|
88
202
|
if ! command -v task >/dev/null 2>&1; then
|
|
89
203
|
echo "⚠️ 'task' (go-task) not found — cannot run the consistency gate locally."
|
|
90
204
|
echo " Install it (https://taskfile.dev) so pre-push can mirror the CI check;"
|
|
@@ -381,6 +495,25 @@ if [ -n "\$prev" ] && [ -n "\$new" ] && [ "\$prev" != "\$new" ]; then
|
|
|
381
495
|
if command -v npm >/dev/null 2>&1 && [ -d node_modules ]; then
|
|
382
496
|
npm run build:cli >/dev/null 2>&1 || true
|
|
383
497
|
fi
|
|
498
|
+
# Installed-hook staleness — REPORT it here, never repair it here.
|
|
499
|
+
#
|
|
500
|
+
# This is the event that CAUSES the staleness, so it is where the news
|
|
501
|
+
# belongs. It is NOT where the repair belongs: linked worktrees SHARE
|
|
502
|
+
# .git/hooks through the common dir, so re-running the installer from a
|
|
503
|
+
# checkout would let one worktree silently redefine the gates every
|
|
504
|
+
# other worktree runs, and an older branch would reinstall older hooks
|
|
505
|
+
# over newer ones. An AI council (claude-sonnet-4-5 + codex-default,
|
|
506
|
+
# 2026-09-05, 2 of 2 seats) refused that mutation unanimously.
|
|
507
|
+
#
|
|
508
|
+
# Output goes to stderr and is NOT swallowed: the rest of this block is
|
|
509
|
+
# "|| true" so a pull is never blocked, and a repair notice nobody can
|
|
510
|
+
# see is the same silence this check exists to remove.
|
|
511
|
+
# The -f guard is not belt-and-braces: this hook is shared across
|
|
512
|
+
# linked worktrees, so it also runs on checkouts that predate the gate.
|
|
513
|
+
if [ -x ./scripts-run ] && [ -d node_modules ] \
|
|
514
|
+
&& [ -f src/scripts/check_installed_hooks_fresh.ts ]; then
|
|
515
|
+
./scripts-run src/scripts/check_installed_hooks_fresh --quiet >&2 || true
|
|
516
|
+
fi
|
|
384
517
|
fi
|
|
385
518
|
fi
|
|
386
519
|
EOF
|
|
@@ -630,9 +630,17 @@ export function _setPinLost(consumer_root: string, session_id: string): void {
|
|
|
630
630
|
_migrateLegacyMarker(consumer_root);
|
|
631
631
|
try {
|
|
632
632
|
atomic_write_text(path.join(consumer_root, pinLostMarker(session_id)), new Date().toISOString());
|
|
633
|
-
} catch {
|
|
634
|
-
//
|
|
635
|
-
//
|
|
633
|
+
} catch (err) {
|
|
634
|
+
// The COST is unchanged and still bounded — one un-restored pin, never a
|
|
635
|
+
// broken turn. What changed on 2026-09-04 is that it stops being silent.
|
|
636
|
+
// The legacy-marker migration above documents exactly how this catch can
|
|
637
|
+
// become PERMANENT for a root (`EEXIST` then `ENOTDIR`, reproduced against
|
|
638
|
+
// Node), and a permanently dead mechanism that never says so is the failure
|
|
639
|
+
// the sibling swallows in `git_authorization_hook` were repaired for.
|
|
640
|
+
process.stderr.write(
|
|
641
|
+
`language-mirror: pin-lost marker write failed (${_errText(err)}) — ` +
|
|
642
|
+
"the pin will NOT be re-emitted after this compaction. Turn outcome unchanged.\n",
|
|
643
|
+
);
|
|
636
644
|
}
|
|
637
645
|
}
|
|
638
646
|
|
|
@@ -1124,8 +1132,16 @@ export function run(
|
|
|
1124
1132
|
// per tool call — and never before the write it is cleaning up after.
|
|
1125
1133
|
_pruneLegacyState(options.consumer_root);
|
|
1126
1134
|
_pruneStaleSessions(options.consumer_root, Date.now());
|
|
1127
|
-
} catch {
|
|
1128
|
-
//
|
|
1135
|
+
} catch (err) {
|
|
1136
|
+
// Direction unchanged: the turn is never blocked. But the pin lives ONLY
|
|
1137
|
+
// in this file, so a failed write silently reverts the next turn to
|
|
1138
|
+
// re-derivation — the user re-states a language they already pinned and
|
|
1139
|
+
// has no way to know why. Named rather than swallowed.
|
|
1140
|
+
process.stderr.write(
|
|
1141
|
+
`language-mirror: pin-state write failed (${_errText(err)}) — ` +
|
|
1142
|
+
"the language pin did NOT persist and the next turn re-derives it. " +
|
|
1143
|
+
"This turn's notice is unchanged.\n",
|
|
1144
|
+
);
|
|
1129
1145
|
}
|
|
1130
1146
|
}
|
|
1131
1147
|
|
|
@@ -1146,6 +1162,12 @@ export function run(
|
|
|
1146
1162
|
*/
|
|
1147
1163
|
export { isSyntheticPrompt };
|
|
1148
1164
|
|
|
1165
|
+
/** One-line, bounded rendering of a caught value for a stderr diagnostic. */
|
|
1166
|
+
function _errText(err: unknown): string {
|
|
1167
|
+
const t = err instanceof Error ? err.message : String(err);
|
|
1168
|
+
return t.replace(/\s+/g, " ").trim().slice(0, 200);
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1149
1171
|
export function main(argv?: string[]): number {
|
|
1150
1172
|
const args = argv ?? process.argv.slice(2);
|
|
1151
1173
|
let consumer_root = process.cwd();
|
package/src/scripts/release.ts
CHANGED
|
@@ -102,7 +102,6 @@
|
|
|
102
102
|
* - `_lib.changelog_eras` imports resolve to the `.ts` twin, never a `.py`.
|
|
103
103
|
*/
|
|
104
104
|
|
|
105
|
-
import { spawnSync } from 'node:child_process';
|
|
106
105
|
import * as fs from 'node:fs';
|
|
107
106
|
import * as path from 'node:path';
|
|
108
107
|
import process from 'node:process';
|
|
@@ -120,16 +119,17 @@ import {
|
|
|
120
119
|
} from './_lib/changelog_eras.js';
|
|
121
120
|
import {
|
|
122
121
|
HEAD_LABELS,
|
|
122
|
+
type MixObligation,
|
|
123
123
|
collect_span_commits,
|
|
124
124
|
derive_category_hits,
|
|
125
125
|
render_derived_head_values,
|
|
126
126
|
} from './_lib/release_highlights.js';
|
|
127
127
|
import {
|
|
128
|
-
NEXT_SECTION_RE,
|
|
129
128
|
RELEASE_HEAD_DEFAULT,
|
|
130
129
|
extract_changelog_section,
|
|
131
130
|
pr_body_from_section,
|
|
132
131
|
release_notes_from_section,
|
|
132
|
+
render_mix_response,
|
|
133
133
|
} from './_lib/release_material.js';
|
|
134
134
|
|
|
135
135
|
// `__doc__.splitlines()[0]` in `_parse_args` — the argparse description. Kept
|
|
@@ -164,11 +164,16 @@ import {
|
|
|
164
164
|
_splitlines,
|
|
165
165
|
} from './release_env.js';
|
|
166
166
|
import type { RunResult } from './release_publication.js';
|
|
167
|
-
// Re-export surface for the
|
|
168
|
-
//
|
|
167
|
+
// Re-export surface for the names tests import from `release.js` and that moved
|
|
168
|
+
// into the publication unit — eleven since the test-count-trend unit followed. `export ... from` rather than a bare import,
|
|
169
169
|
// because these are not USED here — an unused import would be dropped and the
|
|
170
170
|
// test import path would break silently.
|
|
171
171
|
export {
|
|
172
|
+
_TEST_LIST_MAX_BUFFER,
|
|
173
|
+
_count_from_list_result,
|
|
174
|
+
_count_tests_current,
|
|
175
|
+
_previous_test_count_from_changelog,
|
|
176
|
+
_render_test_trend_line,
|
|
172
177
|
_failed_check_names,
|
|
173
178
|
_failed_checks_report,
|
|
174
179
|
_is_non_fast_forward,
|
|
@@ -176,7 +181,7 @@ export {
|
|
|
176
181
|
_no_checks_action,
|
|
177
182
|
_required_contexts_from_rules,
|
|
178
183
|
} from './release_publication.js';
|
|
179
|
-
import { _cap_body, jsonDumpsIndent
|
|
184
|
+
import { _cap_body, jsonDumpsIndent } from './release_env.js';
|
|
180
185
|
import {
|
|
181
186
|
_MERGE_UPDATE_ROUNDS,
|
|
182
187
|
_branch_exists_local,
|
|
@@ -195,7 +200,10 @@ import {
|
|
|
195
200
|
create_and_push_annotated_tag,
|
|
196
201
|
guard_publication,
|
|
197
202
|
checkout_release_branch,
|
|
203
|
+
_render_test_trend_line,
|
|
198
204
|
guard_release_curation,
|
|
205
|
+
local_release_gate_argv,
|
|
206
|
+
measure_mix_obligation,
|
|
199
207
|
die,
|
|
200
208
|
gh,
|
|
201
209
|
git,
|
|
@@ -500,7 +508,11 @@ function render_changelog_entry(
|
|
|
500
508
|
prev: string | null,
|
|
501
509
|
commits: readonly Commit[],
|
|
502
510
|
today: string,
|
|
503
|
-
opts: {
|
|
511
|
+
opts: {
|
|
512
|
+
test_trend_line?: string | null;
|
|
513
|
+
head?: Readonly<Record<string, string>>;
|
|
514
|
+
mix?: MixObligation | null;
|
|
515
|
+
} = {},
|
|
504
516
|
): [string, string] {
|
|
505
517
|
const test_trend_line = opts.test_trend_line ?? null;
|
|
506
518
|
let heading: string;
|
|
@@ -539,6 +551,11 @@ function render_changelog_entry(
|
|
|
539
551
|
|
|
540
552
|
// The curated head sits above the generated log, which stays unchanged.
|
|
541
553
|
const body_lines: string[] = [...render_release_head(opts.head ?? {})];
|
|
554
|
+
// Under the head and outside it — rationale on `MIX_RESPONSE_PLACEHOLDER`.
|
|
555
|
+
if (opts.mix?.triggered) {
|
|
556
|
+
body_lines.push('');
|
|
557
|
+
body_lines.push(...render_mix_response(opts.mix.level));
|
|
558
|
+
}
|
|
542
559
|
const ordered_labels = ['BREAKING CHANGES', ...SECTIONS.map(([label]) => label)];
|
|
543
560
|
for (const label of ordered_labels) {
|
|
544
561
|
const bucket = grouped[label] ?? [];
|
|
@@ -581,139 +598,6 @@ function _changelog_line(c: Commit): string {
|
|
|
581
598
|
return `* ${scope}${c.subject} ([${short}](${link}))`;
|
|
582
599
|
}
|
|
583
600
|
|
|
584
|
-
// ─── test-count trend (road-to-feedback-followups P3.2) ───────────────────────
|
|
585
|
-
|
|
586
|
-
const _TEST_COUNT_LINE_RE = /^Tests:\s+(\d+)/m;
|
|
587
|
-
|
|
588
|
-
/**
|
|
589
|
-
* Buffer ceiling for the `vitest list` probe, in bytes.
|
|
590
|
-
*
|
|
591
|
-
* `spawnSync` buffers the child's whole stdout in memory and fails with
|
|
592
|
-
* ENOBUFS past `maxBuffer`, whose default is 1 MiB. The probe emits one line
|
|
593
|
-
* per test case, so its output grows with the suite: at 9470 cases the listing
|
|
594
|
-
* is ~1.25 MB, i.e. already past the default. That is what silently dropped
|
|
595
|
-
* the footer from the 9.10.0 notes and turned the `changelog-entry` gate red.
|
|
596
|
-
* 64 MiB is ~50× the current listing — headroom for a suite many times this
|
|
597
|
-
* size, at no cost when the output is small.
|
|
598
|
-
*/
|
|
599
|
-
const _TEST_LIST_MAX_BUFFER = 64 * 1024 * 1024;
|
|
600
|
-
|
|
601
|
-
/**
|
|
602
|
-
* Count test cases from a finished `vitest list` spawn result, or return null
|
|
603
|
-
* when the probe did not produce a usable listing.
|
|
604
|
-
*
|
|
605
|
-
* Split out from `_count_tests_current` so the failure modes are testable
|
|
606
|
-
* without spawning the real (~14s, >1 MB) collection. `warn` is injected for
|
|
607
|
-
* the same reason; it defaults to stderr.
|
|
608
|
-
*/
|
|
609
|
-
function _count_from_list_result(
|
|
610
|
-
res: {
|
|
611
|
-
error?: (Error & { code?: string }) | undefined;
|
|
612
|
-
status: number | null;
|
|
613
|
-
stdout: string | null;
|
|
614
|
-
},
|
|
615
|
-
warn: (msg: string) => void = (msg) => void process.stderr.write(msg),
|
|
616
|
-
): number | null {
|
|
617
|
-
// The trend line is informational and never blocks a release — but a
|
|
618
|
-
// SILENT drop is what shipped 9.8.0 and 9.10.0 without a footer, so every
|
|
619
|
-
// degradation says why. The `changelog-entry` CI gate treats a missing
|
|
620
|
-
// footer as fatal, so this warning is the difference between fixing the
|
|
621
|
-
// probe now and finding out from a red release PR.
|
|
622
|
-
if (res.error) {
|
|
623
|
-
// ENOENT (no npx) · ETIMEDOUT · ENOBUFS (listing past maxBuffer).
|
|
624
|
-
const code = res.error.code ?? res.error.message;
|
|
625
|
-
warn(
|
|
626
|
-
`⚠️ test-count probe failed (${code}) — the \`Tests:\` footer will ` +
|
|
627
|
-
`be omitted from the release notes, which fails the ` +
|
|
628
|
-
`\`CHANGELOG entry exists for head version\` gate.\n`,
|
|
629
|
-
);
|
|
630
|
-
return null;
|
|
631
|
-
}
|
|
632
|
-
if ((res.status ?? 1) !== 0) {
|
|
633
|
-
warn(
|
|
634
|
-
`⚠️ test-count probe exited ${res.status ?? '(null)'} — the ` +
|
|
635
|
-
`\`Tests:\` footer will be omitted from the release notes.\n`,
|
|
636
|
-
);
|
|
637
|
-
return null;
|
|
638
|
-
}
|
|
639
|
-
const lines = (res.stdout ?? '').split('\n').filter((l) => l.trim().length > 0);
|
|
640
|
-
if (lines.length === 0) {
|
|
641
|
-
warn('⚠️ test-count probe listed 0 cases — the `Tests:` footer will be omitted.\n');
|
|
642
|
-
return null;
|
|
643
|
-
}
|
|
644
|
-
return lines.length;
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
/**
|
|
648
|
-
* Return the collected vitest test-case count on the current tree
|
|
649
|
-
* (`npx vitest list`, one line per case; ~14s wall). Returns null when
|
|
650
|
-
* collection fails — the trend line is informational, never a release
|
|
651
|
-
* blocker, but every failure warns (see `_count_from_list_result`).
|
|
652
|
-
* (Replaced the dead `pytest --collect-only` probe on 2026-07-08,
|
|
653
|
-
* road-to-truth-and-reference-hygiene Phase 3: the Python suite was retired
|
|
654
|
-
* with ADR-200, so the old probe always degraded to null and the `Tests:`
|
|
655
|
-
* footer silently vanished from release notes.)
|
|
656
|
-
*/
|
|
657
|
-
function _count_tests_current(): number | null {
|
|
658
|
-
// Recursion/cost guard: `npx vitest list` inside a vitest-driven release
|
|
659
|
-
// test would collect the whole suite from within the suite (the child
|
|
660
|
-
// inherits VITEST=… from the runner) — the exact recursion the caller
|
|
661
|
-
// comment warns about. Degrade to null there, same as any other
|
|
662
|
-
// collection failure.
|
|
663
|
-
if (process.env['VITEST'] !== undefined) {
|
|
664
|
-
return null;
|
|
665
|
-
}
|
|
666
|
-
const res = spawnSync('npx', ['vitest', 'list'], {
|
|
667
|
-
cwd: REPO_ROOT,
|
|
668
|
-
encoding: 'utf-8',
|
|
669
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
670
|
-
timeout: 180_000,
|
|
671
|
-
maxBuffer: _TEST_LIST_MAX_BUFFER,
|
|
672
|
-
});
|
|
673
|
-
return _count_from_list_result(res);
|
|
674
|
-
}
|
|
675
|
-
|
|
676
|
-
/**
|
|
677
|
-
* Read CHANGELOG.md and return the most recent `Tests: N` footer under the
|
|
678
|
-
* `prev_tag` heading, or null when not found.
|
|
679
|
-
*/
|
|
680
|
-
function _previous_test_count_from_changelog(prev_tag: string | null): number | null {
|
|
681
|
-
if (!prev_tag || !fs.existsSync(CHANGELOG)) {
|
|
682
|
-
return null;
|
|
683
|
-
}
|
|
684
|
-
const text = fs.readFileSync(CHANGELOG, 'utf-8');
|
|
685
|
-
const heading_re = new RegExp(`^##\\s+\\[?${reEscape(prev_tag)}\\b`, 'm');
|
|
686
|
-
const m = heading_re.exec(text);
|
|
687
|
-
if (!m) {
|
|
688
|
-
return null;
|
|
689
|
-
}
|
|
690
|
-
const headEnd = m.index + m[0].length;
|
|
691
|
-
const rest = text.slice(headEnd);
|
|
692
|
-
const next_heading = NEXT_SECTION_RE.exec(rest);
|
|
693
|
-
const sectionEnd = headEnd + (next_heading ? next_heading.index : rest.length);
|
|
694
|
-
const section = text.slice(headEnd, sectionEnd);
|
|
695
|
-
const count_match = _TEST_COUNT_LINE_RE.exec(section);
|
|
696
|
-
return count_match ? Number.parseInt(count_match[1] as string, 10) : null;
|
|
697
|
-
}
|
|
698
|
-
|
|
699
|
-
/**
|
|
700
|
-
* Return the `Tests: N (+M since X.Y.Z)` footer line, or null when the current
|
|
701
|
-
* count cannot be determined. Silent on collection errors.
|
|
702
|
-
*/
|
|
703
|
-
function _render_test_trend_line(prev_tag: string | null): string | null {
|
|
704
|
-
const current = _count_tests_current();
|
|
705
|
-
if (current === null) {
|
|
706
|
-
return null;
|
|
707
|
-
}
|
|
708
|
-
const previous = _previous_test_count_from_changelog(prev_tag);
|
|
709
|
-
if (previous === null || !prev_tag) {
|
|
710
|
-
return `Tests: ${current}`;
|
|
711
|
-
}
|
|
712
|
-
const delta = current - previous;
|
|
713
|
-
const sign = delta >= 0 ? '+' : '';
|
|
714
|
-
return `Tests: ${current} (${sign}${delta} since ${prev_tag})`;
|
|
715
|
-
}
|
|
716
|
-
|
|
717
601
|
/**
|
|
718
602
|
* Insert `entry` inside the current era block.
|
|
719
603
|
*
|
|
@@ -1287,6 +1171,10 @@ function execute(
|
|
|
1287
1171
|
// `git push -u` is naturally idempotent — it prints "Everything
|
|
1288
1172
|
// up-to-date" when remote already matches. push_release_branch
|
|
1289
1173
|
// additionally absorbs a remote that moved under us.
|
|
1174
|
+
// Not a `_step`: a second `[4/10]` makes the cited evidence anchors ambiguous.
|
|
1175
|
+
process.stdout.write(' · verifying release gates locally (`task release:verify -- --cheap`)\n');
|
|
1176
|
+
run(local_release_gate_argv());
|
|
1177
|
+
|
|
1290
1178
|
_step(4, total, `Push ${branch} to ${REMOTE}`);
|
|
1291
1179
|
push_release_branch(branch);
|
|
1292
1180
|
}
|
|
@@ -1834,9 +1722,13 @@ function main(argv: readonly string[] | null = null): number {
|
|
|
1834
1722
|
// made the preview print the `_none_` skeleton while the real run wrote
|
|
1835
1723
|
// pre-filled lines — a preview that contradicts its own output, which is
|
|
1836
1724
|
// the exact class of surface disagreement this pre-fill exists to end.
|
|
1725
|
+
// Unconditional for the same preview-parity reason as `_derive_head_prefill`,
|
|
1726
|
+
// at a different cost: one `git show` per commit — measured 3.2 s over a
|
|
1727
|
+
// 120-commit span, paid 3-4× per release. Not the ~25 ms of the line above.
|
|
1837
1728
|
const [full, body] = render_changelog_entry(target, prev, commits, today, {
|
|
1838
1729
|
test_trend_line,
|
|
1839
1730
|
head: _derive_head_prefill(prev),
|
|
1731
|
+
mix: measure_mix_obligation(target, prev),
|
|
1840
1732
|
});
|
|
1841
1733
|
|
|
1842
1734
|
// Era-split planning — gate on the POST-release view (2026-07-07 fix).
|
|
@@ -1954,10 +1846,6 @@ export {
|
|
|
1954
1846
|
_detect_in_flight_target,
|
|
1955
1847
|
print_preview,
|
|
1956
1848
|
latest_tag,
|
|
1957
|
-
_render_test_trend_line,
|
|
1958
|
-
_previous_test_count_from_changelog,
|
|
1959
|
-
_count_from_list_result,
|
|
1960
|
-
_TEST_LIST_MAX_BUFFER,
|
|
1961
1849
|
Commit,
|
|
1962
1850
|
Plan,
|
|
1963
1851
|
SystemExitError,
|
|
@@ -209,6 +209,18 @@ interface WorldConfig {
|
|
|
209
209
|
* it fakes is one every resumed release actually passes through.
|
|
210
210
|
*/
|
|
211
211
|
pr_already_merged?: boolean;
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Make `task release:verify` fail, as a red local release gate would.
|
|
215
|
+
*
|
|
216
|
+
* Without this the drill returns OK for EVERY `task` invocation, so the
|
|
217
|
+
* pre-push gate run added 2026-09-05 would be exercised only on its happy
|
|
218
|
+
* path — wired but never proven to stop anything. The whole point of that
|
|
219
|
+
* step is that a red local gate costs one edit instead of a branch, a PR
|
|
220
|
+
* and a CI run, and that property is only real if a scenario watches it
|
|
221
|
+
* hold.
|
|
222
|
+
*/
|
|
223
|
+
release_verify_fails?: boolean;
|
|
212
224
|
}
|
|
213
225
|
|
|
214
226
|
/**
|
|
@@ -232,6 +244,20 @@ export function defaultChangelogFixture(target: string): string {
|
|
|
232
244
|
'',
|
|
233
245
|
'- fixture entry for the release drill',
|
|
234
246
|
'',
|
|
247
|
+
// Both lines are section-level publication obligations
|
|
248
|
+
// (`section_publication_blockers`), so a fixture without them refuses
|
|
249
|
+
// at the curation guard and every sequencing scenario dies at step 2 —
|
|
250
|
+
// the same "fixture trips the guard the drill exists to sequence"
|
|
251
|
+
// failure this builder's docstring already warns about, for the two
|
|
252
|
+
// obligations added 2026-09-05. The mix answer is written OUT rather
|
|
253
|
+
// than measured: measuring would couple every scenario to the live
|
|
254
|
+
// repository's commit span, which is the coupling `changelog` was
|
|
255
|
+
// introduced to remove.
|
|
256
|
+
'> **Governance mix:** governance-only 2 vs consumer-only 1 (taxonomy 1.0.0).',
|
|
257
|
+
'> Next cycle ships the fixture consumer work, tracked in the drill.',
|
|
258
|
+
'',
|
|
259
|
+
'Tests: 1000 (+0 since 0.0.0)',
|
|
260
|
+
'',
|
|
235
261
|
'## [0.0.0]',
|
|
236
262
|
'',
|
|
237
263
|
'- earlier release, present so the section boundary is exercised',
|
|
@@ -267,6 +293,8 @@ class FakeWorld {
|
|
|
267
293
|
pr_merged = false;
|
|
268
294
|
release_created = false;
|
|
269
295
|
branch_exists_remote = true;
|
|
296
|
+
/** Whether the simulated `task release:verify` reports a red local gate. */
|
|
297
|
+
private readonly release_verify_fails: boolean;
|
|
270
298
|
/** Live PR body (release-truth Phase 1: refreshed from the changelog at head). */
|
|
271
299
|
pr_body = '';
|
|
272
300
|
|
|
@@ -296,6 +324,7 @@ class FakeWorld {
|
|
|
296
324
|
this.merge_fails_hard = cfg.merge_fails_hard ?? false;
|
|
297
325
|
this.checks_fail = cfg.checks_fail ?? false;
|
|
298
326
|
this.pr_merged = cfg.pr_already_merged ?? false;
|
|
327
|
+
this.release_verify_fails = cfg.release_verify_fails ?? false;
|
|
299
328
|
}
|
|
300
329
|
|
|
301
330
|
exec(args: readonly string[]): ExecResult {
|
|
@@ -374,7 +403,14 @@ class FakeWorld {
|
|
|
374
403
|
|
|
375
404
|
// ── task ─────────────────────────────────────────────────────────
|
|
376
405
|
if (args[0] === 'task') {
|
|
377
|
-
|
|
406
|
+
// `release-prepare` — regeneration is out of drill scope.
|
|
407
|
+
// `release:verify` — the pre-push local gate run; simulated
|
|
408
|
+
// red only when a scenario asks for it, so the step's stopping
|
|
409
|
+
// power is exercised rather than assumed.
|
|
410
|
+
if (args[1] === 'release:verify' && this.release_verify_fails) {
|
|
411
|
+
return { status: 1, stdout: '', stderr: '❌ 1 release gate(s) would fail the release PR: release-shape.\n' };
|
|
412
|
+
}
|
|
413
|
+
return OK;
|
|
378
414
|
}
|
|
379
415
|
|
|
380
416
|
// ── gh ───────────────────────────────────────────────────────────
|
|
@@ -541,6 +577,40 @@ const SCENARIOS: Record<string, Scenario> = {
|
|
|
541
577
|
return f;
|
|
542
578
|
},
|
|
543
579
|
},
|
|
580
|
+
'local-gates-refuse-before-push': {
|
|
581
|
+
summary:
|
|
582
|
+
'step 4: a red local release gate stops the run BEFORE the branch is pushed — no branch on origin, no PR, no CI',
|
|
583
|
+
config: { release_verify_fails: true },
|
|
584
|
+
expect_success: false,
|
|
585
|
+
verify: (w, err) => {
|
|
586
|
+
const f: string[] = [];
|
|
587
|
+
_expect(
|
|
588
|
+
(err ?? '').includes('release:verify'),
|
|
589
|
+
`died for the wrong reason: ${err ?? '(no error)'}`,
|
|
590
|
+
f,
|
|
591
|
+
);
|
|
592
|
+
// The three remote side effects the 14.17.0 run actually produced
|
|
593
|
+
// before `check_release_highlights` refused it on the PR. Each is
|
|
594
|
+
// a separate assertion because each was a separate cost.
|
|
595
|
+
_expect(
|
|
596
|
+
!w.calls.some((c) => c.startsWith('git push')),
|
|
597
|
+
'the release branch was pushed after the local gates refused',
|
|
598
|
+
f,
|
|
599
|
+
);
|
|
600
|
+
_expect(
|
|
601
|
+
!w.calls.some((c) => c.startsWith('gh pr create')),
|
|
602
|
+
'a pull request was opened after the local gates refused',
|
|
603
|
+
f,
|
|
604
|
+
);
|
|
605
|
+
_expect(
|
|
606
|
+
!w.calls.some((c) => c.includes('pr checks')),
|
|
607
|
+
'CI was watched after the local gates refused',
|
|
608
|
+
f,
|
|
609
|
+
);
|
|
610
|
+
return f;
|
|
611
|
+
},
|
|
612
|
+
},
|
|
613
|
+
|
|
544
614
|
'marker-refuses-before-commit': {
|
|
545
615
|
summary:
|
|
546
616
|
'step 3: a draft marker refuses BEFORE the release commit — no local release state, and therefore no remote state either',
|