@event4u/agent-config 16.0.0 → 16.1.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 +297 -137
- package/CONTRIBUTING.md +9 -0
- package/README.md +2 -2
- package/dist/agent-src/commands/analyze/inbox.md +26 -0
- package/dist/agent-src/commands/analyze/roadmap-repos.md +27 -0
- package/dist/agent-src/commands/challenge-me/closure.md +178 -0
- package/dist/agent-src/commands/challenge-me.md +9 -2
- package/dist/agent-src/commands/council/default.md +25 -5
- package/dist/agent-src/commands/feature/plan.md +28 -1
- package/dist/agent-src/commands/feature/roadmap.md +28 -1
- package/dist/agent-src/commands/implement-ticket.md +27 -0
- package/dist/agent-src/commands/jira-ticket.md +27 -0
- package/dist/agent-src/commands/pr/create.md +29 -0
- package/dist/agent-src/commands/pr/merge.md +60 -9
- package/dist/agent-src/commands/roadmap/ai-council.md +7 -1
- package/dist/agent-src/commands/roadmap/create.md +74 -27
- package/dist/agent-src/commands/roadmap/materialize.md +27 -0
- package/dist/agent-src/commands/roadmap/process-full.md +123 -18
- package/dist/agent-src/commands/team.md +22 -0
- package/dist/agent-src/contexts/authority/scope-growth-ownership.md +80 -0
- package/dist/agent-src/contexts/communication/rules-auto/user-interaction-mechanics.md +47 -0
- package/dist/agent-src/contexts/execution/auto-orchestration-activation.md +44 -3
- package/dist/agent-src/contexts/execution/autonomy-mechanics.md +15 -15
- package/dist/agent-src/contexts/execution/decision-closure-execution.md +93 -0
- package/dist/agent-src/contexts/execution/fix-loop-ladder.md +108 -0
- package/dist/agent-src/contexts/execution/plan-confidence-gate.md +4 -2
- package/dist/agent-src/contexts/execution/roadmap-process-loop.md +75 -6
- package/dist/agent-src/guidelines/agent-infra/active-remediation-mechanics.md +57 -0
- package/dist/agent-src/guidelines/agent-infra/evaluator-independence-mechanics.md +102 -0
- package/dist/agent-src/guidelines/design-fidelity-routing.md +29 -0
- package/dist/agent-src/rules/autonomous-execution.md +15 -8
- package/dist/agent-src/rules/delegation-policy.md +23 -45
- package/dist/agent-src/rules/design-fidelity.md +32 -37
- package/dist/agent-src/rules/evaluator-independence.md +43 -49
- package/dist/agent-src/rules/test-first.md +41 -0
- package/dist/agent-src/skills/accessibility-auditor/data/manifest.json +3 -3
- package/dist/agent-src/skills/ai-council/SKILL.md +11 -3
- package/dist/agent-src/skills/api-design/data/manifest.json +3 -3
- package/dist/agent-src/skills/database/data/manifest.json +4 -4
- package/dist/agent-src/skills/database/data/query-tuning.csv +3 -3
- package/dist/agent-src/skills/design-review/SKILL.md +50 -0
- package/dist/agent-src/skills/existing-ui-audit/SKILL.md +14 -4
- package/dist/agent-src/skills/experiment-loop/SKILL.md +31 -1
- package/dist/agent-src/skills/roadmap-writing/SKILL.md +1 -1
- package/dist/agent-src/skills/storybook-workshop/SKILL.md +42 -3
- package/dist/agent-src/skills/threat-modeling/SKILL.md +9 -4
- package/dist/agent-src/skills/threat-modeling/data/manifest.json +4 -4
- package/dist/agent-src/skills/verify-repair-loop/SKILL.md +37 -3
- package/dist/agent-src/templates/agent-settings.md +1 -1
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/roadmaps.md +188 -0
- package/dist/agent-src/templates/scripts/work_engine/directives/backend/_self_fix.ts +62 -2
- package/dist/agent-src/templates/scripts/work_engine/directives/backend/test.ts +1 -0
- package/dist/agent-src/templates/scripts/work_engine/directives/backend/verify.ts +1 -0
- package/dist/cli-delegate/check_update_banner.js +40 -2
- package/dist/cli-delegate/chunk-4E2W63DA.js +335 -0
- package/dist/cli-delegate/chunk-5R7HOLAF.js +99 -0
- package/dist/cli-delegate/{chunk-IVBPRCQO.js → chunk-6KFEC3G7.js} +1 -1
- package/dist/cli-delegate/{chunk-2TAQ2K4V.js → chunk-6KM6ORRY.js} +55 -386
- package/dist/cli-delegate/{chunk-2XY7ILBT.js → chunk-FC75PGJE.js} +42 -2
- package/dist/cli-delegate/{chunk-GYC3VRYN.js → chunk-FXYUYU4N.js} +57 -13
- package/dist/cli-delegate/{chunk-55ACLDUE.js → chunk-GJCBB2UG.js} +168 -10
- package/dist/cli-delegate/{chunk-AK6HODNQ.js → chunk-HDVVGPQW.js} +873 -539
- package/dist/cli-delegate/{chunk-H32DGUJH.js → chunk-P6ZHP5W2.js} +1 -1
- package/dist/cli-delegate/{chunk-57PN4GYJ.js → chunk-RGNIWTEA.js} +1 -1
- package/dist/cli-delegate/chunk-ROYYTEZU.js +39 -0
- package/dist/cli-delegate/{chunk-3FKR64R5.js → chunk-UNCRJBT3.js} +31 -7
- package/dist/cli-delegate/{chunk-2DK2VJWG.js → chunk-WGXTVKCQ.js} +20 -6
- package/dist/cli-delegate/chunk-YDPILCOL.js +152 -0
- package/dist/cli-delegate/cmd_analyze_session.js +1 -1
- package/dist/cli-delegate/cmd_brand_status.js +1 -1
- package/dist/cli-delegate/cmd_conformance.js +90 -56
- package/dist/cli-delegate/cmd_doctor.js +7 -5
- package/dist/cli-delegate/cmd_explain.js +2 -2
- package/dist/cli-delegate/cmd_export.js +9 -7
- package/dist/cli-delegate/cmd_fleet.js +4 -2
- package/dist/cli-delegate/cmd_handoff.js +4 -4
- package/dist/cli-delegate/cmd_mcp_available.js +1 -1
- package/dist/cli-delegate/cmd_migrate.js +2 -2
- package/dist/cli-delegate/cmd_packs_active.js +2 -2
- package/dist/cli-delegate/cmd_preflight.js +4 -2
- package/dist/cli-delegate/cmd_prune.js +10 -8
- package/dist/cli-delegate/cmd_route_audit.js +2 -2
- package/dist/cli-delegate/cmd_route_explain.js +1 -1
- package/dist/cli-delegate/cmd_session_recycle.js +2 -2
- package/dist/cli-delegate/cmd_settings_get.js +28 -2
- package/dist/cli-delegate/cmd_settings_migrate.js +9 -7
- package/dist/cli-delegate/cmd_settings_set.js +1 -1
- package/dist/cli-delegate/cmd_sync.js +10 -8
- package/dist/cli-delegate/cmd_uninstall.js +16 -13
- package/dist/cli-delegate/cmd_update.js +1 -1
- package/dist/cli-delegate/cmd_upgrade.js +1 -1
- package/dist/cli-delegate/cmd_validate.js +12 -10
- package/dist/cli-delegate/cmd_versions.js +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +85 -38
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +4 -4
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +10 -8
- package/dist/discovery/trust-report.md +3 -3
- package/dist/discovery/workspaces.json +8 -6
- package/dist/hooks/dispatch.js +1832 -1007
- package/dist/install/claudeRuleRewrite.js +14 -3
- package/dist/install/conflict.js +60 -10
- package/dist/install/conflictTracker.js +218 -0
- package/dist/install/fsPrimitives.js +54 -0
- package/dist/install/install.mjs +2007 -1481
- package/dist/install/manifestFiles.js +259 -0
- package/dist/install/preflight.js +14 -2
- package/dist/install/preserve.js +157 -0
- package/dist/install/recordedOwnership.js +132 -0
- package/dist/install/txlog.js +11 -2
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +398 -39
- package/dist/router.json +1 -1
- package/dist/scripts/_lib/agent_settings.js +3 -2
- package/dist/scripts/_lib/fs_atomic.js +167 -0
- package/dist/scripts/_lib/installed_tools.js +460 -0
- package/dist/scripts/_lib/lean_projection_mode.js +3 -2
- package/dist/scripts/_lib/settings_renamed_keys.js +78 -0
- package/dist/server/routes/install.js +8 -0
- package/dist/server/routes/wizard.js +9 -3
- package/dist/server/schemas/settings.js +17 -5
- package/dist/shared/missionExecution.js +97 -0
- package/dist/ui/assets/{index-D0jaCRPM.js → index-DH1xc62v.js} +17 -17
- package/dist/ui/index.html +1 -1
- package/docs/MIGRATION.md +103 -0
- package/docs/contracts/settings-classes.md +26 -7
- package/docs/guidelines/agent-infra/active-remediation-mechanics.md +57 -0
- package/docs/guidelines/agent-infra/evaluator-independence-mechanics.md +102 -0
- package/docs/guidelines/design-fidelity-routing.md +29 -0
- package/package.json +1 -1
- package/src/agent-src/templates/scripts/work_engine/directives/backend/_self_fix.ts +62 -2
- package/src/agent-src/templates/scripts/work_engine/directives/backend/test.ts +1 -0
- package/src/agent-src/templates/scripts/work_engine/directives/backend/verify.ts +1 -0
- package/src/config/agent-settings.template.yml +139 -27
- package/src/config/auto-rule-budget.json +16 -0
- package/src/config/ci-local-parity.yml +18 -5
- package/src/config/continuity-surface.json +21 -0
- package/src/config/gate-coverage.yml +146 -13
- package/src/config/gate-violation-baselines.json +6 -6
- package/src/config/hook-token-budget.json +2 -0
- package/src/config/loop-surfaces.yaml +215 -0
- package/src/config/platform-anchor.json +3 -2
- package/src/config/release-gate-locality.yml +11 -0
- package/src/config/routing-coverage-seed.json +18 -4
- package/src/config/rule-enforcement-baseline.json +0 -1
- package/src/install/claudeRuleRewrite.ts +16 -2
- package/src/install/conflict.ts +65 -9
- package/src/install/conflictTracker.ts +299 -0
- package/src/install/fsPrimitives.ts +54 -0
- package/src/install/manifestFiles.ts +312 -0
- package/src/install/preflight.ts +16 -2
- package/src/install/preserve.ts +205 -0
- package/src/install/recordedOwnership.ts +149 -0
- package/src/install/txlog.ts +11 -2
- package/src/install/types.ts +19 -4
- package/src/scripts/_cli/cmd_conformance.ts +88 -15
- package/src/scripts/_cli/cmd_doctor.ts +4 -3
- package/src/scripts/_cli/cmd_route_explain.ts +9 -2
- package/src/scripts/_cli/cmd_settings_get.ts +44 -0
- package/src/scripts/_cli/doctor_execution.ts +90 -0
- package/src/scripts/_lib/activation_payload.ts +247 -0
- package/src/scripts/_lib/agent_settings.ts +4 -2
- package/src/scripts/_lib/authority_path.ts +102 -0
- package/src/scripts/_lib/cascade_base.ts +94 -0
- package/src/scripts/_lib/changelog_eras.ts +7 -2
- package/src/scripts/_lib/continuation_ladder.ts +149 -2
- package/src/scripts/_lib/council_transport.ts +129 -0
- package/src/scripts/_lib/delivery_ready.ts +112 -0
- package/src/scripts/_lib/forge_protection.ts +267 -0
- package/src/scripts/_lib/hook_platform_keys.ts +27 -0
- package/src/scripts/_lib/json_python_parity.ts +127 -0
- package/src/scripts/_lib/lean_projection_mode.ts +14 -3
- package/src/scripts/_lib/loop_surfaces.ts +382 -0
- package/src/scripts/_lib/mission_record.ts +190 -0
- package/src/scripts/_lib/obligation_frequency.ts +217 -0
- package/src/scripts/_lib/obligations.ts +402 -0
- package/src/scripts/_lib/orchestration_record.ts +11 -20
- package/src/scripts/_lib/release_highlights.ts +199 -10
- package/src/scripts/_lib/release_holds.ts +493 -0
- package/src/scripts/_lib/release_migration_gate.ts +184 -0
- package/src/scripts/_lib/rides_along.ts +102 -0
- package/src/scripts/_lib/settings_renamed_keys.ts +82 -0
- package/src/scripts/_lib/test_delta.ts +166 -0
- package/src/scripts/_lib/test_provenance.ts +171 -0
- package/src/scripts/_lib/typed_op_grant.ts +246 -0
- package/src/scripts/_lib/typed_op_watch.ts +185 -0
- package/src/scripts/ai_council/config.ts +8 -11
- package/src/scripts/ai_council/fallback_config.ts +5 -3
- package/src/scripts/ai_council/ownership_classes.ts +118 -0
- package/src/scripts/ask_block_census.ts +285 -15
- package/src/scripts/check_enforcement_matrix.ts +624 -0
- package/src/scripts/check_gate_reachability.ts +173 -10
- package/src/scripts/check_generator_sync.ts +954 -0
- package/src/scripts/check_preamble_payload_budget.ts +75 -2
- package/src/scripts/check_release_highlights.ts +33 -1
- package/src/scripts/check_release_holds.ts +567 -0
- package/src/scripts/check_rule_projection_integrity.ts +22 -12
- package/src/scripts/check_test_delta.ts +294 -0
- package/src/scripts/check_test_weakening.ts +299 -0
- package/src/scripts/closure_scan.ts +333 -0
- package/src/scripts/compile_hook_manifest.ts +43 -3
- package/src/scripts/council_record_shape.ts +209 -0
- package/src/scripts/hook_manifest.json +1 -1
- package/src/scripts/hook_manifest.yaml +88 -9
- package/src/scripts/hooks/concern_registry.ts +4 -0
- package/src/scripts/hooks/design_pass_hook.ts +128 -3
- package/src/scripts/hooks/evidence_independence.ts +96 -0
- package/src/scripts/hooks/memory_recall_hook.ts +540 -0
- package/src/scripts/hooks/obligation_settle_hook.ts +182 -0
- package/src/scripts/hooks/rule_inject_hook.ts +49 -1
- package/src/scripts/hooks/run_continuation_hook.ts +17 -20
- package/src/scripts/hooks_doctor.ts +85 -0
- package/src/scripts/hooks_status.ts +19 -0
- package/src/scripts/install.ts +106 -206
- package/src/scripts/janitor.ts +7 -0
- package/src/scripts/lint_decision_classes.ts +464 -0
- package/src/scripts/lint_hook_manifest.ts +11 -0
- package/src/scripts/lint_major_migration_sections.ts +450 -0
- package/src/scripts/lint_plan_risk_register.ts +47 -3
- package/src/scripts/lint_roadmap_blockers.ts +61 -0
- package/src/scripts/lint_roadmap_producers.ts +202 -0
- package/src/scripts/measure_rule_budget.ts +101 -5
- package/src/scripts/new_roadmap.ts +37 -0
- package/src/scripts/orchestration_record.ts +0 -5
- package/src/scripts/release.ts +14 -98
- package/src/scripts/release_drill.ts +267 -1
- package/src/scripts/release_publication.ts +51 -0
- package/src/scripts/report_enforcement_drift.ts +566 -0
- package/src/scripts/report_host_injection_effect.ts +3 -11
- package/src/scripts/report_standing_payload_by_host.ts +26 -152
- package/src/scripts/schemas/agent-settings.schema.json +23 -0
- package/src/scripts/schemas/command.schema.json +4 -0
- package/src/scripts/scope_growth.ts +219 -0
- package/src/scripts/trigger_eval_grandfather.json +0 -1
- package/src/scripts/ui_conformance_probe.ts +622 -0
- package/src/server/schemas/settings.ts +31 -7
- package/src/shared/missionExecution.ts +126 -0
- package/dist/cli-delegate/chunk-523HCFYO.js +0 -90
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Release holds — parse a roadmap's `## Release holds` declarations and
|
|
3
|
+
* evaluate each one against the checkboxes that open and clear it.
|
|
4
|
+
*
|
|
5
|
+
* Template rule 28 is the contract. The one sentence that governs every
|
|
6
|
+
* decision in this file: a hold is a positive declaration about a named tree
|
|
7
|
+
* state, never a side effect of open checkboxes. Roadmap *incompleteness* is
|
|
8
|
+
* never a release condition, so nothing here counts open boxes.
|
|
9
|
+
*
|
|
10
|
+
* NO THIRD PARSER. The checkbox vocabulary comes from `check_roadmap_trackable`
|
|
11
|
+
* and the fence-blanking and inline-marker grammar from `lint_roadmap_blockers`
|
|
12
|
+
* — the two gates that already own those shapes. A copy would drift, and the
|
|
13
|
+
* drift would be silent: a marker this file accepted and `lint_roadmap_blockers`
|
|
14
|
+
* rejected would be a hold that reddens one gate and is invisible to the other.
|
|
15
|
+
*
|
|
16
|
+
* The load-bearing asymmetry, from rule 28's state table: `not-evaluable`
|
|
17
|
+
* REFUSES. There is no path on which "could not evaluate" reads as safe, which
|
|
18
|
+
* is why every parse failure in this file produces a hold in that state rather
|
|
19
|
+
* than an absent hold or a thrown error.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
23
|
+
import * as path from 'node:path';
|
|
24
|
+
|
|
25
|
+
import { CHECKBOX_RE } from '../check_roadmap_trackable.js';
|
|
26
|
+
import { _stripFencedCode } from '../lint_roadmap_blockers.js';
|
|
27
|
+
import { entryConditionParts, _frontmatter } from '../lint_roadmap_later_disposition.js';
|
|
28
|
+
|
|
29
|
+
/** Rule 28's state table. Only `unopened` and `cleared` permit a cut. */
|
|
30
|
+
export type HoldState = 'unopened' | 'open' | 'cleared' | 'not-evaluable';
|
|
31
|
+
|
|
32
|
+
/** Rule 28's channel vocabulary. An omitted `Channel:` parses to `all`. */
|
|
33
|
+
export type HoldChannel = 'all' | 'latest';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Which cut is being checked, in rule 28's own two tokens.
|
|
37
|
+
*
|
|
38
|
+
* `all` (the default) is a STABLE `X.Y.Z` cut: every open hold refuses it,
|
|
39
|
+
* because a `latest` hold refuses a stable cut and an `all` hold refuses
|
|
40
|
+
* everything. `latest` is a `-next.N` PRERELEASE: only `all` holds refuse it,
|
|
41
|
+
* because a `latest` hold permits the opt-in channel by its own definition.
|
|
42
|
+
*
|
|
43
|
+
* The two tokens are the hold field's tokens on purpose — one vocabulary, not
|
|
44
|
+
* two — and they name the set of hold channels that refuse the cut, which is
|
|
45
|
+
* the only reading under which the flag's two values give different answers.
|
|
46
|
+
*/
|
|
47
|
+
export type CutChannel = 'all' | 'latest';
|
|
48
|
+
|
|
49
|
+
export interface Hold {
|
|
50
|
+
readonly id: string;
|
|
51
|
+
readonly channel: HoldChannel;
|
|
52
|
+
readonly openedBy: string;
|
|
53
|
+
readonly clearedBy: string;
|
|
54
|
+
/** The clearing step's `verify:` text — the command that proves the fix. */
|
|
55
|
+
readonly clearVerify: string;
|
|
56
|
+
readonly state: HoldState;
|
|
57
|
+
readonly file: string;
|
|
58
|
+
/** Non-empty iff `state === 'not-evaluable'`; each entry is a reason. */
|
|
59
|
+
readonly malformed: readonly string[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const HOLDS_SECTION_RE = /^##[ \t]+Release[ \t]+holds[ \t]*$/im;
|
|
63
|
+
const NEXT_H2_RE = /^##[ \t]+\S/m;
|
|
64
|
+
const HOLD_HEADING_RE = /^###[ \t]+hold:[ \t]*(.+?)[ \t]*$/gim;
|
|
65
|
+
|
|
66
|
+
const CHANNEL_FIELD_RE = /^-[ \t]*\*\*Channel:\*\*[ \t]*`?([A-Za-z-]+)`?/im;
|
|
67
|
+
const OPENED_BY_FIELD_RE = /^-[ \t]*\*\*Opened by:\*\*[ \t]*`?([^`\n<]+?)`?[ \t]*$/im;
|
|
68
|
+
const CLEARED_BY_FIELD_RE = /^-[ \t]*\*\*Cleared by:\*\*[ \t]*`?([^`\n<]+?)`?[ \t]*$/im;
|
|
69
|
+
const STATE_FIELD_RE = /^-[ \t]*\*\*State:\*\*[ \t]*\S/im;
|
|
70
|
+
const WHY_NOT_GUARD_RE = /^-[ \t]*\*\*Why not a guard:\*\*[ \t]*\S/im;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* An inline hold marker, and it must sit on a real checkbox line.
|
|
74
|
+
*
|
|
75
|
+
* Same shape as `lint_roadmap_blockers`' `BLOCKED_BY_LINE_RE`, and for the same
|
|
76
|
+
* reason that file states: a marker in prose is documentation, not a binding.
|
|
77
|
+
* `CHECKBOX_RE`'s own source is spliced in so the accepted mark set cannot
|
|
78
|
+
* drift from the dashboard's.
|
|
79
|
+
*/
|
|
80
|
+
const CHECKBOX_PREFIX = CHECKBOX_RE.source.replace(/^\^/, '').replace(/\[ \\t\\f\\v\]$/, '');
|
|
81
|
+
const OPENS_MARKER_RE = new RegExp(
|
|
82
|
+
`^${CHECKBOX_PREFIX}.*<!--[ \\t]*opens-hold:[ \\t]*([a-z0-9-]+)[ \\t]*-->`,
|
|
83
|
+
'i',
|
|
84
|
+
);
|
|
85
|
+
const CLEARS_MARKER_RE = new RegExp(
|
|
86
|
+
`^${CHECKBOX_PREFIX}.*<!--[ \\t]*clears-hold:[ \\t]*([a-z0-9-]+)[ \\t]*-->`,
|
|
87
|
+
'i',
|
|
88
|
+
);
|
|
89
|
+
/** The same two markers ANYWHERE, so a marker off a checkbox is detectable. */
|
|
90
|
+
const LOOSE_MARKER_RE = /<!--[ \t]*(opens|clears)-hold:[ \t]*([a-z0-9-]+)[ \t]*-->/gi;
|
|
91
|
+
|
|
92
|
+
/** Rule 13 applies inside a hold entry too — no version, tag or date. */
|
|
93
|
+
const VERSION_IN_ENTRY_RE = /\bv?\d+\.\d+\.\d+\b|\b\d{4}-\d{2}-\d{2}\b/;
|
|
94
|
+
|
|
95
|
+
/** A `verify:` field on the clearing step (rule 23). */
|
|
96
|
+
const VERIFY_FIELD_RE = /^[ \t]*verify:[ \t]*\S/im;
|
|
97
|
+
|
|
98
|
+
function isChecked(mark: string): boolean {
|
|
99
|
+
return mark === 'x' || mark === 'X';
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The mark of the checkbox carrying `marker` for `id`, or null if absent. */
|
|
103
|
+
function markerMark(lines: readonly string[], re: RegExp, id: string): string | null {
|
|
104
|
+
for (const line of lines) {
|
|
105
|
+
const m = re.exec(line);
|
|
106
|
+
if (m && m[2]?.toLowerCase() === id.toLowerCase()) {
|
|
107
|
+
return m[1] ?? null;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The lines of the step a marker sits on, plus its continuation lines.
|
|
115
|
+
*
|
|
116
|
+
* A `verify:` field belongs to the step, and a step's body runs on until the
|
|
117
|
+
* next checkbox or a blank-line-then-heading. Returning the span rather than
|
|
118
|
+
* the single line is what lets the clearer's `verify:` be found at all.
|
|
119
|
+
*/
|
|
120
|
+
function stepSpan(lines: readonly string[], re: RegExp, id: string): string[] {
|
|
121
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
122
|
+
const m = re.exec(lines[i]!);
|
|
123
|
+
if (!m || m[2]?.toLowerCase() !== id.toLowerCase()) {
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
const span = [lines[i]!];
|
|
127
|
+
for (let j = i + 1; j < lines.length; j += 1) {
|
|
128
|
+
const next = lines[j]!;
|
|
129
|
+
if (CHECKBOX_RE.test(next) || /^#{1,6}[ \t]/.test(next)) {
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
span.push(next);
|
|
133
|
+
}
|
|
134
|
+
return span;
|
|
135
|
+
}
|
|
136
|
+
return [];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Parse and evaluate every hold declared in `text`.
|
|
141
|
+
*
|
|
142
|
+
* A file with no `## Release holds` section yields `[]` — the normal shape, and
|
|
143
|
+
* deliberately not an error: rule 28's default is that there is no hold.
|
|
144
|
+
*/
|
|
145
|
+
export function evaluateHolds(text: string, file: string): Hold[] {
|
|
146
|
+
const stripped = _stripFencedCode(text);
|
|
147
|
+
const sectionMatch = HOLDS_SECTION_RE.exec(stripped);
|
|
148
|
+
const lines = stripped.split('\n');
|
|
149
|
+
|
|
150
|
+
// A marker with no section is a binding pointing at nothing. It cannot be
|
|
151
|
+
// ignored (that would fail open) and it has no entry to report against, so
|
|
152
|
+
// it becomes a not-evaluable hold under the id the marker names.
|
|
153
|
+
if (!sectionMatch) {
|
|
154
|
+
const orphans = new Map<string, string>();
|
|
155
|
+
LOOSE_MARKER_RE.lastIndex = 0;
|
|
156
|
+
let lm: RegExpExecArray | null;
|
|
157
|
+
while ((lm = LOOSE_MARKER_RE.exec(stripped)) !== null) {
|
|
158
|
+
orphans.set(lm[2]!.toLowerCase(), lm[1]!.toLowerCase());
|
|
159
|
+
}
|
|
160
|
+
return [...orphans.keys()].sort().map((id) => ({
|
|
161
|
+
id,
|
|
162
|
+
channel: 'all' as const,
|
|
163
|
+
openedBy: '',
|
|
164
|
+
clearedBy: '',
|
|
165
|
+
clearVerify: '',
|
|
166
|
+
state: 'not-evaluable' as const,
|
|
167
|
+
file,
|
|
168
|
+
malformed: [`marker \`${id}\` has no \`## Release holds\` entry`],
|
|
169
|
+
}));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const rest = stripped.slice(sectionMatch.index + sectionMatch[0].length);
|
|
173
|
+
const h2 = NEXT_H2_RE.exec(rest);
|
|
174
|
+
const section = h2 ? rest.slice(0, h2.index) : rest;
|
|
175
|
+
|
|
176
|
+
const entries: { id: string; body: string }[] = [];
|
|
177
|
+
HOLD_HEADING_RE.lastIndex = 0;
|
|
178
|
+
let hm: RegExpExecArray | null;
|
|
179
|
+
const starts: { id: string; at: number; end: number }[] = [];
|
|
180
|
+
while ((hm = HOLD_HEADING_RE.exec(section)) !== null) {
|
|
181
|
+
starts.push({ id: hm[1]!.trim(), at: hm.index, end: HOLD_HEADING_RE.lastIndex });
|
|
182
|
+
}
|
|
183
|
+
for (let i = 0; i < starts.length; i += 1) {
|
|
184
|
+
const next = starts[i + 1]?.at ?? section.length;
|
|
185
|
+
entries.push({ id: starts[i]!.id, body: section.slice(starts[i]!.end, next) });
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const seen = new Map<string, number>();
|
|
189
|
+
for (const e of entries) {
|
|
190
|
+
const k = e.id.toLowerCase();
|
|
191
|
+
seen.set(k, (seen.get(k) ?? 0) + 1);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return entries.map(({ id, body }) => {
|
|
195
|
+
const malformed: string[] = [];
|
|
196
|
+
|
|
197
|
+
if ((seen.get(id.toLowerCase()) ?? 0) > 1) {
|
|
198
|
+
malformed.push(`duplicate hold id \`${id}\``);
|
|
199
|
+
}
|
|
200
|
+
if (!/^[a-z0-9-]+$/.test(id)) {
|
|
201
|
+
malformed.push(`hold id \`${id}\` is not kebab-case`);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const chanRaw = CHANNEL_FIELD_RE.exec(body)?.[1]?.toLowerCase();
|
|
205
|
+
let channel: HoldChannel = 'all';
|
|
206
|
+
if (chanRaw === undefined) {
|
|
207
|
+
channel = 'all';
|
|
208
|
+
} else if (chanRaw === 'all' || chanRaw === 'latest') {
|
|
209
|
+
channel = chanRaw;
|
|
210
|
+
} else {
|
|
211
|
+
malformed.push(`\`Channel: ${chanRaw}\` is not \`all\` or \`latest\``);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const openedBy = OPENED_BY_FIELD_RE.exec(body)?.[1]?.trim() ?? '';
|
|
215
|
+
const clearedBy = CLEARED_BY_FIELD_RE.exec(body)?.[1]?.trim() ?? '';
|
|
216
|
+
if (!openedBy) {
|
|
217
|
+
malformed.push('missing `Opened by:`');
|
|
218
|
+
}
|
|
219
|
+
if (!clearedBy) {
|
|
220
|
+
malformed.push('missing `Cleared by:`');
|
|
221
|
+
}
|
|
222
|
+
if (!STATE_FIELD_RE.test(body)) {
|
|
223
|
+
malformed.push('missing `State:`');
|
|
224
|
+
}
|
|
225
|
+
// Rule 28 makes this one mandatory by name: it is the field that keeps
|
|
226
|
+
// a hold the last rung rather than the first tool.
|
|
227
|
+
if (!WHY_NOT_GUARD_RE.test(body)) {
|
|
228
|
+
malformed.push('missing `Why not a guard:` (rule 28 makes it mandatory)');
|
|
229
|
+
}
|
|
230
|
+
if (VERSION_IN_ENTRY_RE.test(body)) {
|
|
231
|
+
malformed.push('hold entry names a version, tag or date (rule 13)');
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const openMark = markerMark(lines, OPENS_MARKER_RE, id);
|
|
235
|
+
const clearMark = markerMark(lines, CLEARS_MARKER_RE, id);
|
|
236
|
+
const clearSpan = stepSpan(lines, CLEARS_MARKER_RE, id).join('\n');
|
|
237
|
+
const clearVerify = (/^[ \t]*verify:[ \t]*(\S.*)$/im.exec(clearSpan)?.[1] ?? '').trim();
|
|
238
|
+
if (openMark === null) {
|
|
239
|
+
malformed.push(`no checkbox carries \`opens-hold: ${id}\``);
|
|
240
|
+
}
|
|
241
|
+
if (clearMark === null) {
|
|
242
|
+
malformed.push(`no checkbox carries \`clears-hold: ${id}\``);
|
|
243
|
+
}
|
|
244
|
+
if (clearMark !== null && !VERIFY_FIELD_RE.test(clearSpan)) {
|
|
245
|
+
malformed.push(`the \`clears-hold: ${id}\` step carries no \`verify:\` field`);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (malformed.length > 0) {
|
|
249
|
+
return {
|
|
250
|
+
id,
|
|
251
|
+
channel,
|
|
252
|
+
openedBy,
|
|
253
|
+
clearedBy,
|
|
254
|
+
clearVerify,
|
|
255
|
+
state: 'not-evaluable' as const,
|
|
256
|
+
file,
|
|
257
|
+
malformed,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const state: HoldState = !isChecked(openMark!)
|
|
262
|
+
? 'unopened'
|
|
263
|
+
: isChecked(clearMark!)
|
|
264
|
+
? 'cleared'
|
|
265
|
+
: 'open';
|
|
266
|
+
|
|
267
|
+
return { id, channel, openedBy, clearedBy, clearVerify, state, file, malformed: [] };
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Read and evaluate one file. An unreadable file is `not-evaluable`, never
|
|
273
|
+
* absent — rule 28's state table has no row on which a read failure permits.
|
|
274
|
+
*/
|
|
275
|
+
export function evaluateFile(path: string): Hold[] {
|
|
276
|
+
let text: string;
|
|
277
|
+
try {
|
|
278
|
+
text = readFileSync(path, 'utf8');
|
|
279
|
+
} catch (exc) {
|
|
280
|
+
return [
|
|
281
|
+
{
|
|
282
|
+
id: '<unreadable>',
|
|
283
|
+
channel: 'all',
|
|
284
|
+
openedBy: '',
|
|
285
|
+
clearedBy: '',
|
|
286
|
+
clearVerify: '',
|
|
287
|
+
state: 'not-evaluable',
|
|
288
|
+
file: path,
|
|
289
|
+
malformed: [`file could not be read: ${(exc as Error).message}`],
|
|
290
|
+
},
|
|
291
|
+
];
|
|
292
|
+
}
|
|
293
|
+
try {
|
|
294
|
+
return evaluateHolds(text, path);
|
|
295
|
+
} catch (exc) {
|
|
296
|
+
return [
|
|
297
|
+
{
|
|
298
|
+
id: '<evaluator-error>',
|
|
299
|
+
channel: 'all',
|
|
300
|
+
openedBy: '',
|
|
301
|
+
clearedBy: '',
|
|
302
|
+
clearVerify: '',
|
|
303
|
+
state: 'not-evaluable',
|
|
304
|
+
file: path,
|
|
305
|
+
malformed: [`evaluator errored: ${(exc as Error).message}`],
|
|
306
|
+
},
|
|
307
|
+
];
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Does `hold` refuse a cut on `cut`? See `CutChannel` for the two tokens. */
|
|
312
|
+
export function refuses(hold: Hold, cut: CutChannel): boolean {
|
|
313
|
+
if (hold.state === 'not-evaluable') {
|
|
314
|
+
return true;
|
|
315
|
+
}
|
|
316
|
+
if (hold.state !== 'open') {
|
|
317
|
+
return false;
|
|
318
|
+
}
|
|
319
|
+
return cut === 'all' ? true : hold.channel === 'all';
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/* ------------------------------------------------------------------ *
|
|
323
|
+
* Lifecycle — rule 28's per-folder half.
|
|
324
|
+
*
|
|
325
|
+
* "A window does not disappear by moving the file." The three folders answer
|
|
326
|
+
* differently and the difference is the whole point:
|
|
327
|
+
*
|
|
328
|
+
* archive/, skipped/ the move is REFUSED while a window is open. Both mean
|
|
329
|
+
* "no further work", and an open window is unfinished
|
|
330
|
+
* work by definition, so the move would retire a refusal
|
|
331
|
+
* nobody would ever clear.
|
|
332
|
+
* later/ the move is PERMITTED — the work resumes — but the open
|
|
333
|
+
* window must be named in `entry_condition.what`, so the
|
|
334
|
+
* thing that has to come back is written where the wake
|
|
335
|
+
* contract can be read. It still lists and still refuses.
|
|
336
|
+
*
|
|
337
|
+
* Deletion is NOT handled here and cannot be: a gate sees files, and a deleted
|
|
338
|
+
* file is not one. Rule 12 forbids the delete; this is a recorded residual.
|
|
339
|
+
* ------------------------------------------------------------------ */
|
|
340
|
+
|
|
341
|
+
/** The folders a move is refused into while a window is open. */
|
|
342
|
+
export const REFUSING_DIRS = ['archive', 'skipped'] as const;
|
|
343
|
+
|
|
344
|
+
export interface LifecycleViolation {
|
|
345
|
+
readonly file: string;
|
|
346
|
+
readonly holdId: string;
|
|
347
|
+
readonly reason: string;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** The roadmap subfolder of `file`, or `''` when it sits in the active root. */
|
|
351
|
+
export function holdFolder(file: string): string {
|
|
352
|
+
const m = /agents\/roadmaps\/([^/]+)\//.exec(file.split(path.sep).join('/'));
|
|
353
|
+
return m ? m[1]! : '';
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Lifecycle violations for one already-evaluated file.
|
|
358
|
+
*
|
|
359
|
+
* Takes the holds rather than re-reading, so the caller's evaluation is the one
|
|
360
|
+
* reported on — a second read could disagree with the first and the disagreement
|
|
361
|
+
* would be invisible.
|
|
362
|
+
*/
|
|
363
|
+
export function lifecycleViolations(
|
|
364
|
+
file: string,
|
|
365
|
+
holds: readonly Hold[],
|
|
366
|
+
text: string,
|
|
367
|
+
): LifecycleViolation[] {
|
|
368
|
+
const out: LifecycleViolation[] = [];
|
|
369
|
+
const folder = holdFolder(file);
|
|
370
|
+
// `not-evaluable` is deliberately included: a declaration nobody can read
|
|
371
|
+
// is not a declaration anybody can clear, so retiring it is the same defect.
|
|
372
|
+
const live = holds.filter((h) => h.state === 'open' || h.state === 'not-evaluable');
|
|
373
|
+
if (live.length === 0) {
|
|
374
|
+
return out;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if ((REFUSING_DIRS as readonly string[]).includes(folder)) {
|
|
378
|
+
for (const h of live) {
|
|
379
|
+
out.push({
|
|
380
|
+
file,
|
|
381
|
+
holdId: h.id,
|
|
382
|
+
reason:
|
|
383
|
+
`refused: a roadmap with a ${h.state} window may not live in \`${folder}/\` ` +
|
|
384
|
+
`— finish the clearing step \`${h.clearedBy || '?'}\` first, or move it to \`later/\``,
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
return out;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (folder === 'later') {
|
|
391
|
+
const what = entryConditionParts(_frontmatter(text)).parts['what'] ?? '';
|
|
392
|
+
for (const h of live) {
|
|
393
|
+
if (!what.toLowerCase().includes(h.id.toLowerCase())) {
|
|
394
|
+
out.push({
|
|
395
|
+
file,
|
|
396
|
+
holdId: h.id,
|
|
397
|
+
reason:
|
|
398
|
+
`a \`later/\` roadmap with an ${h.state} window must name the hold id in ` +
|
|
399
|
+
`\`entry_condition.what\` — it is absent there`,
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
return out;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* The refusal message, or `null` when the cut is permitted.
|
|
409
|
+
*
|
|
410
|
+
* One formatter, used by BOTH `check_release_holds --require-safe` and
|
|
411
|
+
* `release.ts`'s pre-flight, because two copies would drift and the drift would
|
|
412
|
+
* land where it hurts most: an operator reading one message at the CLI and a
|
|
413
|
+
* different one at the cut.
|
|
414
|
+
*
|
|
415
|
+
* Five fields per hold — roadmap, hold, opener, closer, and the closer's
|
|
416
|
+
* `verify:` command — because a refusal that does not say what to run to clear
|
|
417
|
+
* it has told the operator they are stuck rather than what to do.
|
|
418
|
+
*/
|
|
419
|
+
export function refusalReport(holds: readonly Hold[], cut: CutChannel): string | null {
|
|
420
|
+
const blocking = holds.filter((h) => refuses(h, cut));
|
|
421
|
+
if (blocking.length === 0) {
|
|
422
|
+
return null;
|
|
423
|
+
}
|
|
424
|
+
const lines: string[] = [
|
|
425
|
+
`release-holds: REFUSED — ${blocking.length} hold(s) block this cut (channel ${cut})`,
|
|
426
|
+
];
|
|
427
|
+
for (const h of blocking) {
|
|
428
|
+
lines.push('');
|
|
429
|
+
lines.push(` hold: ${h.id} [${h.state}, channel ${h.channel}]`);
|
|
430
|
+
lines.push(` roadmap: ${h.file}`);
|
|
431
|
+
if (h.state === 'not-evaluable') {
|
|
432
|
+
for (const why of h.malformed) {
|
|
433
|
+
lines.push(` not evaluable: ${why}`);
|
|
434
|
+
}
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
437
|
+
lines.push(` opened by: ${h.openedBy}`);
|
|
438
|
+
lines.push(` cleared by: ${h.clearedBy}`);
|
|
439
|
+
lines.push(` its verify: ${h.clearVerify || '(none recorded)'}`);
|
|
440
|
+
}
|
|
441
|
+
lines.push(
|
|
442
|
+
'',
|
|
443
|
+
' Three ways forward, and the choice is yours — there is no override flag:',
|
|
444
|
+
' 1. finish the clearing step (run its `verify:` above, then flip it)',
|
|
445
|
+
' 2. cut `-next.N` instead, if every blocking hold is `Channel: latest`',
|
|
446
|
+
' 3. use a release line — docs/contracts/release-trunk-sync.md',
|
|
447
|
+
);
|
|
448
|
+
return lines.join('\n');
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* The release pre-flight — refuse the cut while a declared tree state is
|
|
453
|
+
* unpublishable (template rule 28).
|
|
454
|
+
*
|
|
455
|
+
* Lives here rather than in `release.ts` for the reason that file's sibling
|
|
456
|
+
* `_lib/release_position.ts` states in its own docblock: `release.ts` sits far
|
|
457
|
+
* over the 1,500-line source ratchet, so growing it is a gate failure by
|
|
458
|
+
* construction, and `_lib/` is where the predicates the pipeline and its gates
|
|
459
|
+
* share already live. Its caller is the LAST check in `preflight()`, which runs
|
|
460
|
+
* before step 1 and before `execute()` — so a refusal costs nothing: no branch,
|
|
461
|
+
* no tag, no push has happened yet.
|
|
462
|
+
*
|
|
463
|
+
* The channel is always `all` because `release.ts` accepts only a bare `X.Y.Z`,
|
|
464
|
+
* so the cut it performs is always the stable one. The `-next.N` escape is named
|
|
465
|
+
* in the refusal text and never taken automatically: silently converting a
|
|
466
|
+
* stable cut into a prerelease is exactly the channel redirect rule 28 forbids,
|
|
467
|
+
* and the choice stays the operator's.
|
|
468
|
+
*
|
|
469
|
+
* `die` is injected rather than imported to keep the dependency pointing one
|
|
470
|
+
* way — `release.ts` already owns the process-exit semantics, and a `_lib`
|
|
471
|
+
* module that exits on its own behalf is not unit-testable.
|
|
472
|
+
*/
|
|
473
|
+
export function releaseHoldPreflight(root: string, die: (msg: string) => void): void {
|
|
474
|
+
const holds: Hold[] = [];
|
|
475
|
+
for (const sub of ['', 'later', 'archive', 'skipped', 'stubs']) {
|
|
476
|
+
const dir = path.join(root, 'agents', 'roadmaps', sub);
|
|
477
|
+
let names: string[];
|
|
478
|
+
try {
|
|
479
|
+
names = readdirSync(dir)
|
|
480
|
+
.filter((n) => n.endsWith('.md'))
|
|
481
|
+
.sort();
|
|
482
|
+
} catch {
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
for (const n of names) {
|
|
486
|
+
holds.push(...evaluateFile(path.join(dir, n)));
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
const report = refusalReport(holds, 'all');
|
|
490
|
+
if (report !== null) {
|
|
491
|
+
die(report);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two MIGRATION.md refusals a major cut has to clear.
|
|
3
|
+
*
|
|
4
|
+
* FORWARD — {@link assert_scheduled_deprecations_clear}: a commitment in the
|
|
5
|
+
* scheduled-deprecations table due at or before the major being cut.
|
|
6
|
+
* BACKWARD — {@link assert_major_migration_section}: a major that SHIPS a
|
|
7
|
+
* breaking change and tells a consumer nothing about it.
|
|
8
|
+
*
|
|
9
|
+
* They live together because they are the two halves of one obligation and
|
|
10
|
+
* were written eleven months apart, the second only after the first half's
|
|
11
|
+
* absence let 15.0.0 and 16.0.0 ship with no section at all. Extracted out of
|
|
12
|
+
* `release.ts` in the same change that added the backward half: that file sits
|
|
13
|
+
* 375 lines over the 1,500-line source ceiling `check_source_size_budget`
|
|
14
|
+
* ratchets, and the gate's own contract names a split as the way a total comes
|
|
15
|
+
* down rather than a baseline going up.
|
|
16
|
+
*
|
|
17
|
+
* Both keep the preview semantics `release.ts` asserts: under `--dry-run` they
|
|
18
|
+
* PRINT what the real run will refuse and return, because `--dry-run exits 0`
|
|
19
|
+
* is a contract that file's tests pin.
|
|
20
|
+
*/
|
|
21
|
+
import * as fs from 'node:fs';
|
|
22
|
+
import * as path from 'node:path';
|
|
23
|
+
|
|
24
|
+
import { REPO_ROOT } from '../release_env.js';
|
|
25
|
+
import { die, run, type RunResult } from '../release_publication.js';
|
|
26
|
+
import {
|
|
27
|
+
MIGRATION_PATH as MAJOR_MIGRATION_PATH,
|
|
28
|
+
pendingMajorFinding,
|
|
29
|
+
} from '../lint_major_migration_sections.js';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Minor/patch components of a bare `X.Y.Z`.
|
|
33
|
+
*
|
|
34
|
+
* A local two-line parse rather than an import: `release.ts`'s `parse_version`
|
|
35
|
+
* is module-private there, and exporting it to serve one caller would widen
|
|
36
|
+
* that file's surface in the change whose point is to narrow it.
|
|
37
|
+
*/
|
|
38
|
+
function _minor_patch(v: string): [number, number] {
|
|
39
|
+
const m = /^(\d+)\.(\d+)\.(\d+)$/.exec(v.trim());
|
|
40
|
+
if (m === null) {
|
|
41
|
+
throw new Error(`not a bare version: ${v}`);
|
|
42
|
+
}
|
|
43
|
+
return [Number(m[2]), Number(m[3])];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Refuse a MAJOR cut that carries a scheduled deprecation due at or before it.
|
|
48
|
+
*
|
|
49
|
+
* **The trigger is the shape of the TARGET, not a comparison with the current
|
|
50
|
+
* version.** A major target is `X.0.0` — and that one predicate covers every
|
|
51
|
+
* path to a major: the `--as major` flag, an explicit `--version 13.0.0`,
|
|
52
|
+
* auto-detection from a `feat!:` commit, AND `--resume`, where
|
|
53
|
+
* `_detect_in_flight_target()` returns the already-bumped `package.json`
|
|
54
|
+
* version so `target === current` and any current-vs-target comparison
|
|
55
|
+
* silently returns. Resume was the fourth path an earlier version of this
|
|
56
|
+
* guard missed while its own comment claimed three paths converged.
|
|
57
|
+
*
|
|
58
|
+
* Refusing a resumed release can strand a partially-completed one, and that is
|
|
59
|
+
* the deliberate trade: a stranded release is recoverable by fixing the table
|
|
60
|
+
* and resuming again, whereas a major shipped over a missed commitment is the
|
|
61
|
+
* failure this whole surface exists to prevent.
|
|
62
|
+
*
|
|
63
|
+
* The target version is PASSED to the gate. Without it the gate falls back to
|
|
64
|
+
* `package.json`, which at the cut to N still reads N-1 — so a row committed
|
|
65
|
+
* to N reads as one major early and passes, and only rows already a major late
|
|
66
|
+
* could ever be refused. That is the lateness being prevented, so measuring
|
|
67
|
+
* against the shipped version would have made the refusal fire exactly one
|
|
68
|
+
* major too late, forever.
|
|
69
|
+
*
|
|
70
|
+
* Runs under `--dry-run` too, unlike the rest of preflight: this check is one
|
|
71
|
+
* subprocess reading two files, so the "keep a preview fast" rationale that
|
|
72
|
+
* excludes the ~15s test-trend collection does not apply — and a preview that
|
|
73
|
+
* reports green for the single condition that will refuse the real run is the
|
|
74
|
+
* case an operator runs a preview to discover. It REPORTS there rather than
|
|
75
|
+
* dying: `--dry-run` exiting 0 before `execute()` and before `preflight()` is a
|
|
76
|
+
* contract this file's own tests assert, and an earlier revision of this guard
|
|
77
|
+
* broke it by sitting above the dry-run branch with no preview mode.
|
|
78
|
+
*
|
|
79
|
+
* @param runner Seam for the gate invocation. Production passes nothing and
|
|
80
|
+
* gets the real `run`; tests inject a stub, because the alternative — reaching
|
|
81
|
+
* this branch only by mutating `docs/MIGRATION.md` — would make the refusal
|
|
82
|
+
* path testable exclusively through a tracked-file edit.
|
|
83
|
+
*/
|
|
84
|
+
export function assert_scheduled_deprecations_clear(
|
|
85
|
+
target: string,
|
|
86
|
+
runner: (args: readonly string[]) => RunResult = (args) => run(args, { check: false, capture: true }),
|
|
87
|
+
opts: { previewOnly?: boolean } = {},
|
|
88
|
+
): void {
|
|
89
|
+
const [minor, patch] = _minor_patch(target);
|
|
90
|
+
if (minor !== 0 || patch !== 0) {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const res = runner([
|
|
94
|
+
'./scripts-run',
|
|
95
|
+
'src/scripts/lint_scheduled_deprecations',
|
|
96
|
+
'--cutting',
|
|
97
|
+
target,
|
|
98
|
+
]);
|
|
99
|
+
if (res.returncode === 0) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
process.stderr.write(res.stdout);
|
|
103
|
+
process.stderr.write(res.stderr);
|
|
104
|
+
if (res.returncode !== 1) {
|
|
105
|
+
// The gate reserves 1 for a finding and 2 for a usage/environment
|
|
106
|
+
// failure. Diagnosing the latter as "the table has an overdue row"
|
|
107
|
+
// sends the releaser to edit a file that is not the problem.
|
|
108
|
+
die(
|
|
109
|
+
`refusing the ${target} cut: the scheduled-deprecations check could not run ` +
|
|
110
|
+
`(exit ${String(res.returncode)}). That is an environment or usage failure, not a ` +
|
|
111
|
+
'finding in docs/MIGRATION.md — fix the invocation or the checkout, then re-run.',
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
if (opts.previewOnly === true) {
|
|
115
|
+
// The preview's job is to SHOW what the real run will refuse. Dying
|
|
116
|
+
// here would break the `--dry-run exits 0` contract asserted elsewhere
|
|
117
|
+
// in this file's tests — trading a documented exit code for a message
|
|
118
|
+
// that has already been printed above.
|
|
119
|
+
process.stderr.write(
|
|
120
|
+
`\n(dry-run) the ${target} cut WOULD BE REFUSED for the reason above. ` +
|
|
121
|
+
'Previewing only; exit code unchanged.\n',
|
|
122
|
+
);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
die(
|
|
126
|
+
`refusing the ${target} cut: the scheduled-deprecations table in docs/MIGRATION.md ` +
|
|
127
|
+
'has a row due at or before this major, or one that cannot be resolved. Act on ' +
|
|
128
|
+
"it — perform the removal in its own change, or revise the row's commitment and " +
|
|
129
|
+
'record why the surface stays — then re-run.',
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Refuse a MAJOR cut whose BREAKING section has no `docs/MIGRATION.md` entry.
|
|
135
|
+
*
|
|
136
|
+
* The BACKWARD half of the pre-flight MIGRATION.md obligation.
|
|
137
|
+
* {@link assert_scheduled_deprecations_clear} above is the forward half —
|
|
138
|
+
* commitments due at a future major — and it is the half that already had a
|
|
139
|
+
* gate. Nothing checked that a major SHIPPING a breaking change told a consumer
|
|
140
|
+
* what to do about it, and 15.0.0 and 16.0.0 both shipped without a section.
|
|
141
|
+
*
|
|
142
|
+
* It runs at the call site rather than inside `preflight()` because the
|
|
143
|
+
* comparand does not exist yet at pre-flight time: the section is rendered
|
|
144
|
+
* later and is not prepended to `CHANGELOG.md` until `execute()`. Reading the
|
|
145
|
+
* file in pre-flight would compare against the PREVIOUS release.
|
|
146
|
+
*
|
|
147
|
+
* Preview semantics match the sibling exactly — `--dry-run` prints what the
|
|
148
|
+
* real run will refuse and returns, because `--dry-run exits 0` is a contract
|
|
149
|
+
* this file's own tests assert.
|
|
150
|
+
*
|
|
151
|
+
* @param migrationReader Seam for the file read, so a test can drive the
|
|
152
|
+
* refusal without mutating a tracked file.
|
|
153
|
+
*/
|
|
154
|
+
export function assert_major_migration_section(
|
|
155
|
+
target: string,
|
|
156
|
+
changelog_entry: string,
|
|
157
|
+
opts: { previewOnly?: boolean } = {},
|
|
158
|
+
migrationReader: () => string = () =>
|
|
159
|
+
fs.readFileSync(path.join(REPO_ROOT, MAJOR_MIGRATION_PATH), 'utf-8'),
|
|
160
|
+
): void {
|
|
161
|
+
let migration: string;
|
|
162
|
+
try {
|
|
163
|
+
migration = migrationReader();
|
|
164
|
+
} catch (exc) {
|
|
165
|
+
die(
|
|
166
|
+
`refusing the ${target} cut: cannot read ${MAJOR_MIGRATION_PATH} ` +
|
|
167
|
+
`(${exc instanceof Error ? exc.message : String(exc)}). That is an environment ` +
|
|
168
|
+
'failure, not a finding — fix the checkout, then re-run.',
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
const finding = pendingMajorFinding(target, changelog_entry, migration);
|
|
172
|
+
if (finding === null) {
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
process.stderr.write(`\n${finding}\n`);
|
|
176
|
+
if (opts.previewOnly === true) {
|
|
177
|
+
process.stderr.write(
|
|
178
|
+
`\n(dry-run) the ${target} cut WOULD BE REFUSED for the reason above. ` +
|
|
179
|
+
'Previewing only; exit code unchanged.\n',
|
|
180
|
+
);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
die(`refusing the ${target} cut: ${MAJOR_MIGRATION_PATH} has no section for ${target}.`);
|
|
184
|
+
}
|