pan-wizard 3.35.0 → 3.37.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/CHANGELOG.md +3645 -0
- package/README.md +46 -52
- package/agents/pan-conductor.md +2 -3
- package/agents/pan-counterfactual.md +1 -2
- package/agents/pan-debugger.md +14 -3
- package/agents/pan-design-checker.md +1 -2
- package/agents/pan-designer.md +2 -3
- package/agents/pan-document_code.md +1 -2
- package/agents/pan-executor.md +25 -0
- package/agents/pan-hardener.md +4 -3
- package/agents/pan-integration-checker.md +1 -2
- package/agents/pan-knowledge.md +1 -2
- package/agents/pan-meta-reviewer.md +3 -3
- package/agents/pan-optimizer.md +4 -4
- package/agents/pan-phase-researcher.md +80 -475
- package/agents/pan-plan-checker.md +40 -3
- package/agents/pan-planner.md +21 -7
- package/agents/pan-previewer.md +3 -2
- package/agents/pan-project-researcher.md +72 -493
- package/agents/pan-release.md +1 -1
- package/agents/pan-reviewer.md +14 -3
- package/agents/pan-roadmapper.md +12 -7
- package/agents/pan-verifier.md +9 -4
- package/bin/install-lib.cjs +134 -14
- package/bin/install.js +59 -12
- package/commands/pan/army.md +1 -1
- package/commands/pan/autodev.md +76 -0
- package/commands/pan/cost.md +21 -1
- package/commands/pan/exec-phase.md +3 -4
- package/commands/pan/focus-auto.md +14 -7
- package/commands/pan/focus-design.md +4 -5
- package/commands/pan/focus-docs.md +24 -0
- package/commands/pan/learn.md +13 -6
- package/commands/pan/milestone-audit.md +8 -1
- package/commands/pan/milestone-done.md +10 -5
- package/commands/pan/milestone-new.md +2 -2
- package/commands/pan/new-project.md +15 -12
- package/commands/pan/plan-phase.md +15 -9
- package/commands/pan/preview.md +1 -1
- package/commands/pan/quick.md +1 -1
- package/commands/pan/review-deep.md +11 -5
- package/commands/pan/settings.md +6 -0
- package/commands/pan/verify-phase.md +6 -2
- package/docs/AGENTS.md +917 -0
- package/docs/ARCHITECTURE.md +1054 -0
- package/docs/CLI-REFERENCE.md +4582 -0
- package/docs/COMPARISON.md +116 -0
- package/docs/DEVELOPMENT.md +490 -0
- package/docs/ECOSYSTEM-REVIEW-2026-06.md +107 -0
- package/docs/ECOSYSTEM-REVIEW-2026-08.md +349 -0
- package/docs/EXAMPLES.md +584 -0
- package/docs/FAQ.md +254 -0
- package/docs/FIELD-HARVEST-2026-07.md +88 -0
- package/docs/FIELD-REPORT-army-2026-06.md +121 -0
- package/docs/HOOKS.md +638 -0
- package/docs/IMPROVEMENT-TODO.md +246 -0
- package/docs/INTERNALS.md +485 -0
- package/docs/MIGRATION-v2-to-v3.md +258 -0
- package/docs/SECURITY-HARDENING.md +230 -0
- package/docs/SKILLS-ENHANCEMENT-PLAN-V2.md +122 -0
- package/docs/SKILLS-FULL-TEXT.md +9685 -0
- package/docs/SKILLS-REFERENCE.md +1037 -0
- package/docs/TROUBLESHOOTING.md +1084 -0
- package/docs/USER-GUIDE.md +1437 -0
- package/docs/archive/SKILLS-ENHANCEMENT-PLAN.md +230 -0
- package/docs/audits/AUDIT-2026-08-e2e-ROADMAP.md +117 -0
- package/docs/audits/AUDIT-2026-08-e2e-VERIFICATION.md +693 -0
- package/docs/audits/AUDIT-2026-08-e2e.md +1546 -0
- package/docs/audits/OUTSTANDING-2026-08.md +275 -0
- package/docs/audits/STABILITY-TEST-2026-08.md +640 -0
- package/docs/branding/README-brand-draft.md +119 -0
- package/docs/branding/handoff/IMPLEMENTATION.md +205 -0
- package/docs/branding/handoff/README-brand-draft.md +119 -0
- package/docs/branding/handoff/image-prompts.md +46 -0
- package/docs/branding/image-prompts.md +46 -0
- package/docs/branding/nano-banana-prompts.txt +60 -0
- package/docs/context-monitor.md +13 -0
- package/docs/decisions/ADR-0001-strategic-feature-prioritization.md +83 -0
- package/docs/decisions/ADR-0002-copilot-cli-runtime.md +71 -0
- package/docs/decisions/ADR-0003-smart-execution.md +84 -0
- package/docs/decisions/ADR-0004-copilot-cli-interaction.md +89 -0
- package/docs/decisions/ADR-0005-command-naming.md +80 -0
- package/docs/decisions/ADR-0006-focus-commands.md +101 -0
- package/docs/decisions/ADR-0007-ship-v1.md +73 -0
- package/docs/decisions/ADR-0008-map-codebase-mermaid-togaf.md +80 -0
- package/docs/decisions/ADR-0009-production-deployment-checklist.md +68 -0
- package/docs/decisions/ADR-0010-standards-integration.md +79 -0
- package/docs/decisions/ADR-0011-help-commands-rewrite.md +64 -0
- package/docs/decisions/ADR-0012-contributors-attribution-index.md +42 -0
- package/docs/decisions/ADR-0013-strategic-feature-additions.md +53 -0
- package/docs/decisions/ADR-0014-internal-cleanup-code-quality.md +53 -0
- package/docs/decisions/ADR-0015-focus-auto-runner.md +296 -0
- package/docs/decisions/ADR-0016-enhanced-doc-sync.md +54 -0
- package/docs/decisions/ADR-0017-enhanced-focus-design.md +69 -0
- package/docs/decisions/ADR-0018-user-reality-testing.md +84 -0
- package/docs/decisions/ADR-0019-end-to-end-user-acceptance-testing.md +113 -0
- package/docs/decisions/ADR-0020-ai-drift-prevention.md +55 -0
- package/docs/decisions/ADR-0021-codebase-mapper-v2.md +76 -0
- package/docs/decisions/ADR-0022-lifecycle-completeness.md +63 -0
- package/docs/decisions/ADR-0023-opus-4-7-adoption.md +89 -0
- package/docs/decisions/ADR-0024-spec-b-v2-completion.md +137 -0
- package/docs/decisions/ADR-0025-behavioral-guardrails-layer.md +129 -0
- package/docs/decisions/ADR-0026-self-improvement-loop.md +171 -0
- package/docs/decisions/ADR-0027-doc-code-link-graph.md +164 -0
- package/docs/decisions/ADR-0028-unified-agents-skills-target.md +46 -0
- package/docs/decisions/ADR-0029-experiment-evaluator.md +76 -0
- package/docs/decisions/ADR-0030-planning-concurrency.md +37 -0
- package/docs/decisions/ADR-0031-focus-auto-backlog-source.md +47 -0
- package/docs/decisions/ADR-0032-squad-model.md +64 -0
- package/docs/decisions/ADR-0033-army-campaign.md +40 -0
- package/docs/decisions/ADR-0034-scheduled-campaigns.md +57 -0
- package/docs/decisions/ADR-0035-army-hud-dashboard.md +40 -0
- package/docs/decisions/ADR-0036-agentic-memory-architecture.md +106 -0
- package/docs/decisions/ADR-0037-pin-security-agents-to-opus.md +51 -0
- package/docs/decisions/ADR-0038-skill-aligned-decomposition.md +41 -0
- package/docs/decisions/ADR-0039-worktree-as-project-variant.md +37 -0
- package/docs/decisions/ADR-0040-memory-injection-defense.md +55 -0
- package/docs/decisions/ADR-0041-mcp-dual-era-bridge.md +47 -0
- package/docs/decisions/ADR-0042-design-parity-both-flows.md +45 -0
- package/docs/decisions/ADR-0043-addressable-planning-roots.md +56 -0
- package/docs/decisions/ADR-0044-bound-the-cached-context.md +104 -0
- package/docs/decisions/ADR-0045-agent-plugins-bundle.md +76 -0
- package/docs/decisions/ADR-0046-subagent-frontmatter-decisions.md +39 -0
- package/docs/decisions/ADR-0047-pan-harness.md +76 -0
- package/docs/decisions/ADR-0048-one-core-per-project.md +43 -0
- package/docs/decisions/ADR-0049-machine-verdicts-and-findings-ledger.md +116 -0
- package/docs/specs/ai_drift_prevention_featureai.md +286 -0
- package/docs/specs/codebase_mapper_v2_featureai.md +930 -0
- package/docs/specs/command_naming_optimization_featureai.md +930 -0
- package/docs/specs/comp_analysis_markdown_v1.md +149 -0
- package/docs/specs/continuous_autonomous_execution_featureai.md +304 -0
- package/docs/specs/contributors_attribution_index_featureai.md +476 -0
- package/docs/specs/copilot_cli_interaction_featureai.md +868 -0
- package/docs/specs/copilot_cli_runtime_featureai.md +647 -0
- package/docs/specs/custom-skills-assessment.md +461 -0
- package/docs/specs/deployment_audit_findings_featureai.md +243 -0
- package/docs/specs/doc_code_link_graph_featureai.md +253 -0
- package/docs/specs/documentation_alignment_cleanup_featureai.md +69 -0
- package/docs/specs/e2e_visual_testing_featureai.md +615 -0
- package/docs/specs/end_to_end_user_acceptance_testing_featureai.md +810 -0
- package/docs/specs/evidence-loop-plan.md +76 -0
- package/docs/specs/evidence_loop_featureai.md +497 -0
- package/docs/specs/focus-auto-optimize-category.md +449 -0
- package/docs/specs/focus-auto-optimize-superplan.md +188 -0
- package/docs/specs/focus_auto_runner_featureai.md +1102 -0
- package/docs/specs/focus_design_v2_featureai.md +197 -0
- package/docs/specs/focus_sync_v2_featureai.md +202 -0
- package/docs/specs/git_integration_overhaul_featureai.md +326 -0
- package/docs/specs/glm_zai_provider_featureai.md +200 -0
- package/docs/specs/googlecli_adoption_featureai.md +661 -0
- package/docs/specs/help_commands_rewrite_featureai.md +860 -0
- package/docs/specs/industry_analysis_strategic_additions_featureai.md +231 -0
- package/docs/specs/industry_comparison_and_feature_gaps_featureai.md +674 -0
- package/docs/specs/internal_cleanup_code_quality_featureai.md +486 -0
- package/docs/specs/investigations/README.md +31 -0
- package/docs/specs/investigations/digests/2026-09-28.md +47 -0
- package/docs/specs/investigations/digests/2026-10-08.md +62 -0
- package/docs/specs/investigations/digests/2026-10-10.md +61 -0
- package/docs/specs/investigations/features/debugger-red-loop-first.md +55 -0
- package/docs/specs/investigations/features/harness-cross-host-behaviour.md +93 -0
- package/docs/specs/investigations/features/optimize-apply-regression-gate.md +66 -0
- package/docs/specs/investigations/features/reviewer-smell-baseline.md +57 -0
- package/docs/specs/investigations/features/trace-failure-capture.md +69 -0
- package/docs/specs/lifecycle_completeness_featureai.md +754 -0
- package/docs/specs/local-first-ci.md +42 -0
- package/docs/specs/map_codebase_mermaid_togaf_featureai.md +805 -0
- package/docs/specs/market-delta-2026-09-superplan.md +310 -0
- package/docs/specs/market-ideas-2026-09.md +131 -0
- package/docs/specs/market-ideas-2026-10.md +428 -0
- package/docs/specs/market-ideas-ledger.md +177 -0
- package/docs/specs/memory-optimization-2026-10.md +285 -0
- package/docs/specs/multi-track-planning-roots.md +143 -0
- package/docs/specs/multi_model_routing_featureai.md +687 -0
- package/docs/specs/multi_model_routing_superplan.md +197 -0
- package/docs/specs/opus_47_existing_enhancements_featureai.md +247 -0
- package/docs/specs/opus_47_extended_features_featureai.md +363 -0
- package/docs/specs/opus_47_extended_features_v2_featureai.md +397 -0
- package/docs/specs/pan_focus_commands_featureai.md +2336 -0
- package/docs/specs/pan_zcode_mcp_bridge_featureai.md +120 -0
- package/docs/specs/product-excellence-superplan.md +315 -0
- package/docs/specs/product_excellence_program_2026_q4_featureai.md +365 -0
- package/docs/specs/production_deployment_readiness_featureai.md +433 -0
- package/docs/specs/production_readiness_ship_v1_featureai.md +698 -0
- package/docs/specs/real_test_enforcement_featureai.md +213 -0
- package/docs/specs/reality-check-2026-09-superplan.md +362 -0
- package/docs/specs/right-sized-ceremony.md +74 -0
- package/docs/specs/roadmap_and_competition_2026_q4_featureai.md +390 -0
- package/docs/specs/self_improvement_loop_featureai.md +875 -0
- package/docs/specs/skill-aligned-decomposition.md +155 -0
- package/docs/specs/standards_integration_featureai.md +833 -0
- package/docs/specs/testing-system-redesign-2026-09.md +267 -0
- package/docs/specs/user_reality_testing_system_featureai.md +788 -0
- package/docs/specs/watch-triggers.json +22 -0
- package/docs/specs/workflow-integration-redesign.md +742 -0
- package/docs/specs/workflow_state_consistency_featureai.md +183 -0
- package/hooks/dist/pan-context-monitor.js +9 -2
- package/hooks/dist/pan-cost-logger.js +5 -0
- package/hooks/dist/pan-guard.js +28 -5
- package/hooks/dist/pan-stop-guard.js +9 -9
- package/hooks/dist/pan-trace-logger.js +5 -0
- package/package.json +10 -2
- package/pan-wizard-core/bin/lib/agents-md.cjs +57 -8
- package/pan-wizard-core/bin/lib/autodev-report.cjs +180 -0
- package/pan-wizard-core/bin/lib/autodev.cjs +642 -0
- package/pan-wizard-core/bin/lib/ci.cjs +143 -0
- package/pan-wizard-core/bin/lib/commands.cjs +1 -1
- package/pan-wizard-core/bin/lib/config.cjs +41 -5
- package/pan-wizard-core/bin/lib/constants.cjs +27 -1
- package/pan-wizard-core/bin/lib/core.cjs +31 -14
- package/pan-wizard-core/bin/lib/cost.cjs +160 -4
- package/pan-wizard-core/bin/lib/cross-family.cjs +164 -0
- package/pan-wizard-core/bin/lib/debug-cases.cjs +104 -0
- package/pan-wizard-core/bin/lib/doc-lint.cjs +5 -3
- package/pan-wizard-core/bin/lib/failure-class.cjs +81 -0
- package/pan-wizard-core/bin/lib/findings.cjs +148 -9
- package/pan-wizard-core/bin/lib/focus.cjs +1 -1
- package/pan-wizard-core/bin/lib/git.cjs +1 -1
- package/pan-wizard-core/bin/lib/hosts.cjs +88 -0
- package/pan-wizard-core/bin/lib/init.cjs +7 -2
- package/pan-wizard-core/bin/lib/knowledge.cjs +2 -0
- package/pan-wizard-core/bin/lib/merge.cjs +50 -0
- package/pan-wizard-core/bin/lib/mutate.cjs +268 -0
- package/pan-wizard-core/bin/lib/next-action.cjs +112 -0
- package/pan-wizard-core/bin/lib/optimize.cjs +2 -2
- package/pan-wizard-core/bin/lib/phase-gate.cjs +15 -9
- package/pan-wizard-core/bin/lib/phase-remove.cjs +91 -11
- package/pan-wizard-core/bin/lib/phase.cjs +85 -13
- package/pan-wizard-core/bin/lib/plan-lint.cjs +310 -0
- package/pan-wizard-core/bin/lib/preview.cjs +26 -4
- package/pan-wizard-core/bin/lib/requirements-lint.cjs +130 -0
- package/pan-wizard-core/bin/lib/research-lint.cjs +268 -0
- package/pan-wizard-core/bin/lib/review-deep.cjs +14 -22
- package/pan-wizard-core/bin/lib/review-vote.cjs +105 -0
- package/pan-wizard-core/bin/lib/roadmap.cjs +1 -0
- package/pan-wizard-core/bin/lib/runner.cjs +2 -16
- package/pan-wizard-core/bin/lib/sizing.cjs +356 -0
- package/pan-wizard-core/bin/lib/squads.cjs +1 -1
- package/pan-wizard-core/bin/lib/state-write.cjs +247 -0
- package/pan-wizard-core/bin/lib/test-gate.cjs +433 -0
- package/pan-wizard-core/bin/lib/uat.cjs +149 -0
- package/pan-wizard-core/bin/lib/usage.cjs +104 -0
- package/pan-wizard-core/bin/lib/verdict.cjs +26 -3
- package/pan-wizard-core/bin/lib/verify-plan-set.cjs +30 -1
- package/pan-wizard-core/bin/lib/verify-preflight.cjs +2 -2
- package/pan-wizard-core/bin/lib/verify-red.cjs +174 -0
- package/pan-wizard-core/bin/lib/verify-retro.cjs +3 -2
- package/pan-wizard-core/bin/lib/verify-scope.cjs +143 -2
- package/pan-wizard-core/bin/lib/verify.cjs +86 -31
- package/pan-wizard-core/bin/lib/worktree.cjs +70 -0
- package/pan-wizard-core/bin/pan-tools.cjs +257 -28
- package/pan-wizard-core/learnings/universal/autonomous-loop.md +2 -2
- package/pan-wizard-core/mcp/orchestrator.cjs +4 -1
- package/pan-wizard-core/references/continuation-format.md +3 -3
- package/pan-wizard-core/references/design-methodology.md +1 -1
- package/pan-wizard-core/references/guardrails.md +1 -1
- package/pan-wizard-core/references/handoff-decisions.md +1 -1
- package/pan-wizard-core/references/model-profiles.md +2 -3
- package/pan-wizard-core/references/questioning.md +24 -0
- package/pan-wizard-core/references/ui-brand.md +4 -6
- package/pan-wizard-core/references/verdict-contract.md +2 -1
- package/pan-wizard-core/templates/config.json +10 -2
- package/pan-wizard-core/templates/debug.md +1 -0
- package/pan-wizard-core/templates/design.md +5 -4
- package/pan-wizard-core/templates/discovery.md +2 -2
- package/pan-wizard-core/templates/phase-prompt.md +7 -0
- package/pan-wizard-core/templates/requirements.md +9 -0
- package/pan-wizard-core/templates/research.md +10 -0
- package/pan-wizard-core/templates/summary-complex.md +5 -1
- package/pan-wizard-core/templates/summary-minimal.md +4 -0
- package/pan-wizard-core/templates/summary-standard.md +5 -1
- package/pan-wizard-core/templates/summary.md +8 -1
- package/pan-wizard-core/workflows/add-phase.md +4 -6
- package/pan-wizard-core/workflows/assumptions.md +19 -1
- package/pan-wizard-core/workflows/diagnose-issues.md +2 -0
- package/pan-wizard-core/workflows/discuss-phase.md +4 -2
- package/pan-wizard-core/workflows/exec-lean.md +69 -0
- package/pan-wizard-core/workflows/exec-phase.md +79 -15
- package/pan-wizard-core/workflows/execute-plan.md +3 -1
- package/{commands/pan → pan-wizard-core/workflows}/focus-doc-audit.md +13 -29
- package/{commands/pan → pan-wizard-core/workflows}/focus-drift-walking.md +3 -18
- package/{commands/pan → pan-wizard-core/workflows}/focus-exec.md +8 -23
- package/{commands/pan → pan-wizard-core/workflows}/focus-plan.md +6 -20
- package/{commands/pan → pan-wizard-core/workflows}/focus-scan.md +3 -16
- package/{commands/pan → pan-wizard-core/workflows}/focus-sync.md +7 -21
- package/pan-wizard-core/workflows/health.md +6 -0
- package/pan-wizard-core/workflows/help.md +114 -77
- package/pan-wizard-core/workflows/insert-phase.md +4 -6
- package/pan-wizard-core/workflows/learn.md +3 -3
- package/pan-wizard-core/workflows/milestone-audit.md +2 -2
- package/pan-wizard-core/workflows/milestone-new.md +38 -95
- package/pan-wizard-core/workflows/new-project.md +96 -258
- package/pan-wizard-core/workflows/optimize.md +107 -5
- package/pan-wizard-core/workflows/phase-tests.md +10 -0
- package/pan-wizard-core/workflows/plan-phase.md +106 -45
- package/pan-wizard-core/workflows/profile.md +50 -0
- package/pan-wizard-core/workflows/progress.md +10 -0
- package/pan-wizard-core/workflows/quick.md +34 -43
- package/pan-wizard-core/workflows/remove-phase.md +11 -1
- package/pan-wizard-core/workflows/research-phase.md +118 -40
- package/pan-wizard-core/workflows/resume-project.md +18 -2
- package/pan-wizard-core/workflows/settings.md +33 -19
- package/pan-wizard-core/workflows/todo-check.md +6 -2
- package/pan-wizard-core/workflows/transition.md +22 -4
- package/pan-wizard-core/workflows/uat-walkthrough.md +90 -0
- package/pan-wizard-core/workflows/update.md +1 -1
- package/pan-wizard-core/workflows/verify-phase.md +39 -46
- package/scripts/build-agent-plugin.js +35 -15
- package/scripts/build-help.cjs +106 -0
- package/scripts/build-plugin.js +10 -1
- package/scripts/deprecate-old-versions.js +70 -3
- package/scripts/generate-skills-docs.py +1 -1
- package/scripts/git-hooks/pre-push +27 -0
- package/scripts/install-git-hooks.js +7 -5
- package/scripts/install-pre-push.cjs +38 -0
- package/scripts/land.cjs +151 -0
- package/scripts/local-ci.cjs +501 -0
- package/scripts/local-schedule.cjs +277 -0
- package/scripts/mutation-probe.cjs +7 -80
- package/scripts/prompt-lint.cjs +32 -0
- package/scripts/prompt-rules.cjs +156 -0
- package/scripts/run-tests.cjs +7 -1
- package/scripts/test-quality-lint.cjs +3 -3
- package/scripts/test-surface.cjs +175 -6
- package/agents/pan-research-synthesizer.md +0 -246
- package/commands/pan/audit-deployment.md +0 -386
- package/commands/pan/dashboard.md +0 -22
- package/commands/pan/discord.md +0 -19
- package/commands/pan/mcp-bridge.md +0 -143
- package/commands/pan/milestone-cleanup.md +0 -19
- package/commands/pan/milestone-gaps.md +0 -35
- package/commands/pan/optimize.md +0 -115
- package/commands/pan/profile.md +0 -76
- package/commands/pan/research-phase.md +0 -189
- package/pan-wizard-core/bin/lib/bridge.cjs +0 -269
- package/pan-wizard-core/templates/research-project/architecture.md +0 -204
- package/pan-wizard-core/templates/research-project/features.md +0 -147
- package/pan-wizard-core/templates/research-project/pitfalls.md +0 -200
- package/pan-wizard-core/templates/research-project/stack.md +0 -120
- package/pan-wizard-core/templates/research-project/summary.md +0 -170
package/scripts/land.cjs
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* land.cjs — land a branch on main without a pull request (`npm run land -- <branch>`;
|
|
4
|
+
* local-first plan, phase 3). GitHub stays the mirror: main is pushed there, never forced.
|
|
5
|
+
*
|
|
6
|
+
* Refuses unless:
|
|
7
|
+
* - a full `npm run ci:local` passed on the branch's current HEAD, from a tree with no
|
|
8
|
+
* uncommitted changes, with no gate skipped (scripts/local-ci.cjs records the verdict in
|
|
9
|
+
* the git directory, pan-ci-local/<sha>.json, shared by every worktree);
|
|
10
|
+
* - the branch contains origin/main, so the squash commit's tree is exactly the tree
|
|
11
|
+
* ci:local passed (merge or rebase main into the branch and run ci:local again if not);
|
|
12
|
+
* - the checkout that has main (or a temporary one) is clean and main can fast-forward
|
|
13
|
+
* to origin/main.
|
|
14
|
+
*
|
|
15
|
+
* Then: fetch origin, fast-forward main to origin/main, `git merge --squash <branch>`, commit
|
|
16
|
+
* with the branch's summary (the title is its first commit's subject, or --title; the body
|
|
17
|
+
* lists every commit), and `git push origin main`. A rejected push leaves the commit on the
|
|
18
|
+
* local main and says so; nothing is forced, and the branch itself is left alone.
|
|
19
|
+
*
|
|
20
|
+
* Usage:
|
|
21
|
+
* node scripts/land.cjs <branch> [--title "..."] [--dry-run] [--no-push] [--allow-skips]
|
|
22
|
+
* [--remote origin] [--main main]
|
|
23
|
+
* Exit 0 landed (or the dry run would land), 1 refused or failed, 2 usage.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
'use strict';
|
|
27
|
+
|
|
28
|
+
const { spawnSync } = require('child_process');
|
|
29
|
+
const fs = require('fs');
|
|
30
|
+
const os = require('os');
|
|
31
|
+
const path = require('path');
|
|
32
|
+
|
|
33
|
+
function git(cwd, args, opts = {}) {
|
|
34
|
+
const r = spawnSync('git', args, { cwd, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, ...opts });
|
|
35
|
+
return { ok: r.status === 0, out: (r.stdout || '').trim(), err: (r.stderr || '').trim() || (r.error ? r.error.message : '') };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function parseArgs(argv) {
|
|
39
|
+
const a = { branch: null, title: null, dryRun: false, push: true, allowSkips: false, remote: 'origin', main: 'main' };
|
|
40
|
+
for (let i = 0; i < argv.length; i++) {
|
|
41
|
+
const k = argv[i];
|
|
42
|
+
if (k === '--title') a.title = argv[++i];
|
|
43
|
+
else if (k === '--dry-run') a.dryRun = true;
|
|
44
|
+
else if (k === '--no-push') a.push = false;
|
|
45
|
+
else if (k === '--allow-skips') a.allowSkips = true;
|
|
46
|
+
else if (k === '--remote') a.remote = argv[++i];
|
|
47
|
+
else if (k === '--main') a.main = argv[++i];
|
|
48
|
+
else if (k.startsWith('--')) throw new Error(`unknown option: ${k}`);
|
|
49
|
+
else if (!a.branch) a.branch = k;
|
|
50
|
+
else throw new Error(`one branch at a time (got ${a.branch} and ${k})`);
|
|
51
|
+
}
|
|
52
|
+
if (!a.branch) throw new Error('usage: land.cjs <branch> [--title T] [--dry-run] [--no-push] [--allow-skips]');
|
|
53
|
+
return a;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Why ci:local's verdict does not clear `sha` for landing, or null when it does. Pure. */
|
|
57
|
+
function verdictProblem(verdict, sha, { allowSkips = false } = {}) {
|
|
58
|
+
if (!verdict) return `no ci:local verdict for ${sha.slice(0, 9)} — run \`npm run ci:local\` on the branch at that commit`;
|
|
59
|
+
if (verdict.headFull !== sha) return `the verdict is for ${String(verdict.headFull).slice(0, 9)}, not ${sha.slice(0, 9)}`;
|
|
60
|
+
if (verdict.options && (verdict.options.only || verdict.options.fast)) return 'the verdict is from a partial run (--only or --fast); run the full ci:local';
|
|
61
|
+
if (verdict.dirtyAtStart !== false) return 'ci:local ran with uncommitted changes, so it did not test this commit alone; commit or stash them and run it again';
|
|
62
|
+
if (verdict.status !== 'pass') return `ci:local failed on ${sha.slice(0, 9)}: ${(verdict.gates || []).filter(g => g.status === 'fail').map(g => g.id).join(', ') || 'see its report'}`;
|
|
63
|
+
const skipped = (verdict.gates || []).filter(g => g.status === 'skip').map(g => g.id);
|
|
64
|
+
if (skipped.length && !allowSkips) return `ci:local skipped ${skipped.join(', ')}; install what they need, or pass --allow-skips`;
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The squash commit's message: a title, and the branch's commits as the body. Pure. */
|
|
69
|
+
function commitMessage(branch, subjects, title) {
|
|
70
|
+
const head = title || subjects[0] || `Land ${branch}`;
|
|
71
|
+
const body = subjects.length > 1 || (title && subjects.length) ? `\n\nSquashed from ${branch}:\n${subjects.map(s => `- ${s}`).join('\n')}` : '';
|
|
72
|
+
return `${head}${body}\n`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// The worktree that has `main` checked out, if any.
|
|
76
|
+
function mainCheckout(cwd, main) {
|
|
77
|
+
const list = git(cwd, ['worktree', 'list', '--porcelain']).out;
|
|
78
|
+
for (const block of list.split(/\n\n+/)) {
|
|
79
|
+
const wt = (block.match(/^worktree (.+)$/m) || [])[1];
|
|
80
|
+
if (wt && new RegExp(`^branch refs/heads/${main.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'm').test(block)) return path.resolve(wt);
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function land(argv, cwd = process.cwd(), log = console.log) {
|
|
86
|
+
let o;
|
|
87
|
+
try { o = parseArgs(argv); } catch (e) { log(`land: ${e.message}`); return 2; }
|
|
88
|
+
const refuse = (why) => { log(`land: refused — ${why}`); return 1; };
|
|
89
|
+
|
|
90
|
+
const sha = git(cwd, ['rev-parse', '--verify', `refs/heads/${o.branch}^{commit}`]);
|
|
91
|
+
if (!sha.ok) return refuse(`no local branch ${o.branch}`);
|
|
92
|
+
if (o.branch === o.main) return refuse(`${o.branch} is the branch it would land on`);
|
|
93
|
+
|
|
94
|
+
const common = path.resolve(cwd, git(cwd, ['rev-parse', '--git-common-dir']).out);
|
|
95
|
+
let verdict = null;
|
|
96
|
+
try { verdict = JSON.parse(fs.readFileSync(path.join(common, 'pan-ci-local', `${sha.out}.json`), 'utf8')); } catch { /* none */ }
|
|
97
|
+
const problem = verdictProblem(verdict, sha.out, o);
|
|
98
|
+
if (problem) return refuse(problem);
|
|
99
|
+
|
|
100
|
+
const fetched = git(cwd, ['fetch', o.remote, o.main]);
|
|
101
|
+
if (!fetched.ok) return refuse(`git fetch ${o.remote} ${o.main}: ${fetched.err}`);
|
|
102
|
+
const upstream = `refs/remotes/${o.remote}/${o.main}`;
|
|
103
|
+
if (!git(cwd, ['merge-base', '--is-ancestor', upstream, sha.out]).ok) {
|
|
104
|
+
return refuse(`${o.branch} does not contain ${o.remote}/${o.main}; merge or rebase it onto ${o.remote}/${o.main}, then run ci:local again`);
|
|
105
|
+
}
|
|
106
|
+
const subjects = git(cwd, ['log', '--reverse', '--format=%s', `${upstream}..${sha.out}`]).out.split('\n').filter(Boolean);
|
|
107
|
+
if (!subjects.length) return refuse(`${o.branch} has nothing that ${o.remote}/${o.main} lacks`);
|
|
108
|
+
const message = commitMessage(o.branch, subjects, o.title);
|
|
109
|
+
|
|
110
|
+
let where = mainCheckout(cwd, o.main);
|
|
111
|
+
if (where && git(where, ['status', '--porcelain']).out) return refuse(`the checkout with ${o.main} (${where}) has uncommitted changes`);
|
|
112
|
+
if (o.dryRun) {
|
|
113
|
+
log(`land: would squash ${subjects.length} commit(s) of ${o.branch} (${sha.out.slice(0, 9)}) onto ${o.remote}/${o.main} in ${where || 'a temporary worktree'}${o.push ? ` and push ${o.main} to ${o.remote}` : ''}:\n\n${message}`);
|
|
114
|
+
return 0;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
let temp = null;
|
|
118
|
+
if (!where) {
|
|
119
|
+
temp = fs.mkdtempSync(path.join(os.tmpdir(), 'pan-land-'));
|
|
120
|
+
const hasMain = git(cwd, ['rev-parse', '--verify', `refs/heads/${o.main}`]).ok;
|
|
121
|
+
const add = git(cwd, hasMain ? ['worktree', 'add', temp, o.main] : ['worktree', 'add', '-b', o.main, temp, upstream]);
|
|
122
|
+
if (!add.ok) { fs.rmSync(temp, { recursive: true, force: true }); return refuse(`could not check out ${o.main}: ${add.err}`); }
|
|
123
|
+
where = temp;
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
const ff = git(where, ['merge', '--ff-only', upstream]);
|
|
127
|
+
if (!ff.ok) return refuse(`local ${o.main} cannot fast-forward to ${o.remote}/${o.main} (it has commits ${o.remote} lacks): ${ff.err}`);
|
|
128
|
+
const sq = git(where, ['merge', '--squash', sha.out]);
|
|
129
|
+
if (!sq.ok) { git(where, ['reset', '--merge']); return refuse(`the squash merge failed: ${sq.err}`); }
|
|
130
|
+
// The branch contains main, so the result is the branch's tree: anything else is a bug here.
|
|
131
|
+
const tree = git(where, ['write-tree']).out;
|
|
132
|
+
const want = git(cwd, ['rev-parse', `${sha.out}^{tree}`]).out;
|
|
133
|
+
if (tree !== want) { git(where, ['reset', '--hard', upstream]); return refuse(`the squashed tree ${tree.slice(0, 9)} is not the tree ci:local passed (${want.slice(0, 9)})`); }
|
|
134
|
+
const commit = git(where, ['commit', '--no-verify', '-F', '-'], { input: message });
|
|
135
|
+
if (!commit.ok) { git(where, ['reset', '--merge']); return refuse(`git commit: ${commit.err}`); }
|
|
136
|
+
const landed = git(where, ['rev-parse', 'HEAD']).out;
|
|
137
|
+
log(`land: ${o.branch} squashed onto ${o.main} as ${landed.slice(0, 9)}`);
|
|
138
|
+
if (!o.push) { log(`land: not pushed (--no-push); push with \`git push ${o.remote} ${o.main}\``); return 0; }
|
|
139
|
+
// Never forced: a push the remote rejects (it moved, or branch protection) stops here.
|
|
140
|
+
const push = git(where, ['push', o.remote, `refs/heads/${o.main}:refs/heads/${o.main}`]);
|
|
141
|
+
if (!push.ok) { log(`land: the push to ${o.remote} was rejected; ${landed.slice(0, 9)} is on the local ${o.main} only:\n${push.err}`); return 1; }
|
|
142
|
+
log(`land: pushed ${o.main} to ${o.remote}`);
|
|
143
|
+
return 0;
|
|
144
|
+
} finally {
|
|
145
|
+
if (temp) { git(cwd, ['worktree', 'remove', '--force', temp]); fs.rmSync(temp, { recursive: true, force: true }); }
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
module.exports = { parseArgs, verdictProblem, commitMessage, mainCheckout, land };
|
|
150
|
+
|
|
151
|
+
if (require.main === module) process.exit(land(process.argv.slice(2)));
|
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* local-ci.cjs — run the GitHub CI gates on this machine (`npm run ci:local`).
|
|
4
|
+
*
|
|
5
|
+
* Runs in parallel, each gate with its own log and temp directory (whole-suite runs are
|
|
6
|
+
* staged so they do not starve each other: containers first, then the host runs one at a time):
|
|
7
|
+
*
|
|
8
|
+
* host-node<N> the unit + scenario suite on the host, once per Node version fnm
|
|
9
|
+
* has installed (18, 20, 22); without fnm, once on the current Node
|
|
10
|
+
* and the other versions are reported skipped
|
|
11
|
+
* linux-node<N> the suite under Node 18, 20 and 22 in node:<N>-bookworm containers,
|
|
12
|
+
* the repo mounted read-only and copied in (needs Docker)
|
|
13
|
+
* harness the PAN Harness at tier 0 with --no-ledger
|
|
14
|
+
* gitleaks secret scan of the history with .gitleaks.toml
|
|
15
|
+
* audit npm audit --omit=dev --audit-level=high
|
|
16
|
+
* links pan-tools links validate
|
|
17
|
+
* surface scripts/test-surface.cjs --check (the suite checks it too; this
|
|
18
|
+
* names the failure)
|
|
19
|
+
* coverage scripts/coverage-gate.cjs
|
|
20
|
+
* codescan only with --codescan: the CodeQL CLI's security-extended suite when
|
|
21
|
+
* installed (on PATH, or %USERPROFILE%\.codeql-cli\codeql), else Semgrep in
|
|
22
|
+
* Docker; --codescan=codeql or =semgrep picks one
|
|
23
|
+
* clean-tree after every other gate: the working tree is as it was before the
|
|
24
|
+
* run (the local form of CI's `git diff --exit-code`, which tolerates
|
|
25
|
+
* uncommitted work you started with)
|
|
26
|
+
*
|
|
27
|
+
* A tool that is not installed is reported SKIP, never PASS. Any FAIL exits 1;
|
|
28
|
+
* --strict makes a SKIP fail too. The report goes to .ci-local/report.json; a full run
|
|
29
|
+
* (no --only, no --fast) also records its verdict for the commit it ran on in the git
|
|
30
|
+
* directory (pan-ci-local/<sha>.json, shared by every worktree), which scripts/land.cjs
|
|
31
|
+
* requires before it lands a branch.
|
|
32
|
+
*
|
|
33
|
+
* Usage:
|
|
34
|
+
* node scripts/local-ci.cjs every gate
|
|
35
|
+
* node scripts/local-ci.cjs --fast the pre-push subset: host suite on the
|
|
36
|
+
* current Node, harness, gitleaks
|
|
37
|
+
* node scripts/local-ci.cjs --only host,gitleaks gates whose id starts with a listed name
|
|
38
|
+
* node scripts/local-ci.cjs --codescan add code scanning; Semgrep scans the files
|
|
39
|
+
* changed against main, or all with
|
|
40
|
+
* --codescan-all; CodeQL always scans the tree
|
|
41
|
+
* node scripts/local-ci.cjs --out <dir> report, logs and lock there, not .ci-local/
|
|
42
|
+
* node scripts/local-ci.cjs --no-docker skip the container gates
|
|
43
|
+
* node scripts/local-ci.cjs --jobs 4 at most 4 light gates at once (default 6)
|
|
44
|
+
* node scripts/local-ci.cjs --root <dir> run against another checkout
|
|
45
|
+
* node scripts/local-ci.cjs --strict a skipped gate fails the run
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
'use strict';
|
|
49
|
+
|
|
50
|
+
const { spawn, spawnSync } = require('child_process');
|
|
51
|
+
const fs = require('fs');
|
|
52
|
+
const os = require('os');
|
|
53
|
+
const path = require('path');
|
|
54
|
+
const crypto = require('crypto');
|
|
55
|
+
|
|
56
|
+
const NODE_VERSIONS = ['18', '20', '22'];
|
|
57
|
+
// Pinned like the CI actions are: a moving tag would change the scan under you.
|
|
58
|
+
const SEMGREP_IMAGE = process.env.PAN_CI_SEMGREP_IMAGE || 'semgrep/semgrep:1.90.0';
|
|
59
|
+
const CODE_EXT = /\.(c?js|mjs)$/;
|
|
60
|
+
const GATE_TIMEOUT_MS = 30 * 60 * 1000;
|
|
61
|
+
const FAST_IDS = ['host-current', 'harness', 'gitleaks'];
|
|
62
|
+
// Measured on a 40-core Windows machine: Node's default (a file per core but one) made the
|
|
63
|
+
// Node 22 suite's wall-clock checks miss their bounds even with nothing else running.
|
|
64
|
+
// PAN_TEST_CONCURRENCY overrides it.
|
|
65
|
+
const HOST_TEST_CONCURRENCY = Math.max(2, Math.min(16, Math.floor(os.cpus().length / 2)));
|
|
66
|
+
|
|
67
|
+
function parseArgs(argv) {
|
|
68
|
+
const a = { fast: false, only: null, codescan: null, codescanAll: false, docker: true, jobs: 6, root: path.resolve(__dirname, '..'), out: null, strict: false, json: false, help: false };
|
|
69
|
+
for (let i = 0; i < argv.length; i++) {
|
|
70
|
+
const k = argv[i];
|
|
71
|
+
if (k === '--fast') a.fast = true;
|
|
72
|
+
else if (k === '--only') a.only = String(argv[++i] || '').split(',').map(s => s.trim()).filter(Boolean);
|
|
73
|
+
else if (k === '--codescan') a.codescan = 'auto';
|
|
74
|
+
else if (k.startsWith('--codescan=')) a.codescan = k.slice('--codescan='.length);
|
|
75
|
+
else if (k === '--codescan-all') { a.codescanAll = true; a.codescan = a.codescan || 'auto'; }
|
|
76
|
+
else if (k === '--no-docker') a.docker = false;
|
|
77
|
+
else if (k === '--jobs') a.jobs = Math.max(1, Number(argv[++i]) || 1);
|
|
78
|
+
else if (k === '--root') a.root = path.resolve(argv[++i] || '.');
|
|
79
|
+
else if (k === '--out') a.out = path.resolve(argv[++i] || '.ci-local');
|
|
80
|
+
else if (k === '--strict') a.strict = true;
|
|
81
|
+
else if (k === '--json') a.json = true;
|
|
82
|
+
else if (k === '--help' || k === '-h') a.help = true;
|
|
83
|
+
else throw new Error(`unknown option: ${k}`);
|
|
84
|
+
}
|
|
85
|
+
if (a.codescan && !['auto', 'semgrep', 'codeql'].includes(a.codescan)) throw new Error(`--codescan takes semgrep or codeql, got ${a.codescan}`);
|
|
86
|
+
return a;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// PATH lookup without a shell, honouring PATHEXT on Windows. Returns the full path or null.
|
|
90
|
+
function which(cmd, env = process.env) {
|
|
91
|
+
const dirs = String(env.PATH || env.Path || '').split(path.delimiter).filter(Boolean);
|
|
92
|
+
const exts = process.platform === 'win32' ? ['', ...String(env.PATHEXT || '.EXE;.CMD;.BAT').toLowerCase().split(';')] : [''];
|
|
93
|
+
for (const d of dirs) {
|
|
94
|
+
for (const e of exts) {
|
|
95
|
+
const p = path.join(d, cmd + e);
|
|
96
|
+
try { if (fs.statSync(p).isFile()) return p; } catch { /* next */ }
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// The CodeQL CLI: on PATH, else where docs/DEVELOPMENT.md unpacks the codeql-bundle,
|
|
103
|
+
// %USERPROFILE%\.codeql-cli\codeql (not under AppData, which a packaged app such as the
|
|
104
|
+
// Claude desktop app sees through a private, virtualised copy).
|
|
105
|
+
function findCodeql(env = process.env) {
|
|
106
|
+
const onPath = which('codeql', env);
|
|
107
|
+
if (onPath) return onPath;
|
|
108
|
+
const home = env.USERPROFILE || env.HOME;
|
|
109
|
+
const base = home ? path.join(home, '.codeql-cli', 'codeql') : null;
|
|
110
|
+
for (const f of base ? ['codeql.exe', 'codeql'] : []) {
|
|
111
|
+
if (fs.existsSync(path.join(base, f))) return path.join(base, f);
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// What fails a CodeQL run, as GitHub's code-scanning check does by default: a result at
|
|
117
|
+
// error level, or one whose rule has a security severity of high or critical (7.0+), unless
|
|
118
|
+
// it was accepted on review (`accepted`: [{rule, path, reason}], from .codeql-accepted.json —
|
|
119
|
+
// the alerts dismissed on GitHub, matched by rule and file since lines move).
|
|
120
|
+
function judgeSarif(doc, accepted = []) {
|
|
121
|
+
const severity = new Map();
|
|
122
|
+
const results = [];
|
|
123
|
+
for (const run of doc.runs || []) {
|
|
124
|
+
const rules = [...((run.tool && run.tool.driver && run.tool.driver.rules) || []), ...((run.tool && run.tool.extensions) || []).flatMap(e => e.rules || [])];
|
|
125
|
+
for (const r of rules) severity.set(r.id, Number((r.properties || {})['security-severity']) || 0);
|
|
126
|
+
results.push(...(run.results || []));
|
|
127
|
+
}
|
|
128
|
+
const where = (r) => (((r.locations || [])[0] || {}).physicalLocation || {});
|
|
129
|
+
const file = (r) => String((where(r).artifactLocation || {}).uri || '').replace(/\\/g, '/');
|
|
130
|
+
const isAccepted = (r) => accepted.some(a => a.rule === r.ruleId && a.path === file(r));
|
|
131
|
+
const serious = results.filter(r => r.level === 'error' || (severity.get(r.ruleId) || 0) >= 7);
|
|
132
|
+
const blocking = serious.filter(r => !isAccepted(r));
|
|
133
|
+
const stale = accepted.filter(a => !results.some(r => a.rule === r.ruleId && a.path === file(r)));
|
|
134
|
+
const shown = blocking.slice(0, 3).map(r => `${r.ruleId} ${file(r)}:${(where(r).region || {}).startLine || '?'}`);
|
|
135
|
+
return {
|
|
136
|
+
status: blocking.length ? 'fail' : 'pass',
|
|
137
|
+
detail: `${results.length} result(s), ${blocking.length} blocking (error level or security severity high+), ${serious.length - blocking.length} accepted${stale.length ? `, ${stale.length} stale acceptance(s)` : ''}${shown.length ? `: ${shown.join('; ')}` : ''}`,
|
|
138
|
+
blocking: blocking.map(r => ({ rule: r.ruleId, path: file(r), line: (where(r).region || {}).startLine })),
|
|
139
|
+
stale,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function readAccepted(root) {
|
|
144
|
+
try {
|
|
145
|
+
return (JSON.parse(fs.readFileSync(path.join(root, '.codeql-accepted.json'), 'utf8')).accepted || []).filter(a => a.rule && a.path && a.reason);
|
|
146
|
+
} catch { return []; }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function quick(cmd, args, opts = {}) {
|
|
150
|
+
const r = spawnSync(cmd, args, { encoding: 'utf8', timeout: 60000, windowsHide: true, ...opts });
|
|
151
|
+
return { status: r.error ? null : r.status, stdout: r.stdout || '', stderr: r.stderr || '' };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Majors fnm has installed, e.g. ['18', '22']. `fnm list` prints "* v18.20.4 default".
|
|
155
|
+
function fnmMajors(fnm, env) {
|
|
156
|
+
const r = quick(fnm, ['list'], { env });
|
|
157
|
+
if (r.status !== 0) return [];
|
|
158
|
+
return [...new Set([...r.stdout.matchAll(/v(\d+)\.\d+\.\d+/g)].map(m => m[1]))];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function dockerUp(docker, env) {
|
|
162
|
+
return !!docker && quick(docker, ['info', '--format', '{{.ServerVersion}}'], { env }).status === 0;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Fingerprint of the working tree: tracked changes plus untracked, non-ignored files.
|
|
166
|
+
function treeState(root, env) {
|
|
167
|
+
const st = quick('git', ['status', '--porcelain=v1', '--untracked-files=all'], { cwd: root, env, maxBuffer: 64 * 1024 * 1024 });
|
|
168
|
+
if (st.status !== 0) return null;
|
|
169
|
+
const diff = quick('git', ['diff', 'HEAD', '--binary'], { cwd: root, env, maxBuffer: 256 * 1024 * 1024 });
|
|
170
|
+
return { lines: st.stdout.split('\n').filter(Boolean), hash: crypto.createHash('sha256').update(st.stdout + '\0' + diff.stdout).digest('hex') };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function changedCodeFiles(root, env) {
|
|
174
|
+
const base = quick('git', ['merge-base', 'HEAD', 'main'], { cwd: root, env }).stdout.trim();
|
|
175
|
+
const tracked = base ? quick('git', ['diff', '--name-only', '--diff-filter=ACMR', base], { cwd: root, env }).stdout : '';
|
|
176
|
+
const untracked = quick('git', ['ls-files', '--others', '--exclude-standard'], { cwd: root, env }).stdout;
|
|
177
|
+
return [...new Set((tracked + '\n' + untracked).split('\n').map(s => s.trim()).filter(f => CODE_EXT.test(f) && fs.existsSync(path.join(root, f))))];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Every gate is { id, title, cmd, args, opts, skip?, interpret? }. A gate with `skip` set is
|
|
181
|
+
// reported SKIP with that reason and never runs. `interpret(result)` may turn an exit-0 run
|
|
182
|
+
// into a skip (coverage on old Node) or add a detail line.
|
|
183
|
+
function planGates(o, env = process.env) {
|
|
184
|
+
const root = o.root;
|
|
185
|
+
const node = process.execPath;
|
|
186
|
+
const cur = process.versions.node.split('.')[0];
|
|
187
|
+
const suite = ['scripts/run-tests.cjs', 'tests', 'tests/scenarios'];
|
|
188
|
+
const gates = [];
|
|
189
|
+
const want = (id) => {
|
|
190
|
+
if (o.fast) return FAST_IDS.some(f => id === f || (f === 'host-current' && id === `host-node${cur}`));
|
|
191
|
+
if (!o.only) return true;
|
|
192
|
+
return o.only.some(p => id.startsWith(p));
|
|
193
|
+
};
|
|
194
|
+
const shell = process.platform === 'win32';
|
|
195
|
+
|
|
196
|
+
// Host suite, per Node version.
|
|
197
|
+
const fnm = which('fnm', env);
|
|
198
|
+
const majors = fnm ? fnmMajors(fnm, env) : [];
|
|
199
|
+
if (o.fast || !fnm) {
|
|
200
|
+
gates.push({ id: `host-node${cur}`, title: `${process.platform} suite · node ${process.versions.node} (current${fnm ? '' : '; fnm not installed'})`, cmd: node, args: suite });
|
|
201
|
+
if (!o.fast) for (const v of NODE_VERSIONS) {
|
|
202
|
+
if (v !== cur) gates.push({ id: `host-node${v}`, title: `${process.platform} suite · node ${v}`, skip: 'fnm not installed — winget install Schniz.fnm, then fnm install ' + v });
|
|
203
|
+
}
|
|
204
|
+
} else {
|
|
205
|
+
for (const v of NODE_VERSIONS) {
|
|
206
|
+
gates.push(majors.includes(v)
|
|
207
|
+
? { id: `host-node${v}`, title: `${process.platform} suite · node ${v} (fnm)`, cmd: fnm, args: ['exec', `--using=${v}`, 'node', ...suite] }
|
|
208
|
+
: { id: `host-node${v}`, title: `${process.platform} suite · node ${v}`, skip: `fnm has no Node ${v} — fnm install ${v}` });
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Linux suite in containers.
|
|
213
|
+
const docker = which('docker', env);
|
|
214
|
+
const haveDocker = o.docker && !o.fast && NODE_VERSIONS.some(v => want(`linux-node${v}`)) && dockerUp(docker, env);
|
|
215
|
+
for (const v of NODE_VERSIONS) {
|
|
216
|
+
const id = `linux-node${v}`;
|
|
217
|
+
if (!o.docker) { gates.push({ id, title: `linux suite · node ${v}`, skip: '--no-docker' }); continue; }
|
|
218
|
+
if (!haveDocker) { gates.push({ id, title: `linux suite · node ${v}`, skip: docker ? 'Docker is not running' : 'Docker not installed' }); continue; }
|
|
219
|
+
// Copy without node_modules and .git (a worktree's .git is a Windows path), give the copy
|
|
220
|
+
// its own one-commit repo so git-reading tests see a checkout, then do what ci.yml does.
|
|
221
|
+
// As the image's unprivileged `node` user, like a CI runner: as root, the tests that make
|
|
222
|
+
// a file read-only to force a failed write see the write succeed.
|
|
223
|
+
const script = [
|
|
224
|
+
'set -e',
|
|
225
|
+
'mkdir -p ~/work',
|
|
226
|
+
'tar -C /src --exclude=./node_modules --exclude=./.git --exclude=./.ci-local --exclude=./hooks/dist -cf - . | tar -C ~/work -xf -',
|
|
227
|
+
'cd ~/work',
|
|
228
|
+
'git init -q && git add -A && git -c user.email=ci@local -c user.name=local-ci commit -qm snapshot',
|
|
229
|
+
'npm ci --no-audit --no-fund',
|
|
230
|
+
'npm run build:hooks',
|
|
231
|
+
'npm run test:all',
|
|
232
|
+
].join(' && ').replace('set -e && ', 'set -e; ');
|
|
233
|
+
gates.push({
|
|
234
|
+
id, title: `linux suite · node ${v} (node:${v}-bookworm)`, cmd: docker,
|
|
235
|
+
args: ['run', '--rm', '-u', 'node', '-e', 'HOME=/home/node', '-v', `${root}:/src:ro`, `node:${v}-bookworm`, 'bash', '-c', script],
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// The double-backed scenarios drive the real Claude Code CLI against the harness's model
|
|
240
|
+
// double (no key, no spend). --require-run makes a missing CLI a failure, not a skip, as
|
|
241
|
+
// the GitHub harness job did before this gate replaced it.
|
|
242
|
+
gates.push({
|
|
243
|
+
id: 'harness', title: 'PAN Harness · tier 0', cmd: node,
|
|
244
|
+
args: ['harness/src/run.cjs', '--tier', '0', '--no-ledger', '--require-run', 'double-executor-body-claude', '--require-run', 'cassette-drift-claude', '--require-run', 'discuss-phase-scripted-user'],
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
const gitleaks = which('gitleaks', env);
|
|
248
|
+
const leakArgs = ['detect', '--no-banner', '--redact', '--verbose', '--source', '.'];
|
|
249
|
+
if (fs.existsSync(path.join(root, '.gitleaks.toml'))) leakArgs.push('--config', '.gitleaks.toml');
|
|
250
|
+
gates.push(gitleaks
|
|
251
|
+
? { id: 'gitleaks', title: 'gitleaks · history', cmd: gitleaks, args: leakArgs }
|
|
252
|
+
: { id: 'gitleaks', title: 'gitleaks · history', skip: 'gitleaks not installed — winget install gitleaks.gitleaks' });
|
|
253
|
+
|
|
254
|
+
gates.push({ id: 'audit', title: 'npm audit · production, high+', cmd: shell ? 'npm audit --omit=dev --audit-level=high' : 'npm', args: shell ? [] : ['audit', '--omit=dev', '--audit-level=high'], opts: { shell } });
|
|
255
|
+
gates.push({ id: 'links', title: 'links validate', cmd: node, args: ['pan-wizard-core/bin/pan-tools.cjs', 'links', 'validate', '--raw'] });
|
|
256
|
+
gates.push({ id: 'surface', title: 'surface registry', cmd: node, args: ['scripts/test-surface.cjs', '--check'] });
|
|
257
|
+
gates.push({
|
|
258
|
+
id: 'coverage', title: 'coverage gate', cmd: node, args: ['scripts/coverage-gate.cjs'],
|
|
259
|
+
interpret: (r) => /coverage gate — skipped/.test(r.output) ? { status: 'skip', detail: 'needs Node 22+' } : null,
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
const codeql = o.codescan ? findCodeql(env) : null;
|
|
263
|
+
const tool = o.codescan === 'auto' ? (codeql ? 'codeql' : 'semgrep') : o.codescan;
|
|
264
|
+
if (tool === 'semgrep') {
|
|
265
|
+
const files = o.codescanAll ? [] : changedCodeFiles(root, env);
|
|
266
|
+
const base = { id: 'codescan', title: `semgrep · ${o.codescanAll ? 'whole repo' : 'changed files'}` };
|
|
267
|
+
if (!dockerUp(docker, env)) gates.push({ ...base, skip: docker ? 'Docker is not running' : 'Docker not installed' });
|
|
268
|
+
else if (!o.codescanAll && files.length === 0) gates.push({ ...base, skip: 'no changed JS files against main' });
|
|
269
|
+
else gates.push({
|
|
270
|
+
...base, cmd: docker,
|
|
271
|
+
args: ['run', '--rm', '-v', `${root}:/src:ro`, '-w', '/src', '-e', 'HOME=/tmp', SEMGREP_IMAGE,
|
|
272
|
+
'semgrep', 'scan', '--config', 'p/javascript', '--config', 'p/nodejs', '--error', '--metrics=off', '--disable-version-check',
|
|
273
|
+
...(o.codescanAll ? ['.'] : files.map(f => f.replace(/\\/g, '/')))],
|
|
274
|
+
});
|
|
275
|
+
} else if (tool === 'codeql') {
|
|
276
|
+
const base = { id: 'codescan', title: 'CodeQL · security-extended · whole tree' };
|
|
277
|
+
if (!codeql) gates.push({ ...base, skip: 'codeql CLI not installed — the codeql-bundle from github.com/github/codeql-action/releases, unpacked to %USERPROFILE%\\.codeql-cli' });
|
|
278
|
+
else {
|
|
279
|
+
const work = fs.mkdtempSync(path.join(os.tmpdir(), 'pan-local-ci-codeql-'));
|
|
280
|
+
const db = path.join(work, 'db');
|
|
281
|
+
const config = path.join(work, 'config.yml');
|
|
282
|
+
// Build output and this script's own output are copies of, or reports on, the source.
|
|
283
|
+
fs.writeFileSync(config, 'paths-ignore:\n - node_modules\n - dist\n - hooks/dist\n - .ci-local\n');
|
|
284
|
+
const sarif = path.join(o.out || path.join(root, '.ci-local'), 'codeql.sarif');
|
|
285
|
+
gates.push({
|
|
286
|
+
...base, cmd: codeql,
|
|
287
|
+
args: ['database', 'create', db, '--language=javascript', `--source-root=${root}`, `--codescanning-config=${config}`, '--overwrite'],
|
|
288
|
+
then: { cmd: codeql, args: ['database', 'analyze', db, 'codeql/javascript-queries:codeql-suites/javascript-security-extended.qls', '--format=sarif-latest', `--output=${sarif}`, '--download'] },
|
|
289
|
+
interpret: () => {
|
|
290
|
+
let doc;
|
|
291
|
+
try { doc = JSON.parse(fs.readFileSync(sarif, 'utf8')); } catch { return { status: 'fail', detail: 'no SARIF written' }; }
|
|
292
|
+
try { fs.rmSync(work, { recursive: true, force: true }); } catch { /* the database is large but disposable */ }
|
|
293
|
+
const v = judgeSarif(doc, readAccepted(root));
|
|
294
|
+
return { status: v.status, detail: `${v.detail} — ${path.relative(root, sarif)}` };
|
|
295
|
+
},
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return gates.filter(g => want(g.id));
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function fmtMs(ms) {
|
|
304
|
+
const s = Math.round(ms / 1000);
|
|
305
|
+
return s >= 60 ? `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s` : `${s}s`;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function runOne(cmd, args, opts, logStream) {
|
|
309
|
+
return new Promise((resolve) => {
|
|
310
|
+
let child;
|
|
311
|
+
try {
|
|
312
|
+
child = spawn(cmd, args, { ...opts, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
313
|
+
} catch (e) {
|
|
314
|
+
logStream.write(`spawn failed: ${e.message}\n`);
|
|
315
|
+
return resolve({ code: null, error: e.message });
|
|
316
|
+
}
|
|
317
|
+
let tail = '';
|
|
318
|
+
const keep = (d) => { logStream.write(d); tail = (tail + d.toString()).slice(-16000); };
|
|
319
|
+
child.stdout.on('data', keep);
|
|
320
|
+
child.stderr.on('data', keep);
|
|
321
|
+
const timer = setTimeout(() => { keep(`\n[local-ci] timed out after ${fmtMs(GATE_TIMEOUT_MS)}\n`); child.kill(); }, GATE_TIMEOUT_MS);
|
|
322
|
+
child.on('error', (e) => { clearTimeout(timer); keep(`\n[local-ci] ${e.message}\n`); resolve({ code: null, error: e.message, tail }); });
|
|
323
|
+
child.on('close', (code) => { clearTimeout(timer); resolve({ code, tail }); });
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Last line that says something, for the one-line detail of a failed gate.
|
|
328
|
+
function lastSignal(text) {
|
|
329
|
+
const lines = String(text).split(/\r?\n/).map(l => l.trim()).filter(Boolean);
|
|
330
|
+
const hit = [...lines].reverse().find(l => /(^ℹ fail|not ok|error|fail|leaks found|stale|drift|vulnerabilit)/i.test(l));
|
|
331
|
+
return (hit || lines[lines.length - 1] || '').slice(0, 160);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async function runGate(g, o, env, logDir) {
|
|
335
|
+
const started = Date.now();
|
|
336
|
+
if (g.skip) return { id: g.id, title: g.title, status: 'skip', detail: g.skip, durationMs: 0 };
|
|
337
|
+
const log = path.join(logDir, `${g.id}.log`);
|
|
338
|
+
const stream = fs.createWriteStream(log);
|
|
339
|
+
// Its own temp directory: tests that use a fixed name under os.tmpdir() must not meet
|
|
340
|
+
// another gate's run of the same test.
|
|
341
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), `pan-lci-${g.id}-`));
|
|
342
|
+
// The host suite runs at most HOST_TEST_CONCURRENCY files at once (scripts/run-tests.cjs).
|
|
343
|
+
const cap = /^host-/.test(g.id) ? { PAN_TEST_CONCURRENCY: env.PAN_TEST_CONCURRENCY || String(HOST_TEST_CONCURRENCY) } : {};
|
|
344
|
+
const opts = { cwd: o.root, env: { ...env, ...cap, TEMP: tmp, TMP: tmp, TMPDIR: tmp }, ...(g.opts || {}) };
|
|
345
|
+
stream.write(`$ ${[g.cmd, ...g.args].join(' ')}\n\n`);
|
|
346
|
+
let r = await runOne(g.cmd, g.args, opts, stream);
|
|
347
|
+
if (r.code === 0 && g.then) {
|
|
348
|
+
stream.write(`\n$ ${[g.then.cmd, ...g.then.args].join(' ')}\n\n`);
|
|
349
|
+
r = await runOne(g.then.cmd, g.then.args, opts, stream);
|
|
350
|
+
}
|
|
351
|
+
await new Promise(res => stream.end(res));
|
|
352
|
+
// Kept on failure: a gate's own logs (the coverage gate's spec log) may live there.
|
|
353
|
+
if (r.code === 0) try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* best effort: a child may still hold a file */ }
|
|
354
|
+
let output = r.tail || '';
|
|
355
|
+
try { output = fs.readFileSync(log, 'utf8'); } catch { /* the tail is enough for a verdict */ }
|
|
356
|
+
let status = r.code === 0 ? 'pass' : 'fail';
|
|
357
|
+
let detail = r.code === 0 ? '' : (r.error ? r.error : `exit ${r.code} — ${lastSignal(r.tail || output)}`);
|
|
358
|
+
if (g.interpret && (r.code === 0 || g.then)) {
|
|
359
|
+
const v = g.interpret({ output, code: r.code });
|
|
360
|
+
if (v) { status = v.status; detail = v.detail; }
|
|
361
|
+
}
|
|
362
|
+
return { id: g.id, title: g.title, status, detail, durationMs: Date.now() - started, command: [g.cmd, ...g.args].join(' '), log: path.relative(o.root, log).replace(/\\/g, '/') };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
async function pool(items, n, fn) {
|
|
366
|
+
const out = new Array(items.length);
|
|
367
|
+
let next = 0;
|
|
368
|
+
await Promise.all(Array.from({ length: Math.min(n, items.length) }, async () => {
|
|
369
|
+
while (next < items.length) { const i = next++; out[i] = await fn(items[i]); }
|
|
370
|
+
}));
|
|
371
|
+
return out;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function summary(report, out = '.ci-local') {
|
|
375
|
+
const mark = { pass: 'PASS', fail: 'FAIL', skip: 'SKIP' };
|
|
376
|
+
const w = Math.max(...report.gates.map(g => g.id.length), 8);
|
|
377
|
+
const lines = [`local CI — ${report.head || 'no git'} · node ${report.node} · ${process.platform}`, ''];
|
|
378
|
+
for (const g of report.gates) {
|
|
379
|
+
lines.push(` ${mark[g.status]} ${g.id.padEnd(w)} ${(g.status === 'skip' ? '' : fmtMs(g.durationMs)).padStart(6)} ${g.detail || g.title}`);
|
|
380
|
+
}
|
|
381
|
+
const c = report.counts;
|
|
382
|
+
lines.push('', `${report.status === 'pass' ? 'PASSED' : 'FAILED'} — ${c.pass} passed, ${c.fail} failed, ${c.skip} skipped in ${fmtMs(report.durationMs)}${c.skip && report.status === 'pass' ? ' (skipped gates did not run: they are not a pass)' : ''}`);
|
|
383
|
+
lines.push(`report: ${path.join(out, 'report.json')} logs: ${path.join(out, 'logs')}`);
|
|
384
|
+
return lines.join('\n');
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
async function main(argv, env = process.env) {
|
|
388
|
+
let o;
|
|
389
|
+
try { o = parseArgs(argv); } catch (e) { console.error(`local-ci: ${e.message}`); return 2; }
|
|
390
|
+
if (o.help) { console.log(fs.readFileSync(__filename, 'utf8').split('*/')[0].replace(/^#!.*\n\/\*\*?/, '').replace(/^ \* ?/gm, '')); return 0; }
|
|
391
|
+
// Run from inside a node --test process, a gate's own `node --test` would see the parent's
|
|
392
|
+
// context and report to it instead of running its files: the gates start from a clean slate.
|
|
393
|
+
env = { ...env };
|
|
394
|
+
delete env.NODE_TEST_CONTEXT;
|
|
395
|
+
|
|
396
|
+
const outDir = o.out || path.join(o.root, '.ci-local');
|
|
397
|
+
const logDir = path.join(outDir, 'logs');
|
|
398
|
+
// One run per checkout: a second one would wipe the logs the first is writing.
|
|
399
|
+
const lock = path.join(outDir, 'run.lock');
|
|
400
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
401
|
+
try {
|
|
402
|
+
fs.writeFileSync(lock, String(process.pid), { flag: 'wx' });
|
|
403
|
+
} catch {
|
|
404
|
+
const pid = Number(fs.readFileSync(lock, 'utf8'));
|
|
405
|
+
let alive = false;
|
|
406
|
+
try { process.kill(pid, 0); alive = true; } catch { /* stale lock */ }
|
|
407
|
+
if (alive && pid !== process.pid) { console.error(`local-ci: another run (pid ${pid}) is using ${path.relative(o.root, outDir)} — wait for it, or delete ${path.relative(o.root, lock)} if it is gone`); return 2; }
|
|
408
|
+
fs.writeFileSync(lock, String(process.pid));
|
|
409
|
+
}
|
|
410
|
+
try {
|
|
411
|
+
return await runAll(o, env, outDir, logDir);
|
|
412
|
+
} finally {
|
|
413
|
+
fs.rmSync(lock, { force: true });
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
async function runAll(o, env, outDir, logDir) {
|
|
418
|
+
fs.rmSync(logDir, { recursive: true, force: true });
|
|
419
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
420
|
+
|
|
421
|
+
const started = Date.now();
|
|
422
|
+
const before = treeState(o.root, env);
|
|
423
|
+
const gates = planGates(o, env);
|
|
424
|
+
const wantsClean = !o.only || o.only.some(p => 'clean-tree'.startsWith(p));
|
|
425
|
+
if (gates.length === 0 && !wantsClean) { console.error('local-ci: no gate matches --only'); return 2; }
|
|
426
|
+
|
|
427
|
+
// The suite and the harness read hooks/dist (gitignored), as CI's build:hooks step provides.
|
|
428
|
+
const needsHooks = gates.some(g => !g.skip && /^(host-|harness|coverage)/.test(g.id));
|
|
429
|
+
const results = [];
|
|
430
|
+
if (needsHooks && fs.existsSync(path.join(o.root, 'scripts', 'build-hooks.js'))) {
|
|
431
|
+
const b = await runGate({ id: 'build-hooks', title: 'build:hooks', cmd: process.execPath, args: ['scripts/build-hooks.js'] }, o, env, logDir);
|
|
432
|
+
if (b.status === 'fail') results.push(b);
|
|
433
|
+
}
|
|
434
|
+
if (!o.json) process.stderr.write(`local-ci: ${gates.filter(g => !g.skip).length} gate(s), ${o.jobs} at a time — logs in ${path.join(path.relative(o.root, outDir) || '.', 'logs')}\n`);
|
|
435
|
+
// Whole-suite runs are heavy. Measured here: a host suite run beside another host suite, or
|
|
436
|
+
// beside the three containers, times its 30-second setup hooks out; beside the harness alone
|
|
437
|
+
// it is fine. So the containers go first, together, and the host suite runs (one per Node
|
|
438
|
+
// version, then the coverage gate's) follow one at a time. The harness and the light gates
|
|
439
|
+
// run beside all of it.
|
|
440
|
+
const one = async (g) => {
|
|
441
|
+
const r = await runGate(g, o, env, logDir);
|
|
442
|
+
if (!o.json && r.status !== 'skip') process.stderr.write(` ${r.status === 'pass' ? 'pass' : r.status} ${r.id} (${fmtMs(r.durationMs)})\n`);
|
|
443
|
+
return r;
|
|
444
|
+
};
|
|
445
|
+
const byGate = new Map();
|
|
446
|
+
const containers = gates.filter(g => !g.skip && g.id.startsWith('linux-'));
|
|
447
|
+
const lane = gates.filter(g => !g.skip && (g.id.startsWith('host-') || g.id === 'coverage'));
|
|
448
|
+
const heavy = async () => {
|
|
449
|
+
await Promise.all(containers.map(async g => byGate.set(g, await one(g))));
|
|
450
|
+
for (const g of lane) byGate.set(g, await one(g));
|
|
451
|
+
};
|
|
452
|
+
const rest = gates.filter(g => !containers.includes(g) && !lane.includes(g));
|
|
453
|
+
await Promise.all([heavy(), pool(rest, Math.max(1, o.jobs - 1), async g => byGate.set(g, await one(g)))]);
|
|
454
|
+
results.push(...gates.map(g => byGate.get(g)));
|
|
455
|
+
|
|
456
|
+
if (wantsClean) {
|
|
457
|
+
const after = treeState(o.root, env);
|
|
458
|
+
if (!before || !after) results.push({ id: 'clean-tree', title: 'working tree unchanged', status: 'skip', detail: 'not a git checkout', durationMs: 0 });
|
|
459
|
+
else {
|
|
460
|
+
const changed = [...new Set([...after.lines.filter(l => !before.lines.includes(l)), ...before.lines.filter(l => !after.lines.includes(l))])];
|
|
461
|
+
const ok = before.hash === after.hash;
|
|
462
|
+
results.push({ id: 'clean-tree', title: 'working tree unchanged', status: ok ? 'pass' : 'fail', durationMs: 0,
|
|
463
|
+
detail: ok ? (before.lines.length ? `unchanged (${before.lines.length} path(s) were already modified before the run)` : 'clean') : `the run changed the tree: ${changed.slice(0, 5).join('; ') || 'content of already-modified files'}` });
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const counts = { pass: 0, fail: 0, skip: 0 };
|
|
468
|
+
for (const r of results) counts[r.status]++;
|
|
469
|
+
const failed = counts.fail > 0 || (o.strict && counts.skip > 0);
|
|
470
|
+
const headFull = quick('git', ['rev-parse', 'HEAD'], { cwd: o.root, env }).stdout.trim();
|
|
471
|
+
const report = {
|
|
472
|
+
status: failed ? 'fail' : 'pass', startedAt: new Date(started).toISOString(), durationMs: Date.now() - started,
|
|
473
|
+
root: o.root, head: headFull.slice(0, 7), headFull, node: process.versions.node, platform: process.platform,
|
|
474
|
+
// Uncommitted work at the start means the gates ran on something other than HEAD.
|
|
475
|
+
dirtyAtStart: before ? before.lines.length > 0 : null,
|
|
476
|
+
options: { fast: o.fast, only: o.only, codescan: o.codescan, docker: o.docker, strict: o.strict, jobs: o.jobs },
|
|
477
|
+
counts, gates: results,
|
|
478
|
+
};
|
|
479
|
+
fs.writeFileSync(path.join(outDir, 'report.json'), JSON.stringify(report, null, 2) + '\n');
|
|
480
|
+
const recorded = o.only || o.fast || !headFull ? null : recordVerdict(o.root, report, env);
|
|
481
|
+
console.log(o.json ? JSON.stringify(report, null, 2) : summary(report, path.relative(o.root, outDir) || '.') + (recorded ? `\nverdict for ${report.head} recorded for land: ${recorded}` : ''));
|
|
482
|
+
return failed ? 1 : 0;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// Where land.cjs looks for a commit's verdict: the git directory every worktree shares.
|
|
486
|
+
function verdictFile(root, sha, env = process.env) {
|
|
487
|
+
const common = quick('git', ['rev-parse', '--git-common-dir'], { cwd: root, env }).stdout.trim();
|
|
488
|
+
return common ? path.join(path.resolve(root, common), 'pan-ci-local', `${sha}.json`) : null;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function recordVerdict(root, report, env) {
|
|
492
|
+
const file = verdictFile(root, report.headFull, env);
|
|
493
|
+
if (!file) return null;
|
|
494
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
495
|
+
fs.writeFileSync(file, JSON.stringify(report, null, 2) + '\n');
|
|
496
|
+
return file;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
module.exports = { parseArgs, planGates, which, findCodeql, judgeSarif, treeState, lastSignal, verdictFile, main };
|
|
500
|
+
|
|
501
|
+
if (require.main === module) main(process.argv.slice(2)).then(code => process.exit(code));
|