@event4u/agent-config 16.2.0 → 16.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.augment-plugin/marketplace.json +3 -3
- package/.augment-plugin/plugin.json +1 -1
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +89 -237
- package/README.md +14 -12
- package/dist/agent-src/commands/analyze/inbox.md +1 -1
- package/dist/agent-src/commands/analyze/roadmap-repos.md +1 -1
- package/dist/agent-src/commands/bug/fix.md +17 -2
- package/dist/agent-src/commands/feature/plan.md +1 -1
- package/dist/agent-src/commands/feature/roadmap.md +1 -1
- package/dist/agent-src/commands/fix/ci.md +1 -1
- package/dist/agent-src/commands/implement-ticket.md +1 -1
- package/dist/agent-src/commands/jira-ticket.md +1 -1
- package/dist/agent-src/commands/roadmap/create.md +1 -1
- package/dist/agent-src/commands/roadmap/materialize.md +1 -1
- package/dist/agent-src/commands/roadmap/process-full.md +4 -4
- package/dist/agent-src/commands/roadmap/resolve-blockers.md +361 -0
- package/dist/agent-src/commands/roadmap/triage-parked.md +325 -0
- package/dist/agent-src/commands/roadmap.md +18 -4
- package/dist/agent-src/commands/tests/create.md +17 -3
- package/dist/agent-src/commands/tests/execute.md +22 -5
- package/dist/agent-src/contexts/authority/decision-revisit-gate-enforcement.md +43 -0
- package/dist/agent-src/contexts/communication/rules-auto/design-fidelity-enforcement.md +24 -0
- package/dist/agent-src/contexts/execution/auto-dispatch-classification.md +6 -6
- package/dist/agent-src/contexts/execution/roadmap-process-loop.md +32 -2
- package/dist/agent-src/contexts/execution/session-canary-enforcement-history.md +67 -0
- package/dist/agent-src/contexts/execution/toolchain-resolver.md +101 -7
- package/dist/agent-src/rules/active-remediation.md +1 -1
- package/dist/agent-src/rules/autonomous-execution.md +1 -0
- package/dist/agent-src/rules/broken-access-control.md +1 -0
- package/dist/agent-src/rules/command-suggestion-policy.md +1 -0
- package/dist/agent-src/rules/context-hygiene.md +1 -1
- package/dist/agent-src/rules/decision-revisit-gate.md +16 -33
- package/dist/agent-src/rules/design-fidelity.md +13 -16
- package/dist/agent-src/rules/doc-screenshot-hygiene.md +1 -0
- package/dist/agent-src/rules/domain-safety-disclaimer.md +1 -0
- package/dist/agent-src/rules/domain-safety-pii.md +1 -0
- package/dist/agent-src/rules/domain-safety-retention.md +1 -0
- package/dist/agent-src/rules/engineering-safety-floor.md +1 -0
- package/dist/agent-src/rules/evaluator-independence.md +3 -2
- package/dist/agent-src/rules/fast-path-marker-visibility.md +1 -0
- package/dist/agent-src/rules/finance-safety-floor.md +1 -0
- package/dist/agent-src/rules/git-history-discipline.md +1 -0
- package/dist/agent-src/rules/image-likeness-and-rights.md +1 -0
- package/dist/agent-src/rules/legal-safety-floor.md +3 -2
- package/dist/agent-src/rules/lethal-trifecta-guard.md +1 -0
- package/dist/agent-src/rules/low-impact-corpus-privacy-floor.md +1 -0
- package/dist/agent-src/rules/media-governance-routing.md +14 -17
- package/dist/agent-src/rules/persona-governance.md +2 -2
- package/dist/agent-src/rules/provider-lifecycle-discipline.md +1 -1
- package/dist/agent-src/rules/question-not-instruction.md +1 -0
- package/dist/agent-src/rules/roadmap-progress-sync.md +1 -1
- package/dist/agent-src/rules/runtime-safety.md +1 -0
- package/dist/agent-src/rules/secret-vcs-guard.md +1 -0
- package/dist/agent-src/rules/security-sensitive-stop.md +3 -2
- package/dist/agent-src/rules/session-canary.md +8 -62
- package/dist/agent-src/rules/settings-ask-protocol.md +1 -0
- package/dist/agent-src/rules/source-confidentiality.md +1 -0
- package/dist/agent-src/rules/strategy-safety-floor.md +1 -0
- package/dist/agent-src/rules/tool-safety.md +1 -0
- package/dist/agent-src/rules/untrusted-input-defense.md +1 -0
- package/dist/agent-src/rules/user-interrupt-priority.md +1 -0
- package/dist/agent-src/scripts/archive_completed_roadmaps.ts +115 -20
- package/dist/agent-src/scripts/deferral_carry.ts +339 -0
- package/dist/agent-src/scripts/update_roadmap_progress.ts +4 -1
- package/dist/agent-src/skills/api-design/data/manifest.json +2 -2
- package/dist/agent-src/skills/api-testing/SKILL.md +17 -0
- package/dist/agent-src/skills/code-intelligence/SKILL.md +14 -2
- package/dist/agent-src/skills/design-system-capture/references/import-procedure.md +1 -1
- package/dist/agent-src/skills/existing-ui-audit/SKILL.md +2 -2
- package/dist/agent-src/skills/fe-design/SKILL.md +1 -1
- package/dist/agent-src/skills/iconography/SKILL.md +1 -1
- package/dist/agent-src/skills/js-library-packaging/SKILL.md +2 -2
- package/dist/agent-src/skills/monorepo-workspace/SKILL.md +1 -1
- package/dist/agent-src/skills/quality-tools/SKILL.md +11 -1
- package/dist/agent-src/skills/quality-tools/references/go-tools.md +76 -0
- package/dist/agent-src/skills/quality-tools/references/python-tools.md +104 -0
- package/dist/agent-src/skills/review-routing/SKILL.md +2 -2
- package/dist/agent-src/skills/skill-improvement-pipeline/SKILL.md +1 -1
- package/dist/agent-src/skills/storybook-workshop/SKILL.md +3 -3
- package/dist/agent-src/skills/tailwind-engineer/SKILL.md +1 -1
- package/dist/agent-src/skills/test-case-discovery/SKILL.md +7 -0
- package/dist/agent-src/skills/test-driven-development/SKILL.md +17 -0
- package/dist/agent-src/skills/test-performance/SKILL.md +19 -1
- package/dist/agent-src/skills/ui-component-architect/SKILL.md +2 -2
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/scripts/work_engine/directives/ui/apply.ts +48 -6
- package/dist/agent-src/templates/scripts/work_engine/stack/runner.ts +774 -109
- package/dist/cli/registry.js +1 -1
- package/dist/cli-delegate/archive_completed_roadmaps.js +92 -12
- package/dist/cli-delegate/check_update_banner.js +12 -1
- package/dist/cli-delegate/chunk-523HCFYO.js +90 -0
- package/dist/cli-delegate/{chunk-M2DPGFZ4.js → chunk-6V6TA25H.js} +22 -152
- package/dist/cli-delegate/chunk-7IQE3D5S.js +191 -0
- package/dist/cli-delegate/{chunk-6KFEC3G7.js → chunk-CQWX336L.js} +1 -1
- package/dist/cli-delegate/chunk-DEIV7XP7.js +108 -0
- package/dist/cli-delegate/chunk-EEHDXSYD.js +2040 -0
- package/dist/cli-delegate/{chunk-P6ZHP5W2.js → chunk-GKE7ZVFA.js} +4 -4
- package/dist/cli-delegate/chunk-JQOM7TTM.js +1201 -0
- package/dist/cli-delegate/{chunk-IGD6ERX6.js → chunk-JQVYKIZA.js} +10 -0
- package/dist/cli-delegate/{chunk-FC75PGJE.js → chunk-JY2I2DXV.js} +12 -1
- package/dist/cli-delegate/chunk-KB5FZ655.js +208 -0
- package/dist/cli-delegate/{chunk-7RQP4UDH.js → chunk-KR6EM23A.js} +235 -1455
- package/dist/cli-delegate/{chunk-LTYVIY76.js → chunk-LIYTLH6O.js} +1 -1
- package/dist/cli-delegate/{chunk-YJ72UIAK.js → chunk-NQ7WT43P.js} +1186 -244
- package/dist/cli-delegate/chunk-NXEILF2K.js +2160 -0
- package/dist/cli-delegate/{chunk-5R7HOLAF.js → chunk-NYX3L2PZ.js} +1 -1
- package/dist/cli-delegate/{chunk-4E2W63DA.js → chunk-OKJUIAPS.js} +62 -1
- package/dist/cli-delegate/{chunk-QAJS5YWY.js → chunk-PHD7OOLE.js} +2 -1
- package/dist/cli-delegate/{chunk-RGNIWTEA.js → chunk-QLDHIPWR.js} +1 -1
- package/dist/cli-delegate/{chunk-7XGBX6ZH.js → chunk-RQNNW7IF.js} +1314 -560
- package/dist/cli-delegate/{chunk-4XFP2Y3E.js → chunk-S4UMZNES.js} +1 -1
- package/dist/cli-delegate/{chunk-6KM6ORRY.js → chunk-S4YWNRKO.js} +24 -8
- package/dist/cli-delegate/{chunk-FP36SWJD.js → chunk-TRI6DQZ6.js} +2781 -340
- package/dist/cli-delegate/chunk-ULQRUR23.js +208 -0
- package/dist/cli-delegate/{chunk-NUHLI2RX.js → chunk-YCU3TIXH.js} +2 -0
- package/dist/cli-delegate/chunk-ZBZV2G5R.js +210 -0
- package/dist/cli-delegate/{chunk-WGXTVKCQ.js → chunk-ZUZKZYCN.js} +5 -3
- package/dist/cli-delegate/cmd_analyze_session.js +1 -1
- package/dist/cli-delegate/cmd_brand_status.js +1 -1
- package/dist/cli-delegate/cmd_conformance.js +1167 -234
- package/dist/cli-delegate/cmd_doctor.js +13 -6
- package/dist/cli-delegate/cmd_doctor_neighbours.js +23 -0
- package/dist/cli-delegate/cmd_explain.js +5 -5
- package/dist/cli-delegate/cmd_export.js +11 -8
- package/dist/cli-delegate/cmd_fleet.js +3 -3
- package/dist/cli-delegate/cmd_handoff.js +10 -7
- package/dist/cli-delegate/cmd_mcp_available.js +1 -1
- package/dist/cli-delegate/cmd_memory_get.js +3 -1
- package/dist/cli-delegate/cmd_migrate.js +2 -2
- package/dist/cli-delegate/cmd_packs_active.js +3 -3
- package/dist/cli-delegate/cmd_preflight.js +6 -6
- package/dist/cli-delegate/cmd_prune.js +11 -8
- package/dist/cli-delegate/cmd_refresh.js +2 -1
- package/dist/cli-delegate/cmd_route_audit.js +8 -6
- package/dist/cli-delegate/cmd_route_explain.js +4 -4
- package/dist/cli-delegate/cmd_session_recycle.js +4 -3
- package/dist/cli-delegate/cmd_settings_get.js +1 -1
- package/dist/cli-delegate/cmd_settings_migrate.js +11 -8
- package/dist/cli-delegate/cmd_settings_set.js +4 -4
- package/dist/cli-delegate/cmd_sync.js +11 -8
- package/dist/cli-delegate/cmd_uninstall.js +11 -8
- package/dist/cli-delegate/cmd_update.js +1 -1
- package/dist/cli-delegate/cmd_upgrade.js +3 -2
- package/dist/cli-delegate/cmd_validate.js +11 -8
- package/dist/cli-delegate/cmd_versions.js +1 -1
- package/dist/cli-delegate/deferral_carry.js +16 -0
- package/dist/cli-delegate/gate_execute.js +2 -2
- package/dist/cli-delegate/roadmap_gates.js +5 -5
- package/dist/cli-delegate/update_roadmap_progress.js +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +127 -75
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +3 -3
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +7 -5
- package/dist/discovery/trust-report.md +2 -2
- package/dist/discovery/workspaces.json +6 -4
- package/dist/hooks/dispatch.js +5357 -6656
- package/dist/hooks/dispatch.sha256 +1 -1
- package/dist/install/claudePathsPlan.js +47 -6
- package/dist/install/claudeRuleRewrite.js +7 -0
- package/dist/install/emit_host_rules_cli.js +39 -36
- package/dist/install/globalRuleScope.js +87 -0
- package/dist/install/host_rule_ownership.js +160 -0
- package/dist/install/install.mjs +2080 -1024
- package/dist/install/installThinLayer.js +261 -0
- package/dist/install/installedRuleLinks.js +176 -0
- package/dist/install/wizard-plan.js +24 -0
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +31965 -27154
- package/dist/scripts/_lib/agent_settings.js +11 -0
- package/dist/scripts/_lib/claude_settings_hooks.js +10 -8
- package/dist/scripts/_lib/installed_layer.js +378 -0
- package/dist/scripts/_lib/lean_projection_mode.js +218 -15
- package/dist/scripts/_lib/rule_consequence_class.js +45 -0
- package/dist/scripts/_lib/rule_injection.js +67 -10
- package/dist/scripts/_lib/rule_law_section.js +122 -0
- package/dist/scripts/_lib/thin_rules.js +458 -0
- package/dist/scripts/_lib/windsurf_render.js +60 -0
- package/dist/scripts/condense.js +14 -26
- package/dist/scripts/project_thin_rules.js +14 -294
- package/dist/server/schemas/settings.js +6 -0
- package/dist/shared/skillRanking.js +74 -13
- package/docs/contracts/settings-classes.md +7 -5
- package/package.json +1 -1
- package/src/agent-src/scripts/archive_completed_roadmaps.ts +115 -20
- package/src/agent-src/scripts/deferral_carry.ts +339 -0
- package/src/agent-src/scripts/update_roadmap_progress.ts +4 -1
- package/src/agent-src/templates/scripts/work_engine/directives/ui/apply.ts +48 -6
- package/src/agent-src/templates/scripts/work_engine/stack/runner.ts +774 -109
- package/src/config/agent-settings.template.yml +62 -22
- package/src/config/continuity-surface.json +7 -0
- package/src/config/evaluator-budgets.json +4 -4
- package/src/config/gate-coverage.yml +96 -3
- package/src/config/gate-violation-baselines.json +17 -9
- package/src/config/hook-bundle-budget.json +25 -0
- package/src/config/hook-token-budget.json +6 -1
- package/src/config/host-instruction-limits.json +33 -0
- package/src/config/routing-coverage-seed.json +24 -3
- package/src/config/rule-activation-census.json +7 -3
- package/src/config/rule-consequence-class.json +161 -0
- package/src/config/rule-law-ceilings.json +99 -0
- package/src/install/claudePathsPlan.ts +46 -4
- package/src/install/claudeRuleRewrite.ts +10 -1
- package/src/install/emit_host_rules_cli.ts +43 -34
- package/src/install/globalRuleScope.ts +109 -0
- package/src/install/host_rule_ownership.ts +170 -0
- package/src/install/installThinLayer.ts +312 -0
- package/src/install/installedRuleLinks.ts +220 -0
- package/src/install/wizard-plan.ts +24 -0
- package/src/scripts/_cli/cmd_doctor.ts +23 -71
- package/src/scripts/_cli/cmd_doctor_neighbours.ts +293 -0
- package/src/scripts/_dispatch.bash +16 -2
- package/src/scripts/_lib/agent_settings.ts +11 -0
- package/src/scripts/_lib/claude_settings_hooks.ts +10 -8
- package/src/scripts/_lib/estate_offsets.ts +241 -0
- package/src/scripts/_lib/exemption_shape.ts +143 -0
- package/src/scripts/_lib/fnv.ts +23 -0
- package/src/scripts/_lib/foreign_scan.ts +106 -0
- package/src/scripts/_lib/gitignore_block.ts +314 -0
- package/src/scripts/_lib/global_deploy_inventory.ts +32 -0
- package/src/scripts/_lib/graph_feeder_record.ts +214 -0
- package/src/scripts/_lib/hook_settings.ts +73 -0
- package/src/scripts/_lib/host_capability.ts +40 -19
- package/src/scripts/_lib/host_hook_merge.ts +165 -0
- package/src/scripts/_lib/installed_layer.ts +538 -0
- package/src/scripts/_lib/json_merge.ts +82 -0
- package/src/scripts/_lib/json_pointers.ts +93 -11
- package/src/scripts/_lib/lean_projection_mode.ts +275 -15
- package/src/scripts/_lib/machine_wake.ts +16 -3
- package/src/scripts/_lib/neighbour_census.ts +760 -0
- package/src/scripts/_lib/neighbour_scan.ts +122 -0
- package/src/scripts/_lib/neighbour_tool_use.ts +189 -0
- package/src/scripts/_lib/prompt_shape.ts +46 -0
- package/src/scripts/_lib/release_migration_gate.ts +19 -3
- package/src/scripts/_lib/reserved_name_sweep.ts +73 -0
- package/src/scripts/_lib/review_coverage.ts +134 -0
- package/src/scripts/_lib/rule_consequence_class.ts +71 -0
- package/src/scripts/_lib/rule_injection.ts +130 -10
- package/src/scripts/_lib/rule_law_section.ts +147 -0
- package/src/scripts/_lib/rule_layer_overlap.ts +58 -0
- package/src/scripts/_lib/skill_origin.ts +254 -0
- package/src/scripts/_lib/structured_ask.ts +64 -21
- package/src/scripts/_lib/thin_rules.ts +540 -0
- package/src/scripts/_lib/touched_file_quality.ts +434 -0
- package/src/scripts/_lib/turn_end_refusals.ts +77 -2
- package/src/scripts/_lib/windsurf_render.ts +63 -0
- package/src/scripts/ai_council/cli_call_budget.ts +13 -5
- package/src/scripts/ai_council/clients.ts +26 -57
- package/src/scripts/ai_council/config.ts +1 -1
- package/src/scripts/ai_council/vendor_defaults.ts +45 -0
- package/src/scripts/audit_skill_overlap.ts +261 -0
- package/src/scripts/before_complete_hook.ts +243 -12
- package/src/scripts/bench_touched_file_quality.ts +238 -0
- package/src/scripts/check_enforcement_matrix.ts +377 -20
- package/src/scripts/check_estate_count.ts +130 -153
- package/src/scripts/check_finding_dispositions.ts +116 -4
- package/src/scripts/check_hook_bundle_composition.ts +370 -0
- package/src/scripts/check_kernel_edit_ratified.ts +49 -11
- package/src/scripts/check_memory.ts +7 -1
- package/src/scripts/check_release_highlights.ts +10 -0
- package/src/scripts/check_release_pr_shape.ts +38 -15
- package/src/scripts/check_standing_rule_delivery.ts +33 -13
- package/src/scripts/check_verify_expectation_delta.ts +521 -0
- package/src/scripts/code_graph/detect.ts +127 -7
- package/src/scripts/code_graph/foreign.ts +194 -0
- package/src/scripts/code_graph/query.ts +217 -4
- package/src/scripts/code_graph/types.ts +26 -2
- package/src/scripts/code_graph/validate.ts +3 -1
- package/src/scripts/code_graph/verbs.ts +103 -0
- package/src/scripts/compile_hook_manifest.ts +33 -1
- package/src/scripts/condense.ts +14 -27
- package/src/scripts/estate_exemption_shape.ts +202 -0
- package/src/scripts/generate_host_cost_table.ts +1 -1
- package/src/scripts/hook_manifest.json +1 -1
- package/src/scripts/hook_manifest.yaml +150 -9
- package/src/scripts/hooks/block_config_weakening.ts +67 -2
- package/src/scripts/hooks/block_plumbing_writes.ts +29 -1
- package/src/scripts/hooks/code_graph_context_hook.ts +252 -23
- package/src/scripts/hooks/concern_failure_policy.ts +280 -0
- package/src/scripts/hooks/dispatch_hook.ts +52 -52
- package/src/scripts/hooks/exit_codes.ts +2 -1
- package/src/scripts/hooks/host_lowering.json +1 -1
- package/src/scripts/hooks/host_lowering.ts +19 -1
- package/src/scripts/hooks/one_question_per_ask_hook.ts +41 -25
- package/src/scripts/hooks/payload_stub.ts +10 -1
- package/src/scripts/hooks/rule_inject_hook.ts +757 -63
- package/src/scripts/hooks/table_fingerprint.ts +21 -0
- package/src/scripts/hooks/telemetry_usage_hook.ts +64 -8
- package/src/scripts/hooks/turn_end_gate_hook.ts +84 -0
- package/src/scripts/hooks_status.ts +112 -0
- package/src/scripts/injection_scan_hook.ts +7 -0
- package/src/scripts/install.ts +72 -185
- package/src/scripts/installed_layer_report.ts +103 -0
- package/src/scripts/language_mirror_hook.ts +15 -6
- package/src/scripts/learning_sidecar.ts +119 -8
- package/src/scripts/lint_hook_manifest.ts +53 -0
- package/src/scripts/lint_major_migration_sections.ts +156 -26
- package/src/scripts/lint_pack_boundaries.ts +63 -5
- package/src/scripts/lint_rule_law_section.ts +517 -0
- package/src/scripts/lint_workflow_security.ts +161 -5
- package/src/scripts/mcp_server/consumer_tool_catalog.json +53 -0
- package/src/scripts/mcp_server/graph_tools.ts +72 -3
- package/src/scripts/measure_routing_signal.ts +54 -16
- package/src/scripts/measure_skill_ranker_baseline.ts +216 -8
- package/src/scripts/memory_learn_hook.ts +91 -6
- package/src/scripts/model_rule_injection.ts +52 -5
- package/src/scripts/project_thin_rules.ts +34 -327
- package/src/scripts/report_evidence_temperature.ts +657 -0
- package/src/scripts/report_installed_rule_links.ts +157 -0
- package/src/scripts/report_label_agreement.ts +233 -0
- package/src/scripts/report_obligation_mechanism.ts +230 -0
- package/src/scripts/report_single_token_triggers.ts +277 -0
- package/src/scripts/report_skill_ranker_confusion.ts +442 -0
- package/src/scripts/report_standing_payload_by_host.ts +188 -15
- package/src/scripts/report_verification_record_coverage.ts +306 -0
- package/src/scripts/rule_activation_census.ts +5 -2
- package/src/scripts/rule_trigger_eval.ts +9 -9
- package/src/scripts/schemas/review-findings.schema.json +3 -2
- package/src/scripts/schemas/rule.schema.json +5 -0
- package/src/scripts/self_review_gate.ts +62 -7
- package/src/scripts/skill_tools/score_skill_relevance.ts +290 -25
- package/src/scripts/sweep_skill_ranker_signals.ts +129 -0
- package/src/scripts/sync_gitignore.ts +29 -235
- package/src/scripts/trigger_eval_grandfather.json +0 -5
- package/src/server/schemas/settings.ts +10 -0
- package/src/shared/skillRanking.ts +117 -11
- package/dist/cli-delegate/chunk-JNMVLWLR.js +0 -4457
- package/dist/cli-delegate/chunk-YDPILCOL.js +0 -152
|
@@ -1,38 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Toolchain resolution — pick the right test/quality runner per stack.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* **The contract lives in `contexts/execution/toolchain-resolver.md`**: what
|
|
5
|
+
* each axis answers, detection order, confidence tiers, the refusal doctrine,
|
|
6
|
+
* the cache key and what resolution costs. This header carries only what a
|
|
7
|
+
* reader OF THIS FILE needs and what the contract page cannot enforce.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
* module
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* of exploding into per-stack command variants.
|
|
9
|
+
* Originally the TypeScript twin of `work_engine/stack/runner.py` (ADR-200
|
|
10
|
+
* py2ts). Leaf module — stdlib only, NO intra-`work_engine` imports — and the
|
|
11
|
+
* public API names stay snake_case, because that style IS the contract. The
|
|
12
|
+
* 1:1 parity claim no longer holds: the behavior-runner axis and the rspec /
|
|
13
|
+
* junit / dotnet-test labels are TypeScript-only, and the parity test was
|
|
14
|
+
* renamed rather than quietly widened. Sibling of {@link "./detect"}, which
|
|
15
|
+
* labels the *frontend* stack.
|
|
17
16
|
*
|
|
18
|
-
* Detection
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* agent can ask), a crash mid-run is not. This mirrors the recoverable-error
|
|
22
|
-
* contract in {@link "./detect"}.
|
|
23
|
-
*
|
|
24
|
-
* Three opt-in flags shape the *selected* command set (the monorepo guard):
|
|
25
|
-
*
|
|
26
|
-
* - `include_e2e` — by default e2e suites (playwright / cypress) are
|
|
27
|
-
* excluded; fast unit tests run first. Pass to add them.
|
|
28
|
-
* - `include_slow` — by default a script tagged `test:slow` /
|
|
29
|
-
* `test:integration` is excluded. Pass to add it.
|
|
30
|
-
* - `php_only` — keep only the PHP ecosystem (the `--php` narrowing the
|
|
31
|
-
* roadmap calls for; "only genuine PHP-space commands stay PHP-locked").
|
|
32
|
-
*
|
|
33
|
-
* The full per-stack inventory is always returned (`runners`); the
|
|
34
|
-
* `selected` tuple is what a command should actually run after applying
|
|
35
|
-
* the flags + the fast-by-default monorepo guard.
|
|
17
|
+
* Detection **never crashes** — a malformed manifest, a missing file or an
|
|
18
|
+
* unknown stack degrades to a LOW-confidence empty result, and EVERY
|
|
19
|
+
* filesystem helper here is guarded, which is what makes that true.
|
|
36
20
|
*/
|
|
37
21
|
import * as fs from 'node:fs';
|
|
38
22
|
import * as path from 'node:path';
|
|
@@ -40,8 +24,10 @@ import * as path from 'node:path';
|
|
|
40
24
|
/**
|
|
41
25
|
* Every test-runner label the resolver can emit.
|
|
42
26
|
*
|
|
43
|
-
* Single source of truth
|
|
44
|
-
*
|
|
27
|
+
* Single source of truth: the fixtures validate against this set without
|
|
28
|
+
* re-deriving it, and a membership test binds it to what the resolver emits —
|
|
29
|
+
* without that binding it would assert set-equality with itself. Mirrors
|
|
30
|
+
* Python's `frozenset`.
|
|
45
31
|
*/
|
|
46
32
|
export const KNOWN_RUNNERS: ReadonlySet<string> = new Set([
|
|
47
33
|
'pest',
|
|
@@ -53,6 +39,31 @@ export const KNOWN_RUNNERS: ReadonlySet<string> = new Set([
|
|
|
53
39
|
'pytest',
|
|
54
40
|
'go-test',
|
|
55
41
|
'cargo-test',
|
|
42
|
+
'rspec',
|
|
43
|
+
'junit',
|
|
44
|
+
'dotnet-test',
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Every behavior-runner label the resolver can emit, plus the refusal.
|
|
49
|
+
*
|
|
50
|
+
* A **separate** set from {@link KNOWN_RUNNERS}: the two answer different
|
|
51
|
+
* questions — *what runs the tests* versus *does this repository already own a
|
|
52
|
+
* tool that reads a specification* — and merging them would put a suite nobody
|
|
53
|
+
* asked to run into `selected`. {@link BEHAVIOR_UNKNOWN} is the refusal label.
|
|
54
|
+
*/
|
|
55
|
+
export const BEHAVIOR_UNKNOWN = 'unknown';
|
|
56
|
+
|
|
57
|
+
export const KNOWN_BEHAVIOR_RUNNERS: ReadonlySet<string> = new Set([
|
|
58
|
+
'behat',
|
|
59
|
+
'cucumber-js',
|
|
60
|
+
'cucumber-ruby',
|
|
61
|
+
'cucumber-jvm',
|
|
62
|
+
'behave',
|
|
63
|
+
'pytest-bdd',
|
|
64
|
+
'reqnroll',
|
|
65
|
+
'specflow',
|
|
66
|
+
BEHAVIOR_UNKNOWN,
|
|
56
67
|
]);
|
|
57
68
|
|
|
58
69
|
// Speed buckets. `fast` runs by default; `slow` needs `include_slow`;
|
|
@@ -77,6 +88,84 @@ const _MANIFESTS = [
|
|
|
77
88
|
'Makefile',
|
|
78
89
|
'Taskfile.yml',
|
|
79
90
|
'Taskfile.yaml',
|
|
91
|
+
'Gemfile',
|
|
92
|
+
'pom.xml',
|
|
93
|
+
'build.gradle',
|
|
94
|
+
'build.gradle.kts',
|
|
95
|
+
// `settings.gradle[.kts]` is a Gradle signal `_jvm_build` acts on, so it
|
|
96
|
+
// must be in the key: a settings-only multi-project root emits a junit row
|
|
97
|
+
// and would otherwise report the greenfield `mtime: 0` forever.
|
|
98
|
+
'settings.gradle',
|
|
99
|
+
'settings.gradle.kts',
|
|
100
|
+
'global.json',
|
|
101
|
+
'Directory.Build.props',
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* .NET project / solution extensions, matched by a root listing.
|
|
106
|
+
*
|
|
107
|
+
* .NET is the one ecosystem whose marker has no fixed FILENAME (the project
|
|
108
|
+
* file is `<whatever>.csproj`), so this is an extension list over the root.
|
|
109
|
+
*
|
|
110
|
+
* **Residual cache gap.** {@link latest_manifest_mtime} stats fixed NAMES, so
|
|
111
|
+
* a signal it does not list is invisible to the key: a project-only .NET
|
|
112
|
+
* scope, `features/support/env.rb`, `spec/spec_helper.rb`, `setup.cfg`,
|
|
113
|
+
* `pytest.ini` and the `mvnw` / `gradlew` wrappers. Known, not closed.
|
|
114
|
+
*/
|
|
115
|
+
const _DOTNET_PROJECT_EXTS = ['.sln', '.slnx', '.csproj', '.fsproj', '.vbproj'];
|
|
116
|
+
|
|
117
|
+
/** The packages that make a .NET project a TEST project, for the HIGH tier. */
|
|
118
|
+
const _DOTNET_TEST_STACK = /Microsoft\.NET\.Test\.Sdk|xunit|nunit|mstest/i;
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* How deep `_dotnet_project_text` looks. The conventional layout keeps the
|
|
122
|
+
* solution at the root and projects at `src/<Name>/<Name>.csproj`, which a
|
|
123
|
+
* root-only scan misses entirely. Depth 2 reaches it; a flat root still costs
|
|
124
|
+
* one listing, since the walk descends only when the scope holds no project.
|
|
125
|
+
*/
|
|
126
|
+
const _DOTNET_SCAN_DEPTH = 2;
|
|
127
|
+
|
|
128
|
+
/** Ceiling on project files read per scope, once the descent is entered at all. */
|
|
129
|
+
const _DOTNET_MAX_PROJECTS = 50;
|
|
130
|
+
|
|
131
|
+
/** Solution extensions — named once, so the descent and its hit test agree. */
|
|
132
|
+
const _DOTNET_SOLUTION_EXTS = ['.sln', '.slnx'];
|
|
133
|
+
|
|
134
|
+
/** Does this scope hold a solution file? The precondition for descending. */
|
|
135
|
+
function _has_dotnet_solution(dir: string): boolean {
|
|
136
|
+
try {
|
|
137
|
+
// `withFileTypes`: an extension list over bare NAMES counts a
|
|
138
|
+
// DIRECTORY called `x.sln` as a target. The walk already filters.
|
|
139
|
+
return fs
|
|
140
|
+
.readdirSync(dir, { withFileTypes: true })
|
|
141
|
+
.some((e) => e.isFile() && _DOTNET_SOLUTION_EXTS.includes(path.extname(e.name).toLowerCase()));
|
|
142
|
+
} catch {
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Fixed-name files the BEHAVIOR axis reads that `_MANIFESTS` does not cover.
|
|
149
|
+
*
|
|
150
|
+
* **The split is historical and no rule separates them.** `_MANIFESTS` was
|
|
151
|
+
* once "what selects an ecosystem", but selection happens through individual
|
|
152
|
+
* `_is_file` / `_read_text` calls, and `.rspec` is a native signal in THIS
|
|
153
|
+
* list. Both are read in one place — so **a new fixed name goes in either;
|
|
154
|
+
* what matters is ONE.** What escapes: {@link _DOTNET_PROJECT_EXTS}.
|
|
155
|
+
*/
|
|
156
|
+
const _BEHAVIOR_MARKERS = [
|
|
157
|
+
'pnpm-workspace.yaml',
|
|
158
|
+
'requirements.txt',
|
|
159
|
+
'gems.rb',
|
|
160
|
+
'.rspec',
|
|
161
|
+
'behat.yml',
|
|
162
|
+
'behat.yaml',
|
|
163
|
+
'behat.yml.dist',
|
|
164
|
+
'behat.dist.yml',
|
|
165
|
+
'cucumber.js',
|
|
166
|
+
'cucumber.cjs',
|
|
167
|
+
'cucumber.mjs',
|
|
168
|
+
'cucumber.json',
|
|
80
169
|
];
|
|
81
170
|
|
|
82
171
|
/** A heterogeneous JSON object, mirroring a Python `dict[str, object]`. */
|
|
@@ -88,16 +177,10 @@ type Wrappers = { [role: string]: string };
|
|
|
88
177
|
/**
|
|
89
178
|
* One detected test runner for one ecosystem.
|
|
90
179
|
*
|
|
91
|
-
* `command` is the exact invocation
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
* concrete signal that matched (dependency, binary, marker file) so the
|
|
96
|
-
* routing decision is auditable, exactly like the non-interactive-contract's
|
|
97
|
-
* `basis` column.
|
|
98
|
-
*
|
|
99
|
-
* Mirrors the Python `@dataclass(frozen=True)` with positional construction
|
|
100
|
-
* and the documented field defaults.
|
|
180
|
+
* `command` is the exact invocation (a wrapper like `make test` when one
|
|
181
|
+
* exists, else the direct tool) and is ROOT-relative. `speed` is
|
|
182
|
+
* {@link SPEED_FAST} / {@link SPEED_SLOW} / {@link SPEED_E2E}; `basis` names
|
|
183
|
+
* the signal that matched.
|
|
101
184
|
*/
|
|
102
185
|
export class RunnerResult {
|
|
103
186
|
readonly ecosystem: string;
|
|
@@ -124,16 +207,41 @@ export class RunnerResult {
|
|
|
124
207
|
}
|
|
125
208
|
}
|
|
126
209
|
|
|
210
|
+
/**
|
|
211
|
+
* One behavior runner found in one scope — DETECTION ONLY.
|
|
212
|
+
*
|
|
213
|
+
* **`command` is relative to `scope_root`, not the project root** — built from
|
|
214
|
+
* files inside the scope (`vendor/bin/behat`, `./gradlew test`), so a consumer
|
|
215
|
+
* runs it WITH `scope_root` as the working directory. The native axis is
|
|
216
|
+
* root-relative; this one is the exception.
|
|
217
|
+
*
|
|
218
|
+
* {@link RunnerResult}'s fields plus `scope_root`, because the axis is
|
|
219
|
+
* **per-scope, not a repository-wide scalar**: one answer would erase which
|
|
220
|
+
* package owns which runner, and would break the tie the refusal exists to
|
|
221
|
+
* refuse. `scope_root` is POSIX-relative, `'.'` for the root; `conflict` is
|
|
222
|
+
* non-empty only on a refusal row. **It never instructs adoption.**
|
|
223
|
+
*/
|
|
224
|
+
export class BehaviorRunnerResult {
|
|
225
|
+
constructor(
|
|
226
|
+
readonly ecosystem: string,
|
|
227
|
+
readonly runner: string,
|
|
228
|
+
readonly command: string,
|
|
229
|
+
readonly scope_root: string,
|
|
230
|
+
readonly confidence: string = HIGH,
|
|
231
|
+
readonly basis = '',
|
|
232
|
+
readonly conflict: readonly string[] = [],
|
|
233
|
+
) {}
|
|
234
|
+
}
|
|
235
|
+
|
|
127
236
|
/**
|
|
128
237
|
* Outcome of one toolchain-resolution pass over a project root.
|
|
129
238
|
*
|
|
130
|
-
* `runners` is the full inventory
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
* cache invalidation just like {@link "./detect".StackResult}.
|
|
239
|
+
* `runners` is the full inventory; `selected` what a command runs after the
|
|
240
|
+
* flags + guard; `quality` the ordered lint commands per ecosystem;
|
|
241
|
+
* `confidence` the overall tier, about the RUNNER and never about `quality`
|
|
242
|
+
* (ruby, jvm and dotnet contribute a runner and no quality commands, so an
|
|
243
|
+
* empty `quality` means none was RESOLVED, not that none is needed — contract
|
|
244
|
+
* page § 3); `mtime` the cache key, as in {@link "./detect".StackResult}.
|
|
137
245
|
*/
|
|
138
246
|
export class ToolchainResult {
|
|
139
247
|
readonly ecosystems: readonly string[];
|
|
@@ -142,6 +250,22 @@ export class ToolchainResult {
|
|
|
142
250
|
readonly quality: readonly string[];
|
|
143
251
|
readonly confidence: string;
|
|
144
252
|
readonly mtime: number;
|
|
253
|
+
/**
|
|
254
|
+
* Per-scope behavior inventory — detection only, deliberately NOT merged
|
|
255
|
+
* into `runners` nor reachable from `selected`, so nothing is ever run
|
|
256
|
+
* BECAUSE this axis detected it.
|
|
257
|
+
*
|
|
258
|
+
* That is a claim about reachability, not about execution, and the two
|
|
259
|
+
* came apart in review: for four of the eight labels the behavior command
|
|
260
|
+
* is byte-identical to a native command already in `selected` —
|
|
261
|
+
* `pytest-bdd`/`pytest`, `cucumber-jvm`/`jvm.command`, and both
|
|
262
|
+
* `reqnroll` and `specflow`/`dotnet test`. Those native rows are
|
|
263
|
+
* SPEED_FAST, so the suite does run by default; it runs as the native
|
|
264
|
+
* runner's work, which this axis neither caused nor can prevent. Stating
|
|
265
|
+
* it as "reported, never run" overreached in exactly the half a reader
|
|
266
|
+
* would act on.
|
|
267
|
+
*/
|
|
268
|
+
readonly behavior_runners: readonly BehaviorRunnerResult[];
|
|
145
269
|
|
|
146
270
|
constructor(args: {
|
|
147
271
|
ecosystems: readonly string[];
|
|
@@ -150,6 +274,7 @@ export class ToolchainResult {
|
|
|
150
274
|
quality: readonly string[];
|
|
151
275
|
confidence: string;
|
|
152
276
|
mtime: number;
|
|
277
|
+
behavior_runners?: readonly BehaviorRunnerResult[];
|
|
153
278
|
}) {
|
|
154
279
|
this.ecosystems = args.ecosystems;
|
|
155
280
|
this.runners = args.runners;
|
|
@@ -157,6 +282,7 @@ export class ToolchainResult {
|
|
|
157
282
|
this.quality = args.quality;
|
|
158
283
|
this.confidence = args.confidence;
|
|
159
284
|
this.mtime = args.mtime;
|
|
285
|
+
this.behavior_runners = args.behavior_runners ?? [];
|
|
160
286
|
}
|
|
161
287
|
|
|
162
288
|
/**
|
|
@@ -183,6 +309,15 @@ export class ToolchainResult {
|
|
|
183
309
|
})),
|
|
184
310
|
selected: this.selected.map((r) => r.command),
|
|
185
311
|
quality: [...this.quality],
|
|
312
|
+
behavior_runners: this.behavior_runners.map((r) => ({
|
|
313
|
+
ecosystem: r.ecosystem,
|
|
314
|
+
runner: r.runner,
|
|
315
|
+
command: r.command,
|
|
316
|
+
scope_root: r.scope_root,
|
|
317
|
+
confidence: r.confidence,
|
|
318
|
+
basis: r.basis,
|
|
319
|
+
conflict: [...r.conflict],
|
|
320
|
+
})),
|
|
186
321
|
};
|
|
187
322
|
}
|
|
188
323
|
}
|
|
@@ -190,20 +325,14 @@ export class ToolchainResult {
|
|
|
190
325
|
/**
|
|
191
326
|
* Inspect `project_root` and resolve its test/quality toolchain.
|
|
192
327
|
*
|
|
193
|
-
* @param project_root
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
* @param opts.
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
* The `--php` narrowing — keep only the PHP ecosystem in `selected` (the
|
|
202
|
-
* full inventory is still returned in `runners`).
|
|
203
|
-
* @returns
|
|
204
|
-
* A {@link ToolchainResult}. Never raises. No manifest / unknown stack →
|
|
205
|
-
* an empty result with `confidence == LOW` so the caller can fall back to
|
|
206
|
-
* asking.
|
|
328
|
+
* @param project_root Manifest directory; no upward walk, the caller picks
|
|
329
|
+
* the scope, matching `detect.detect_stack`.
|
|
330
|
+
* @param opts.include_slow @param opts.include_e2e Monorepo guard, off by
|
|
331
|
+
* default: `selected` carries only fast unit suites.
|
|
332
|
+
* @param opts.php_only The `--php` narrowing — PHP only in `selected`; the
|
|
333
|
+
* full inventory stays in `runners`.
|
|
334
|
+
* @returns A {@link ToolchainResult}. Never raises; no manifest or unknown
|
|
335
|
+
* stack → empty at LOW confidence, so the caller can ask.
|
|
207
336
|
*/
|
|
208
337
|
export function resolve_toolchain(
|
|
209
338
|
project_root: string,
|
|
@@ -228,6 +357,9 @@ export function resolve_toolchain(
|
|
|
228
357
|
_is_file(path.join(project_root, 'pytest.ini'));
|
|
229
358
|
const has_go = _is_file(path.join(project_root, 'go.mod'));
|
|
230
359
|
const has_cargo = _is_file(path.join(project_root, 'Cargo.toml'));
|
|
360
|
+
const gemfile_text = _ruby_gemfile_text(project_root);
|
|
361
|
+
const jvm = _jvm_build(project_root);
|
|
362
|
+
const dotnet_basis = _dotnet_basis(project_root);
|
|
231
363
|
|
|
232
364
|
const wrappers = _task_runner_wrappers(project_root, pkg);
|
|
233
365
|
|
|
@@ -255,10 +387,18 @@ export function resolve_toolchain(
|
|
|
255
387
|
);
|
|
256
388
|
quality.push('cargo clippy');
|
|
257
389
|
}
|
|
390
|
+
runners.push(..._ruby_runners(project_root, gemfile_text));
|
|
391
|
+
if (jvm !== null) {
|
|
392
|
+
runners.push(..._jvm_runners(jvm));
|
|
393
|
+
}
|
|
394
|
+
if (dotnet_basis !== null) {
|
|
395
|
+
runners.push(..._dotnet_runners(dotnet_basis));
|
|
396
|
+
}
|
|
258
397
|
|
|
259
398
|
const ecosystems = _dictFromKeys(runners.map((r) => r.ecosystem));
|
|
260
399
|
const selected = _apply_guard(runners, { include_slow, include_e2e, php_only });
|
|
261
400
|
const confidence = _overall_confidence(runners);
|
|
401
|
+
const scopes = _behavior_scopes(project_root);
|
|
262
402
|
|
|
263
403
|
return new ToolchainResult({
|
|
264
404
|
ecosystems,
|
|
@@ -266,7 +406,11 @@ export function resolve_toolchain(
|
|
|
266
406
|
selected: [...selected],
|
|
267
407
|
quality: _dictFromKeys(quality),
|
|
268
408
|
confidence,
|
|
269
|
-
|
|
409
|
+
// Computed ONCE and threaded into both consumers: workspace discovery
|
|
410
|
+
// reads a manifest and lists per glob parent, and both the cache probe
|
|
411
|
+
// and the behavior axis need the same list.
|
|
412
|
+
mtime: latest_manifest_mtime(project_root, scopes),
|
|
413
|
+
behavior_runners: resolve_behavior_runners(project_root, scopes),
|
|
270
414
|
});
|
|
271
415
|
}
|
|
272
416
|
|
|
@@ -300,20 +444,31 @@ export function write_config(project_root: string, result: ToolchainResult): str
|
|
|
300
444
|
* when no manifest exists (greenfield) — a stable sentinel, not a
|
|
301
445
|
* missing-file error.
|
|
302
446
|
*/
|
|
303
|
-
export function latest_manifest_mtime(
|
|
447
|
+
export function latest_manifest_mtime(
|
|
448
|
+
project_root: string,
|
|
449
|
+
scopes?: readonly string[],
|
|
450
|
+
): number {
|
|
304
451
|
const mtimes: number[] = [];
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
452
|
+
const names = [..._MANIFESTS, ..._BEHAVIOR_MARKERS];
|
|
453
|
+
// Every scope, not only the root: `behavior_runners` is cached per scope,
|
|
454
|
+
// so a root-only key cannot see Behat arriving in a package — stale forever
|
|
455
|
+
// in the common case, and widening only invalidates more often. Cost as a
|
|
456
|
+
// SHAPE, since the arithmetic was published wrong once: both name lists
|
|
457
|
+
// stat-ed per scope, bounded by _MAX_BEHAVIOR_SCOPES, plus
|
|
458
|
+
// `_behavior_scopes` when `scopes` is omitted.
|
|
459
|
+
for (const scope of scopes ?? _behavior_scopes(project_root)) {
|
|
460
|
+
const dir = scope === '.' ? project_root : path.join(project_root, scope);
|
|
461
|
+
for (const name of names) {
|
|
462
|
+
const p = path.join(dir, name);
|
|
463
|
+
if (_is_file(p)) {
|
|
464
|
+
mtimes.push(_stat_mtime(p));
|
|
465
|
+
}
|
|
309
466
|
}
|
|
310
467
|
}
|
|
311
468
|
return mtimes.length > 0 ? Math.max(...mtimes) : 0.0;
|
|
312
469
|
}
|
|
313
470
|
|
|
314
|
-
// --------------------------------------------------------------------------
|
|
315
471
|
// Ecosystem resolvers
|
|
316
|
-
// --------------------------------------------------------------------------
|
|
317
472
|
|
|
318
473
|
function _php_runners(root: string, composer: Manifest, wrappers: Wrappers): RunnerResult[] {
|
|
319
474
|
const deps = _all_dependencies(composer, 'require', 'require-dev');
|
|
@@ -422,6 +577,533 @@ function _python_runners(pyproject_text: string): RunnerResult[] {
|
|
|
422
577
|
];
|
|
423
578
|
}
|
|
424
579
|
|
|
580
|
+
/**
|
|
581
|
+
* Ruby — `rspec` ONLY on an explicit signal, never a default: Ruby ships
|
|
582
|
+
* minitest in the stdlib, so a Gemfile with no rspec signal is probably
|
|
583
|
+
* minitest, and a MEDIUM `rspec` would be a guess wearing a label.
|
|
584
|
+
*/
|
|
585
|
+
/**
|
|
586
|
+
* The Ruby dependency declaration, under either name Bundler accepts —
|
|
587
|
+
* reading only `Gemfile` made a `gems.rb` project invisible to both branches.
|
|
588
|
+
*/
|
|
589
|
+
interface Gemfile {
|
|
590
|
+
text: string;
|
|
591
|
+
file: string;
|
|
592
|
+
/** The FILE exists — `bundle exec` keys on this, never on the text parsing. */
|
|
593
|
+
present: boolean;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function _ruby_gemfile_text(dir: string): Gemfile {
|
|
597
|
+
let present = false;
|
|
598
|
+
for (const name of ['Gemfile', 'gems.rb']) {
|
|
599
|
+
const full = path.join(dir, name);
|
|
600
|
+
// Same presence-not-content split the Gradle branch makes.
|
|
601
|
+
present = present || _is_file(full);
|
|
602
|
+
const text = _read_text(full);
|
|
603
|
+
if (text !== '') {
|
|
604
|
+
// The NAME travels with the text: the basis is serialised and
|
|
605
|
+
// names the file that matched, so a `gems.rb` project is not told
|
|
606
|
+
// its signal came from a `Gemfile` it does not have.
|
|
607
|
+
return { text, file: name, present: true };
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
return { text: '', file: '', present };
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/** `gem 'rspec'` / `gem "cucumber"` — a DECLARATION, not the word anywhere. */
|
|
614
|
+
function _gem_declared(text: string, gem: string): boolean {
|
|
615
|
+
// Same discipline as `_PY_BEHAVE`: a bare `\brspec\b` fires on a
|
|
616
|
+
// commented-out gem, a changelog line, or any name containing the word. A
|
|
617
|
+
// hyphenated SUFFIX is allowed (`rspec-rails`, `cucumber-rails`) but a
|
|
618
|
+
// suffix ONLY, so `cucumber` matches no gem merely ending in it.
|
|
619
|
+
return new RegExp(`^[ \\t]*gem[ \\t]+["']${gem}(-[A-Za-z0-9_-]+)?["']`, 'm').test(text);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function _ruby_runners(root: string, gemfile: Gemfile): RunnerResult[] {
|
|
623
|
+
const dot_rspec = _is_file(path.join(root, '.rspec'));
|
|
624
|
+
const spec_helper = _is_file(path.join(root, 'spec', 'spec_helper.rb'));
|
|
625
|
+
const in_gemfile = _gem_declared(gemfile.text, 'rspec');
|
|
626
|
+
// No `Gemfile` precondition: each of the three signals stands on its own,
|
|
627
|
+
// as the contract table says. Gating on the Gemfile made the two marker
|
|
628
|
+
// bases unreachable in the projects that have only markers.
|
|
629
|
+
if (!in_gemfile && !dot_rspec && !spec_helper) {
|
|
630
|
+
return [];
|
|
631
|
+
}
|
|
632
|
+
const basis = in_gemfile
|
|
633
|
+
? `rspec in ${gemfile.file}`
|
|
634
|
+
: dot_rspec
|
|
635
|
+
? '.rspec present'
|
|
636
|
+
: 'spec/spec_helper.rb present';
|
|
637
|
+
// `bundle exec` ABORTS without a Gemfile, and the marker signals exist
|
|
638
|
+
// precisely for projects that have none — so the prefix keys on the file
|
|
639
|
+
// that makes it work, not on the signal that found the suite.
|
|
640
|
+
const cmd = gemfile.present ? 'bundle exec rspec' : 'rspec';
|
|
641
|
+
return [new RunnerResult('ruby', 'rspec', cmd, SPEED_FAST, HIGH, basis)];
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/** Which JVM build system answered, its text, and the command prefix to use. */
|
|
645
|
+
interface JvmBuild {
|
|
646
|
+
tool: 'maven' | 'gradle';
|
|
647
|
+
text: string;
|
|
648
|
+
command: string;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* Locate the JVM build file and pick the invocation.
|
|
653
|
+
*
|
|
654
|
+
* Wrapper-first, like `_task_runner_wrappers`: `./gradlew` / `./mvnw` pin the
|
|
655
|
+
* build-tool version, so the ambient `gradle` / `mvn` can fail on one the
|
|
656
|
+
* project never used.
|
|
657
|
+
*/
|
|
658
|
+
function _jvm_build(root: string): JvmBuild | null {
|
|
659
|
+
const pom = _read_text(path.join(root, 'pom.xml'));
|
|
660
|
+
if (pom !== '') {
|
|
661
|
+
const wrapper = _is_file(path.join(root, 'mvnw'));
|
|
662
|
+
return { tool: 'maven', text: pom, command: wrapper ? './mvnw test' : 'mvn test' };
|
|
663
|
+
}
|
|
664
|
+
// PRESENCE, not content: a Gradle multi-project root routinely has an
|
|
665
|
+
// empty `build.gradle`, or none with `settings.gradle[.kts]` declaring
|
|
666
|
+
// the modules, and both looked like "no Gradle here". Maven above still
|
|
667
|
+
// gates on content — recorded in the roadmap residue.
|
|
668
|
+
const gradleFile = ['build.gradle', 'build.gradle.kts', 'settings.gradle', 'settings.gradle.kts'].find(
|
|
669
|
+
(n) => _is_file(path.join(root, n)),
|
|
670
|
+
);
|
|
671
|
+
if (gradleFile !== undefined) {
|
|
672
|
+
const wrapper = _is_file(path.join(root, 'gradlew'));
|
|
673
|
+
return {
|
|
674
|
+
tool: 'gradle',
|
|
675
|
+
text:
|
|
676
|
+
_read_text(path.join(root, 'build.gradle')) +
|
|
677
|
+
'\n' +
|
|
678
|
+
_read_text(path.join(root, 'build.gradle.kts')),
|
|
679
|
+
command: wrapper ? './gradlew test' : 'gradle test',
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
return null;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function _jvm_runners(build: JvmBuild): RunnerResult[] {
|
|
686
|
+
const named = /\bjunit\b/i.test(build.text);
|
|
687
|
+
const conf = named ? HIGH : MEDIUM;
|
|
688
|
+
const basis = named
|
|
689
|
+
? `junit in the ${build.tool} build file`
|
|
690
|
+
: `${build.tool} build file present, no explicit runner`;
|
|
691
|
+
return [new RunnerResult('jvm', 'junit', build.command, SPEED_FAST, conf, basis)];
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* .NET — the basis string, or `null` when nothing marks a .NET project.
|
|
696
|
+
*
|
|
697
|
+
* Mirrors the PHP gradation, awarding HIGH only for a NAMED runner: HIGH
|
|
698
|
+
* needs a PROJECT file naming a TEST stack (`Microsoft.NET.Test.Sdk`, xunit,
|
|
699
|
+
* nunit, mstest); a bare project or solution is MEDIUM, the analogue of the
|
|
700
|
+
* bare manifest. That stops a bare non-test project raising the WHOLE
|
|
701
|
+
* repository to HIGH, but does NOT keep the row out of `selected` —
|
|
702
|
+
* `_apply_guard` has never read `confidence` — which is why a marker with no
|
|
703
|
+
* project anywhere emits no row.
|
|
704
|
+
*/
|
|
705
|
+
function _dotnet_basis(root: string): { basis: string; confidence: string } | null {
|
|
706
|
+
let names: string[];
|
|
707
|
+
try {
|
|
708
|
+
// SORTED: the basis names the matched file and is serialised, so an
|
|
709
|
+
// unsorted listing churns a stable config on rename. FILES only — a
|
|
710
|
+
// directory named `x.csproj` is no target, and this row reaches
|
|
711
|
+
// `selected`.
|
|
712
|
+
names = fs
|
|
713
|
+
.readdirSync(root, { withFileTypes: true })
|
|
714
|
+
.filter((e) => e.isFile())
|
|
715
|
+
.map((e) => e.name)
|
|
716
|
+
.sort();
|
|
717
|
+
} catch {
|
|
718
|
+
names = [];
|
|
719
|
+
}
|
|
720
|
+
// A ROOT TARGET, not a target anywhere. `dotnet test` is NOT recursive:
|
|
721
|
+
// with no project or solution in the working directory it fails MSB1003,
|
|
722
|
+
// whatever sits below. So the row is gated on this listing, and a scope
|
|
723
|
+
// whose only .NET evidence is an SDK pin gets NO row — confidence does not
|
|
724
|
+
// gate `selected`, so a row here would be a command that cannot run.
|
|
725
|
+
const target = names.find((n) => _DOTNET_PROJECT_EXTS.includes(path.extname(n).toLowerCase()));
|
|
726
|
+
if (target === undefined) {
|
|
727
|
+
return null;
|
|
728
|
+
}
|
|
729
|
+
// The DESCENT still matters for the tier: a root `.sln` is a valid target
|
|
730
|
+
// whose own body names no package, so the projects it references decide
|
|
731
|
+
// HIGH vs MEDIUM.
|
|
732
|
+
return _DOTNET_TEST_STACK.test(_dotnet_project_text(root))
|
|
733
|
+
? { basis: 'test stack named in a .NET project file', confidence: HIGH }
|
|
734
|
+
: { basis: `${target} present, no test stack named`, confidence: MEDIUM };
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function _dotnet_runners(found: { basis: string; confidence: string }): RunnerResult[] {
|
|
738
|
+
return [
|
|
739
|
+
new RunnerResult(
|
|
740
|
+
'dotnet',
|
|
741
|
+
'dotnet-test',
|
|
742
|
+
'dotnet test',
|
|
743
|
+
SPEED_FAST,
|
|
744
|
+
found.confidence,
|
|
745
|
+
found.basis,
|
|
746
|
+
),
|
|
747
|
+
];
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// --- Behavior-runner axis — per scope, list-shaped, detection only -------
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* Resolve every behavior runner this repository carries, one row per scope.
|
|
754
|
+
*
|
|
755
|
+
* Scopes are the root plus each declared workspace package: one row cannot
|
|
756
|
+
* represent the ordinary monorepo, a behavior runner in one package and plain
|
|
757
|
+
* unit tests in another. Two runners of ONE ecosystem in one scope refuse,
|
|
758
|
+
* naming both, never a pick. Never raises — an unreadable manifest yields
|
|
759
|
+
* fewer rows.
|
|
760
|
+
*/
|
|
761
|
+
export function resolve_behavior_runners(
|
|
762
|
+
project_root: string,
|
|
763
|
+
scopes?: readonly string[],
|
|
764
|
+
): BehaviorRunnerResult[] {
|
|
765
|
+
const out: BehaviorRunnerResult[] = [];
|
|
766
|
+
for (const scope of scopes ?? _behavior_scopes(project_root)) {
|
|
767
|
+
const dir = scope === '.' ? project_root : path.join(project_root, scope);
|
|
768
|
+
// Grouped by ECOSYSTEM: two ecosystems' behavior runners are a
|
|
769
|
+
// polyglot repository, not a conflict — a PHP app with a JS frontend
|
|
770
|
+
// is the commonest shape there is, and `unknown` throws both answers
|
|
771
|
+
// away. Only two runners in ONE ecosystem are mutually exclusive.
|
|
772
|
+
const rows = _behavior_runners_in_scope(dir, scope);
|
|
773
|
+
for (const ecosystem of _dictFromKeys(rows.map((r) => r.ecosystem))) {
|
|
774
|
+
const found = rows.filter((r) => r.ecosystem === ecosystem);
|
|
775
|
+
const names = [...new Set(found.map((r) => r.runner))].sort();
|
|
776
|
+
if (names.length <= 1) {
|
|
777
|
+
// One answer per ecosystem, however many signals produced it.
|
|
778
|
+
// The `>1 row, 1 name` case is unreachable today; without it a
|
|
779
|
+
// future branch emitting a label twice refuses it against itself.
|
|
780
|
+
out.push(found[0] as BehaviorRunnerResult);
|
|
781
|
+
continue;
|
|
782
|
+
}
|
|
783
|
+
out.push(
|
|
784
|
+
new BehaviorRunnerResult(
|
|
785
|
+
ecosystem,
|
|
786
|
+
BEHAVIOR_UNKNOWN,
|
|
787
|
+
'',
|
|
788
|
+
scope,
|
|
789
|
+
LOW,
|
|
790
|
+
`two ${ecosystem} behavior runners in one scope ` +
|
|
791
|
+
`(${names.join(', ')}) — refusing to choose, as the frontend ` +
|
|
792
|
+
'detector refuses between mutually exclusive workspaces',
|
|
793
|
+
names,
|
|
794
|
+
),
|
|
795
|
+
);
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
return out;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/** Behavior runners inside ONE scope directory, before conflict handling. */
|
|
802
|
+
function _behavior_runners_in_scope(dir: string, scope: string): BehaviorRunnerResult[] {
|
|
803
|
+
const out: BehaviorRunnerResult[] = [];
|
|
804
|
+
const at = (n: string): string => path.join(dir, n);
|
|
805
|
+
const row = (ecosystem: string, runner: string, command: string, basis: string): void => {
|
|
806
|
+
out.push(new BehaviorRunnerResult(ecosystem, runner, command, scope, HIGH, basis));
|
|
807
|
+
};
|
|
808
|
+
|
|
809
|
+
const php_deps = _all_dependencies(_read_json(at('composer.json')), 'require', 'require-dev');
|
|
810
|
+
const behat_config = ['behat.yml', 'behat.yaml', 'behat.yml.dist', 'behat.dist.yml'].find((n) =>
|
|
811
|
+
_is_file(at(n)),
|
|
812
|
+
);
|
|
813
|
+
if ('behat/behat' in php_deps || behat_config !== undefined) {
|
|
814
|
+
// "in the composer manifest", NOT "in composer require": the literal
|
|
815
|
+
// reads as an install instruction to a grep, and this axis may never be
|
|
816
|
+
// mistaken for one. And `vendor/bin/behat` exists only after a composer
|
|
817
|
+
// install, so a config-only scope gets the bare binary.
|
|
818
|
+
const declared = 'behat/behat' in php_deps;
|
|
819
|
+
const basis = declared
|
|
820
|
+
? 'behat/behat in the composer manifest'
|
|
821
|
+
: `${behat_config} present`;
|
|
822
|
+
row('php', 'behat', declared ? 'vendor/bin/behat' : 'behat', basis);
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// OWN dependencies only, unlike the native axis: a peer or optional
|
|
826
|
+
// declaration says a CONSUMER must supply cucumber — which is exactly how a
|
|
827
|
+
// formatter or preset declares it. On an axis whose doctrine is no guessing,
|
|
828
|
+
// reading one as ownership is the guess.
|
|
829
|
+
const js_deps = _all_dependencies(
|
|
830
|
+
_read_json(at('package.json')),
|
|
831
|
+
'dependencies',
|
|
832
|
+
'devDependencies',
|
|
833
|
+
);
|
|
834
|
+
const cucumber_config = ['cucumber.js', 'cucumber.cjs', 'cucumber.mjs', 'cucumber.json'].find(
|
|
835
|
+
(n) => _is_file(at(n)),
|
|
836
|
+
);
|
|
837
|
+
if ('@cucumber/cucumber' in js_deps || 'cucumber' in js_deps || cucumber_config !== undefined) {
|
|
838
|
+
const dep = '@cucumber/cucumber' in js_deps ? '@cucumber/cucumber' : 'cucumber';
|
|
839
|
+
// `npx` FETCHES when the package is absent, so on the config-only
|
|
840
|
+
// path it would let a detection-only axis install. Degrades like the
|
|
841
|
+
// behat and ruby rows.
|
|
842
|
+
const declared = dep in js_deps;
|
|
843
|
+
const basis = declared ? `${dep} in package deps` : `${cucumber_config} present`;
|
|
844
|
+
row('js', 'cucumber-js', declared ? 'npx cucumber-js' : 'cucumber-js', basis);
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
// Joined with a newline, like `_dotnet_project_text`: concatenated bare, a
|
|
848
|
+
// `pyproject.toml` with no trailing newline fuses its last token to the
|
|
849
|
+
// first of `requirements.txt`, making a token present in neither.
|
|
850
|
+
const py_text = [_read_text(at('pyproject.toml')), _read_text(at('requirements.txt'))].join('\n');
|
|
851
|
+
if (_PY_BEHAVE.test(py_text)) {
|
|
852
|
+
row('python', 'behave', 'behave', 'behave declared as a python dependency');
|
|
853
|
+
}
|
|
854
|
+
// Anchored like `_PY_BEHAVE`, for a sharper reason than symmetry: an
|
|
855
|
+
// unanchored match on a commented-out `# pytest-bdd` beside a real
|
|
856
|
+
// `behave` declaration made TWO python rows, which the grouping turned
|
|
857
|
+
// into a FALSE refusal. A false positive here deletes a true row.
|
|
858
|
+
if (_PY_BDD.test(py_text)) {
|
|
859
|
+
row('python', 'pytest-bdd', 'pytest', 'pytest-bdd declared as a python dependency');
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
const gemfile = _ruby_gemfile_text(dir);
|
|
863
|
+
const cucumber_gem = _gem_declared(gemfile.text, 'cucumber');
|
|
864
|
+
if (cucumber_gem || _is_file(path.join(dir, 'features', 'support', 'env.rb'))) {
|
|
865
|
+
// `bundle exec` ABORTS without a Gemfile and `env.rb` is the signal
|
|
866
|
+
// for scopes that have none — same guard as `_ruby_runners`.
|
|
867
|
+
const basis = cucumber_gem
|
|
868
|
+
? `cucumber in ${gemfile.file}`
|
|
869
|
+
: 'features/support/env.rb present';
|
|
870
|
+
const cmd = gemfile.present ? 'bundle exec cucumber' : 'cucumber';
|
|
871
|
+
row('ruby', 'cucumber-ruby', cmd, basis);
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
// NOTE: `jvm.text` is the ROOT build files only, so a Gradle multi-project
|
|
875
|
+
// root whose modules declare cucumber is missed — recorded in the roadmap
|
|
876
|
+
// residue rather than fixed, because reading the included modules is new
|
|
877
|
+
// detection work rather than a correction.
|
|
878
|
+
const jvm = _jvm_build(dir);
|
|
879
|
+
if (jvm !== null && /io\.cucumber|cucumber-java|cucumber-junit/i.test(jvm.text)) {
|
|
880
|
+
row('jvm', 'cucumber-jvm', jvm.command, `cucumber in the ${jvm.tool} build file`);
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
// Two rows, not an `else if`. The chain was a silent pick: this reads the
|
|
884
|
+
// CONCATENATED text of every project, so a Reqnroll project beside a
|
|
885
|
+
// SpecFlow one collapsed into one HIGH row — the guess the refusal exists
|
|
886
|
+
// to prevent. Two rows let the grouping refuse, as it does for python.
|
|
887
|
+
const dotnet_text = _dotnet_project_text(dir);
|
|
888
|
+
if (/\bReqnroll\b/i.test(dotnet_text)) {
|
|
889
|
+
row('dotnet', 'reqnroll', 'dotnet test', 'Reqnroll in a project file');
|
|
890
|
+
}
|
|
891
|
+
if (/\bSpecFlow\b/i.test(dotnet_text)) {
|
|
892
|
+
row('dotnet', 'specflow', 'dotnet test', 'SpecFlow in a project file');
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
return out;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/**
|
|
899
|
+
* Concatenated text of the .NET project files belonging to this scope.
|
|
900
|
+
*
|
|
901
|
+
* Scans the scope and, holding no project file of its own, descends to
|
|
902
|
+
* {@link _DOTNET_SCAN_DEPTH}: the `root/src/<Name>/<Name>.csproj` layout,
|
|
903
|
+
* where the root carries only a `.sln` and a `.sln` names no package. Sorted
|
|
904
|
+
* at every level, so deterministic. Depth is bounded, and files READ are
|
|
905
|
+
* bounded — directories LISTED are not, and the max-projects exits fire only
|
|
906
|
+
* once a project was found.
|
|
907
|
+
*/
|
|
908
|
+
function _dotnet_project_text(dir: string): string {
|
|
909
|
+
const texts: string[] = [];
|
|
910
|
+
// Gated on a solution. The gate suppresses the DESCENT only: this
|
|
911
|
+
// directory is listed here and again by the walk, in every scope of every
|
|
912
|
+
// repository. NOT pruned at scope boundaries either, so a root solution
|
|
913
|
+
// plus package scopes can still double-attribute — both residue.
|
|
914
|
+
const solution = _has_dotnet_solution(dir);
|
|
915
|
+
const walk = (at: string, depth: number): void => {
|
|
916
|
+
if (texts.length >= _DOTNET_MAX_PROJECTS) {
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
let entries: fs.Dirent[];
|
|
920
|
+
try {
|
|
921
|
+
entries = fs.readdirSync(at, { withFileTypes: true });
|
|
922
|
+
} catch {
|
|
923
|
+
return;
|
|
924
|
+
}
|
|
925
|
+
entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
926
|
+
let hit = false;
|
|
927
|
+
for (const e of entries) {
|
|
928
|
+
if (!e.isFile() || !_DOTNET_PROJECT_EXTS.includes(path.extname(e.name).toLowerCase())) {
|
|
929
|
+
continue;
|
|
930
|
+
}
|
|
931
|
+
// A SOLUTION's body is project NAMES, never packages — neither
|
|
932
|
+
// read as project text nor a reason to stop descending. Read,
|
|
933
|
+
// `MyApp.SpecFlow.Tests` emits a HIGH `specflow` row no project
|
|
934
|
+
// supports, and a legacy SpecFlow name beside a Reqnroll one makes
|
|
935
|
+
// two dotnet rows the grouping REFUSES — a false positive deleting
|
|
936
|
+
// a true row. Presence is still a descent signal.
|
|
937
|
+
if (_DOTNET_SOLUTION_EXTS.includes(path.extname(e.name).toLowerCase())) {
|
|
938
|
+
continue;
|
|
939
|
+
}
|
|
940
|
+
if (texts.length >= _DOTNET_MAX_PROJECTS) {
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
943
|
+
texts.push(_read_text(path.join(at, e.name)));
|
|
944
|
+
hit = true;
|
|
945
|
+
}
|
|
946
|
+
if (hit || !solution || depth >= _DOTNET_SCAN_DEPTH) {
|
|
947
|
+
return;
|
|
948
|
+
}
|
|
949
|
+
for (const e of entries) {
|
|
950
|
+
if (e.isDirectory() && !e.name.startsWith('.') && e.name !== 'node_modules') {
|
|
951
|
+
walk(path.join(at, e.name), depth + 1);
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
};
|
|
955
|
+
walk(dir, 0);
|
|
956
|
+
return texts.join('\n');
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
/** Hard ceiling on scopes scanned — a pathological glob cannot stall a turn. */
|
|
960
|
+
const _MAX_BEHAVIOR_SCOPES = 200;
|
|
961
|
+
|
|
962
|
+
/**
|
|
963
|
+
* `behave` DECLARED AS A DEPENDENCY, never merely mentioned.
|
|
964
|
+
*
|
|
965
|
+
* `behave` is an ordinary English verb, unlike every sibling token here, so a
|
|
966
|
+
* bare `\bbehave\b` fires HIGH on a description or changelog line. Anchored to
|
|
967
|
+
* a line-start declaration, terminated by a version specifier, a separator or
|
|
968
|
+
* a `#` comment. Misses the SINGLE-LINE array form only — the multi-line
|
|
969
|
+
* array and the poetry inline table both match — a missed row either way,
|
|
970
|
+
* which is the cheap direction here.
|
|
971
|
+
*/
|
|
972
|
+
const _PY_BEHAVE = /^[ \t]*["']?behave["']?[ \t]*(?:$|[=<>~!,;[#])/m;
|
|
973
|
+
|
|
974
|
+
/** `pytest-bdd` declared as a dependency — same anchoring, same reason. */
|
|
975
|
+
const _PY_BDD = /^[ \t]*["']?pytest-bdd["']?[ \t]*(?:$|[=<>~!,;[#])/m;
|
|
976
|
+
|
|
977
|
+
/**
|
|
978
|
+
* The `packages:` sequence of a `pnpm-workspace.yaml`, and ONLY that key.
|
|
979
|
+
*
|
|
980
|
+
* Matching every `- item` line is wrong in a way that reads as working: a
|
|
981
|
+
* real workspace file carries sibling sequences (`onlyBuiltDependencies`,
|
|
982
|
+
* `patchedDependencies`), so `- esbuild` under one became the scope
|
|
983
|
+
* `esbuild`. A line-state reader, not a YAML parse, because this module is
|
|
984
|
+
* leaf by contract: track whether the current top-level key is `packages`
|
|
985
|
+
* and accept items only while it is; any column-0 key ends the section.
|
|
986
|
+
*
|
|
987
|
+
* KNOWN GAP: a MULTI-LINE flow sequence (`packages: [` then item lines) is
|
|
988
|
+
* dropped whole — the single-line flow branch consumes the opener. Residue.
|
|
989
|
+
*/
|
|
990
|
+
export function _pnpm_packages(text: string): string[] {
|
|
991
|
+
const out: string[] = [];
|
|
992
|
+
let inPackages = false;
|
|
993
|
+
const push = (raw: string): void => {
|
|
994
|
+
// A trailing `# comment` is legal YAML; strip before unquoting.
|
|
995
|
+
const v = raw.replace(/\s+#.*$/, '').trim().replace(/^['"]|['"]$/g, '').trim();
|
|
996
|
+
if (v !== '') out.push(v);
|
|
997
|
+
};
|
|
998
|
+
// `\r?\n`, not `\n`: `.` never matches `\r`, so on a CRLF file the key
|
|
999
|
+
// regex below matched NOTHING and the whole workspace silently collapsed
|
|
1000
|
+
// to the root scope — the same silent-drop class as the flow gap above.
|
|
1001
|
+
for (const line of text.split(/\r?\n/)) {
|
|
1002
|
+
if (line.trim() === '' || /^\s*#/.test(line)) {
|
|
1003
|
+
continue;
|
|
1004
|
+
}
|
|
1005
|
+
const key = /^([A-Za-z0-9_-]+)\s*:(.*)$/.exec(line);
|
|
1006
|
+
if (key) {
|
|
1007
|
+
inPackages = key[1] === 'packages';
|
|
1008
|
+
// A FLOW sequence on the key line is the same declaration
|
|
1009
|
+
// inline; consuming the line discarded the whole workspace.
|
|
1010
|
+
const rest = (key[2] ?? '').trim();
|
|
1011
|
+
if (inPackages && rest.startsWith('[')) {
|
|
1012
|
+
for (const part of rest.replace(/^\[|\]$/g, '').split(',')) {
|
|
1013
|
+
push(part);
|
|
1014
|
+
}
|
|
1015
|
+
inPackages = false;
|
|
1016
|
+
}
|
|
1017
|
+
continue;
|
|
1018
|
+
}
|
|
1019
|
+
if (!inPackages) {
|
|
1020
|
+
continue;
|
|
1021
|
+
}
|
|
1022
|
+
// `^\s*-`, not `^\s+-`: a zero-indent block sequence is valid YAML.
|
|
1023
|
+
const item = /^\s*-\s*(.+?)\s*$/.exec(line);
|
|
1024
|
+
if (item && item[1] !== undefined) {
|
|
1025
|
+
push(item[1]);
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
return out;
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
/**
|
|
1032
|
+
* The scopes to scan: the root, plus each declared workspace package.
|
|
1033
|
+
*
|
|
1034
|
+
* A LOCAL re-read, not an import of the frontend detector: this module is
|
|
1035
|
+
* leaf by contract. Only the two forms that declare package locations are
|
|
1036
|
+
* read; a `turbo.json` / `nx.json` root sits beside one in practice.
|
|
1037
|
+
*/
|
|
1038
|
+
/** A segment no workspace package lives in — `node_modules`, dot-dirs. */
|
|
1039
|
+
function _excluded_scope(name: string): boolean {
|
|
1040
|
+
return name.split('/').some((seg) => seg.startsWith('.') || seg === 'node_modules');
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
export function _behavior_scopes(project_root: string): string[] {
|
|
1044
|
+
const globs: string[] = [];
|
|
1045
|
+
const pkg = _read_json(path.join(project_root, 'package.json'));
|
|
1046
|
+
const ws = pkg['workspaces'];
|
|
1047
|
+
if (Array.isArray(ws)) {
|
|
1048
|
+
globs.push(...ws.filter((g): g is string => typeof g === 'string'));
|
|
1049
|
+
} else if (_isDict(ws) && Array.isArray(ws['packages'])) {
|
|
1050
|
+
globs.push(...(ws['packages'] as unknown[]).filter((g): g is string => typeof g === 'string'));
|
|
1051
|
+
}
|
|
1052
|
+
globs.push(..._pnpm_packages(_read_text(path.join(project_root, 'pnpm-workspace.yaml'))));
|
|
1053
|
+
|
|
1054
|
+
const scopes = ['.'];
|
|
1055
|
+
for (const raw of globs) {
|
|
1056
|
+
// Checked DURING expansion, not sliced off the result, which bounds
|
|
1057
|
+
// only after the listing is paid. Non-resolvers still cost a syscall.
|
|
1058
|
+
if (scopes.length >= _MAX_BEHAVIOR_SCOPES) {
|
|
1059
|
+
break;
|
|
1060
|
+
}
|
|
1061
|
+
const g = raw.replace(/^\.\//, '').replace(/\/+$/, '');
|
|
1062
|
+
if (g === '' || g.startsWith('!') || g.startsWith('/') || g.includes('..')) {
|
|
1063
|
+
continue;
|
|
1064
|
+
}
|
|
1065
|
+
// One predicate over the WHOLE pattern, before the fork: checking only
|
|
1066
|
+
// the glob child let `node_modules/*` admit what the literal refuses.
|
|
1067
|
+
if (_excluded_scope(g)) {
|
|
1068
|
+
continue;
|
|
1069
|
+
}
|
|
1070
|
+
if (g.endsWith('/*')) {
|
|
1071
|
+
const parent = g.slice(0, -2);
|
|
1072
|
+
let children: fs.Dirent[];
|
|
1073
|
+
try {
|
|
1074
|
+
children = fs.readdirSync(path.join(project_root, parent), { withFileTypes: true });
|
|
1075
|
+
} catch {
|
|
1076
|
+
continue;
|
|
1077
|
+
}
|
|
1078
|
+
// SORTED: scope order becomes serialised row order.
|
|
1079
|
+
children.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
1080
|
+
for (const c of children) {
|
|
1081
|
+
if (scopes.length >= _MAX_BEHAVIOR_SCOPES) {
|
|
1082
|
+
break;
|
|
1083
|
+
}
|
|
1084
|
+
if (c.isDirectory() && !_excluded_scope(c.name)) {
|
|
1085
|
+
scopes.push(`${parent}/${c.name}`);
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
} else if (!g.includes('*')) {
|
|
1089
|
+
// Existence-checked; the exclusion already ran above the fork.
|
|
1090
|
+
try {
|
|
1091
|
+
// `lstatSync`, matching the glob branch's `Dirent.isDirectory()`:
|
|
1092
|
+
// `statSync` follows a symlink, so one package was a scope when
|
|
1093
|
+
// declared literally and skipped when matched by a glob.
|
|
1094
|
+
if (fs.lstatSync(path.join(project_root, g)).isDirectory()) {
|
|
1095
|
+
scopes.push(g);
|
|
1096
|
+
}
|
|
1097
|
+
} catch {
|
|
1098
|
+
/* declared but absent — not a scope */
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
// A deeper or mid-path glob (`a/*/b`, `**`) is skipped, not
|
|
1102
|
+
// half-expanded: a scope that does not exist is worse than one fewer.
|
|
1103
|
+
}
|
|
1104
|
+
return _dictFromKeys(scopes);
|
|
1105
|
+
}
|
|
1106
|
+
|
|
425
1107
|
function _php_quality(root: string, composer: Manifest, _wrappers: Wrappers): string[] {
|
|
426
1108
|
const deps = _all_dependencies(composer, 'require', 'require-dev');
|
|
427
1109
|
const out: string[] = [];
|
|
@@ -463,9 +1145,7 @@ function _python_quality(pyproject_text: string): string[] {
|
|
|
463
1145
|
return out;
|
|
464
1146
|
}
|
|
465
1147
|
|
|
466
|
-
// --------------------------------------------------------------------------
|
|
467
1148
|
// Task-runner wrappers (Makefile / Taskfile / package scripts)
|
|
468
|
-
// --------------------------------------------------------------------------
|
|
469
1149
|
|
|
470
1150
|
/**
|
|
471
1151
|
* Map logical roles to a wrapper command when one exists.
|
|
@@ -531,16 +1211,12 @@ export interface PackageManagerResolution {
|
|
|
531
1211
|
/**
|
|
532
1212
|
* Resolve the package manager per `monorepo-workspace/SKILL.md` § 1.
|
|
533
1213
|
*
|
|
534
|
-
* Two
|
|
535
|
-
*
|
|
536
|
-
*
|
|
537
|
-
*
|
|
538
|
-
*
|
|
539
|
-
*
|
|
540
|
-
* - **Two lockfiles is a finding, not a tie to break.** The old code returned
|
|
541
|
-
* `pnpm` for a repository carrying both `pnpm-lock.yaml` and `yarn.lock`,
|
|
542
|
-
* silently picking the first branch it tested. That is a guess presented as
|
|
543
|
-
* an answer, and the skill says to report both and stop.
|
|
1214
|
+
* Two behaviors the previous three-branch version lacked, both from the skill
|
|
1215
|
+
* rather than invented here: `packageManager` in the root `package.json` is
|
|
1216
|
+
* the DECLARATION and wins when present, because Corepack enforces it and a
|
|
1217
|
+
* lockfile is only an inference; and **two lockfiles is a finding, not a tie
|
|
1218
|
+
* to break** — the old code returned `pnpm` for a repository carrying both
|
|
1219
|
+
* `pnpm-lock.yaml` and `yarn.lock`, a guess presented as an answer.
|
|
544
1220
|
*/
|
|
545
1221
|
export function _resolve_package_manager(
|
|
546
1222
|
root: string,
|
|
@@ -583,9 +1259,7 @@ function _package_manager(root: string, pkg?: Manifest): string {
|
|
|
583
1259
|
return _resolve_package_manager(root, pkg).manager ?? 'npm';
|
|
584
1260
|
}
|
|
585
1261
|
|
|
586
|
-
// --------------------------------------------------------------------------
|
|
587
1262
|
// Monorepo guard + confidence
|
|
588
|
-
// --------------------------------------------------------------------------
|
|
589
1263
|
|
|
590
1264
|
function _apply_guard(
|
|
591
1265
|
runners: RunnerResult[],
|
|
@@ -617,9 +1291,7 @@ function _overall_confidence(runners: RunnerResult[]): string {
|
|
|
617
1291
|
return MEDIUM;
|
|
618
1292
|
}
|
|
619
1293
|
|
|
620
|
-
// --------------------------------------------------------------------------
|
|
621
1294
|
// Shared readers (mirror detect.py's recoverable-error contract)
|
|
622
|
-
// --------------------------------------------------------------------------
|
|
623
1295
|
|
|
624
1296
|
function _read_json(p: string): Manifest {
|
|
625
1297
|
if (!_is_file(p)) {
|
|
@@ -677,9 +1349,7 @@ function _script_command(scripts: Manifest, names: string[], manager: string): s
|
|
|
677
1349
|
return '';
|
|
678
1350
|
}
|
|
679
1351
|
|
|
680
|
-
// --------------------------------------------------------------------------
|
|
681
1352
|
// stdlib parity helpers
|
|
682
|
-
// --------------------------------------------------------------------------
|
|
683
1353
|
|
|
684
1354
|
/** Python `Path.is_file()` — true only for a regular file (follows symlinks). */
|
|
685
1355
|
function _is_file(p: string): boolean {
|
|
@@ -696,8 +1366,15 @@ function _is_file(p: string): boolean {
|
|
|
696
1366
|
* the parity tests treat the serialized `mtime` as non-deterministic.
|
|
697
1367
|
*/
|
|
698
1368
|
function _stat_mtime(p: string): number {
|
|
699
|
-
|
|
700
|
-
|
|
1369
|
+
// GUARDED, like every sibling helper. `latest_manifest_mtime` stats after
|
|
1370
|
+
// a SEPARATE `_is_file`, so a file deleted between the two threw out of
|
|
1371
|
+
// `resolve_toolchain` and broke its never-raises contract. A missing file
|
|
1372
|
+
// contributes no mtime, exactly as one that was never there.
|
|
1373
|
+
try {
|
|
1374
|
+
return Number(fs.statSync(p, { bigint: true }).mtimeNs) / 1e9;
|
|
1375
|
+
} catch {
|
|
1376
|
+
return 0;
|
|
1377
|
+
}
|
|
701
1378
|
}
|
|
702
1379
|
|
|
703
1380
|
/** Python `isinstance(x, dict)` — a plain (non-array, non-null) object. */
|
|
@@ -723,30 +1400,18 @@ function _dictFromKeys(seq: string[]): string[] {
|
|
|
723
1400
|
return [...new Set(seq)];
|
|
724
1401
|
}
|
|
725
1402
|
|
|
726
|
-
// --------------------------------------------------------------------------
|
|
727
1403
|
// JSON serialisation — byte-parity with `json.dumps(..., indent=2,
|
|
728
1404
|
// sort_keys=True)` for `write_config`.
|
|
729
|
-
// --------------------------------------------------------------------------
|
|
730
1405
|
|
|
731
1406
|
/**
|
|
732
1407
|
* Mirror Python `json.dumps(obj, indent=2, sort_keys=True)` byte-for-byte.
|
|
733
1408
|
*
|
|
734
|
-
* Two CPython
|
|
735
|
-
*
|
|
736
|
-
*
|
|
737
|
-
*
|
|
738
|
-
*
|
|
739
|
-
* -
|
|
740
|
-
* float/int tag, so a JS `number` that is integral renders without `.0`.
|
|
741
|
-
* In this module the only float is `mtime`; the parity tests normalize it
|
|
742
|
-
* (its exact byte-repr is not reproducible across CPython/V8), but the
|
|
743
|
-
* serialiser still applies the `N.0` rule via a tagged float wrapper so the
|
|
744
|
-
* *shape* matches. `mtime` is the sole value passed through that wrapper.
|
|
745
|
-
*
|
|
746
|
-
* 2-space indent, `": "` key separator, `","` item separator with the
|
|
747
|
-
* indent-driven newline, `{}` / `[]` for empties, non-ASCII verbatim
|
|
748
|
-
* (`ensure_ascii` defaults to `True` in CPython, but `to_config` carries no
|
|
749
|
-
* non-ASCII, so the distinction never surfaces).
|
|
1409
|
+
* Two CPython behaviors `JSON.stringify` does NOT reproduce, done here:
|
|
1410
|
+
* `sort_keys=True` (code-point key order — pre-sorted recursively) and
|
|
1411
|
+
* integer-valued floats rendering `N.0`, which JSON cannot tag. The only
|
|
1412
|
+
* float is `mtime`; the parity tests normalize its value (not reproducible
|
|
1413
|
+
* across CPython/V8) while the tagged wrapper keeps the *shape*. Also:
|
|
1414
|
+
* 2-space indent, `": "` / `","` separators, `{}` / `[]` for empties.
|
|
750
1415
|
*/
|
|
751
1416
|
function jsonDumps(obj: unknown, opts: { indent: number; sort_keys: boolean }): string {
|
|
752
1417
|
return _encode(obj, opts, 0);
|