@opengsd/gsd-core 1.14.0 → 1.16.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.opencode/plugins/gsd-core.js +85 -5
- package/README.ja-JP.md +3 -3
- package/README.ko-KR.md +3 -3
- package/README.pt-BR.md +3 -3
- package/README.zh-CN.md +3 -3
- package/agents/gsd-code-fixer.compact.md +7 -6
- package/agents/gsd-code-fixer.md +9 -8
- package/agents/gsd-code-reviewer.compact.md +5 -3
- package/agents/gsd-code-reviewer.md +8 -6
- package/agents/gsd-debug-session-manager.compact.md +17 -2
- package/agents/gsd-debug-session-manager.md +17 -2
- package/agents/gsd-debugger.md +3 -3
- package/agents/gsd-eval-auditor.compact.md +1 -1
- package/agents/gsd-eval-auditor.md +1 -1
- package/agents/gsd-executor.md +17 -12
- package/agents/gsd-intel-updater.compact.md +1 -1
- package/agents/gsd-intel-updater.md +1 -1
- package/agents/gsd-mempalace-curator.md +2 -2
- package/agents/gsd-phase-researcher.md +19 -11
- package/agents/gsd-plan-checker.md +15 -9
- package/agents/gsd-planner.md +15 -11
- package/agents/gsd-project-researcher.compact.md +1 -1
- package/agents/gsd-project-researcher.md +1 -1
- package/agents/gsd-research-synthesizer.compact.md +1 -1
- package/agents/gsd-research-synthesizer.md +1 -1
- package/agents/gsd-ui-auditor.compact.md +21 -30
- package/agents/gsd-ui-auditor.md +166 -37
- package/agents/gsd-ui-researcher.compact.md +1 -1
- package/agents/gsd-ui-researcher.md +1 -1
- package/agents/gsd-verifier.md +37 -14
- package/bin/install.js +764 -287
- package/commands/gsd/add-tests.md +6 -1
- package/commands/gsd/ai-integration-phase.md +6 -1
- package/commands/gsd/audit-fix.md +5 -0
- package/commands/gsd/audit-milestone.md +6 -1
- package/commands/gsd/autonomous.md +7 -2
- package/commands/gsd/capture.md +9 -5
- package/commands/gsd/code-review.md +7 -2
- package/commands/gsd/complete-milestone.md +4 -0
- package/commands/gsd/config.md +7 -3
- package/commands/gsd/debug.md +11 -7
- package/commands/gsd/discuss-phase.md +7 -3
- package/commands/gsd/docs-update.md +12 -7
- package/commands/gsd/eval-review.md +6 -1
- package/commands/gsd/execute-phase.md +12 -7
- package/commands/gsd/extract-learnings.md +5 -0
- package/commands/gsd/fast.md +4 -0
- package/commands/gsd/forensics.md +5 -1
- package/commands/gsd/graphify.md +10 -6
- package/commands/gsd/health.md +5 -0
- package/commands/gsd/help.md +7 -2
- package/commands/gsd/import.md +7 -3
- package/commands/gsd/inbox.md +5 -0
- package/commands/gsd/ingest-docs.md +5 -1
- package/commands/gsd/manager.md +6 -1
- package/commands/gsd/map-codebase.md +7 -3
- package/commands/gsd/mempalace-capture.md +12 -4
- package/commands/gsd/mempalace-recall.md +5 -1
- package/commands/gsd/milestone-summary.md +5 -1
- package/commands/gsd/mvp-phase.md +8 -3
- package/commands/gsd/new-milestone.md +6 -1
- package/commands/gsd/new-project.md +5 -0
- package/commands/gsd/next.md +6 -1
- package/commands/gsd/ns-context.md +4 -0
- package/commands/gsd/ns-ideate.md +4 -0
- package/commands/gsd/ns-manage.md +4 -0
- package/commands/gsd/ns-project.md +4 -0
- package/commands/gsd/ns-review.md +4 -0
- package/commands/gsd/ns-workflow.md +4 -0
- package/commands/gsd/onboard.md +6 -1
- package/commands/gsd/pause-work.md +5 -1
- package/commands/gsd/phase.md +8 -4
- package/commands/gsd/plan-phase.md +6 -1
- package/commands/gsd/plan-review-convergence.md +11 -7
- package/commands/gsd/pr-branch.md +4 -0
- package/commands/gsd/profile-user.md +5 -1
- package/commands/gsd/progress.md +7 -2
- package/commands/gsd/quick-batch.md +21 -9
- package/commands/gsd/quick.md +12 -7
- package/commands/gsd/review.md +7 -4
- package/commands/gsd/secure-phase.md +6 -1
- package/commands/gsd/ship.md +5 -0
- package/commands/gsd/sketch.md +7 -2
- package/commands/gsd/spec-phase.md +5 -1
- package/commands/gsd/spike.md +8 -3
- package/commands/gsd/surface.md +5 -1
- package/commands/gsd/thread.md +4 -0
- package/commands/gsd/ui-phase.md +6 -1
- package/commands/gsd/ui-review.md +6 -1
- package/commands/gsd/ultraplan-phase.md +5 -1
- package/commands/gsd/undo.md +5 -1
- package/commands/gsd/update.md +6 -2
- package/commands/gsd/validate-phase.md +6 -1
- package/commands/gsd/verify-work.md +6 -1
- package/commands/gsd/workspace.md +7 -3
- package/gsd-core/bin/gsd-tools.cjs +477 -78
- package/gsd-core/bin/lib/active-workstream-store.cjs +15 -0
- package/gsd-core/bin/lib/adr-parser.cjs +3 -1
- package/gsd-core/bin/lib/agent-install-check.cjs +4 -1
- package/gsd-core/bin/lib/audit.cjs +144 -42
- package/gsd-core/bin/lib/broken-windows.cjs +13 -13
- package/gsd-core/bin/lib/capability-activation.cjs +9 -4
- package/gsd-core/bin/lib/capability-registry.cjs +197 -222
- package/gsd-core/bin/lib/capability-validator.cjs +16 -1
- package/gsd-core/bin/lib/check-auto-mode.cjs +35 -0
- package/gsd-core/bin/lib/check-command-router.cjs +164 -1625
- package/gsd-core/bin/lib/cjs-command-router-adapter.cjs +13 -2
- package/gsd-core/bin/lib/cli-exit.cjs +12 -0
- package/gsd-core/bin/lib/codex-agent-toml.cjs +32 -33
- package/gsd-core/bin/lib/command-aliases.cjs +7 -0
- package/gsd-core/bin/lib/command-routing-hub.cjs +48 -1
- package/gsd-core/bin/lib/commands.cjs +343 -207
- package/gsd-core/bin/lib/complexity-trigger.cjs +8 -7
- package/gsd-core/bin/lib/config-loader.cjs +65 -4
- package/gsd-core/bin/lib/config.cjs +76 -19
- package/gsd-core/bin/lib/core-utils.cjs +6 -1
- package/gsd-core/bin/lib/coverage.cjs +4 -8
- package/gsd-core/bin/lib/decision-coverage-support.cjs +259 -0
- package/gsd-core/bin/lib/decisions.cjs +30 -14
- package/gsd-core/bin/lib/drift.cjs +177 -42
- package/gsd-core/bin/lib/frontmatter-fence.cjs +90 -0
- package/gsd-core/bin/lib/frontmatter-splice.cjs +494 -0
- package/gsd-core/bin/lib/frontmatter.cjs +426 -234
- package/gsd-core/bin/lib/gap-checker.cjs +72 -29
- package/gsd-core/bin/lib/gate-api-coverage-verify-pre.cjs +381 -0
- package/gsd-core/bin/lib/gate-args.cjs +53 -0
- package/gsd-core/bin/lib/gate-codebase-drift.cjs +285 -0
- package/gsd-core/bin/lib/gate-config.cjs +46 -0
- package/gsd-core/bin/lib/gate-context-drift.cjs +141 -0
- package/gsd-core/bin/lib/gate-decision-coverage-plan.cjs +169 -0
- package/gsd-core/bin/lib/gate-decision-coverage-verify.cjs +126 -0
- package/gsd-core/bin/lib/gate-evaluation-scope.cjs +555 -0
- package/gsd-core/bin/lib/gate-evidence.cjs +138 -0
- package/gsd-core/bin/lib/gate-exit.cjs +27 -0
- package/gsd-core/bin/lib/gate-gap-analysis-plan-post.cjs +61 -0
- package/gsd-core/bin/lib/gate-phase-context.cjs +170 -0
- package/gsd-core/bin/lib/gate-predicate-evaluator.cjs +1 -1
- package/gsd-core/bin/lib/gate-predicate.cjs +165 -0
- package/gsd-core/bin/lib/gate-prohibition-enforcement.cjs +94 -0
- package/gsd-core/bin/lib/gate-schema-drift.cjs +165 -0
- package/gsd-core/bin/lib/gate-tdd-red-evidence.cjs +100 -0
- package/gsd-core/bin/lib/gate-tdd-review-checkpoint.cjs +182 -0
- package/gsd-core/bin/lib/gate-ui-plan.cjs +86 -0
- package/gsd-core/bin/lib/gate-ui-safety.cjs +80 -0
- package/gsd-core/bin/lib/gate-verdict.cjs +64 -0
- package/gsd-core/bin/lib/gate-verify-command-paths.cjs +78 -0
- package/gsd-core/bin/lib/gate-verify-failure-directions.cjs +41 -0
- package/gsd-core/bin/lib/graphify.cjs +10 -2
- package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +43 -47
- package/gsd-core/bin/lib/health-diagnostic.cjs +45 -8
- package/gsd-core/bin/lib/host-runtime-detection.cjs +9 -0
- package/gsd-core/bin/lib/init.cjs +364 -132
- package/gsd-core/bin/lib/install-engine.cjs +30 -33
- package/gsd-core/bin/lib/install-profiles.cjs +7 -4
- package/gsd-core/bin/lib/installer-migrations.cjs +8 -1
- package/gsd-core/bin/lib/io.cjs +122 -3
- package/gsd-core/bin/lib/loop-resolver.cjs +95 -0
- package/gsd-core/bin/lib/markdown-sectionizer.cjs +75 -1
- package/gsd-core/bin/lib/milestone.cjs +37 -6
- package/gsd-core/bin/lib/model-resolver.cjs +171 -62
- package/gsd-core/bin/lib/observability/event.cjs +1 -1
- package/gsd-core/bin/lib/observability/logger.cjs +46 -1
- package/gsd-core/bin/lib/pattern.cjs +10 -0
- package/gsd-core/bin/lib/phase-command-router.cjs +20 -5
- package/gsd-core/bin/lib/phase-estimation.cjs +5 -4
- package/gsd-core/bin/lib/phase-id-card.cjs +32 -0
- package/gsd-core/bin/lib/phase-id-display.cjs +78 -0
- package/gsd-core/bin/lib/phase-id.cjs +110 -8
- package/gsd-core/bin/lib/phase-lifecycle.cjs +9 -2
- package/gsd-core/bin/lib/phase-locator.cjs +29 -10
- package/gsd-core/bin/lib/phase-status.cjs +360 -0
- package/gsd-core/bin/lib/phase.cjs +489 -88
- package/gsd-core/bin/lib/plan-document.cjs +142 -20
- package/gsd-core/bin/lib/plan-drift-guard.cjs +5 -0
- package/gsd-core/bin/lib/planning-document.cjs +692 -0
- package/gsd-core/bin/lib/planning-inspect.cjs +60 -9
- package/gsd-core/bin/lib/planning-snapshot.cjs +18 -0
- package/gsd-core/bin/lib/planning-workspace.cjs +83 -55
- package/gsd-core/bin/lib/pr-branch-patterns.cjs +57 -0
- package/gsd-core/bin/lib/pristine-baseline.cjs +10 -0
- package/gsd-core/bin/lib/probe-core.cjs +7 -1
- package/gsd-core/bin/lib/profile-output.cjs +6 -3
- package/gsd-core/bin/lib/prohibition-enforcement.cjs +0 -55
- package/gsd-core/bin/lib/project-root.cjs +41 -2
- package/gsd-core/bin/lib/quick-batch-command-router.cjs +35 -9
- package/gsd-core/bin/lib/quick-batch-dispatch.cjs +11 -8
- package/gsd-core/bin/lib/real-home-guard.cjs +9 -1
- package/gsd-core/bin/lib/report-parser.cjs +269 -0
- package/gsd-core/bin/lib/review-lane-descriptor.cjs +10 -30
- package/gsd-core/bin/lib/review-reviewer-selection.cjs +2 -2
- package/gsd-core/bin/lib/roadmap-command-router.cjs +25 -19
- package/gsd-core/bin/lib/roadmap-parser.cjs +242 -15
- package/gsd-core/bin/lib/roadmap-upgrade.cjs +1653 -65
- package/gsd-core/bin/lib/roadmap.cjs +405 -88
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +373 -187
- package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +5 -2
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +57 -1
- package/gsd-core/bin/lib/runtime-homes.cjs +14 -7
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +522 -624
- package/gsd-core/bin/lib/runtime-name-policy.cjs +246 -21
- package/gsd-core/bin/lib/runtime-slash.cjs +47 -30
- package/gsd-core/bin/lib/shell-command-projection.cjs +47 -8
- package/gsd-core/bin/lib/smart-entry.cjs +19 -3
- package/gsd-core/bin/lib/stale-bake-guard.cjs +32 -48
- package/gsd-core/bin/lib/state-contract.cjs +15 -18
- package/gsd-core/bin/lib/state-document.cjs +100 -22
- package/gsd-core/bin/lib/state-transition.cjs +39 -2
- package/gsd-core/bin/lib/state.cjs +256 -105
- package/gsd-core/bin/lib/surface.cjs +19 -2
- package/gsd-core/bin/lib/tdd-red-evidence.cjs +48 -79
- package/gsd-core/bin/lib/uat-predicate.cjs +359 -38
- package/gsd-core/bin/lib/uat.cjs +432 -7
- package/gsd-core/bin/lib/ui-consideration-probe.cjs +15 -2
- package/gsd-core/bin/lib/ui-frontend-evidence.cjs +167 -40
- package/gsd-core/bin/lib/undo-commit-selection.cjs +131 -0
- package/gsd-core/bin/lib/vendor/README.md +31 -9
- package/gsd-core/bin/lib/vendor/saxes.cjs +1934 -0
- package/gsd-core/bin/lib/vendor/saxes.cjs.LICENSE.txt +92 -0
- package/gsd-core/bin/lib/vendor/tap-parser.cjs +8927 -0
- package/gsd-core/bin/lib/vendor/tap-parser.cjs.LICENSE.txt +152 -0
- package/gsd-core/bin/lib/verification-command-router.cjs +2 -1
- package/gsd-core/bin/lib/verification.cjs +1378 -274
- package/gsd-core/bin/lib/verify-command-grounding.cjs +46 -2
- package/gsd-core/bin/lib/verify-command-router.cjs +18 -7
- package/gsd-core/bin/lib/verify.cjs +357 -531
- package/gsd-core/bin/lib/workstream-inventory-builder.cjs +14 -6
- package/gsd-core/bin/lib/workstream-inventory.cjs +31 -21
- package/gsd-core/bin/lib/workstream-name-policy.cjs +31 -1
- package/gsd-core/bin/lib/workstream.cjs +11 -2
- package/gsd-core/bin/lib/worktree-base-ref.cjs +482 -73
- package/gsd-core/bin/lib/worktree-safety.cjs +784 -51
- package/gsd-core/bin/shared/config-defaults.manifest.json +8 -0
- package/gsd-core/bin/shared/config-schema.manifest.json +5 -0
- package/gsd-core/references/autonomous-smart-discuss.md +2 -1
- package/gsd-core/references/autonomous-ui-design-contract.md +3 -3
- package/gsd-core/references/checkpoints.md +5 -3
- package/gsd-core/references/edge-probe-fixtures/01-round-half-even/expected-coverage.json +28 -3
- package/gsd-core/references/edge-probe-fixtures/02-merge-intervals/expected-coverage.json +37 -4
- package/gsd-core/references/edge-probe-fixtures/03-truncate-graphemes/expected-coverage.json +28 -3
- package/gsd-core/references/edge-probe-fixtures/04-money-rounding/expected-coverage.json +28 -3
- package/gsd-core/references/edge-probe-fixtures/05-list-dedupe/expected-coverage.json +37 -4
- package/gsd-core/references/edge-probe-fixtures/06-resolved-mixed/expected-coverage.json +37 -4
- package/gsd-core/references/edge-probe.md +195 -21
- package/gsd-core/references/execute-mvp-tdd.md +5 -10
- package/gsd-core/references/execute-phase-between-wave-reset.md +10 -6
- package/gsd-core/references/execute-phase-response-language.md +1 -1
- package/gsd-core/references/execute-phase-wave-guard.md +22 -11
- package/gsd-core/references/gsd-run-resolver.md +1 -1
- package/gsd-core/references/loop-hook-dispatch.md +7 -1
- package/gsd-core/references/model-profiles.md +1 -1
- package/gsd-core/references/offer-next.md +1 -1
- package/gsd-core/references/phase-argument-parsing.md +9 -7
- package/gsd-core/references/phase-id-convention.md +28 -0
- package/gsd-core/references/planner-gap-closure.md +2 -0
- package/gsd-core/references/planner-load-graph-context.md +24 -13
- package/gsd-core/references/planner-verify-command-grounding.md +14 -0
- package/gsd-core/references/planning-config.md +12 -3
- package/gsd-core/references/spidr-splitting.md +1 -1
- package/gsd-core/references/tdd.md +37 -8
- package/gsd-core/references/ui-consideration-probe.md +10 -5
- package/gsd-core/references/verifier-phase-gates.md +5 -2
- package/gsd-core/references/verify-command-path-resolvability.md +10 -2
- package/gsd-core/references/verify-mvp-mode.md +2 -2
- package/gsd-core/references/workstream-flag.md +33 -3
- package/gsd-core/references/worktree-path-safety.md +321 -0
- package/gsd-core/templates/README.md +1 -1
- package/gsd-core/templates/UAT.md +17 -1
- package/gsd-core/templates/config.json +2 -11
- package/gsd-core/templates/verification-report.md +1 -1
- package/gsd-core/workflows/_runtime-launcher.snippet.sh +1 -1
- package/gsd-core/workflows/add-backlog.md +1 -1
- package/gsd-core/workflows/add-phase.md +8 -7
- package/gsd-core/workflows/add-tests.md +4 -3
- package/gsd-core/workflows/add-todo.md +6 -5
- package/gsd-core/workflows/ai-integration-phase.md +13 -4
- package/gsd-core/workflows/audit-fix.md +1 -1
- package/gsd-core/workflows/audit-milestone.md +4 -3
- package/gsd-core/workflows/audit-uat.md +1 -1
- package/gsd-core/workflows/autonomous/steps/converge-fail-fast.md +9 -18
- package/gsd-core/workflows/autonomous.md +43 -23
- package/gsd-core/workflows/check-todos.md +7 -6
- package/gsd-core/workflows/cleanup.md +2 -2
- package/gsd-core/workflows/code-review/steps/dispatch-fix.md +4 -3
- package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +20 -13
- package/gsd-core/workflows/code-review-fix.md +112 -25
- package/gsd-core/workflows/code-review.md +146 -135
- package/gsd-core/workflows/complete-milestone/detail/elaboration.md +4 -3
- package/gsd-core/workflows/complete-milestone.md +13 -8
- package/gsd-core/workflows/debug.md +32 -7
- package/gsd-core/workflows/diagnose-issues.md +3 -2
- package/gsd-core/workflows/discuss-phase/modes/advisor.md +1 -1
- package/gsd-core/workflows/discuss-phase/modes/chain.md +1 -1
- package/gsd-core/workflows/discuss-phase-assumptions.md +4 -3
- package/gsd-core/workflows/discuss-phase.md +4 -3
- package/gsd-core/workflows/do.md +2 -2
- package/gsd-core/workflows/docs-update.md +6 -5
- package/gsd-core/workflows/edit-phase.md +4 -3
- package/gsd-core/workflows/eval-review.md +14 -5
- package/gsd-core/workflows/execute-phase/detail/elaboration.md +2 -2
- package/gsd-core/workflows/execute-phase/steps/code-review-disposition.md +1019 -0
- package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +15 -4
- package/gsd-core/workflows/execute-phase/steps/completion-reconciliation.md +10 -7
- package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +37 -3
- package/gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md +3 -1
- package/gsd-core/workflows/execute-phase/steps/partial-wave.md +2 -2
- package/gsd-core/workflows/execute-phase/steps/per-plan-executor-routing.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +46 -9
- package/gsd-core/workflows/execute-phase/steps/protected-branch.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/ready-wave-gate.md +37 -0
- package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/regression-gate.md +2 -0
- package/gsd-core/workflows/execute-phase/steps/tdd-applicability-resolution.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/threat-id-gate.md +28 -0
- package/gsd-core/workflows/execute-phase/steps/verify-phase-goal.md +187 -0
- package/gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md +2 -3
- package/gsd-core/workflows/execute-phase/steps/worktree-base-check.md +25 -0
- package/gsd-core/workflows/execute-phase.md +96 -178
- package/gsd-core/workflows/execute-plan.md +18 -23
- package/gsd-core/workflows/explore.md +4 -4
- package/gsd-core/workflows/extract-learnings.md +4 -2
- package/gsd-core/workflows/fast.md +1 -1
- package/gsd-core/workflows/forensics.md +1 -1
- package/gsd-core/workflows/graduation.md +1 -1
- package/gsd-core/workflows/health.md +3 -2
- package/gsd-core/workflows/help/modes/full.compact.md +3 -3
- package/gsd-core/workflows/help/modes/full.md +5 -5
- package/gsd-core/workflows/help/modes/topic.md +15 -5
- package/gsd-core/workflows/import.md +4 -3
- package/gsd-core/workflows/inbox.md +2 -2
- package/gsd-core/workflows/ingest-docs.md +3 -3
- package/gsd-core/workflows/insert-phase.md +4 -3
- package/gsd-core/workflows/list-seeds.md +1 -1
- package/gsd-core/workflows/list-workspaces.md +1 -1
- package/gsd-core/workflows/manager.md +6 -4
- package/gsd-core/workflows/map-codebase.md +5 -4
- package/gsd-core/workflows/milestone-summary.md +3 -2
- package/gsd-core/workflows/mvp-phase.md +14 -14
- package/gsd-core/workflows/new-milestone.md +11 -11
- package/gsd-core/workflows/new-project/steps/auto-mode-config.md +3 -3
- package/gsd-core/workflows/new-project/steps/codebase-map-offer.md +1 -1
- package/gsd-core/workflows/new-project.md +7 -7
- package/gsd-core/workflows/new-workspace.md +2 -2
- package/gsd-core/workflows/next.md +1 -1
- package/gsd-core/workflows/note.md +1 -1
- package/gsd-core/workflows/onboard.md +1 -1
- package/gsd-core/workflows/pause-work.md +2 -2
- package/gsd-core/workflows/plan-phase/detail/elaboration.md +1 -1
- package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +17 -5
- package/gsd-core/workflows/plan-phase/steps/closed-phase-gate.md +1 -1
- package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +1 -1
- package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +23 -5
- package/gsd-core/workflows/plan-phase.md +50 -24
- package/gsd-core/workflows/plan-review-convergence.md +21 -5
- package/gsd-core/workflows/plant-seed.md +62 -20
- package/gsd-core/workflows/pr-branch.md +113 -13
- package/gsd-core/workflows/profile-user.md +2 -2
- package/gsd-core/workflows/progress.md +19 -49
- package/gsd-core/workflows/quick/steps/plan-checker-loop.md +27 -0
- package/gsd-core/workflows/quick/steps/quick-verification.md +4 -4
- package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +30 -11
- package/gsd-core/workflows/quick-batch/steps/batch-init.md +1 -1
- package/gsd-core/workflows/quick-batch/steps/completion.md +1 -1
- package/gsd-core/workflows/quick-batch/steps/merge-wave.md +1 -1
- package/gsd-core/workflows/quick-batch/steps/planner-wave.md +1 -1
- package/gsd-core/workflows/quick-batch/steps/research-phase.md +1 -1
- package/gsd-core/workflows/quick-batch/steps/resume-mode.md +1 -1
- package/gsd-core/workflows/quick-batch/steps/verification-wave.md +9 -3
- package/gsd-core/workflows/quick-batch/steps/worktree-dispatch.md +1 -1
- package/gsd-core/workflows/quick-batch.md +15 -10
- package/gsd-core/workflows/quick.md +62 -39
- package/gsd-core/workflows/reapply-patches.md +9 -3
- package/gsd-core/workflows/remove-phase.md +3 -2
- package/gsd-core/workflows/remove-workspace.md +2 -2
- package/gsd-core/workflows/resume-project.md +3 -2
- package/gsd-core/workflows/review.md +33 -17
- package/gsd-core/workflows/scan.md +3 -2
- package/gsd-core/workflows/secure-phase.md +13 -13
- package/gsd-core/workflows/settings-advanced.md +30 -10
- package/gsd-core/workflows/settings-integrations.md +2 -3
- package/gsd-core/workflows/settings.md +4 -4
- package/gsd-core/workflows/ship.md +14 -13
- package/gsd-core/workflows/sketch-wrap-up.md +1 -1
- package/gsd-core/workflows/sketch.md +1 -1
- package/gsd-core/workflows/smart-entry.md +2 -2
- package/gsd-core/workflows/spec-phase.md +15 -5
- package/gsd-core/workflows/spike-wrap-up.md +1 -1
- package/gsd-core/workflows/spike.md +1 -1
- package/gsd-core/workflows/stats.md +1 -1
- package/gsd-core/workflows/sync-skills.md +5 -5
- package/gsd-core/workflows/thread.md +2 -2
- package/gsd-core/workflows/transition.md +13 -23
- package/gsd-core/workflows/ui-phase.md +48 -11
- package/gsd-core/workflows/ui-review.md +21 -6
- package/gsd-core/workflows/ultraplan-phase.md +3 -2
- package/gsd-core/workflows/undo.md +339 -20
- package/gsd-core/workflows/update.md +7 -7
- package/gsd-core/workflows/validate-phase.md +12 -13
- package/gsd-core/workflows/verify-work/detail/elaboration.md +43 -3
- package/gsd-core/workflows/verify-work/steps/automated-ui-verification.md +1 -1
- package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +5 -3
- package/gsd-core/workflows/verify-work.md +129 -55
- package/hooks/dist/gsd-agent-isolation-guard.js +32 -0
- package/hooks/dist/gsd-check-update-worker.js +8 -0
- package/hooks/dist/gsd-check-update.js +8 -0
- package/hooks/dist/gsd-context-monitor.js +31 -8
- package/hooks/dist/gsd-cursor-subagent-start.js +8 -0
- package/hooks/dist/gsd-secret-read-guard.js +161 -4
- package/hooks/dist/gsd-statusline.js +85 -20
- package/hooks/dist/gsd-update-banner.js +8 -0
- package/hooks/dist/gsd-validate-commit.sh +63 -4
- package/hooks/dist/gsd-windsurf-pre-write.js +11 -2
- package/hooks/dist/gsd-workflow-guard.js +5 -4
- package/hooks/dist/gsd-worktree-path-guard.js +6 -2
- package/hooks/dist/lib/cli-exit.js +12 -0
- package/hooks/dist/lib/git-probe.js +17 -1
- package/hooks/dist/lib/isolation-sentinel.js +2 -2
- package/hooks/gsd-agent-isolation-guard.js +32 -0
- package/hooks/gsd-check-update-worker.js +8 -0
- package/hooks/gsd-check-update.js +8 -0
- package/hooks/gsd-context-monitor.js +31 -8
- package/hooks/gsd-cursor-subagent-start.js +8 -0
- package/hooks/gsd-secret-read-guard.js +161 -4
- package/hooks/gsd-statusline.js +85 -20
- package/hooks/gsd-update-banner.js +8 -0
- package/hooks/gsd-validate-commit.sh +63 -4
- package/hooks/gsd-windsurf-pre-write.js +11 -2
- package/hooks/gsd-workflow-guard.js +5 -4
- package/hooks/gsd-worktree-path-guard.js +6 -2
- package/hooks/hooks.json +5 -5
- package/hooks/lib/cli-exit.js +12 -0
- package/hooks/lib/git-probe.js +17 -1
- package/hooks/lib/isolation-sentinel.js +2 -2
- package/package.json +22 -4
- package/scripts/build-hooks.js +15 -6
- package/scripts/changeset/parse.cjs +52 -4
- package/scripts/check-contract-drift.cjs +127 -11
- package/scripts/ci-timeout-report.cjs +770 -4
- package/scripts/command-contract-helpers.cjs +15 -8
- package/scripts/docs-guard-registry.cjs +34 -0
- package/scripts/gen-features.cjs +13 -8
- package/scripts/gen-hooks-cli-exit.cjs +12 -28
- package/scripts/gen-loop-host-contract.cjs +79 -1
- package/scripts/gen-platform-conformance-tier.cjs +187 -1
- package/scripts/gen-plugin-skills.cjs +87 -1
- package/scripts/gen-research-agents.cjs +24 -31
- package/scripts/gen-scripts-cli-exit.cjs +30 -3
- package/scripts/gen-test-timings.cjs +32 -7
- package/scripts/lib/cli-exit.cjs +12 -0
- package/scripts/lib/macos-conformance-tier.generated.cjs +34 -2
- package/scripts/lib/ndjson-reporter.cjs +31 -5
- package/scripts/lib/platform-conformance-tier.generated.cjs +45 -5
- package/scripts/lib/registration-ledger-preload.cjs +155 -0
- package/scripts/lib/vendor-bundle.cjs +59 -0
- package/scripts/lib/vendor-licenses/saxes-6.0.0.txt +64 -0
- package/scripts/lint-allow-test-rule-refs.effective-ceiling.json +1 -1
- package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +1 -1
- package/scripts/lint-completion-predicate-drift.cjs +18 -19
- package/scripts/lint-descriptions.cjs +7 -3
- package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +38 -1
- package/scripts/lint-eslint-glob-coverage.allowlist.json +20 -0
- package/scripts/lint-frontmatter-fence-drift.cjs +313 -0
- package/scripts/lint-frontmatter-scalar-broad-grep.cjs +122 -16
- package/scripts/lint-phase-arg-assignment.cjs +257 -0
- package/scripts/lint-phase-enumeration-drift.cjs +12 -8
- package/scripts/lint-phase-id-drift.cjs +319 -5
- package/scripts/lint-planning-document-positive-control.cjs +329 -0
- package/scripts/lint-pr-branch-pattern-drift.cjs +148 -0
- package/scripts/lint-response-language-coverage.cjs +3 -0
- package/scripts/lint-retired-runtime-name.cjs +619 -0
- package/scripts/lint-skill-deps.cjs +7 -3
- package/scripts/lint-state-write-path-drift.cjs +93 -0
- package/scripts/lint-test-file-count.allowlist.json +38 -9
- package/scripts/lint-test-file-count.cjs +34 -1
- package/scripts/lint-vendored-deps.cjs +41 -5
- package/scripts/lint-workflow-shellcheck-baseline.json +25 -10
- package/scripts/mutation-matrix.cjs +50 -5
- package/scripts/prompt-injection-scan.sh +4 -0
- package/scripts/release-tarball-smoke.cjs +194 -1
- package/scripts/require-issue-link-policy.cjs +6 -2
- package/scripts/sync-runtime-launcher.cjs +184 -2
- package/scripts/verify-npm-publish.cjs +76 -20
- package/skills/gsd-add-tests/SKILL.md +6 -1
- package/skills/gsd-ai-integration-phase/SKILL.md +6 -1
- package/skills/gsd-audit-fix/SKILL.md +5 -0
- package/skills/gsd-audit-milestone/SKILL.md +6 -1
- package/skills/gsd-autonomous/SKILL.md +7 -2
- package/skills/gsd-capture/SKILL.md +9 -5
- package/skills/gsd-code-review/SKILL.md +7 -2
- package/skills/gsd-complete-milestone/SKILL.md +4 -0
- package/skills/gsd-config/SKILL.md +8 -4
- package/skills/gsd-debug/SKILL.md +11 -7
- package/skills/gsd-discuss-phase/SKILL.md +7 -3
- package/skills/gsd-docs-update/SKILL.md +12 -7
- package/skills/gsd-eval-review/SKILL.md +6 -1
- package/skills/gsd-execute-phase/SKILL.md +12 -7
- package/skills/gsd-extract-learnings/SKILL.md +5 -0
- package/skills/gsd-fast/SKILL.md +4 -0
- package/skills/gsd-forensics/SKILL.md +5 -1
- package/skills/gsd-graphify/SKILL.md +10 -6
- package/skills/gsd-health/SKILL.md +5 -0
- package/skills/gsd-help/SKILL.md +7 -2
- package/skills/gsd-import/SKILL.md +7 -3
- package/skills/gsd-inbox/SKILL.md +5 -0
- package/skills/gsd-ingest-docs/SKILL.md +5 -1
- package/skills/gsd-manager/SKILL.md +6 -1
- package/skills/gsd-map-codebase/SKILL.md +7 -3
- package/skills/gsd-mempalace-capture/SKILL.md +12 -4
- package/skills/gsd-mempalace-recall/SKILL.md +5 -1
- package/skills/gsd-milestone-summary/SKILL.md +5 -1
- package/skills/gsd-mvp-phase/SKILL.md +8 -3
- package/skills/gsd-new-milestone/SKILL.md +6 -1
- package/skills/gsd-new-project/SKILL.md +5 -0
- package/skills/gsd-next/SKILL.md +6 -1
- package/skills/gsd-ns-context/SKILL.md +4 -0
- package/skills/gsd-ns-ideate/SKILL.md +4 -0
- package/skills/gsd-ns-manage/SKILL.md +4 -0
- package/skills/gsd-ns-project/SKILL.md +4 -0
- package/skills/gsd-ns-review/SKILL.md +4 -0
- package/skills/gsd-ns-workflow/SKILL.md +4 -0
- package/skills/gsd-onboard/SKILL.md +6 -1
- package/skills/gsd-pause-work/SKILL.md +5 -1
- package/skills/gsd-phase/SKILL.md +8 -4
- package/skills/gsd-plan-phase/SKILL.md +6 -1
- package/skills/gsd-plan-review-convergence/SKILL.md +10 -6
- package/skills/gsd-pr-branch/SKILL.md +4 -0
- package/skills/gsd-profile-user/SKILL.md +5 -1
- package/skills/gsd-progress/SKILL.md +7 -2
- package/skills/gsd-quick/SKILL.md +16 -10
- package/skills/gsd-quick-batch/SKILL.md +21 -9
- package/skills/gsd-review/SKILL.md +7 -4
- package/skills/gsd-review-backlog/SKILL.md +3 -2
- package/skills/gsd-secure-phase/SKILL.md +6 -1
- package/skills/gsd-ship/SKILL.md +5 -0
- package/skills/gsd-sketch/SKILL.md +7 -2
- package/skills/gsd-spec-phase/SKILL.md +5 -1
- package/skills/gsd-spike/SKILL.md +8 -3
- package/skills/gsd-surface/SKILL.md +5 -1
- package/skills/gsd-thread/SKILL.md +4 -0
- package/skills/gsd-ui-phase/SKILL.md +6 -1
- package/skills/gsd-ui-review/SKILL.md +6 -1
- package/skills/gsd-ultraplan-phase/SKILL.md +5 -1
- package/skills/gsd-undo/SKILL.md +5 -1
- package/skills/gsd-update/SKILL.md +6 -2
- package/skills/gsd-validate-phase/SKILL.md +6 -1
- package/skills/gsd-verify-work/SKILL.md +6 -1
- package/skills/gsd-workspace/SKILL.md +7 -3
- package/skills/gsd-workstreams/SKILL.md +6 -6
- package/vscode/package.json +1 -1
|
@@ -16,6 +16,21 @@
|
|
|
16
16
|
* Invoked from a GitHub Actions workflow via actions/github-script, e.g.:
|
|
17
17
|
* const report = require(`${process.env.GITHUB_WORKSPACE}/scripts/ci-timeout-report.cjs`);
|
|
18
18
|
* const result = await report.main({ github, context, core });
|
|
19
|
+
*
|
|
20
|
+
* Also exports a second, independent check (#5101): `checkShardBalance`
|
|
21
|
+
* evaluates the #5071 Windows conformance shard-balance criteria (median gap
|
|
22
|
+
* and per-shard p90 vs. frozen baselines) from live `next` push runs and
|
|
23
|
+
* opens/comments on one tracking issue when the shards drift out of balance.
|
|
24
|
+
* It is wired into ci-timeout-report.yml as its own workflow step, not into
|
|
25
|
+
* `main()`.
|
|
26
|
+
*
|
|
27
|
+
* Rolling-PR helpers: the workflow keeps one PR (`ROLLING_PR`) rebuilt on
|
|
28
|
+
* `next` each run. `seedFromRollingPr` seeds the history with the open PR's
|
|
29
|
+
* pending rows, but only rows that pass `sanitizeHistoryText` AND that the
|
|
30
|
+
* Actions API confirms (`matchesApiJob`), because the branch is untrusted
|
|
31
|
+
* (`mergeHistoryTexts` shares `historyRecordKey` with dedupeAgainstHistory), and
|
|
32
|
+
* `evaluateRollingPrApproval` is the gate deciding whether the workflow may
|
|
33
|
+
* approve that PR.
|
|
19
34
|
*/
|
|
20
35
|
|
|
21
36
|
const yaml = require('js-yaml');
|
|
@@ -63,6 +78,21 @@ function resolveJobTimeoutMinutes({ jobName, workflowFile, workflowYamlText, cov
|
|
|
63
78
|
*/
|
|
64
79
|
function parseJobRecord({ job, workflowFile, workflowYamlText, covered }) {
|
|
65
80
|
if (!job.completed_at) return null;
|
|
81
|
+
// #5088: a job that never executed has no duration to report. GitHub's jobs
|
|
82
|
+
// API marks it two ways: `conclusion: 'skipped'` (it still carries a
|
|
83
|
+
// `completed_at`), and — for skipped AND cancelled-before-start jobs alike —
|
|
84
|
+
// a `completed_at` one second BEFORE `started_at`. Passing either shape to
|
|
85
|
+
// computeElapsedPct throws, and nothing above this catches, so one such job
|
|
86
|
+
// used to discard the whole scheduled report. Skip it here, at the API
|
|
87
|
+
// boundary; computeElapsedPct stays strict because the in-job near-cap check
|
|
88
|
+
// feeds it a live job's own clock, where a negative span is a real error. A
|
|
89
|
+
// `cancelled` job with a real span is NOT skipped: a job killed at its
|
|
90
|
+
// timeout-minutes cap is reported as cancelled, and is exactly what this
|
|
91
|
+
// report exists to record.
|
|
92
|
+
// The exact rules (skipped, missing/unparseable timestamps, zero-or-negative
|
|
93
|
+
// span, non-string name) live in jobSpanMs, shared with the #5101
|
|
94
|
+
// shard-balance check.
|
|
95
|
+
if (jobSpanMs(job) === null) return null;
|
|
66
96
|
|
|
67
97
|
const timeoutMinutes = resolveJobTimeoutMinutes({ jobName: job.name, workflowFile, workflowYamlText, covered });
|
|
68
98
|
if (timeoutMinutes == null) return null;
|
|
@@ -102,18 +132,406 @@ function buildReportLines(runs, { workflowFile, workflowYamlText, covered }) {
|
|
|
102
132
|
return records;
|
|
103
133
|
}
|
|
104
134
|
|
|
135
|
+
/**
|
|
136
|
+
* Identity of one history line: `runId::jobName`, or `null` when the line is
|
|
137
|
+
* blank, unparseable, not a JSON object, or lacks `runId`/`jobName`. Shared by
|
|
138
|
+
* dedupeAgainstHistory and mergeHistoryTexts so the two cannot drift.
|
|
139
|
+
*
|
|
140
|
+
* @param {?string} lineText
|
|
141
|
+
* @returns {?string}
|
|
142
|
+
*/
|
|
143
|
+
function historyRecordKey(lineText) {
|
|
144
|
+
const text = String(lineText ?? '').replace(/\r$/, '').trim();
|
|
145
|
+
if (!text) return null;
|
|
146
|
+
let rec;
|
|
147
|
+
try {
|
|
148
|
+
rec = JSON.parse(text);
|
|
149
|
+
} catch {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
return recordKey(rec);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* `runId::jobName` for a parsed record, or `null` when `rec` is not a
|
|
157
|
+
* non-null, non-array object carrying both `runId` and `jobName`.
|
|
158
|
+
*
|
|
159
|
+
* @param {*} rec
|
|
160
|
+
* @returns {?string}
|
|
161
|
+
*/
|
|
162
|
+
function recordKey(rec) {
|
|
163
|
+
if (rec === null || typeof rec !== 'object' || Array.isArray(rec)) return null;
|
|
164
|
+
if (rec.runId == null || rec.jobName == null) return null;
|
|
165
|
+
return `${rec.runId}::${rec.jobName}`;
|
|
166
|
+
}
|
|
167
|
+
|
|
105
168
|
function dedupeAgainstHistory(newRecords, historyText) {
|
|
106
169
|
const seen = new Set();
|
|
107
170
|
for (const line of String(historyText || '').split('\n')) {
|
|
171
|
+
const key = historyRecordKey(line);
|
|
172
|
+
if (key !== null) seen.add(key);
|
|
173
|
+
}
|
|
174
|
+
return newRecords.filter((r) => !seen.has(recordKey(r)));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Union of history texts, first occurrence wins, input order preserved. Lines
|
|
179
|
+
* without a record key (malformed / incomplete) are kept once by exact text so
|
|
180
|
+
* no data is silently dropped. Blank lines are dropped; CRLF is normalized.
|
|
181
|
+
*
|
|
182
|
+
* @param {...?string} texts
|
|
183
|
+
* @returns {string}
|
|
184
|
+
*/
|
|
185
|
+
function mergeHistoryTexts(...texts) {
|
|
186
|
+
const seen = new Set();
|
|
187
|
+
const kept = [];
|
|
188
|
+
for (const text of texts) {
|
|
189
|
+
if (typeof text !== 'string') continue;
|
|
190
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
191
|
+
const line = raw.replace(/\r$/, '');
|
|
192
|
+
if (!line.trim()) continue;
|
|
193
|
+
const key = historyRecordKey(line);
|
|
194
|
+
const identity = key === null ? `raw:${line}` : key;
|
|
195
|
+
if (seen.has(identity)) continue;
|
|
196
|
+
seen.add(identity);
|
|
197
|
+
kept.push(line);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return kept.length > 0 ? `${kept.join('\n')}\n` : '';
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// The rolling branch is untrusted input: anyone with write access can push to
|
|
204
|
+
// it, and its rows are seeded into the history the bot then republishes. Only
|
|
205
|
+
// rows exactly matching what parseJobRecord emits are accepted.
|
|
206
|
+
const HISTORY_RECORD_LIMITS = Object.freeze({ maxLineLength: 1024, maxLines: 20000 });
|
|
207
|
+
|
|
208
|
+
const HISTORY_RECORD_KEYS = new Set([
|
|
209
|
+
'runId', 'jobName', 'workflowFile', 'sha', 'runEvent', 'completedAt', 'elapsedMs', 'timeoutMinutes', 'pct',
|
|
210
|
+
]);
|
|
211
|
+
|
|
212
|
+
const isAbsentOrNull = (v) => v === undefined || v === null;
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* @param {*} rec
|
|
216
|
+
* @returns {boolean}
|
|
217
|
+
*/
|
|
218
|
+
function isValidHistoryRecord(rec) {
|
|
219
|
+
if (rec === null || typeof rec !== 'object' || Array.isArray(rec)) return false;
|
|
220
|
+
if (!Object.keys(rec).every((k) => HISTORY_RECORD_KEYS.has(k))) return false;
|
|
221
|
+
if (!Number.isSafeInteger(rec.runId) || rec.runId <= 0) return false;
|
|
222
|
+
if (typeof rec.jobName !== 'string' || rec.jobName.length < 1 || rec.jobName.length > 200) return false;
|
|
223
|
+
// Control characters (U+0000-U+001F, U+007F), checked by code unit so the
|
|
224
|
+
// pattern needs no control-character regex (eslint no-control-regex).
|
|
225
|
+
for (let i = 0; i < rec.jobName.length; i += 1) {
|
|
226
|
+
const code = rec.jobName.charCodeAt(i);
|
|
227
|
+
if (code <= 0x1f || code === 0x7f) return false;
|
|
228
|
+
}
|
|
229
|
+
if (typeof rec.workflowFile !== 'string' || !/^[A-Za-z0-9._-]{1,100}\.ya?ml$/.test(rec.workflowFile)) return false;
|
|
230
|
+
if (!isAbsentOrNull(rec.sha) && !(typeof rec.sha === 'string' && /^[0-9a-f]{40}$/.test(rec.sha))) return false;
|
|
231
|
+
if (!isAbsentOrNull(rec.runEvent) && !(typeof rec.runEvent === 'string' && /^[a-z_]{1,50}$/.test(rec.runEvent))) return false;
|
|
232
|
+
if (
|
|
233
|
+
!isAbsentOrNull(rec.completedAt)
|
|
234
|
+
&& !(typeof rec.completedAt === 'string' && rec.completedAt.length <= 40 && !Number.isNaN(Date.parse(rec.completedAt)))
|
|
235
|
+
) {
|
|
236
|
+
return false;
|
|
237
|
+
}
|
|
238
|
+
if (!Number.isFinite(rec.elapsedMs) || rec.elapsedMs < 0) return false;
|
|
239
|
+
if (!Number.isFinite(rec.timeoutMinutes) || rec.timeoutMinutes <= 0) return false;
|
|
240
|
+
if (!Number.isFinite(rec.pct) || rec.pct < 0) return false;
|
|
241
|
+
return true;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Keeps only well-formed, in-schema, size-bounded lines of untrusted history
|
|
246
|
+
* text, deduped by record key.
|
|
247
|
+
*
|
|
248
|
+
* @param {*} text
|
|
249
|
+
* @param {{maxLineLength?: number, maxLines?: number}} [limits]
|
|
250
|
+
* @returns {{text: string, kept: number, dropped: number}}
|
|
251
|
+
*/
|
|
252
|
+
function sanitizeHistoryText(text, { maxLineLength, maxLines } = HISTORY_RECORD_LIMITS) {
|
|
253
|
+
if (typeof text !== 'string') return { text: '', kept: 0, dropped: 0 };
|
|
254
|
+
const keptLines = [];
|
|
255
|
+
let dropped = 0;
|
|
256
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
257
|
+
const line = raw.replace(/\r$/, '');
|
|
108
258
|
if (!line.trim()) continue;
|
|
259
|
+
if (keptLines.length >= maxLines || line.length > maxLineLength) {
|
|
260
|
+
dropped += 1;
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
let rec;
|
|
109
264
|
try {
|
|
110
|
-
|
|
111
|
-
seen.add(`${rec.runId}::${rec.jobName}`);
|
|
265
|
+
rec = JSON.parse(line);
|
|
112
266
|
} catch {
|
|
113
|
-
|
|
267
|
+
dropped += 1;
|
|
268
|
+
continue;
|
|
114
269
|
}
|
|
270
|
+
if (!isValidHistoryRecord(rec)) {
|
|
271
|
+
dropped += 1;
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
keptLines.push(line);
|
|
115
275
|
}
|
|
116
|
-
|
|
276
|
+
const merged = mergeHistoryTexts(keptLines.length > 0 ? `${keptLines.join('\n')}\n` : '');
|
|
277
|
+
return { text: merged, kept: merged === '' ? 0 : merged.split('\n').length - 1, dropped };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// The single rolling PR the workflow maintains; the workflow reads these values.
|
|
281
|
+
const ROLLING_PR = Object.freeze({
|
|
282
|
+
branch: 'chore/4036-ci-timeout-budget-history',
|
|
283
|
+
base: 'next',
|
|
284
|
+
historyFile: 'tests/ci-timeout-budget-history.jsonl',
|
|
285
|
+
title: 'chore(#4036): CI timeout budget history update',
|
|
286
|
+
body: [
|
|
287
|
+
'Refs #4036',
|
|
288
|
+
'',
|
|
289
|
+
'Automated, data-only update to `tests/ci-timeout-budget-history.jsonl`: new job/shard wall-clock vs. `timeout-minutes` records collected by `.github/workflows/ci-timeout-report.yml`.',
|
|
290
|
+
'',
|
|
291
|
+
'This is the single rolling PR for these records. Each scheduled run rebuilds the branch on the current `next` tip with every pending record, so it never conflicts with a sibling and is up to date as of the run. It is approved by the workflow only when it is exactly this branch, from this repository, at the commit the workflow pushed, changing only the history file, and then merges through auto-merge once required checks pass.',
|
|
292
|
+
'',
|
|
293
|
+
'<!-- pr-template-exempt: automated data-only rolling PR maintained by .github/workflows/ci-timeout-report.yml (#5115) -->',
|
|
294
|
+
].join('\n'),
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Approve-or-refuse gate for the rolling PR. Strict: approves only an OPEN,
|
|
299
|
+
* same-repository PR on the rolling branch, targeting the base, at the exact
|
|
300
|
+
* commit the workflow pushed, changing only the history file.
|
|
301
|
+
*
|
|
302
|
+
* @param {{pr?: object, expectedHeadOid?: string}} [args]
|
|
303
|
+
* @returns {{approve: boolean, reason: string}}
|
|
304
|
+
*/
|
|
305
|
+
function evaluateRollingPrApproval({ pr, expectedHeadOid } = {}) {
|
|
306
|
+
const refuse = (reason) => ({ approve: false, reason });
|
|
307
|
+
if (!pr || typeof pr !== 'object' || !expectedHeadOid) return refuse('missing-input');
|
|
308
|
+
if (pr.state !== 'OPEN') return refuse('not-open');
|
|
309
|
+
if (pr.isCrossRepository !== false) return refuse('cross-repository');
|
|
310
|
+
if (pr.headRefName !== ROLLING_PR.branch) return refuse('wrong-branch');
|
|
311
|
+
if (pr.baseRefName !== ROLLING_PR.base) return refuse('wrong-base');
|
|
312
|
+
if (pr.headRefOid !== expectedHeadOid) return refuse('head-moved');
|
|
313
|
+
if (
|
|
314
|
+
!Array.isArray(pr.files)
|
|
315
|
+
|| pr.files.length !== 1
|
|
316
|
+
|| String(pr.files[0] && pr.files[0].path).replace(/\\/g, '/') !== ROLLING_PR.historyFile
|
|
317
|
+
) {
|
|
318
|
+
return refuse('unexpected-files');
|
|
319
|
+
}
|
|
320
|
+
return { approve: true, reason: 'ok' };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Lines of `text` whose identity (record key, else exact text — the same rule
|
|
325
|
+
* as mergeHistoryTexts) is not present in `baseText`, deduped, in input order.
|
|
326
|
+
*
|
|
327
|
+
* @param {*} text
|
|
328
|
+
* @param {*} baseText
|
|
329
|
+
* @returns {string}
|
|
330
|
+
*/
|
|
331
|
+
function subtractHistoryText(text, baseText) {
|
|
332
|
+
const identityOf = (line) => {
|
|
333
|
+
const key = historyRecordKey(line);
|
|
334
|
+
return key === null ? `raw:${line}` : key;
|
|
335
|
+
};
|
|
336
|
+
const linesOf = (value) => (typeof value === 'string' ? value : '')
|
|
337
|
+
.split(/\r?\n/)
|
|
338
|
+
.map((raw) => raw.replace(/\r$/, ''))
|
|
339
|
+
.filter((line) => line.trim());
|
|
340
|
+
|
|
341
|
+
const seen = new Set(linesOf(baseText).map(identityOf));
|
|
342
|
+
const kept = [];
|
|
343
|
+
for (const line of linesOf(text)) {
|
|
344
|
+
const identity = identityOf(line);
|
|
345
|
+
if (seen.has(identity)) continue;
|
|
346
|
+
seen.add(identity);
|
|
347
|
+
kept.push(line);
|
|
348
|
+
}
|
|
349
|
+
return kept.length > 0 ? `${kept.join('\n')}\n` : '';
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Workflow file name from an Actions run `path`, which the API may return with
|
|
354
|
+
* an `@<ref>` suffix (e.g. `.github/workflows/test.yml@refs/heads/next`).
|
|
355
|
+
*
|
|
356
|
+
* @param {*} runPath
|
|
357
|
+
* @returns {string}
|
|
358
|
+
*/
|
|
359
|
+
function workflowFileFromRunPath(runPath) {
|
|
360
|
+
return String(runPath || '').split('@')[0].split('/').pop();
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* True only when `rec` equals, field for field, the record parseJobRecord
|
|
365
|
+
* would emit from the Actions API's run and job (the exact shape
|
|
366
|
+
* buildReportLines passes), so every stored field is verified and only jobs
|
|
367
|
+
* main() itself would record are accepted.
|
|
368
|
+
*
|
|
369
|
+
* @param {object} rec
|
|
370
|
+
* @param {{run?: object, job?: object, workflowYamlText?: ?string, covered?: ?object}} [api]
|
|
371
|
+
* @returns {boolean}
|
|
372
|
+
*/
|
|
373
|
+
function matchesApiJob(rec, {
|
|
374
|
+
run, job, workflowYamlText, covered,
|
|
375
|
+
} = {}) {
|
|
376
|
+
if (!rec || typeof rec !== 'object') return false;
|
|
377
|
+
if (!run || typeof run !== 'object' || !job || typeof job !== 'object') return false;
|
|
378
|
+
const workflowFile = workflowFileFromRunPath(run.path);
|
|
379
|
+
if (!WORKFLOW_FILES.includes(workflowFile)) return false;
|
|
380
|
+
const built = parseJobRecord({
|
|
381
|
+
job: {
|
|
382
|
+
...job, run_id: run.id, head_sha: run.head_sha, runEvent: run.event,
|
|
383
|
+
},
|
|
384
|
+
workflowFile,
|
|
385
|
+
workflowYamlText,
|
|
386
|
+
covered,
|
|
387
|
+
});
|
|
388
|
+
if (built === null) return false;
|
|
389
|
+
const expected = JSON.parse(JSON.stringify(built));
|
|
390
|
+
const expectedKeys = Object.keys(expected);
|
|
391
|
+
const recKeys = Object.keys(rec);
|
|
392
|
+
if (expectedKeys.length !== recKeys.length) return false;
|
|
393
|
+
return expectedKeys.every((k) => Object.prototype.hasOwnProperty.call(rec, k) && Object.is(expected[k], rec[k]));
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Per-workflow inputs parseJobRecord needs, loaded exactly as main() does.
|
|
398
|
+
*
|
|
399
|
+
* @param {string} workflowFile
|
|
400
|
+
* @param {{readFileSync: Function}} fsImpl
|
|
401
|
+
* @returns {{workflowYamlText: ?string, covered: ?object}}
|
|
402
|
+
*/
|
|
403
|
+
function loadWorkflowContext(workflowFile, fsImpl) {
|
|
404
|
+
if (workflowFile === 'mutation.yml') {
|
|
405
|
+
return { workflowYamlText: null, covered: require('./mutation-matrix.cjs').COVERED };
|
|
406
|
+
}
|
|
407
|
+
return {
|
|
408
|
+
workflowYamlText: fsImpl.readFileSync(path.join(WORKFLOWS_DIR, workflowFile), 'utf8'),
|
|
409
|
+
covered: null,
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Seeds the history file with the open rolling PR's pending rows, but only
|
|
415
|
+
* those the Actions API confirms: the branch is writable by any collaborator,
|
|
416
|
+
* so a well-formed row is not evidence the job ran. Impure — invoked from
|
|
417
|
+
* actions/github-script. Any non-404 API failure rejects so the workflow step
|
|
418
|
+
* fails and the publish step is skipped.
|
|
419
|
+
*
|
|
420
|
+
* @param {{github: object, context: object, core: object, historyPath?: string, fs?: object, maxRuns?: number}} args
|
|
421
|
+
* @returns {Promise<{status: string, pr?: number, candidate: number, verified: number, dropped: number}>}
|
|
422
|
+
*/
|
|
423
|
+
async function seedFromRollingPr({
|
|
424
|
+
github, context, core, historyPath = HISTORY_PATH, fs: fsImpl = fs, maxRuns = 200,
|
|
425
|
+
workflowContext = (workflowFile) => loadWorkflowContext(workflowFile, fsImpl),
|
|
426
|
+
}) {
|
|
427
|
+
const { owner, repo } = context.repo;
|
|
428
|
+
const contextCache = new Map();
|
|
429
|
+
const contextFor = (workflowFile) => {
|
|
430
|
+
if (!contextCache.has(workflowFile)) contextCache.set(workflowFile, workflowContext(workflowFile));
|
|
431
|
+
return contextCache.get(workflowFile);
|
|
432
|
+
};
|
|
433
|
+
const zeros = { candidate: 0, verified: 0, dropped: 0 };
|
|
434
|
+
|
|
435
|
+
const listed = await github.rest.pulls.list({
|
|
436
|
+
owner, repo, state: 'open', base: ROLLING_PR.base, head: `${owner}:${ROLLING_PR.branch}`, per_page: 10,
|
|
437
|
+
});
|
|
438
|
+
const pr = (listed.data || []).find((p) => (
|
|
439
|
+
p.head && p.head.repo && p.head.repo.full_name === `${owner}/${repo}` && p.head.ref === ROLLING_PR.branch
|
|
440
|
+
));
|
|
441
|
+
if (!pr) return { status: 'no-pr', ...zeros };
|
|
442
|
+
|
|
443
|
+
let branchText;
|
|
444
|
+
try {
|
|
445
|
+
const { data } = await github.rest.repos.getContent({
|
|
446
|
+
owner, repo, path: ROLLING_PR.historyFile, ref: pr.head.sha, mediaType: { format: 'raw' },
|
|
447
|
+
});
|
|
448
|
+
if (typeof data === 'string') branchText = data;
|
|
449
|
+
else if (Buffer.isBuffer(data)) branchText = data.toString('utf8');
|
|
450
|
+
else if (data instanceof ArrayBuffer) branchText = Buffer.from(data).toString('utf8');
|
|
451
|
+
else if (data instanceof Uint8Array) branchText = Buffer.from(data).toString('utf8');
|
|
452
|
+
else branchText = null;
|
|
453
|
+
} catch (err) {
|
|
454
|
+
if (err && err.status === 404) return { status: 'no-file', pr: pr.number, ...zeros };
|
|
455
|
+
throw err;
|
|
456
|
+
}
|
|
457
|
+
if (branchText === null) {
|
|
458
|
+
// A directory listing or object payload: the path is not a text file on the
|
|
459
|
+
// branch. The remedy is a clean rebuild from next plus this run's records.
|
|
460
|
+
core.warning(`ci-timeout-report: rolling PR #${pr.number} history file is not a text file — rebuilding from next and this run's records`);
|
|
461
|
+
return { status: 'unreadable-file', pr: pr.number, ...zeros };
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
let baseText = '';
|
|
465
|
+
try {
|
|
466
|
+
baseText = fsImpl.readFileSync(historyPath, 'utf8');
|
|
467
|
+
} catch {
|
|
468
|
+
baseText = '';
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const s = sanitizeHistoryText(subtractHistoryText(branchText, baseText));
|
|
472
|
+
const rowsByRun = new Map();
|
|
473
|
+
for (const rowLine of s.text.split('\n').filter(Boolean)) {
|
|
474
|
+
const rec = JSON.parse(rowLine);
|
|
475
|
+
if (!rowsByRun.has(rec.runId)) rowsByRun.set(rec.runId, []);
|
|
476
|
+
rowsByRun.get(rec.runId).push({ line: rowLine, rec });
|
|
477
|
+
}
|
|
478
|
+
const candidate = s.kept;
|
|
479
|
+
|
|
480
|
+
const verifiedLines = [];
|
|
481
|
+
let rowsDropped = 0;
|
|
482
|
+
let rowsBeyondCap = 0;
|
|
483
|
+
let runIndex = 0;
|
|
484
|
+
for (const [runId, rows] of rowsByRun) {
|
|
485
|
+
runIndex += 1;
|
|
486
|
+
if (runIndex > maxRuns) {
|
|
487
|
+
rowsDropped += rows.length;
|
|
488
|
+
rowsBeyondCap += rows.length;
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
let run;
|
|
492
|
+
let jobs;
|
|
493
|
+
try {
|
|
494
|
+
run = (await github.rest.actions.getWorkflowRun({ owner, repo, run_id: runId })).data;
|
|
495
|
+
jobs = await github.paginate(github.rest.actions.listJobsForWorkflowRun, {
|
|
496
|
+
owner, repo, run_id: runId, per_page: 50,
|
|
497
|
+
});
|
|
498
|
+
} catch (err) {
|
|
499
|
+
if (err && err.status === 404) {
|
|
500
|
+
rowsDropped += rows.length;
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
503
|
+
throw err;
|
|
504
|
+
}
|
|
505
|
+
const workflowFile = workflowFileFromRunPath(run && run.path);
|
|
506
|
+
if (!WORKFLOW_FILES.includes(workflowFile)) {
|
|
507
|
+
rowsDropped += rows.length;
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
const { workflowYamlText, covered } = contextFor(workflowFile);
|
|
511
|
+
for (const row of rows) {
|
|
512
|
+
if (jobs.some((job) => matchesApiJob(row.rec, {
|
|
513
|
+
run, job, workflowYamlText, covered,
|
|
514
|
+
}))) verifiedLines.push(row.line);
|
|
515
|
+
else rowsDropped += 1;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const verified = verifiedLines.length;
|
|
520
|
+
if (verified > 0) {
|
|
521
|
+
fsImpl.writeFileSync(historyPath, mergeHistoryTexts(baseText, `${verifiedLines.join('\n')}\n`));
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
if (rowsBeyondCap > 0) {
|
|
525
|
+
core.warning(`ci-timeout-report: ${rowsBeyondCap} pending row(s) from runs beyond the ${maxRuns}-run verification cap were not carried forward`);
|
|
526
|
+
}
|
|
527
|
+
const dropped = s.dropped + rowsDropped;
|
|
528
|
+
if (dropped > 0) {
|
|
529
|
+
core.warning(`ci-timeout-report: dropped ${dropped} pending row(s) from the rolling PR that failed schema or API verification`);
|
|
530
|
+
}
|
|
531
|
+
core.info(`ci-timeout-report: rolling PR #${pr.number}: ${candidate} candidate row(s), ${verified} API-verified, ${dropped} dropped`);
|
|
532
|
+
return {
|
|
533
|
+
status: 'seeded', pr: pr.number, candidate, verified, dropped,
|
|
534
|
+
};
|
|
117
535
|
}
|
|
118
536
|
|
|
119
537
|
function formatHistoryLine(record) {
|
|
@@ -217,6 +635,333 @@ async function main({
|
|
|
217
635
|
return { added: deduped.length, nearCap: nearCapCount };
|
|
218
636
|
}
|
|
219
637
|
|
|
638
|
+
// #5101 — Windows conformance shard-balance check (#5071 criteria), frozen.
|
|
639
|
+
// baselineP90Ms is keyed by `shard` string ("i/n") and frozen independently
|
|
640
|
+
// so a future edit cannot mutate it via the outer object being frozen only
|
|
641
|
+
// shallowly.
|
|
642
|
+
const SHARD_BALANCE = Object.freeze({
|
|
643
|
+
workflowFile: 'test.yml',
|
|
644
|
+
branch: 'next',
|
|
645
|
+
event: 'push',
|
|
646
|
+
since: '2026-09-28T14:17:51Z', // #5097 merge
|
|
647
|
+
minRuns: 10,
|
|
648
|
+
windowRuns: 20,
|
|
649
|
+
maxMedianGapMs: 120000,
|
|
650
|
+
baselineShardTotal: 3,
|
|
651
|
+
baselineP90Ms: Object.freeze({
|
|
652
|
+
'1/3': 1824000,
|
|
653
|
+
'2/3': 2046000,
|
|
654
|
+
'3/3': 2208000,
|
|
655
|
+
}),
|
|
656
|
+
issueTitle: 'CI: Windows conformance shard balance regressed',
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
const WINDOWS_SHARD_JOB_RE = /^conformance test \(windows-latest, [^,)]+, shard (\d+)\/(\d+)\)$/;
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* ms elapsed for a job, or `null` when the job never actually executed
|
|
663
|
+
* (skipped; missing/unparseable start or completion timestamp; completed at
|
|
664
|
+
* or before started). The single definition of "never executed" for this
|
|
665
|
+
* file: parseJobRecord (#5088) and extractShardSamples (#5101) both call it.
|
|
666
|
+
* A zero-length span counts as never executed because GitHub's timestamps
|
|
667
|
+
* have one-second resolution and no job that ran is provisioned, run and
|
|
668
|
+
* torn down within the same second; a record without a string `name` is
|
|
669
|
+
* skipped because resolveJobTimeoutMinutes would throw on it and lose the
|
|
670
|
+
* whole report.
|
|
671
|
+
*
|
|
672
|
+
* @param {{name?: string, conclusion?: string, started_at?: ?string, completed_at?: ?string}} job
|
|
673
|
+
* @returns {?number}
|
|
674
|
+
*/
|
|
675
|
+
function jobSpanMs(job) {
|
|
676
|
+
if (typeof job.name !== 'string') return null;
|
|
677
|
+
if (job.conclusion === 'skipped') return null;
|
|
678
|
+
|
|
679
|
+
const started = Date.parse(job.started_at);
|
|
680
|
+
const completed = Date.parse(job.completed_at);
|
|
681
|
+
if (!Number.isFinite(started) || !Number.isFinite(completed)) return null;
|
|
682
|
+
if (completed <= started) return null;
|
|
683
|
+
|
|
684
|
+
return completed - started;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
function median(values) {
|
|
688
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
689
|
+
const n = sorted.length;
|
|
690
|
+
const mid = Math.floor(n / 2);
|
|
691
|
+
return n % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// Nearest-rank percentile: sorted ascending, index Math.ceil(p*n)-1, clamped.
|
|
695
|
+
function percentile(values, p) {
|
|
696
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
697
|
+
const n = sorted.length;
|
|
698
|
+
const idx = Math.max(0, Math.ceil(p * n) - 1);
|
|
699
|
+
return sorted[idx];
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* @param {Array<{run: object, jobs: object[]}>} runsWithJobs
|
|
704
|
+
* @param {object} config
|
|
705
|
+
* @returns {Array<{runId: number, createdAt: string, index: number, total: number, shard: string, elapsedMs: number}>}
|
|
706
|
+
*/
|
|
707
|
+
function extractShardSamples(runsWithJobs, config) {
|
|
708
|
+
const samples = [];
|
|
709
|
+
for (const { run, jobs } of runsWithJobs) {
|
|
710
|
+
if (Date.parse(run.created_at) < Date.parse(config.since)) continue;
|
|
711
|
+
|
|
712
|
+
for (const job of jobs) {
|
|
713
|
+
const m = typeof job.name === 'string' ? job.name.match(WINDOWS_SHARD_JOB_RE) : null;
|
|
714
|
+
if (!m) continue;
|
|
715
|
+
|
|
716
|
+
const elapsedMs = jobSpanMs(job);
|
|
717
|
+
if (elapsedMs == null) continue;
|
|
718
|
+
|
|
719
|
+
samples.push({
|
|
720
|
+
runId: run.id,
|
|
721
|
+
createdAt: run.created_at,
|
|
722
|
+
index: Number(m[1]),
|
|
723
|
+
total: Number(m[2]),
|
|
724
|
+
shard: `${m[1]}/${m[2]}`,
|
|
725
|
+
elapsedMs,
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
return samples;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* @param {Array<ReturnType<typeof extractShardSamples>[number]>} samples
|
|
734
|
+
* @param {object} config
|
|
735
|
+
* @returns {{status: string, runs: number, total: ?number, window: number, perShard: object[], gapMs: ?number, breaches: object[]}}
|
|
736
|
+
*/
|
|
737
|
+
function evaluateShardBalance(samples, config) {
|
|
738
|
+
if (samples.length === 0) {
|
|
739
|
+
return {
|
|
740
|
+
status: 'insufficient-data', runs: 0, total: null, window: config.windowRuns, perShard: [], gapMs: null, breaches: [],
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// Newest layout = the `total` of the sample belonging to the run with the
|
|
745
|
+
// latest createdAt.
|
|
746
|
+
let newestSample = samples[0];
|
|
747
|
+
for (const s of samples) {
|
|
748
|
+
if (Date.parse(s.createdAt) > Date.parse(newestSample.createdAt)) newestSample = s;
|
|
749
|
+
}
|
|
750
|
+
const newestTotal = newestSample.total;
|
|
751
|
+
const sameLayout = samples.filter((s) => s.total === newestTotal);
|
|
752
|
+
|
|
753
|
+
// Group by runId, keep the newest config.windowRuns runs by createdAt.
|
|
754
|
+
const runsByIdMap = new Map();
|
|
755
|
+
for (const s of sameLayout) {
|
|
756
|
+
if (!runsByIdMap.has(s.runId)) runsByIdMap.set(s.runId, { createdAt: s.createdAt, samples: [] });
|
|
757
|
+
runsByIdMap.get(s.runId).samples.push(s);
|
|
758
|
+
}
|
|
759
|
+
const runEntries = [...runsByIdMap.entries()].sort(
|
|
760
|
+
(a, b) => Date.parse(b[1].createdAt) - Date.parse(a[1].createdAt),
|
|
761
|
+
);
|
|
762
|
+
const keptRunEntries = runEntries.slice(0, config.windowRuns);
|
|
763
|
+
const keptRunIds = new Set(keptRunEntries.map(([runId]) => runId));
|
|
764
|
+
const keptSamples = sameLayout.filter((s) => keptRunIds.has(s.runId));
|
|
765
|
+
|
|
766
|
+
const runsCount = keptRunIds.size;
|
|
767
|
+
|
|
768
|
+
if (newestTotal !== config.baselineShardTotal) {
|
|
769
|
+
const perShard = buildPerShard(keptSamples, newestTotal, config);
|
|
770
|
+
return {
|
|
771
|
+
status: 'baseline-mismatch', runs: runsCount, total: newestTotal, window: config.windowRuns, perShard, gapMs: null, breaches: [],
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
const perShard = buildPerShard(keptSamples, newestTotal, config);
|
|
776
|
+
const populated = perShard.filter((s) => s.n > 0);
|
|
777
|
+
|
|
778
|
+
if (runsCount < config.minRuns || populated.length < 2) {
|
|
779
|
+
return {
|
|
780
|
+
status: 'insufficient-data', runs: runsCount, total: newestTotal, window: config.windowRuns, perShard, gapMs: null, breaches: [],
|
|
781
|
+
};
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
let slowest = populated[0];
|
|
785
|
+
let fastest = populated[0];
|
|
786
|
+
for (const s of populated) {
|
|
787
|
+
if (s.medianMs > slowest.medianMs) slowest = s;
|
|
788
|
+
if (s.medianMs < fastest.medianMs) fastest = s;
|
|
789
|
+
}
|
|
790
|
+
const gapMs = slowest.medianMs - fastest.medianMs;
|
|
791
|
+
|
|
792
|
+
const breaches = [];
|
|
793
|
+
if (gapMs > config.maxMedianGapMs) {
|
|
794
|
+
breaches.push({
|
|
795
|
+
kind: 'gap', slowest: slowest.shard, fastest: fastest.shard, gapMs, limitMs: config.maxMedianGapMs,
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
for (const s of perShard) {
|
|
799
|
+
if (s.p90Ms != null && s.baselineP90Ms != null && s.p90Ms > s.baselineP90Ms) {
|
|
800
|
+
breaches.push({
|
|
801
|
+
kind: 'p90', shard: s.shard, p90Ms: s.p90Ms, baselineP90Ms: s.baselineP90Ms,
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
return {
|
|
807
|
+
status: breaches.length > 0 ? 'breach' : 'ok',
|
|
808
|
+
runs: runsCount,
|
|
809
|
+
total: newestTotal,
|
|
810
|
+
window: config.windowRuns,
|
|
811
|
+
perShard,
|
|
812
|
+
gapMs,
|
|
813
|
+
breaches,
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
function buildPerShard(samples, total, config) {
|
|
818
|
+
const byShard = new Map();
|
|
819
|
+
for (const s of samples) {
|
|
820
|
+
if (!byShard.has(s.shard)) byShard.set(s.shard, []);
|
|
821
|
+
byShard.get(s.shard).push(s.elapsedMs);
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
const perShard = [];
|
|
825
|
+
for (let index = 1; index <= total; index += 1) {
|
|
826
|
+
const shard = `${index}/${total}`;
|
|
827
|
+
const values = byShard.get(shard) || [];
|
|
828
|
+
if (values.length === 0) {
|
|
829
|
+
perShard.push({
|
|
830
|
+
shard, n: 0, medianMs: null, p90Ms: null, baselineP90Ms: config.baselineP90Ms[shard] ?? null,
|
|
831
|
+
});
|
|
832
|
+
continue;
|
|
833
|
+
}
|
|
834
|
+
perShard.push({
|
|
835
|
+
shard,
|
|
836
|
+
n: values.length,
|
|
837
|
+
medianMs: median(values),
|
|
838
|
+
p90Ms: percentile(values, 0.9),
|
|
839
|
+
baselineP90Ms: config.baselineP90Ms[shard] ?? null,
|
|
840
|
+
});
|
|
841
|
+
}
|
|
842
|
+
return perShard;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
function msToMinutes(ms) {
|
|
846
|
+
return (ms / 60000).toFixed(1);
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/**
|
|
850
|
+
* Pure markdown formatter for a shard-balance evaluation result.
|
|
851
|
+
* @param {ReturnType<typeof evaluateShardBalance>} result
|
|
852
|
+
* @returns {string}
|
|
853
|
+
*/
|
|
854
|
+
function formatShardBalanceSummary(result) {
|
|
855
|
+
const lines = [];
|
|
856
|
+
lines.push('### Windows conformance shard balance (#5071)');
|
|
857
|
+
lines.push('');
|
|
858
|
+
lines.push(`Status: **${result.status}** (${result.runs}/${result.window} runs evaluated)`);
|
|
859
|
+
lines.push('');
|
|
860
|
+
lines.push('| shard | runs | median (min) | p90 (min) | baseline p90 (min) |');
|
|
861
|
+
lines.push('|---|---|---|---|---|');
|
|
862
|
+
for (const s of result.perShard) {
|
|
863
|
+
lines.push(`| ${s.shard} | ${s.n} | ${s.medianMs == null ? '—' : msToMinutes(s.medianMs)} | ${s.p90Ms == null ? '—' : msToMinutes(s.p90Ms)} | ${s.baselineP90Ms == null ? '—' : msToMinutes(s.baselineP90Ms)} |`);
|
|
864
|
+
}
|
|
865
|
+
for (const b of result.breaches) {
|
|
866
|
+
if (b.kind === 'gap') {
|
|
867
|
+
lines.push(`- gap breach: shard ${b.slowest} vs ${b.fastest} — ${msToMinutes(b.gapMs)} min gap (limit ${msToMinutes(b.limitMs)} min)`);
|
|
868
|
+
} else if (b.kind === 'p90') {
|
|
869
|
+
lines.push(`- p90 breach: shard ${b.shard} — ${msToMinutes(b.p90Ms)} min (baseline ${msToMinutes(b.baselineP90Ms)} min)`);
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
return lines.join('\n');
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
/**
|
|
876
|
+
* Orchestration entry point for the #5101 shard-balance check — impure,
|
|
877
|
+
* invoked from actions/github-script as an independent workflow step.
|
|
878
|
+
*
|
|
879
|
+
* @param {{github: object, context: object, core: object, config?: object}} args
|
|
880
|
+
* @returns {Promise<object>}
|
|
881
|
+
*/
|
|
882
|
+
async function checkShardBalance({
|
|
883
|
+
github, context, core, config = SHARD_BALANCE,
|
|
884
|
+
}) {
|
|
885
|
+
try {
|
|
886
|
+
const { owner, repo } = context.repo;
|
|
887
|
+
|
|
888
|
+
const runLimit = config.windowRuns * 2;
|
|
889
|
+
// The Actions API returns runs newest-first, so stopping paging once
|
|
890
|
+
// `runLimit` runs are collected keeps the newest ones and bounds API
|
|
891
|
+
// cost regardless of how far back `config.since` reaches (Octokit
|
|
892
|
+
// paginate's documented `done()` early exit).
|
|
893
|
+
let fetched = 0;
|
|
894
|
+
const runsList = await github.paginate(github.rest.actions.listWorkflowRuns, {
|
|
895
|
+
owner,
|
|
896
|
+
repo,
|
|
897
|
+
workflow_id: config.workflowFile,
|
|
898
|
+
branch: config.branch,
|
|
899
|
+
event: config.event,
|
|
900
|
+
status: 'completed',
|
|
901
|
+
created: `>=${config.since}`,
|
|
902
|
+
per_page: Math.min(100, runLimit),
|
|
903
|
+
}, (response, done) => {
|
|
904
|
+
fetched += response.data.length;
|
|
905
|
+
if (fetched >= runLimit) done();
|
|
906
|
+
return response.data;
|
|
907
|
+
});
|
|
908
|
+
|
|
909
|
+
const sortedRuns = [...runsList].sort(
|
|
910
|
+
(a, b) => Date.parse(b.created_at) - Date.parse(a.created_at),
|
|
911
|
+
);
|
|
912
|
+
const candidateRuns = sortedRuns.slice(0, runLimit);
|
|
913
|
+
|
|
914
|
+
const runsWithJobs = [];
|
|
915
|
+
for (const run of candidateRuns) {
|
|
916
|
+
const jobs = await github.paginate(github.rest.actions.listJobsForWorkflowRun, {
|
|
917
|
+
owner,
|
|
918
|
+
repo,
|
|
919
|
+
run_id: run.id,
|
|
920
|
+
per_page: 100,
|
|
921
|
+
});
|
|
922
|
+
runsWithJobs.push({ run, jobs });
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
const samples = extractShardSamples(runsWithJobs, config);
|
|
926
|
+
const result = evaluateShardBalance(samples, config);
|
|
927
|
+
|
|
928
|
+
const summary = formatShardBalanceSummary(result);
|
|
929
|
+
if (core.summary) {
|
|
930
|
+
core.summary.addRaw(summary);
|
|
931
|
+
await core.summary.write();
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
let issue = { action: 'none' };
|
|
935
|
+
|
|
936
|
+
if (result.status === 'breach' || result.status === 'baseline-mismatch') {
|
|
937
|
+
const breachKinds = result.breaches.map((b) => b.kind).join(', ');
|
|
938
|
+
core.warning(`ci-timeout-report shard-balance: status=${result.status}${breachKinds ? ` breaches=${breachKinds}` : ''}`);
|
|
939
|
+
|
|
940
|
+
const body = `${summary}\n\nSee #5071 / #5101 and docs/how-to/read-ci-timeout-signals.md for background on this check.`;
|
|
941
|
+
|
|
942
|
+
const searchResult = await github.rest.search.issuesAndPullRequests({
|
|
943
|
+
q: `repo:${owner}/${repo} is:issue is:open in:title "${config.issueTitle}"`,
|
|
944
|
+
});
|
|
945
|
+
const existing = (searchResult.data.items || []).find(
|
|
946
|
+
(item) => item.title === config.issueTitle && !item.pull_request,
|
|
947
|
+
);
|
|
948
|
+
|
|
949
|
+
if (existing) {
|
|
950
|
+
await github.rest.issues.createComment({ owner, repo, issue_number: existing.number, body });
|
|
951
|
+
issue = { action: 'commented', number: existing.number };
|
|
952
|
+
} else {
|
|
953
|
+
const created = await github.rest.issues.create({ owner, repo, title: config.issueTitle, body });
|
|
954
|
+
issue = { action: 'created', number: created.data.number };
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
return { ...result, issue };
|
|
959
|
+
} catch (err) {
|
|
960
|
+
core.warning(`ci-timeout-report: shard-balance check failed: ${err.message}`);
|
|
961
|
+
return { status: 'error', error: err.message, issue: { action: 'none' } };
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
|
|
220
965
|
module.exports = {
|
|
221
966
|
HISTORY_PATH,
|
|
222
967
|
WORKFLOWS_DIR,
|
|
@@ -225,6 +970,27 @@ module.exports = {
|
|
|
225
970
|
parseJobRecord,
|
|
226
971
|
buildReportLines,
|
|
227
972
|
dedupeAgainstHistory,
|
|
973
|
+
historyRecordKey,
|
|
974
|
+
recordKey,
|
|
975
|
+
mergeHistoryTexts,
|
|
976
|
+
HISTORY_RECORD_LIMITS,
|
|
977
|
+
isValidHistoryRecord,
|
|
978
|
+
sanitizeHistoryText,
|
|
979
|
+
ROLLING_PR,
|
|
980
|
+
evaluateRollingPrApproval,
|
|
981
|
+
subtractHistoryText,
|
|
982
|
+
workflowFileFromRunPath,
|
|
983
|
+
matchesApiJob,
|
|
984
|
+
seedFromRollingPr,
|
|
228
985
|
formatHistoryLine,
|
|
229
986
|
main,
|
|
987
|
+
SHARD_BALANCE,
|
|
988
|
+
WINDOWS_SHARD_JOB_RE,
|
|
989
|
+
jobSpanMs,
|
|
990
|
+
median,
|
|
991
|
+
percentile,
|
|
992
|
+
extractShardSamples,
|
|
993
|
+
evaluateShardBalance,
|
|
994
|
+
formatShardBalanceSummary,
|
|
995
|
+
checkShardBalance,
|
|
230
996
|
};
|