@opengsd/gsd-core 1.11.0 → 1.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.opencode/plugins/gsd-core.js +12 -0
- package/agents/gsd-code-fixer.md +1 -1
- package/agents/gsd-debug-session-manager.md +1 -1
- package/agents/gsd-debugger.md +1 -1
- package/agents/gsd-dom-verifier.md +169 -0
- package/agents/gsd-eval-auditor.md +1 -1
- package/agents/gsd-executor.md +78 -42
- package/agents/gsd-framework-selector.md +1 -3
- package/agents/gsd-intel-updater.md +1 -1
- package/agents/gsd-mempalace-curator.md +0 -1
- package/agents/gsd-pattern-mapper.md +11 -0
- package/agents/gsd-phase-researcher.md +3 -1
- package/agents/gsd-plan-checker.md +91 -112
- package/agents/gsd-planner.md +20 -4
- package/agents/gsd-project-researcher.md +1 -1
- package/agents/gsd-research-synthesizer.md +2 -2
- package/agents/gsd-roadmapper.md +15 -11
- package/agents/gsd-ui-checker.md +82 -7
- package/agents/gsd-ui-researcher.md +70 -3
- package/agents/gsd-verifier.md +24 -2
- package/bin/install.js +847 -200
- package/commands/gsd/discuss-phase.md +1 -1
- package/commands/gsd/execute-phase.md +1 -1
- package/commands/gsd/import.md +1 -1
- package/commands/gsd/ns-workflow.md +2 -1
- package/commands/gsd/phase.md +1 -1
- package/commands/gsd/quick-batch.md +105 -0
- package/commands/gsd/quick.md +8 -4
- package/commands/gsd/surface.md +18 -8
- package/gsd-core/bin/gsd-tools.cjs +761 -100
- package/gsd-core/bin/lib/active-workstream-store.cjs +8 -0
- package/gsd-core/bin/lib/adr-parser.cjs +13 -7
- package/gsd-core/bin/lib/agent-install-check.cjs +162 -0
- package/gsd-core/bin/lib/api-coverage.cjs +30 -9
- package/gsd-core/bin/lib/artifacts.cjs +2 -0
- package/gsd-core/bin/lib/assumption-delta.cjs +30 -11
- package/gsd-core/bin/lib/audit.cjs +163 -41
- package/gsd-core/bin/lib/broken-windows.cjs +306 -28
- package/gsd-core/bin/lib/capability-activation.cjs +27 -0
- package/gsd-core/bin/lib/capability-lock.cjs +10 -4
- package/gsd-core/bin/lib/capability-registry.cjs +785 -144
- package/gsd-core/bin/lib/capability-state.cjs +25 -4
- package/gsd-core/bin/lib/capability-validator.cjs +321 -18
- package/gsd-core/bin/lib/capability-writer.cjs +14 -4
- package/gsd-core/bin/lib/check-command-router.cjs +229 -6
- package/gsd-core/bin/lib/claude-orchestration.cjs +10 -25
- package/gsd-core/bin/lib/cli-exit.cjs +496 -10
- package/gsd-core/bin/lib/clusters.cjs +1 -0
- package/gsd-core/bin/lib/code-review-depth.cjs +288 -0
- package/gsd-core/bin/lib/codex-agent-toml.cjs +410 -4
- package/gsd-core/bin/lib/command-aliases.cjs +16 -0
- package/gsd-core/bin/lib/command-arg-projection.cjs +144 -14
- package/gsd-core/bin/lib/command-routing-hub.cjs +31 -2
- package/gsd-core/bin/lib/commands.cjs +877 -54
- package/gsd-core/bin/lib/complexity-trigger.cjs +26 -6
- package/gsd-core/bin/lib/config-loader.cjs +121 -29
- package/gsd-core/bin/lib/config.cjs +92 -2
- package/gsd-core/bin/lib/configuration.cjs +129 -37
- package/gsd-core/bin/lib/core-utils.cjs +118 -14
- package/gsd-core/bin/lib/decisions.cjs +213 -1
- package/gsd-core/bin/lib/edge-probe.cjs +23 -2
- package/gsd-core/bin/lib/estimate-cli.cjs +55 -11
- package/gsd-core/bin/lib/exit-code-registry.cjs +98 -0
- package/gsd-core/bin/lib/file-overlap-partitioner.cjs +74 -0
- package/gsd-core/bin/lib/frontmatter.cjs +975 -326
- package/gsd-core/bin/lib/gap-checker.cjs +41 -8
- package/gsd-core/bin/lib/git-base-branch.cjs +182 -39
- package/gsd-core/bin/lib/health-diagnostic-rules/consistency.cjs +7 -3
- package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +8 -2
- package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +60 -14
- package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +75 -22
- package/gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs +22 -8
- package/gsd-core/bin/lib/health-diagnostic.cjs +23 -3
- package/gsd-core/bin/lib/host-integration.cjs +96 -11
- package/gsd-core/bin/lib/init-command-router.cjs +132 -21
- package/gsd-core/bin/lib/init.cjs +252 -56
- package/gsd-core/bin/lib/install-engine.cjs +252 -15
- package/gsd-core/bin/lib/install-model-override-resolver.cjs +78 -1
- package/gsd-core/bin/lib/install-profiles.cjs +100 -18
- package/gsd-core/bin/lib/installer-migration-report.cjs +4 -0
- package/gsd-core/bin/lib/installer-migrations/010-antigravity-retire-confighome-artifacts.cjs +169 -0
- package/gsd-core/bin/lib/installer-migrations.cjs +10 -7
- package/gsd-core/bin/lib/intel.cjs +101 -26
- package/gsd-core/bin/lib/io.cjs +195 -15
- package/gsd-core/bin/lib/learnings.cjs +85 -14
- package/gsd-core/bin/lib/legacy-cleanup.cjs +8 -2
- package/gsd-core/bin/lib/loop-resolver.cjs +14 -8
- package/gsd-core/bin/lib/markdown-table.cjs +175 -4
- package/gsd-core/bin/lib/milestone.cjs +112 -7
- package/gsd-core/bin/lib/model-catalog.cjs +177 -19
- package/gsd-core/bin/lib/model-resolver.cjs +10 -28
- package/gsd-core/bin/lib/onboard-projection.cjs +5 -1
- package/gsd-core/bin/lib/phase-command-router.cjs +13 -6
- package/gsd-core/bin/lib/phase-estimation.cjs +17 -8
- package/gsd-core/bin/lib/phase-id.cjs +321 -13
- package/gsd-core/bin/lib/phase-lifecycle.cjs +24 -16
- package/gsd-core/bin/lib/phase-locator.cjs +138 -17
- package/gsd-core/bin/lib/phase.cjs +1175 -115
- package/gsd-core/bin/lib/plan-document.cjs +273 -0
- package/gsd-core/bin/lib/plan-scan.cjs +13 -2
- package/gsd-core/bin/lib/planning-command-router.cjs +61 -0
- package/gsd-core/bin/lib/planning-inspect.cjs +1168 -0
- package/gsd-core/bin/lib/planning-snapshot.cjs +165 -34
- package/gsd-core/bin/lib/planning-workspace.cjs +159 -28
- package/gsd-core/bin/lib/probe-core.cjs +4 -1
- package/gsd-core/bin/lib/profile-pipeline-command-router.cjs +50 -7
- package/gsd-core/bin/lib/profile-pipeline.cjs +6 -3
- package/gsd-core/bin/lib/quick-batch-command-router.cjs +285 -0
- package/gsd-core/bin/lib/quick-batch-dispatch.cjs +250 -0
- package/gsd-core/bin/lib/quick-batch.cjs +840 -0
- package/gsd-core/bin/lib/real-home-guard.cjs +419 -0
- package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +71 -45
- package/gsd-core/bin/lib/review-lane-descriptor.cjs +62 -14
- package/gsd-core/bin/lib/review-lane-invocation.cjs +73 -1
- package/gsd-core/bin/lib/review-lane-runner.cjs +136 -10
- package/gsd-core/bin/lib/roadmap-command-router.cjs +45 -31
- package/gsd-core/bin/lib/roadmap-parser.cjs +577 -41
- package/gsd-core/bin/lib/roadmap.cjs +248 -64
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +329 -41
- package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +16 -17
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +320 -109
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +487 -83
- package/gsd-core/bin/lib/runtime-identity.cjs +234 -0
- package/gsd-core/bin/lib/runtime-slash.cjs +72 -2
- package/gsd-core/bin/lib/shell-command-projection.cjs +75 -8
- package/gsd-core/bin/lib/smart-entry.cjs +19 -31
- package/gsd-core/bin/lib/spec-section.cjs +12 -7
- package/gsd-core/bin/lib/state-command-router.cjs +47 -18
- package/gsd-core/bin/lib/state-contract.cjs +359 -0
- package/gsd-core/bin/lib/state-document.cjs +216 -5
- package/gsd-core/bin/lib/state-md-schema.cjs +231 -0
- package/gsd-core/bin/lib/state-transition.cjs +850 -145
- package/gsd-core/bin/lib/state.cjs +1629 -287
- package/gsd-core/bin/lib/surface.cjs +33 -10
- package/gsd-core/bin/lib/task-command-router.cjs +111 -1
- package/gsd-core/bin/lib/task-content-resolution.cjs +368 -0
- package/gsd-core/bin/lib/tdd-red-evidence.cjs +133 -0
- package/gsd-core/bin/lib/teams-status.cjs +4 -1
- package/gsd-core/bin/lib/uat-predicate.cjs +58 -20
- package/gsd-core/bin/lib/uat.cjs +2542 -387
- package/gsd-core/bin/lib/ui-consideration-probe.cjs +9 -1
- package/gsd-core/bin/lib/ui-safety-gate.cjs +37 -7
- package/gsd-core/bin/lib/unusable-input.cjs +13 -0
- package/gsd-core/bin/lib/update-context.cjs +6 -2
- package/gsd-core/bin/lib/validate-command-router.cjs +2 -2
- package/gsd-core/bin/lib/validate.cjs +230 -12
- package/gsd-core/bin/lib/vendor/README.md +43 -5
- package/gsd-core/bin/lib/vendor/js-yaml.cjs +3014 -0
- package/gsd-core/bin/lib/verification-command-router.cjs +2 -1
- package/gsd-core/bin/lib/verification.cjs +287 -13
- package/gsd-core/bin/lib/verify-command-grounding.cjs +846 -0
- package/gsd-core/bin/lib/verify-command-router.cjs +1 -0
- package/gsd-core/bin/lib/verify.cjs +441 -56
- package/gsd-core/bin/lib/workstream-inventory.cjs +20 -2
- package/gsd-core/bin/lib/workstream-name-policy.cjs +25 -4
- package/gsd-core/bin/lib/worktree-base-ref.cjs +66 -12
- package/gsd-core/bin/lib/worktree-safety.cjs +185 -21
- package/gsd-core/bin/shared/config-defaults.manifest.json +7 -1
- package/gsd-core/bin/shared/config-schema.manifest.json +13 -0
- package/gsd-core/bin/shared/exit-codes.json +8 -0
- package/gsd-core/bin/shared/exit-codes.sh +20 -0
- package/gsd-core/bin/shared/model-catalog.json +8 -1
- package/gsd-core/bin/verify-reapply-patches.cjs +70 -3
- package/gsd-core/references/agent-contracts.md +6 -5
- package/gsd-core/references/api-coverage.md +24 -2
- package/gsd-core/references/autonomous-smart-discuss.md +3 -3
- package/gsd-core/references/checkpoints.md +37 -19
- package/gsd-core/references/decimal-phase-calculation.md +5 -5
- package/gsd-core/references/edge-probe.md +17 -5
- package/gsd-core/references/execute-mvp-tdd.md +18 -18
- package/gsd-core/references/execute-phase-between-wave-reset.md +9 -12
- package/gsd-core/references/execute-phase-response-language.md +6 -0
- package/gsd-core/references/execute-phase-wave-guard.md +11 -9
- package/gsd-core/references/executor-examples.md +42 -0
- package/gsd-core/references/failing-direction.md +78 -0
- package/gsd-core/references/few-shot-examples/plan-checker.md +15 -15
- package/gsd-core/references/gate-prompts.md +1 -1
- package/gsd-core/references/git-integration.md +5 -5
- package/gsd-core/references/git-planning-commit.md +3 -3
- package/gsd-core/references/gsd-run-resolver.md +1 -1
- package/gsd-core/references/loop-hook-dispatch.md +22 -0
- package/gsd-core/references/model-profiles.md +1 -1
- package/gsd-core/references/mvp-concepts.md +2 -2
- package/gsd-core/references/nyquist-compliance.md +74 -0
- package/gsd-core/references/offer-next.md +3 -5
- package/gsd-core/references/phase-argument-parsing.md +3 -3
- package/gsd-core/references/plan-checker-examples.md +41 -0
- package/gsd-core/references/planner-antipatterns.md +25 -0
- package/gsd-core/references/planner-chunked.md +5 -1
- package/gsd-core/references/planner-coupling.md +42 -0
- package/gsd-core/references/planner-failing-direction.md +53 -0
- package/gsd-core/references/planner-human-verify-mode.md +15 -1
- package/gsd-core/references/planner-quick-batch.md +71 -0
- package/gsd-core/references/planner-reviews.md +47 -0
- package/gsd-core/references/planner-revision.md +76 -3
- package/gsd-core/references/planner-verify-command-grounding.md +17 -0
- package/gsd-core/references/planning-config.md +39 -9
- package/gsd-core/references/response-language-directive.md +9 -0
- package/gsd-core/references/reviewer-instances.md +31 -0
- package/gsd-core/references/revision-loop.md +118 -11
- package/gsd-core/references/runtime-aware-dispatch.md +1 -1
- package/gsd-core/references/tdd.md +15 -12
- package/gsd-core/references/ui-brand.md +65 -21
- package/gsd-core/references/ui-consideration-probe.md +1 -1
- package/gsd-core/references/universal-anti-patterns.md +2 -2
- package/gsd-core/references/verifier-evidence-gate.md +160 -0
- package/gsd-core/references/verify-command-path-resolvability.md +42 -0
- package/gsd-core/references/verify-mvp-mode.md +1 -1
- package/gsd-core/references/workstream-flag.md +11 -11
- package/gsd-core/templates/README.md +1 -1
- package/gsd-core/templates/SECURITY.md +3 -3
- package/gsd-core/templates/UI-SPEC.md +25 -3
- package/gsd-core/templates/VALIDATION.md +3 -3
- package/gsd-core/templates/phase-prompt.md +7 -0
- package/gsd-core/templates/state.md +7 -0
- package/gsd-core/templates/verification-report.md +5 -0
- package/gsd-core/workflows/_runtime-launcher.snippet.sh +1 -1
- package/gsd-core/workflows/add-backlog.md +3 -1
- package/gsd-core/workflows/add-phase.md +5 -3
- package/gsd-core/workflows/add-tests.md +4 -9
- package/gsd-core/workflows/add-todo.md +2 -2
- package/gsd-core/workflows/ai-integration-phase.md +5 -10
- package/gsd-core/workflows/analyze-dependencies.md +2 -0
- package/gsd-core/workflows/audit-fix.md +14 -3
- package/gsd-core/workflows/audit-milestone.md +11 -9
- package/gsd-core/workflows/audit-uat.md +19 -2
- package/gsd-core/workflows/autonomous/steps/converge-fail-fast.md +2 -2
- package/gsd-core/workflows/autonomous.md +12 -26
- package/gsd-core/workflows/check-todos.md +2 -2
- package/gsd-core/workflows/cleanup.md +3 -3
- package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +16 -14
- package/gsd-core/workflows/code-review-fix.md +3 -1
- package/gsd-core/workflows/code-review.md +192 -69
- package/gsd-core/workflows/complete-milestone.md +28 -14
- package/gsd-core/workflows/debug.md +6 -4
- package/gsd-core/workflows/diagnose-issues.md +17 -7
- package/gsd-core/workflows/discuss-phase/modes/advisor.md +3 -1
- package/gsd-core/workflows/discuss-phase/modes/all.md +2 -0
- package/gsd-core/workflows/discuss-phase/modes/analyze.md +2 -0
- package/gsd-core/workflows/discuss-phase/modes/auto.md +2 -0
- package/gsd-core/workflows/discuss-phase/modes/batch.md +2 -0
- package/gsd-core/workflows/discuss-phase/modes/chain.md +5 -7
- package/gsd-core/workflows/discuss-phase/modes/default.md +2 -0
- package/gsd-core/workflows/discuss-phase/modes/power.md +2 -0
- package/gsd-core/workflows/discuss-phase/modes/text.md +3 -1
- package/gsd-core/workflows/discuss-phase/templates/context.md +2 -0
- package/gsd-core/workflows/discuss-phase/templates/discussion-log.md +2 -0
- package/gsd-core/workflows/discuss-phase-assumptions/steps/auto-advance-dispatch.md +1 -3
- package/gsd-core/workflows/discuss-phase-assumptions.md +3 -3
- package/gsd-core/workflows/discuss-phase-power.md +2 -0
- package/gsd-core/workflows/discuss-phase.md +2 -2
- package/gsd-core/workflows/do.md +46 -19
- package/gsd-core/workflows/docs-update.md +6 -5
- package/gsd-core/workflows/edit-phase.md +3 -1
- package/gsd-core/workflows/eval-review.md +5 -10
- package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +3 -1
- package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +129 -11
- package/gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/partial-wave.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/per-plan-executor-routing.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +29 -5
- package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +2 -2
- package/gsd-core/workflows/execute-phase/steps/protected-branch.md +21 -0
- package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +4 -2
- package/gsd-core/workflows/execute-phase/steps/tdd-applicability-resolution.md +25 -0
- package/gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md +39 -0
- package/gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md +2 -0
- package/gsd-core/workflows/execute-phase.md +68 -66
- package/gsd-core/workflows/execute-plan.md +25 -20
- package/gsd-core/workflows/explore.md +3 -1
- package/gsd-core/workflows/extract-learnings.md +3 -1
- package/gsd-core/workflows/fast.md +8 -2
- package/gsd-core/workflows/forensics.md +3 -1
- package/gsd-core/workflows/graduation.md +6 -6
- package/gsd-core/workflows/health.md +4 -7
- package/gsd-core/workflows/help/modes/brief.md +2 -0
- package/gsd-core/workflows/help/modes/default.md +2 -0
- package/gsd-core/workflows/help/modes/full.md +12 -0
- package/gsd-core/workflows/help/modes/topic.md +2 -0
- package/gsd-core/workflows/help.md +2 -0
- package/gsd-core/workflows/import.md +17 -14
- package/gsd-core/workflows/inbox.md +5 -6
- package/gsd-core/workflows/ingest-docs.md +45 -12
- package/gsd-core/workflows/insert-phase.md +7 -5
- package/gsd-core/workflows/list-phase-assumptions.md +2 -0
- package/gsd-core/workflows/list-seeds.md +7 -3
- package/gsd-core/workflows/list-workspaces.md +3 -1
- package/gsd-core/workflows/manager.md +15 -26
- package/gsd-core/workflows/map-codebase.md +3 -1
- package/gsd-core/workflows/milestone-summary.md +3 -1
- package/gsd-core/workflows/mvp-phase.md +3 -3
- package/gsd-core/workflows/new-milestone.md +10 -22
- package/gsd-core/workflows/new-project/steps/auto-mode-config.md +1 -1
- package/gsd-core/workflows/new-project.md +17 -29
- package/gsd-core/workflows/new-workspace.md +2 -2
- package/gsd-core/workflows/next.md +4 -2
- package/gsd-core/workflows/node-repair.md +2 -0
- package/gsd-core/workflows/note.md +2 -0
- package/gsd-core/workflows/onboard.md +1 -1
- package/gsd-core/workflows/pause-work.md +20 -5
- package/gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md +1 -1
- package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +100 -18
- package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +4 -4
- package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +12 -3
- package/gsd-core/workflows/plan-phase.md +251 -54
- package/gsd-core/workflows/plan-review-convergence.md +148 -19
- package/gsd-core/workflows/plant-seed.md +3 -3
- package/gsd-core/workflows/pr-branch.md +195 -51
- package/gsd-core/workflows/profile-user.md +17 -15
- package/gsd-core/workflows/progress/steps/forensic-audit.md +1 -1
- package/gsd-core/workflows/progress.md +52 -15
- package/gsd-core/workflows/quick/steps/discussion-phase.md +1 -3
- package/gsd-core/workflows/quick/steps/plan-checker-loop.md +38 -5
- package/gsd-core/workflows/quick/steps/quick-verification.md +2 -4
- package/gsd-core/workflows/quick/steps/research-phase.md +5 -7
- package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +3 -3
- package/gsd-core/workflows/quick-batch/steps/batch-init.md +55 -0
- package/gsd-core/workflows/quick-batch/steps/completion.md +65 -0
- package/gsd-core/workflows/quick-batch/steps/merge-wave.md +100 -0
- package/gsd-core/workflows/quick-batch/steps/plan-checker-loop.md +147 -0
- package/gsd-core/workflows/quick-batch/steps/planner-wave.md +158 -0
- package/gsd-core/workflows/quick-batch/steps/research-phase.md +95 -0
- package/gsd-core/workflows/quick-batch/steps/resume-mode.md +49 -0
- package/gsd-core/workflows/quick-batch/steps/verification-wave.md +73 -0
- package/gsd-core/workflows/quick-batch/steps/worktree-dispatch.md +169 -0
- package/gsd-core/workflows/quick-batch.md +203 -0
- package/gsd-core/workflows/quick.md +33 -32
- package/gsd-core/workflows/reapply-patches.md +2 -0
- package/gsd-core/workflows/remove-phase.md +6 -4
- package/gsd-core/workflows/remove-workspace.md +3 -3
- package/gsd-core/workflows/resume-project.md +14 -14
- package/gsd-core/workflows/review.md +404 -21
- package/gsd-core/workflows/scan.md +3 -1
- package/gsd-core/workflows/section-manifest.json +12 -0
- package/gsd-core/workflows/secure-phase.md +3 -3
- package/gsd-core/workflows/session-report.md +2 -0
- package/gsd-core/workflows/settings-advanced.md +9 -9
- package/gsd-core/workflows/settings-integrations.md +66 -32
- package/gsd-core/workflows/settings.md +4 -6
- package/gsd-core/workflows/ship.md +22 -16
- package/gsd-core/workflows/sketch-wrap-up.md +13 -17
- package/gsd-core/workflows/sketch.md +13 -19
- package/gsd-core/workflows/smart-entry.md +4 -6
- package/gsd-core/workflows/spec-phase.md +31 -4
- package/gsd-core/workflows/spike-wrap-up.md +9 -11
- package/gsd-core/workflows/spike.md +21 -32
- package/gsd-core/workflows/stats.md +4 -2
- package/gsd-core/workflows/sync-skills.md +13 -5
- package/gsd-core/workflows/thread.md +13 -7
- package/gsd-core/workflows/transition.md +7 -5
- package/gsd-core/workflows/ui-phase.md +36 -21
- package/gsd-core/workflows/ui-review.md +7 -11
- package/gsd-core/workflows/ultraplan-phase.md +7 -13
- package/gsd-core/workflows/undo.md +9 -17
- package/gsd-core/workflows/update.md +47 -48
- package/gsd-core/workflows/validate-phase.md +3 -3
- package/gsd-core/workflows/verify-work/steps/automated-ui-verification.md +25 -1
- package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +1 -1
- package/gsd-core/workflows/verify-work.md +106 -21
- package/hooks/dist/gsd-agent-isolation-guard.js +77 -38
- package/hooks/dist/gsd-check-update-worker.js +19 -2
- package/hooks/dist/gsd-config-reload.js +18 -12
- package/hooks/dist/gsd-context-monitor.js +302 -22
- package/hooks/dist/gsd-cursor-post-tool.js +3 -1
- package/hooks/dist/gsd-cursor-pre-tool.js +3 -1
- package/hooks/dist/gsd-cursor-session-start.js +2 -1
- package/hooks/dist/gsd-cursor-stop.js +2 -1
- package/hooks/dist/gsd-cursor-subagent-start.js +28 -23
- package/hooks/dist/gsd-cursor-subagent-stop.js +3 -1
- package/hooks/dist/gsd-ensure-canonical-path.js +2 -1
- package/hooks/dist/gsd-graphify-update.sh +22 -18
- package/hooks/dist/gsd-node-runner.sh +77 -0
- package/hooks/dist/gsd-phase-boundary.sh +1 -0
- package/hooks/dist/gsd-prompt-guard.js +46 -12
- package/hooks/dist/gsd-read-guard.js +18 -7
- package/hooks/dist/gsd-read-injection-scanner.js +22 -13
- package/hooks/dist/gsd-secret-read-guard.js +1079 -0
- package/hooks/dist/gsd-session-state.sh +1 -0
- package/hooks/dist/gsd-statusline.js +222 -29
- package/hooks/dist/gsd-validate-commit.sh +523 -12
- package/hooks/dist/gsd-windsurf-pre-command.js +16 -11
- package/hooks/dist/gsd-windsurf-pre-write.js +22 -13
- package/hooks/dist/gsd-workflow-guard.js +36 -17
- package/hooks/dist/gsd-worktree-path-guard.js +36 -21
- package/hooks/dist/gsd-write-guard.js +35 -25
- package/hooks/dist/lib/cli-exit.js +560 -0
- package/hooks/dist/lib/exit-code-registry.js +98 -0
- package/hooks/dist/lib/git-cmd.js +210 -1
- package/hooks/dist/lib/git-probe.js +84 -0
- package/hooks/dist/lib/hook-exit.js +81 -0
- package/hooks/dist/lib/injection-patterns.js +36 -6
- package/hooks/dist/managed-hooks-registry.cjs +4 -0
- package/hooks/gsd-agent-isolation-guard.js +77 -38
- package/hooks/gsd-check-update-worker.js +19 -2
- package/hooks/gsd-config-reload.js +18 -12
- package/hooks/gsd-context-monitor.js +302 -22
- package/hooks/gsd-cursor-post-tool.js +3 -1
- package/hooks/gsd-cursor-pre-tool.js +3 -1
- package/hooks/gsd-cursor-session-start.js +2 -1
- package/hooks/gsd-cursor-stop.js +2 -1
- package/hooks/gsd-cursor-subagent-start.js +28 -23
- package/hooks/gsd-cursor-subagent-stop.js +3 -1
- package/hooks/gsd-ensure-canonical-path.js +2 -1
- package/hooks/gsd-graphify-update.sh +22 -18
- package/hooks/gsd-node-runner.sh +77 -0
- package/hooks/gsd-phase-boundary.sh +1 -0
- package/hooks/gsd-prompt-guard.js +46 -12
- package/hooks/gsd-read-guard.js +18 -7
- package/hooks/gsd-read-injection-scanner.js +22 -13
- package/hooks/gsd-secret-read-guard.js +1079 -0
- package/hooks/gsd-session-state.sh +1 -0
- package/hooks/gsd-statusline.js +222 -29
- package/hooks/gsd-validate-commit.sh +523 -12
- package/hooks/gsd-windsurf-pre-command.js +16 -11
- package/hooks/gsd-windsurf-pre-write.js +22 -13
- package/hooks/gsd-workflow-guard.js +36 -17
- package/hooks/gsd-worktree-path-guard.js +36 -21
- package/hooks/gsd-write-guard.js +35 -25
- package/hooks/hooks.json +6 -0
- package/hooks/lib/cli-exit.js +560 -0
- package/hooks/lib/exit-code-registry.js +98 -0
- package/hooks/lib/git-cmd.js +210 -1
- package/hooks/lib/git-probe.js +84 -0
- package/hooks/lib/hook-exit.js +81 -0
- package/hooks/lib/injection-patterns.js +36 -6
- package/hooks/managed-hooks-registry.cjs +4 -0
- package/package.json +14 -9
- package/scripts/base64-scan.sh +74 -12
- package/scripts/build-hooks.js +12 -0
- package/scripts/check-glossary-refs.cjs +77 -15
- package/scripts/check-mutation-score-ratchet.cjs +156 -0
- package/scripts/ci-check-job-near-cap.cjs +49 -0
- package/scripts/ci-pr-mergeability.cjs +262 -0
- package/scripts/ci-test-scope.cjs +52 -12
- package/scripts/ci-timeout-report.cjs +230 -0
- package/scripts/docs-guard-registry.cjs +406 -0
- package/scripts/gen-capability-registry.cjs +8 -6
- package/scripts/gen-exit-code-docs.cjs +318 -0
- package/scripts/gen-exit-code-registry.cjs +891 -0
- package/scripts/gen-features.cjs +836 -0
- package/scripts/gen-hooks-cli-exit.cjs +239 -0
- package/scripts/gen-install-tree-fixtures.cjs +2 -2
- package/scripts/gen-loop-host-contract.cjs +189 -4
- package/scripts/gen-scripts-cli-exit.cjs +185 -0
- package/scripts/gen-state-md-docs.cjs +727 -0
- package/scripts/{test-failure-reasons.cjs → gsd-test-gate-reasons.cjs} +6 -0
- package/scripts/lib/ci-job-timing.cjs +72 -0
- package/scripts/lib/cli-exit.cjs +546 -44
- package/scripts/lib/drift-scan.cjs +32 -2
- package/scripts/lib/exit-code-registry.cjs +98 -0
- package/scripts/lib/ndjson-reporter.cjs +119 -0
- package/scripts/lib/shellcheck-fetch.cjs +247 -0
- package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -6
- package/scripts/lint-allow-test-rule-refs.effective-ceiling.json +1 -1
- package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +1 -1
- package/scripts/lint-docs-guard-registration.cjs +495 -0
- package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +198 -0
- package/scripts/lint-eslint-glob-coverage.allowlist.json +4 -0
- package/scripts/{lint-fix-has-regression-test.cjs → lint-fix-has-regression-tests.cjs} +12 -6
- package/scripts/lint-health-diagnostic-rule-table.cjs +65 -8
- package/scripts/lint-mutation-test-derivation-drift.cjs +86 -0
- package/scripts/lint-phase-enumeration-drift.cjs +45 -14
- package/scripts/lint-phase-id-drift.cjs +133 -8
- package/scripts/lint-planning-prompt-drift.cjs +38 -1
- package/scripts/lint-portable-grep.cjs +176 -0
- package/scripts/lint-removed-but-needed.cjs +184 -16
- package/scripts/lint-response-language-coverage.cjs +524 -0
- package/scripts/lint-seam-enforcement.cjs +182 -0
- package/scripts/lint-slug-derivation-drift.cjs +921 -0
- package/scripts/lint-source-test-name-collision.cjs +241 -0
- package/scripts/lint-state-write-path-drift.cjs +337 -432
- package/scripts/lint-test-file-count.allowlist.json +124 -4
- package/scripts/lint-test-file-count.cjs +25 -3
- package/scripts/lint-unreachable-guard-drift.cjs +51 -64
- package/scripts/lint-vendored-deps.cjs +208 -35
- package/scripts/lint-workflow-shellcheck-baseline.json +1027 -0
- package/scripts/lint-workflow-shellcheck.cjs +614 -0
- package/scripts/mutation-matrix.cjs +599 -50
- package/scripts/npm-audit-baseline.cjs +376 -0
- package/scripts/prompt-injection-scan.sh +83 -14
- package/scripts/require-issue-link-policy.cjs +16 -1
- package/scripts/secret-scan.sh +75 -13
- package/scripts/select-docs-guards.cjs +56 -0
- package/scripts/sync-runtime-launcher.cjs +22 -3
- package/skills/gsd-discuss-phase/SKILL.md +1 -1
- package/skills/gsd-execute-phase/SKILL.md +1 -1
- package/skills/gsd-import/SKILL.md +1 -1
- package/skills/gsd-ns-workflow/SKILL.md +1 -0
- package/skills/gsd-phase/SKILL.md +1 -1
- package/skills/gsd-quick/SKILL.md +8 -4
- package/skills/gsd-quick-batch/SKILL.md +105 -0
- package/skills/gsd-surface/SKILL.md +18 -8
- package/vscode/package.json +1 -1
- package/bin/lib/ui-safety-gate.cjs +0 -109
- package/scripts/lint-emitted-drift-ack.cjs +0 -344
- package/scripts/state-write-path-drift-baseline.json +0 -19
|
@@ -0,0 +1,921 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Anti-divergence drift guard for the SLUG-DERIVATION seam (issue #3987,
|
|
6
|
+
* closing epic #3473's last two residuals).
|
|
7
|
+
*
|
|
8
|
+
* `src/core-utils.cts`'s `generateSlugInternal(text, maxLen)` is the SINGLE
|
|
9
|
+
* canonical owner of "turn arbitrary text into a filesystem-safe slug":
|
|
10
|
+
* `transliterateForSlug` (lowercase, then a per-character Cyrillic map) ->
|
|
11
|
+
* `.replace(/[^a-z0-9]+/g, '-')` -> optional truncation to `maxLen` ->
|
|
12
|
+
* `.replace(/^-+|-+$/g, '')` (trim runs AFTER truncation, #2849 — truncating
|
|
13
|
+
* first can leave a trailing separator the trim step exists to remove).
|
|
14
|
+
* `#3883` deleted 11 hand-inlined copies of the pre-#2849/#2848 shape — a
|
|
15
|
+
* single chained expression `.toLowerCase()` -> `.replace(/[^a-z0-9]+/g,
|
|
16
|
+
* '-')` -> `.replace(/^-+|-+$/g, '')`, optionally `.substring(0, 60)` —
|
|
17
|
+
* none of which transliterated, and all of which trimmed BEFORE truncating.
|
|
18
|
+
* This guard is what stops a twelfth copy.
|
|
19
|
+
*
|
|
20
|
+
* SECURITY-REVIEW HARDENING PASS (post-#3987 review, this same issue). Three
|
|
21
|
+
* MAJOR findings from an isolated security review changed how this guard
|
|
22
|
+
* works internally; every detail below reflects the FIXED behavior:
|
|
23
|
+
*
|
|
24
|
+
* MAJOR 1 — exemption scoping was fail-open. The original tracker only
|
|
25
|
+
* updated `currentFunction` on a column-0 `function` line and never reset
|
|
26
|
+
* it, so an allowlisted function's exemption bled forward into every line
|
|
27
|
+
* until the NEXT top-level `function` declaration — a re-derivation
|
|
28
|
+
* planted anywhere in that dead zone (measured: 50 exempted lines for an
|
|
29
|
+
* 11-line function) silently escaped detection. Fixed by computing each
|
|
30
|
+
* allowlisted function's REAL body extent via brace-depth matching on a
|
|
31
|
+
* string/comment/template-literal-masked copy of the file
|
|
32
|
+
* (`findAllowlistedFunctionExtents` / `maskNonCode` / `maskRegexLiterals`
|
|
33
|
+
* below) — a statement is only exempted if it falls strictly inside the
|
|
34
|
+
* named function's actual `{ ... }` body, nothing before or after.
|
|
35
|
+
*
|
|
36
|
+
* MAJOR 2 — the old `CHARCLASS_REPLACE_RE`'s `[^\]]*` character-class body
|
|
37
|
+
* was matched directly against the UNBOUNDED joined-statement text, with no
|
|
38
|
+
* size limit, and was re-attempted from every `.replace(/[^` occurrence —
|
|
39
|
+
* quadratic on a single pathological line (measured 54s at 1.28MB). Fixed
|
|
40
|
+
* by routing every regex-literal extraction through
|
|
41
|
+
* `drift-scan.cjs`'s `readRegexLiteralAt` (a bounded, non-backtracking,
|
|
42
|
+
* single-pass tokenizer this guard imported but never called), so
|
|
43
|
+
* classification regexes only ever run against an already-delimited
|
|
44
|
+
* literal capped at `MAX_REGEX_LITERAL_LEN` characters. A
|
|
45
|
+
* `MAX_FILE_SIZE_BYTES` cap (see below) bounds total scan cost too.
|
|
46
|
+
*
|
|
47
|
+
* MAJOR 3 — the detector was measurably narrow (15 of 25 known-equivalent
|
|
48
|
+
* re-derivation shapes evaded it). Widened, where cheap and
|
|
49
|
+
* false-positive-free, to also catch: `replaceAll` (alongside `replace`);
|
|
50
|
+
* a small enumerated closed set of trim-regex spellings (`[-]+` character
|
|
51
|
+
* class, parenthesized alternatives, `-*` quantifier, `\-` escaped
|
|
52
|
+
* hyphen, and alternative order swapped); the `{1,}` quantifier as an
|
|
53
|
+
* equivalent of `+`; `.split(<negated class>).join('-')` as an alternate
|
|
54
|
+
* collapse mechanism; the literal `new RegExp('[^a-z0-9]+', 'g')` form;
|
|
55
|
+
* and call arguments spanning multiple physical lines after `.replace(`
|
|
56
|
+
* (not just the existing leading-`.` chain-continuation case) via
|
|
57
|
+
* paren-depth-aware statement joining. Deliberately NOT chased (needs data
|
|
58
|
+
* flow, not textual matching): `new RegExp` built from a variable, and the
|
|
59
|
+
* two-statement/temp-var form — see the guard's test file for both, kept
|
|
60
|
+
* as documented known gaps.
|
|
61
|
+
*
|
|
62
|
+
* DETECTOR (measured against the real deleted shape and the real repo tree —
|
|
63
|
+
* see the guard's own test file for the flag/TRUE/SANCTIONED/FALSE census). A
|
|
64
|
+
* re-derivation is one logical STATEMENT — not merely one source LINE; a
|
|
65
|
+
* chained `.replace()` call is routinely wrapped across several lines by this
|
|
66
|
+
* repo's formatter — carrying BOTH:
|
|
67
|
+
* (a) a `.replace()`/`.replaceAll()` call whose first argument is a negated
|
|
68
|
+
* character class (a `/[^...]+/` regex literal, or the literal form
|
|
69
|
+
* `new RegExp('[^...]+', 'flags')`) and whose replacement argument is
|
|
70
|
+
* exactly `'-'` — collapsing every non-slug character run to a single
|
|
71
|
+
* hyphen — OR a `.split(<same negated class>).join('-')` pair doing the
|
|
72
|
+
* same collapse via a different API shape; AND
|
|
73
|
+
* (b) a `.replace()`/`.replaceAll()` call whose first argument is one of a
|
|
74
|
+
* small enumerated set of hyphen-trim regex spellings and whose
|
|
75
|
+
* replacement argument is exactly `''` — trimming leading/trailing
|
|
76
|
+
* hyphen runs.
|
|
77
|
+
* Both clauses require the SAME replacement discipline as the owner
|
|
78
|
+
* (collapse specifically to `'-'`, trim specifically to `''`) — a nearby
|
|
79
|
+
* sanitizer that collapses to a DIFFERENT character (e.g. `'_'`) is a
|
|
80
|
+
* different derivation, not a copy of this one, and must not fire.
|
|
81
|
+
*
|
|
82
|
+
* WHY STATEMENT-SCOPED, NOT LINE-SCOPED. A candidate detector that matches
|
|
83
|
+
* per LINE (mirroring `lint-phase-enumeration-drift.cjs`'s style) was
|
|
84
|
+
* measured against the real tree and rejected: it produced 18 hits, 7 of
|
|
85
|
+
* which were unrelated (an unrelated `[^A-Za-z0-9._-]+` filename sanitizer
|
|
86
|
+
* sharing a physical line with an unrelated hyphen-trim, and test-fixture
|
|
87
|
+
* labels) — a material false-positive rate. Scoping detection to one logical
|
|
88
|
+
* statement (joining a chain's continuation lines — those starting with `.`
|
|
89
|
+
* — AND any line still inside an unbalanced open `(` from a `.replace(`/
|
|
90
|
+
* `.split(` call, back onto the statement that opened it) is what gives this
|
|
91
|
+
* guard its precision.
|
|
92
|
+
*
|
|
93
|
+
* SCOPE. `src/`, `scripts/`, `tests/`, `eslint-rules/` — NOT
|
|
94
|
+
* `gsd-core/bin/lib/**` or `bin/install.js`, which are `src/`'s own BUILT
|
|
95
|
+
* OUTPUT (via `npm run build:lib` / the installer bundling step): scanning
|
|
96
|
+
* them in addition to `src/` would double-count every authored re-derivation
|
|
97
|
+
* once for its source and once for its compiled mirror. Both are simply
|
|
98
|
+
* absent from SCAN_DIRS below, so no extra exclusion logic is needed.
|
|
99
|
+
*
|
|
100
|
+
* SANCTIONED EXEMPTIONS (never a bare denylist — each entry names the exact
|
|
101
|
+
* function it exempts and WHY, mirroring `lint-completion-ratio-drift.cjs`'s
|
|
102
|
+
* `FUNCTION_SCOPED_EXEMPTIONS`; an unrelated re-derivation added anywhere
|
|
103
|
+
* else in these same files, or in a same-named function outside the exact
|
|
104
|
+
* scoped file, is still caught — and, post MAJOR-1 fix, so is one added
|
|
105
|
+
* AFTER the exempted function's own closing brace):
|
|
106
|
+
* - `src/core-utils.cts` `generateSlugInternal` — the canonical owner
|
|
107
|
+
* itself. Its char-class collapse and hyphen-trim sit in two DIFFERENT
|
|
108
|
+
* statements today, so it escapes this detector BY CONSTRUCTION without
|
|
109
|
+
* needing an entry here. Listed explicitly anyway: an IMPLICIT escape is
|
|
110
|
+
* a latent bug — a future refactor that folds those two lines into one
|
|
111
|
+
* chained statement (functionally a no-op) must not silently make the
|
|
112
|
+
* guard start flagging its own owner.
|
|
113
|
+
* - `src/gsd2-import.cts` `slugify` — declared deliberately DIFFERENT from
|
|
114
|
+
* `generateSlugInternal` by #3883 (a distinct truncation contract: no
|
|
115
|
+
* 60-char cap at all, vs the owner's default); it already calls the
|
|
116
|
+
* SHARED `transliterateForSlug` primitive, so this is not an independent
|
|
117
|
+
* re-derivation of the transliteration step — only of the collapse/trim
|
|
118
|
+
* shape it deliberately keeps un-consolidated.
|
|
119
|
+
* - `src/runtime-artifact-conversion.cts` `normalizeKimiSkillName` — a Kimi
|
|
120
|
+
* runtime skill-name normalizer in a completely different domain (CLI
|
|
121
|
+
* skill invocation names, never a `.planning/` phase/plan/milestone
|
|
122
|
+
* slug); its negated class (`[^a-z0-9-]`) deliberately PRESERVES
|
|
123
|
+
* hyphens (a skill name may already contain them), the opposite of the
|
|
124
|
+
* slug seam's contract. Shaped like the re-derivation textually; not one
|
|
125
|
+
* by domain.
|
|
126
|
+
* - `scripts/generate-package-identity.cjs` `slugifyPackageName` —
|
|
127
|
+
* npm-scope-name-to-cache-filename prep. Runs PRE-BUILD (`npm run
|
|
128
|
+
* generate:identity`, step 1 of `npm run build`, before `build:lib`
|
|
129
|
+
* compiles `src/core-utils.cts`), so it structurally cannot `require()`
|
|
130
|
+
* the seam it would otherwise route through.
|
|
131
|
+
*
|
|
132
|
+
* The tree-walk / root-confinement / regex-literal-tokenizer / sanitizer
|
|
133
|
+
* machinery is SHARED with the sibling drift guards via
|
|
134
|
+
* `scripts/lib/drift-scan.cjs` (ADR-3180 Decision 4).
|
|
135
|
+
*
|
|
136
|
+
* KNOWN, ACCEPTED limits of this scan (same tradeoff the sibling drift guards
|
|
137
|
+
* document): `new RegExp` built from a variable (not a literal string) and
|
|
138
|
+
* the two-statement/temp-var re-derivation form are NOT detected — both need
|
|
139
|
+
* real data-flow analysis, which a textual heuristic cannot do safely without
|
|
140
|
+
* risking noise; quoted-string and regex-literal recognition is single-line
|
|
141
|
+
* only (matching `readRegexLiteralAt`'s own "a literal cannot span lines"
|
|
142
|
+
* rule) — a re-derivation whose string/regex argument is itself broken across
|
|
143
|
+
* a line via unescaped continuation is left unhandled, the same class of
|
|
144
|
+
* tradeoff the sibling drift guards' own per-line scans document.
|
|
145
|
+
*/
|
|
146
|
+
|
|
147
|
+
const path = require('node:path');
|
|
148
|
+
const driftScan = require('./lib/drift-scan.cjs');
|
|
149
|
+
const { MAX_REGEX_LITERAL_LEN, sanitizeForReport, scanTree, readRegexLiteralAt } = driftScan;
|
|
150
|
+
|
|
151
|
+
// Authored source across the four surfaces the brief scopes this guard to.
|
|
152
|
+
// `gsd-core/bin/lib/**` (src/'s build output) and `bin/install.js` are never
|
|
153
|
+
// visited because they are not in this list — see the header comment.
|
|
154
|
+
const SCAN_DIRS = ['src', 'scripts', 'tests', 'eslint-rules'];
|
|
155
|
+
// `.mjs`/`.tsx`/`.jsx` added (MINOR fix): the original set silently never
|
|
156
|
+
// opened any file with these extensions under the scanned roots at all — not
|
|
157
|
+
// merely "no violations found", but genuinely unread.
|
|
158
|
+
const SCAN_EXT = new Set(['.cts', '.ts', '.mts', '.mjs', '.cjs', '.js', '.tsx', '.jsx']);
|
|
159
|
+
|
|
160
|
+
// MAJOR 2 defense-in-depth: an upper bound on the SIZE of any single file
|
|
161
|
+
// this guard will read and scan, independent of the bounded-tokenizer fix
|
|
162
|
+
// below. Every real file under SCAN_DIRS today is well under 200KB; 2MB is
|
|
163
|
+
// ~10x headroom over the largest legitimate source file in this repo, while
|
|
164
|
+
// still bounding the worst-case per-file cost a maliciously huge tracked
|
|
165
|
+
// file (e.g. a generated fixture accidentally checked in with a scanned
|
|
166
|
+
// extension) could impose — the bounded tokenizer fix makes a 1.28MB file
|
|
167
|
+
// fast (see the guard's own test file for the measured timing), but nothing
|
|
168
|
+
// stops a fork PR from adding a 50MB one, so a hard cap remains cheap
|
|
169
|
+
// insurance. Files over the cap are skipped (not flagged) — same "silently
|
|
170
|
+
// unreadable" treatment `scanTree` already gives a file it cannot open.
|
|
171
|
+
const MAX_FILE_SIZE_BYTES = 2 * 1024 * 1024;
|
|
172
|
+
|
|
173
|
+
// This guard's OWN unit-test file is a categorically different case from
|
|
174
|
+
// every other FUNCTION_SCOPED_EXEMPTIONS entry below: scanning `tests/` for
|
|
175
|
+
// REAL re-derivations (the whole reason this guard covers `tests/` at all —
|
|
176
|
+
// #3987's two TRUE positives were `scripts/qa-smell-ratchet.cjs` and
|
|
177
|
+
// `tests/planning-inspect.test.cjs`) means this guard's own fixtures —
|
|
178
|
+
// LITERAL STRINGS handed to `findSlugDerivationDrift` to prove it detects
|
|
179
|
+
// the real deleted #3883 shape — textually match the exact pattern they
|
|
180
|
+
// exist to demonstrate. They never execute as a real slug derivation at
|
|
181
|
+
// runtime; they are detector test data, the same role `RuleTester` fixtures
|
|
182
|
+
// play for an ESLint rule's own test file. Exempting this ONE file by path
|
|
183
|
+
// is not a loophole for a real re-derivation (every OTHER file in `tests/`
|
|
184
|
+
// remains fully covered) — it is what lets the detector's positive-match
|
|
185
|
+
// tests exist at all without permanently reporting themselves as findings.
|
|
186
|
+
const SELF_TEST_FILE = path.join('tests', 'slug-derivation-drift-guard.test.cjs');
|
|
187
|
+
|
|
188
|
+
// Upper bound on a quoted-string argument this scanner will extract (e.g. a
|
|
189
|
+
// `.replace()` replacement argument). Mirrors MAX_REGEX_LITERAL_LEN's
|
|
190
|
+
// reasoning: every real replacement value this detector cares about (`'-'`,
|
|
191
|
+
// `''`) is one or zero characters, so this bound is pure headroom, never a
|
|
192
|
+
// real constraint, and it keeps `readQuotedStringAt` a bounded, linear,
|
|
193
|
+
// non-backtracking scan with no size-dependent cost.
|
|
194
|
+
const MAX_QUOTED_STRING_LEN = 200;
|
|
195
|
+
|
|
196
|
+
// Optional `export ` modifier, mirroring the sibling guards' function
|
|
197
|
+
// tracker — only a column-0 top-level `function` declaration is a candidate
|
|
198
|
+
// for a FUNCTION_SCOPED_EXEMPTIONS entry. (Its extent, once matched, is
|
|
199
|
+
// computed precisely via brace-depth — see `findAllowlistedFunctionExtents`
|
|
200
|
+
// — not merely "until the next line matching this regex", which was
|
|
201
|
+
// MAJOR-1's fail-open bug.)
|
|
202
|
+
const TOP_LEVEL_FUNCTION_RE = /^(?:export\s+)?function\s+([A-Za-z0-9_]+)\s*\(/;
|
|
203
|
+
|
|
204
|
+
// Per the header comment: NOT a bare file allowlist — each entry is scoped
|
|
205
|
+
// to the SPECIFIC function, with its reason recorded above (mirroring
|
|
206
|
+
// `lint-completion-ratio-drift.cjs`'s `FUNCTION_SCOPED_EXEMPTIONS`). An
|
|
207
|
+
// unrelated re-derivation added anywhere else in these same files — INCLUDING
|
|
208
|
+
// after the named function's own closing brace — is still caught (MAJOR 1).
|
|
209
|
+
const FUNCTION_SCOPED_EXEMPTIONS = new Map([
|
|
210
|
+
[path.join('src', 'core-utils.cts'), new Set(['generateSlugInternal'])],
|
|
211
|
+
[path.join('src', 'gsd2-import.cts'), new Set(['slugify'])],
|
|
212
|
+
[path.join('src', 'runtime-artifact-conversion.cts'), new Set(['normalizeKimiSkillName'])],
|
|
213
|
+
[path.join('scripts', 'generate-package-identity.cjs'), new Set(['slugifyPackageName'])],
|
|
214
|
+
]);
|
|
215
|
+
|
|
216
|
+
// ─── Bounded, string/regex/comment-aware line tokenizer ───────────────────
|
|
217
|
+
//
|
|
218
|
+
// Every helper in this section works on ONE physical line (or a bounded
|
|
219
|
+
// slice of text) and is a straight left-to-right scan with no backtracking —
|
|
220
|
+
// the same non-catastrophic shape as `readRegexLiteralAt` in drift-scan.cjs,
|
|
221
|
+
// which this section reuses directly rather than re-deriving its own
|
|
222
|
+
// (weaker) character-class matcher, which is exactly the class of mistake
|
|
223
|
+
// MAJOR 2 found.
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Read a single/double/backtick-quoted string literal starting at
|
|
227
|
+
* `text[start]`. Returns `{ text, inner, end }` (`text` includes the quotes,
|
|
228
|
+
* `inner` is the content between them, `end` is one past the closing quote)
|
|
229
|
+
* or `null` if no matching close is found within `MAX_QUOTED_STRING_LEN`
|
|
230
|
+
* characters or before a newline — a quoted string, like a regex literal,
|
|
231
|
+
* is not expected to span a line in the shapes this detector cares about.
|
|
232
|
+
*/
|
|
233
|
+
function readQuotedStringAt(text, start) {
|
|
234
|
+
const quote = text[start];
|
|
235
|
+
if (quote !== "'" && quote !== '"' && quote !== '`') return null;
|
|
236
|
+
const limit = Math.min(text.length, start + MAX_QUOTED_STRING_LEN);
|
|
237
|
+
let i = start + 1;
|
|
238
|
+
while (i < limit) {
|
|
239
|
+
const ch = text[i];
|
|
240
|
+
if (ch === '\\') {
|
|
241
|
+
i += 2; // escape consumes the next character, whatever it is
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
if (ch === '\n') return null;
|
|
245
|
+
if (ch === quote) return { text: text.slice(start, i + 1), inner: text.slice(start + 1, i), end: i + 1 };
|
|
246
|
+
i++;
|
|
247
|
+
}
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Heuristic used to disambiguate a `/` as the START of a regex literal
|
|
253
|
+
* (rather than division/a closing comment marker) — the standard
|
|
254
|
+
* "what came before it" tokenizer rule: a regex may open at the start of a
|
|
255
|
+
* line, or right after an operator/punctuation/`return` that can only be
|
|
256
|
+
* followed by an expression, never a value. This is the same disambiguation
|
|
257
|
+
* every one of this detector's real call sites (`.replace(/…/`,
|
|
258
|
+
* `.split(/…/`) always satisfies (the char before `/` is always `(` or `,`),
|
|
259
|
+
* so a conservative heuristic here costs nothing in practice.
|
|
260
|
+
*/
|
|
261
|
+
// Bound on the trailing-context buffer `looksLikeRegexStart` inspects. Long
|
|
262
|
+
// enough to see the word "return" plus a little slack; deliberately NOT the
|
|
263
|
+
// full accumulated output — see `looksLikeRegexStart`'s own comment for why
|
|
264
|
+
// that distinction is load-bearing (MAJOR-2 regression class).
|
|
265
|
+
const REGEX_START_TAIL_LEN = 10;
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* `precedingTail` is a BOUNDED trailing slice of the text scanned so far
|
|
269
|
+
* (see `REGEX_START_TAIL_LEN`), never the full accumulated output. This
|
|
270
|
+
* matters: an earlier draft of this heuristic re-derived the tail from the
|
|
271
|
+
* FULL output string on every `/` encountered, which is exactly MAJOR 2's
|
|
272
|
+
* bug shape reintroduced one level up — `String.prototype.replace` on an
|
|
273
|
+
* ever-growing string, called once per `/` in the input, is quadratic on a
|
|
274
|
+
* long line with many `/` characters (measured: a 2.88MB adversarial line
|
|
275
|
+
* took 12.5s with a full-string tail; a bounded tail is O(1) per call
|
|
276
|
+
* regardless of total input size). Every CALLER of this function is
|
|
277
|
+
* responsible for maintaining `precedingTail` as a small rolling buffer.
|
|
278
|
+
*/
|
|
279
|
+
function looksLikeRegexStart(precedingTail) {
|
|
280
|
+
const trimmed = precedingTail.replace(/\s+$/, '');
|
|
281
|
+
if (trimmed.length === 0) return true;
|
|
282
|
+
const last = trimmed[trimmed.length - 1];
|
|
283
|
+
if ('(,=:;[!&|?+-*%{'.includes(last)) return true;
|
|
284
|
+
return trimmed.endsWith('return');
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Bounded (O(1) w.r.t. total accumulated text) update of a rolling tail buffer. */
|
|
288
|
+
function updateTail(tail, appended) {
|
|
289
|
+
return (tail + appended).slice(-REGEX_START_TAIL_LEN);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Scan one physical line, string/regex-literal-aware, producing:
|
|
294
|
+
* - `text`: the line with any trailing `//` line-comment removed (a `//`
|
|
295
|
+
* found INSIDE a string or regex literal, e.g. `'http://x'`, is not a
|
|
296
|
+
* comment — MINOR fix: the previous version cut at the first `//`
|
|
297
|
+
* unconditionally);
|
|
298
|
+
* - `parenDelta`: net `(` minus `)` count, skipping any that appear inside
|
|
299
|
+
* a string or regex literal (so `.replace(/[)]/g, ')')`'s internal
|
|
300
|
+
* parens/regex content never desyncs a caller's paren-depth tracking);
|
|
301
|
+
* - `semicolons`: offsets (into `text`) of every top-level `;` — one NOT
|
|
302
|
+
* inside a string or regex literal (MINOR fix: the previous version did
|
|
303
|
+
* a naive `line.split(';')`, so a `;` embedded in a regex character
|
|
304
|
+
* class, e.g. `/[^a-z0-9;]+/`, wrongly split one statement into two).
|
|
305
|
+
*/
|
|
306
|
+
function scanLineTokens(line) {
|
|
307
|
+
const n = line.length;
|
|
308
|
+
let i = 0;
|
|
309
|
+
let out = '';
|
|
310
|
+
let tail = ''; // bounded rolling context for looksLikeRegexStart — see its comment
|
|
311
|
+
let parenDelta = 0;
|
|
312
|
+
const semicolons = [];
|
|
313
|
+
while (i < n) {
|
|
314
|
+
const ch = line[i];
|
|
315
|
+
if (ch === "'" || ch === '"' || ch === '`') {
|
|
316
|
+
const str = readQuotedStringAt(line, i);
|
|
317
|
+
if (str) {
|
|
318
|
+
out += str.text;
|
|
319
|
+
tail = updateTail(tail, str.text);
|
|
320
|
+
i = str.end;
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
// Unterminated within bound/line: fail safe by consuming the rest of
|
|
324
|
+
// the line as opaque text rather than re-entering character-by-character
|
|
325
|
+
// scanning mid-string (which could misparse quote-internal punctuation
|
|
326
|
+
// as code).
|
|
327
|
+
out += line.slice(i);
|
|
328
|
+
break;
|
|
329
|
+
}
|
|
330
|
+
if (ch === '/' && line[i + 1] === '/') break; // real line comment (not inside a string — handled above)
|
|
331
|
+
if (ch === '/' && looksLikeRegexStart(tail)) {
|
|
332
|
+
const lit = readRegexLiteralAt(line, i);
|
|
333
|
+
if (lit) {
|
|
334
|
+
out += lit.text;
|
|
335
|
+
tail = updateTail(tail, lit.text);
|
|
336
|
+
i = lit.end;
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
if (ch === '(') parenDelta++;
|
|
341
|
+
else if (ch === ')') parenDelta--;
|
|
342
|
+
else if (ch === ';') semicolons.push(out.length);
|
|
343
|
+
out += ch;
|
|
344
|
+
tail = updateTail(tail, ch);
|
|
345
|
+
i++;
|
|
346
|
+
}
|
|
347
|
+
return { text: out, parenDelta, semicolons };
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Strip comment text from a line, string-literal-aware (MINOR fix). Full
|
|
352
|
+
* doc-comment lines (`*`/`/**`-prefixed, or a bare `//` line) are blanked
|
|
353
|
+
* outright, matching the previous behavior for this repo's jsdoc shape
|
|
354
|
+
* (every line of a block comment here starts with `*`); anything else is run
|
|
355
|
+
* through `scanLineTokens`, which only treats a `//` as a comment marker
|
|
356
|
+
* when it is not inside a string or regex literal.
|
|
357
|
+
*/
|
|
358
|
+
function stripComments(line) {
|
|
359
|
+
const trimmed = line.trim();
|
|
360
|
+
if (trimmed.startsWith('*') || trimmed.startsWith('/*') || trimmed.startsWith('//')) return '';
|
|
361
|
+
return scanLineTokens(line).text;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Join a chained method call's continuation lines (those whose trimmed,
|
|
366
|
+
* comment-stripped text starts with `.`) back onto the line that opened the
|
|
367
|
+
* chain, producing one "logical statement" per opening line; split a single
|
|
368
|
+
* physical line into multiple statements at each top-level `;` (string/regex
|
|
369
|
+
* -aware, see `scanLineTokens`); AND (MAJOR-3 widen) keep merging any
|
|
370
|
+
* following line/fragment, regardless of whether it starts with `.`, while
|
|
371
|
+
* the statement's own paren-depth (also string/regex-aware) is still open —
|
|
372
|
+
* this is what recognizes a `.replace(`/`.split(` call whose arguments were
|
|
373
|
+
* wrapped across lines WITHOUT a leading-`.` continuation on each one, e.g.:
|
|
374
|
+
*
|
|
375
|
+
* x.replace(
|
|
376
|
+
* /[^a-z0-9]+/g,
|
|
377
|
+
* '-'
|
|
378
|
+
* ).replace(/^-+|-+$/g, '')
|
|
379
|
+
*
|
|
380
|
+
* A statement is only finalized (pushed, and merging stops) at a top-level
|
|
381
|
+
* `;` once its own paren-depth has returned to zero — a `;` that appears
|
|
382
|
+
* while still inside an open `(` (not a real shape for this detector's
|
|
383
|
+
* `.replace()`/`.split()` call sites, but handled defensively) does not
|
|
384
|
+
* split the statement.
|
|
385
|
+
*
|
|
386
|
+
* Returns `[{ startLine, text }]` — `startLine` is 1-based, matching the
|
|
387
|
+
* sibling guards' reporting convention.
|
|
388
|
+
*/
|
|
389
|
+
function buildLogicalStatements(lines) {
|
|
390
|
+
const statements = [];
|
|
391
|
+
let current = null; // { startLine, text, openDepth }
|
|
392
|
+
for (let i = 0; i < lines.length; i++) {
|
|
393
|
+
const trimmedRaw = lines[i].trim();
|
|
394
|
+
if (trimmedRaw.startsWith('*') || trimmedRaw.startsWith('/*') || trimmedRaw.startsWith('//')) continue; // full-line comment
|
|
395
|
+
|
|
396
|
+
const { text: strippedLine, semicolons } = scanLineTokens(lines[i]);
|
|
397
|
+
if (!strippedLine.trim()) continue; // blank/comment-only lines never break or start a statement
|
|
398
|
+
|
|
399
|
+
const rawFragments = [];
|
|
400
|
+
let cursor = 0;
|
|
401
|
+
for (const pos of semicolons) {
|
|
402
|
+
rawFragments.push(strippedLine.slice(cursor, pos));
|
|
403
|
+
cursor = pos + 1;
|
|
404
|
+
}
|
|
405
|
+
rawFragments.push(strippedLine.slice(cursor));
|
|
406
|
+
const fragments = rawFragments.map((f) => f.trim()).filter((f) => f.length > 0);
|
|
407
|
+
|
|
408
|
+
for (let f = 0; f < fragments.length; f++) {
|
|
409
|
+
const frag = fragments[f];
|
|
410
|
+
const isFirstFragmentOfLine = f === 0;
|
|
411
|
+
const isLastFragmentOfLine = f === fragments.length - 1;
|
|
412
|
+
const midOpenParen = current !== null && current.openDepth > 0;
|
|
413
|
+
|
|
414
|
+
if (isFirstFragmentOfLine && current && (midOpenParen || frag.startsWith('.'))) {
|
|
415
|
+
current.text += ' ' + frag;
|
|
416
|
+
} else {
|
|
417
|
+
if (current) statements.push(current);
|
|
418
|
+
current = { startLine: i + 1, text: frag, openDepth: 0 };
|
|
419
|
+
}
|
|
420
|
+
current.openDepth += scanLineTokens(frag).parenDelta;
|
|
421
|
+
|
|
422
|
+
// A fragment that is not the LAST one on its line was terminated by a
|
|
423
|
+
// top-level `;` immediately after it. It is a complete statement no
|
|
424
|
+
// later fragment may merge into, UNLESS it is (defensively) still
|
|
425
|
+
// inside an open paren — see the doc comment above.
|
|
426
|
+
if (!isLastFragmentOfLine && current.openDepth <= 0) {
|
|
427
|
+
statements.push(current);
|
|
428
|
+
current = null;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
if (current) statements.push(current);
|
|
433
|
+
return statements;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// ─── Collapse / trim classification (operates on an EXTRACTED, bounded
|
|
437
|
+
// regex-literal body — never on unbounded raw text; this is the MAJOR-2 fix)
|
|
438
|
+
|
|
439
|
+
// A negated character class collapsed to a single hyphen — the class body is
|
|
440
|
+
// matched escape-aware (`\\.` or any non-`]`/non-`\` char), which is what
|
|
441
|
+
// lets `[^a-z0-9\]]` (an escaped `]` inside the class) parse correctly; the
|
|
442
|
+
// previous `[^\]]*` body matcher broke on exactly this shape (MINOR fix).
|
|
443
|
+
// Quantifier may be `+`, `*`, `{1,}` (MAJOR-3 widen: `{1,}` is `+`'s
|
|
444
|
+
// equivalent), or absent; an optional `\s*` may sit on either side of the
|
|
445
|
+
// class (MAJOR-3 widen). All bounded: this runs against an already-extracted
|
|
446
|
+
// literal body capped at MAX_REGEX_LITERAL_LEN, never unbounded text.
|
|
447
|
+
const COLLAPSE_BODY_RE = /^(?:\\s\*)?\[\^(?:\\.|[^\]\\])*\](?:[+*]|\{1,\})?(?:\\s\*)?$/;
|
|
448
|
+
|
|
449
|
+
function isCollapseBody(body) {
|
|
450
|
+
return COLLAPSE_BODY_RE.test(body);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Normalize the small enumerated set of equivalent hyphen-trim regex
|
|
455
|
+
* spellings (MAJOR-3 widen) down to a canonical `^-<quant>|-<quant>$` shape
|
|
456
|
+
* (in either order) before comparing: unwraps a `(^-+)`/`(-+$)` parenthesized
|
|
457
|
+
* alternative, unescapes a literal `\-` to `-`, and collapses a `[-]`
|
|
458
|
+
* single-hyphen character class to a bare `-`. Deliberately a small,
|
|
459
|
+
* enumerated normalization — not a permissive catch-all regex — per the
|
|
460
|
+
* review's instruction to widen ONLY where the resulting shape is a closed,
|
|
461
|
+
* auditable set.
|
|
462
|
+
*/
|
|
463
|
+
function canonicalizeTrimBody(rawBody) {
|
|
464
|
+
return rawBody
|
|
465
|
+
.replace(/\((\^[^)]*)\)/g, '$1')
|
|
466
|
+
.replace(/\(([^)]*\$)\)/g, '$1')
|
|
467
|
+
.replace(/\\-/g, '-')
|
|
468
|
+
.replace(/\[-\]/g, '-');
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function isTrimBodyPart(part, anchor) {
|
|
472
|
+
return anchor === 'start' ? /^\^-[+*]?$/.test(part) : /^-[+*]?\$$/.test(part);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function isTrimBody(rawBody) {
|
|
476
|
+
const parts = canonicalizeTrimBody(rawBody).split('|');
|
|
477
|
+
if (parts.length !== 2) return false;
|
|
478
|
+
const [p1, p2] = parts;
|
|
479
|
+
return (
|
|
480
|
+
(isTrimBodyPart(p1, 'start') && isTrimBodyPart(p2, 'end')) ||
|
|
481
|
+
(isTrimBodyPart(p2, 'start') && isTrimBodyPart(p1, 'end'))
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** Split a bounded `/body/flags` regex-literal text into `{ body, flags }`, or null. */
|
|
486
|
+
function splitRegexLiteral(literalText) {
|
|
487
|
+
const m = /^\/(.*)\/([a-z]*)$/.exec(literalText);
|
|
488
|
+
return m ? { body: m[1], flags: m[2] } : null;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Parse the literal-argument form `new RegExp('pattern'[, 'flags'])` starting
|
|
493
|
+
* at `text[start]` (which must be the `n` of `new`). Only LITERAL string
|
|
494
|
+
* arguments are handled — per the review's explicit instruction, a variable
|
|
495
|
+
* built into `new RegExp(...)` needs data-flow analysis this textual scanner
|
|
496
|
+
* does not attempt, and is a documented known gap, not silently mishandled.
|
|
497
|
+
* Returns `{ body, flags, end }` or null.
|
|
498
|
+
*/
|
|
499
|
+
function parseNewRegExpLiteral(text, start) {
|
|
500
|
+
if (text.slice(start, start + 10) !== 'new RegExp') return null;
|
|
501
|
+
let i = start + 10;
|
|
502
|
+
while (i < text.length && /\s/.test(text[i])) i++;
|
|
503
|
+
if (text[i] !== '(') return null;
|
|
504
|
+
i++;
|
|
505
|
+
while (i < text.length && /\s/.test(text[i])) i++;
|
|
506
|
+
const patternArg = readQuotedStringAt(text, i);
|
|
507
|
+
if (!patternArg) return null;
|
|
508
|
+
i = patternArg.end;
|
|
509
|
+
while (i < text.length && /\s/.test(text[i])) i++;
|
|
510
|
+
let flags = '';
|
|
511
|
+
if (text[i] === ',') {
|
|
512
|
+
i++;
|
|
513
|
+
while (i < text.length && /\s/.test(text[i])) i++;
|
|
514
|
+
const flagsArg = readQuotedStringAt(text, i);
|
|
515
|
+
if (flagsArg) {
|
|
516
|
+
flags = flagsArg.inner;
|
|
517
|
+
i = flagsArg.end;
|
|
518
|
+
while (i < text.length && /\s/.test(text[i])) i++;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
if (text[i] !== ')') return null;
|
|
522
|
+
return { body: patternArg.inner, flags, end: i + 1 };
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Find every `.replace(...)`/`.replaceAll(...)` call in `text` (MAJOR-3
|
|
527
|
+
* widen: `replaceAll` alongside `replace`) whose first argument is a
|
|
528
|
+
* recognizable regex (a `/…/` literal OR the literal `new RegExp(...)` form)
|
|
529
|
+
* and whose second argument is a quoted string, returning
|
|
530
|
+
* `[{ body, flags, replacement }]`. All literal extraction is bounded
|
|
531
|
+
* (`readRegexLiteralAt`/`readQuotedStringAt`), so this is safe to run against
|
|
532
|
+
* a long statement text — the MAJOR-2 fix.
|
|
533
|
+
*/
|
|
534
|
+
function findReplaceCalls(text) {
|
|
535
|
+
const calls = [];
|
|
536
|
+
const callRe = /\.(replace|replaceAll)\(/g;
|
|
537
|
+
while (callRe.exec(text)) {
|
|
538
|
+
let idx = callRe.lastIndex;
|
|
539
|
+
while (idx < text.length && /\s/.test(text[idx])) idx++;
|
|
540
|
+
let regexInfo = null;
|
|
541
|
+
if (text[idx] === '/') {
|
|
542
|
+
const lit = readRegexLiteralAt(text, idx);
|
|
543
|
+
if (lit) {
|
|
544
|
+
const split = splitRegexLiteral(lit.text);
|
|
545
|
+
if (split) {
|
|
546
|
+
regexInfo = split;
|
|
547
|
+
idx = lit.end;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
} else if (text.slice(idx, idx + 10) === 'new RegExp') {
|
|
551
|
+
const parsed = parseNewRegExpLiteral(text, idx);
|
|
552
|
+
if (parsed) {
|
|
553
|
+
regexInfo = { body: parsed.body, flags: parsed.flags };
|
|
554
|
+
idx = parsed.end;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
if (!regexInfo) continue;
|
|
558
|
+
while (idx < text.length && /[\s,]/.test(text[idx])) idx++;
|
|
559
|
+
const replacementArg = readQuotedStringAt(text, idx);
|
|
560
|
+
calls.push({ regexInfo, replacement: replacementArg ? replacementArg.inner : null });
|
|
561
|
+
}
|
|
562
|
+
return calls;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* MAJOR-3 widen: `.split(<negated class>).join('-')` is an alternate way to
|
|
567
|
+
* express the SAME collapse-to-hyphen shape as
|
|
568
|
+
* `.replace(<negated class>, '-')`. Only the literal-regex `.split(/…/)` form
|
|
569
|
+
* is handled (mirrors `findReplaceCalls`'s own `new RegExp` literal-only
|
|
570
|
+
* scope for the same textual-scan-cannot-do-data-flow reason).
|
|
571
|
+
*/
|
|
572
|
+
function findSplitJoinCollapse(text) {
|
|
573
|
+
const splitRe = /\.split\(\s*/g;
|
|
574
|
+
while (splitRe.exec(text)) {
|
|
575
|
+
const argStart = splitRe.lastIndex;
|
|
576
|
+
if (text[argStart] !== '/') continue;
|
|
577
|
+
const lit = readRegexLiteralAt(text, argStart);
|
|
578
|
+
if (!lit) continue;
|
|
579
|
+
let after = lit.end;
|
|
580
|
+
while (after < text.length && /\s/.test(text[after])) after++;
|
|
581
|
+
if (text[after] !== ')') continue;
|
|
582
|
+
after++;
|
|
583
|
+
const joinMatch = /^\s*\.join\(\s*(['"`])-\1\s*\)/.exec(text.slice(after, after + 32));
|
|
584
|
+
if (!joinMatch) continue;
|
|
585
|
+
const split = splitRegexLiteral(lit.text);
|
|
586
|
+
if (split && isCollapseBody(split.body)) return true;
|
|
587
|
+
}
|
|
588
|
+
return false;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/** True if `stmtText` (one logical statement) carries both the collapse and trim clauses. */
|
|
592
|
+
function statementHasSlugDerivation(stmtText) {
|
|
593
|
+
let hasCollapse = false;
|
|
594
|
+
let hasTrim = false;
|
|
595
|
+
for (const call of findReplaceCalls(stmtText)) {
|
|
596
|
+
if (call.replacement === null) continue;
|
|
597
|
+
if (call.replacement === '-' && isCollapseBody(call.regexInfo.body)) hasCollapse = true;
|
|
598
|
+
if (call.replacement === '' && isTrimBody(call.regexInfo.body)) hasTrim = true;
|
|
599
|
+
}
|
|
600
|
+
if (!hasCollapse) hasCollapse = findSplitJoinCollapse(stmtText);
|
|
601
|
+
return hasCollapse && hasTrim;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// ─── MAJOR-1 fix: precise allowlisted-function body extent ────────────────
|
|
605
|
+
//
|
|
606
|
+
// Computes the REAL `{ ... }` body range of each allowlisted function via
|
|
607
|
+
// brace-depth matching on a masked copy of the file (comments, strings, and
|
|
608
|
+
// template literals replaced with same-length whitespace/newlines) — not
|
|
609
|
+
// "from this column-0 `function` line until the next one", which is what let
|
|
610
|
+
// the exemption bleed past the function it names.
|
|
611
|
+
|
|
612
|
+
/** Find the index one past a single/double-quoted string starting at `start`, masking is caller's job. Multi-line-safe (unlike readQuotedStringAt, which is intentionally single-line for the DETECTOR's own bounded-scan needs). */
|
|
613
|
+
function findQuotedEndMultiline(text, start) {
|
|
614
|
+
const quote = text[start];
|
|
615
|
+
const n = text.length;
|
|
616
|
+
let i = start + 1;
|
|
617
|
+
while (i < n) {
|
|
618
|
+
if (text[i] === '\\') {
|
|
619
|
+
i += 2;
|
|
620
|
+
continue;
|
|
621
|
+
}
|
|
622
|
+
if (text[i] === quote) return i + 1;
|
|
623
|
+
i++;
|
|
624
|
+
}
|
|
625
|
+
return n;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* Find the index one past a backtick template literal starting at `start`,
|
|
630
|
+
* recursively skipping nested `${ ... }` substitutions (which may themselves
|
|
631
|
+
* contain nested templates/strings/comments/braces). Every brace opened
|
|
632
|
+
* inside a substitution closes inside that SAME substitution (it is valid
|
|
633
|
+
* JS/TS), so masking the whole template literal — substitutions included —
|
|
634
|
+
* as non-code is safe for the purpose of an ENCLOSING function's brace-depth
|
|
635
|
+
* extent: any braces inside it are locally balanced and net to zero either
|
|
636
|
+
* way.
|
|
637
|
+
*/
|
|
638
|
+
function findTemplateEnd(text, start) {
|
|
639
|
+
const n = text.length;
|
|
640
|
+
let i = start + 1;
|
|
641
|
+
const substitutionDepths = [];
|
|
642
|
+
while (i < n) {
|
|
643
|
+
if (text[i] === '\\') {
|
|
644
|
+
i += 2;
|
|
645
|
+
continue;
|
|
646
|
+
}
|
|
647
|
+
if (substitutionDepths.length === 0) {
|
|
648
|
+
if (text[i] === '`') return i + 1;
|
|
649
|
+
if (text[i] === '$' && text[i + 1] === '{') {
|
|
650
|
+
substitutionDepths.push(1);
|
|
651
|
+
i += 2;
|
|
652
|
+
continue;
|
|
653
|
+
}
|
|
654
|
+
i++;
|
|
655
|
+
continue;
|
|
656
|
+
}
|
|
657
|
+
if (text[i] === '`') {
|
|
658
|
+
i = findTemplateEnd(text, i);
|
|
659
|
+
continue;
|
|
660
|
+
}
|
|
661
|
+
if (text[i] === "'" || text[i] === '"') {
|
|
662
|
+
i = findQuotedEndMultiline(text, i);
|
|
663
|
+
continue;
|
|
664
|
+
}
|
|
665
|
+
if (text[i] === '/' && text[i + 1] === '/') {
|
|
666
|
+
while (i < n && text[i] !== '\n') i++;
|
|
667
|
+
continue;
|
|
668
|
+
}
|
|
669
|
+
if (text[i] === '/' && text[i + 1] === '*') {
|
|
670
|
+
const j = text.indexOf('*/', i + 2);
|
|
671
|
+
i = j === -1 ? n : j + 2;
|
|
672
|
+
continue;
|
|
673
|
+
}
|
|
674
|
+
if (text[i] === '{') {
|
|
675
|
+
substitutionDepths[substitutionDepths.length - 1]++;
|
|
676
|
+
i++;
|
|
677
|
+
continue;
|
|
678
|
+
}
|
|
679
|
+
if (text[i] === '}') {
|
|
680
|
+
substitutionDepths[substitutionDepths.length - 1]--;
|
|
681
|
+
if (substitutionDepths[substitutionDepths.length - 1] === 0) substitutionDepths.pop();
|
|
682
|
+
i++;
|
|
683
|
+
continue;
|
|
684
|
+
}
|
|
685
|
+
i++;
|
|
686
|
+
}
|
|
687
|
+
return n; // unterminated -> EOF (fail-closed: masked to end of file, never past it)
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* Replace every comment, string, and template literal in `text` with
|
|
692
|
+
* same-length whitespace (preserving newlines, so downstream line-number math
|
|
693
|
+
* stays correct), leaving all other characters — including every REAL code
|
|
694
|
+
* brace/paren — untouched.
|
|
695
|
+
*/
|
|
696
|
+
function maskNonCode(text) {
|
|
697
|
+
const n = text.length;
|
|
698
|
+
let out = '';
|
|
699
|
+
let i = 0;
|
|
700
|
+
while (i < n) {
|
|
701
|
+
const two = text.slice(i, i + 2);
|
|
702
|
+
if (two === '//') {
|
|
703
|
+
let j = i;
|
|
704
|
+
while (j < n && text[j] !== '\n') j++;
|
|
705
|
+
out += ' '.repeat(j - i);
|
|
706
|
+
i = j;
|
|
707
|
+
continue;
|
|
708
|
+
}
|
|
709
|
+
if (two === '/*') {
|
|
710
|
+
const found = text.indexOf('*/', i + 2);
|
|
711
|
+
const j = found === -1 ? n : found + 2;
|
|
712
|
+
for (let k = i; k < j; k++) out += text[k] === '\n' ? '\n' : ' ';
|
|
713
|
+
i = j;
|
|
714
|
+
continue;
|
|
715
|
+
}
|
|
716
|
+
const ch = text[i];
|
|
717
|
+
if (ch === "'" || ch === '"') {
|
|
718
|
+
const j = findQuotedEndMultiline(text, i);
|
|
719
|
+
for (let k = i; k < j; k++) out += text[k] === '\n' ? '\n' : ' ';
|
|
720
|
+
i = j;
|
|
721
|
+
continue;
|
|
722
|
+
}
|
|
723
|
+
if (ch === '`') {
|
|
724
|
+
const j = findTemplateEnd(text, i);
|
|
725
|
+
for (let k = i; k < j; k++) out += text[k] === '\n' ? '\n' : ' ';
|
|
726
|
+
i = j;
|
|
727
|
+
continue;
|
|
728
|
+
}
|
|
729
|
+
out += ch;
|
|
730
|
+
i++;
|
|
731
|
+
}
|
|
732
|
+
return out;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* Second masking pass, run PER LINE (regex literals cannot span lines) over
|
|
737
|
+
* text already comment/string/template-masked by `maskNonCode`: masks any
|
|
738
|
+
* regex literal so an unbalanced brace inside a character class (e.g.
|
|
739
|
+
* `/[{]/`) cannot desync brace-depth counting for an enclosing function.
|
|
740
|
+
*/
|
|
741
|
+
function maskRegexLiterals(masked) {
|
|
742
|
+
return masked
|
|
743
|
+
.split('\n')
|
|
744
|
+
.map((line) => {
|
|
745
|
+
let out = '';
|
|
746
|
+
let tail = ''; // bounded rolling context — see looksLikeRegexStart's comment
|
|
747
|
+
let i = 0;
|
|
748
|
+
while (i < line.length) {
|
|
749
|
+
if (line[i] === '/' && looksLikeRegexStart(tail)) {
|
|
750
|
+
const lit = readRegexLiteralAt(line, i);
|
|
751
|
+
if (lit) {
|
|
752
|
+
const masked = ' '.repeat(lit.end - i);
|
|
753
|
+
out += masked;
|
|
754
|
+
tail = updateTail(tail, masked);
|
|
755
|
+
i = lit.end;
|
|
756
|
+
continue;
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
out += line[i];
|
|
760
|
+
tail = updateTail(tail, line[i]);
|
|
761
|
+
i++;
|
|
762
|
+
}
|
|
763
|
+
return out;
|
|
764
|
+
})
|
|
765
|
+
.join('\n');
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/** Find the index of the `{`/`}` that closes the one opened at `openIdx` in `masked`, or -1 (unterminated -> caller decides fallback). */
|
|
769
|
+
function findMatchingBrace(masked, openIdx) {
|
|
770
|
+
let depth = 0;
|
|
771
|
+
for (let i = openIdx; i < masked.length; i++) {
|
|
772
|
+
if (masked[i] === '{') depth++;
|
|
773
|
+
else if (masked[i] === '}') {
|
|
774
|
+
depth--;
|
|
775
|
+
if (depth === 0) return i;
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
return -1;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/** Find the index of the `)` that closes the `(` at `openIdx` in `masked`, or -1. */
|
|
782
|
+
function findMatchingParen(masked, openIdx) {
|
|
783
|
+
let depth = 0;
|
|
784
|
+
for (let i = openIdx; i < masked.length; i++) {
|
|
785
|
+
if (masked[i] === '(') depth++;
|
|
786
|
+
else if (masked[i] === ')') {
|
|
787
|
+
depth--;
|
|
788
|
+
if (depth === 0) return i;
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
return -1;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/**
|
|
795
|
+
* Compute `{ startLine, endLine }` (1-based, inclusive) for every function in
|
|
796
|
+
* `exemptFunctionNames` that is declared as a column-0 top-level
|
|
797
|
+
* `function name(` in `text`. Brace-depth matching runs on `masked`
|
|
798
|
+
* (comments/strings/templates/regex-literals all masked to whitespace), so
|
|
799
|
+
* only REAL code braces/parens are counted — a destructured parameter like
|
|
800
|
+
* `function f({ a, b }) { ... }`'s own braces are correctly skipped past via
|
|
801
|
+
* paren-matching of the parameter list BEFORE brace-matching begins.
|
|
802
|
+
*/
|
|
803
|
+
function findAllowlistedFunctionExtents(text, exemptFunctionNames) {
|
|
804
|
+
if (!exemptFunctionNames || exemptFunctionNames.size === 0) return [];
|
|
805
|
+
const masked = maskRegexLiterals(maskNonCode(text));
|
|
806
|
+
const lines = text.split('\n');
|
|
807
|
+
const lineStartOffsets = [];
|
|
808
|
+
let offset = 0;
|
|
809
|
+
for (const line of lines) {
|
|
810
|
+
lineStartOffsets.push(offset);
|
|
811
|
+
offset += line.length + 1; // +1 for the '\n' split removed
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
const extents = [];
|
|
815
|
+
for (let i = 0; i < lines.length; i++) {
|
|
816
|
+
const m = TOP_LEVEL_FUNCTION_RE.exec(lines[i]);
|
|
817
|
+
if (!m || !exemptFunctionNames.has(m[1])) continue;
|
|
818
|
+
|
|
819
|
+
const declStart = lineStartOffsets[i];
|
|
820
|
+
const parenIdx = masked.indexOf('(', declStart);
|
|
821
|
+
if (parenIdx === -1) continue;
|
|
822
|
+
const parenEnd = findMatchingParen(masked, parenIdx);
|
|
823
|
+
if (parenEnd === -1) continue;
|
|
824
|
+
const braceIdx = masked.indexOf('{', parenEnd);
|
|
825
|
+
if (braceIdx === -1) continue;
|
|
826
|
+
const braceEnd = findMatchingBrace(masked, braceIdx);
|
|
827
|
+
const endOffset = braceEnd === -1 ? masked.length - 1 : braceEnd;
|
|
828
|
+
|
|
829
|
+
let endLine = lines.length - 1;
|
|
830
|
+
for (let li = 0; li < lineStartOffsets.length; li++) {
|
|
831
|
+
if (lineStartOffsets[li] > endOffset) {
|
|
832
|
+
endLine = li - 1;
|
|
833
|
+
break;
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
extents.push({ name: m[1], startLine: i + 1, endLine: endLine + 1 });
|
|
837
|
+
}
|
|
838
|
+
return extents;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/**
|
|
842
|
+
* Pure: find every unsanctioned slug-derivation re-derivation in `text`.
|
|
843
|
+
* `relPath` is the repo-relative path, used both to report file:line and to
|
|
844
|
+
* apply the narrow, function-scoped exemptions above.
|
|
845
|
+
* Returns [{ line, found }].
|
|
846
|
+
*/
|
|
847
|
+
function findSlugDerivationDrift(text, relPath) {
|
|
848
|
+
const out = [];
|
|
849
|
+
const lines = text.split('\n');
|
|
850
|
+
const exemptFunctions = FUNCTION_SCOPED_EXEMPTIONS.get(relPath) || null;
|
|
851
|
+
const exemptExtents = exemptFunctions ? findAllowlistedFunctionExtents(text, exemptFunctions) : [];
|
|
852
|
+
|
|
853
|
+
for (const stmt of buildLogicalStatements(lines)) {
|
|
854
|
+
if (!statementHasSlugDerivation(stmt.text)) continue;
|
|
855
|
+
|
|
856
|
+
const inExemptExtent = exemptExtents.some(
|
|
857
|
+
(ext) => stmt.startLine >= ext.startLine && stmt.startLine <= ext.endLine,
|
|
858
|
+
);
|
|
859
|
+
if (inExemptExtent) continue;
|
|
860
|
+
|
|
861
|
+
out.push({ line: stmt.startLine, found: stmt.text.slice(0, MAX_REGEX_LITERAL_LEN) });
|
|
862
|
+
}
|
|
863
|
+
return out;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/**
|
|
867
|
+
* Scan the authored source tree and return every unsanctioned re-derivation,
|
|
868
|
+
* each annotated with the repo-relative file path.
|
|
869
|
+
*/
|
|
870
|
+
function scanRepo(root) {
|
|
871
|
+
return scanTree({
|
|
872
|
+
root,
|
|
873
|
+
scanDirs: SCAN_DIRS,
|
|
874
|
+
scanExt: SCAN_EXT,
|
|
875
|
+
onFile(rel, text) {
|
|
876
|
+
if (rel === SELF_TEST_FILE) return []; // see SELF_TEST_FILE's own comment above
|
|
877
|
+
if (Buffer.byteLength(text, 'utf8') > MAX_FILE_SIZE_BYTES) return []; // see MAX_FILE_SIZE_BYTES's own comment above
|
|
878
|
+
return findSlugDerivationDrift(text, rel).map((d) => ({ file: rel, ...d }));
|
|
879
|
+
},
|
|
880
|
+
});
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
function main() {
|
|
884
|
+
const root = path.join(__dirname, '..');
|
|
885
|
+
const violations = scanRepo(root);
|
|
886
|
+
if (violations.length === 0) {
|
|
887
|
+
process.stdout.write('ok slug-derivation-drift: no unsanctioned slug re-derivations outside core-utils.cts generateSlugInternal\n');
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
process.stderr.write('slug-derivation-drift: independent re-derivation(s) of the slug-generation seam found.\n');
|
|
891
|
+
process.stderr.write('Use src/core-utils.cts `generateSlugInternal(text, maxLen)` instead of re-deriving\n');
|
|
892
|
+
process.stderr.write('the collapse/trim (or transliterate/collapse/trim) slug shape:\n');
|
|
893
|
+
for (const d of violations) {
|
|
894
|
+
// `d.file` is exactly as attacker-controlled as `d.found`: a repo can
|
|
895
|
+
// legally track a filename containing control bytes / bidi overrides,
|
|
896
|
+
// and it is a fork-PR-authored value reaching a CI log the same way the
|
|
897
|
+
// matched statement text does — sanitize it at the same reporting
|
|
898
|
+
// boundary.
|
|
899
|
+
process.stderr.write(` ${sanitizeForReport(d.file)}:${d.line} ${sanitizeForReport(d.found)}\n`);
|
|
900
|
+
}
|
|
901
|
+
process.exitCode = 1;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
if (require.main === module) main();
|
|
905
|
+
|
|
906
|
+
module.exports = {
|
|
907
|
+
findSlugDerivationDrift,
|
|
908
|
+
scanRepo,
|
|
909
|
+
buildLogicalStatements,
|
|
910
|
+
stripComments,
|
|
911
|
+
scanLineTokens,
|
|
912
|
+
isCollapseBody,
|
|
913
|
+
isTrimBody,
|
|
914
|
+
COLLAPSE_BODY_RE,
|
|
915
|
+
findAllowlistedFunctionExtents,
|
|
916
|
+
FUNCTION_SCOPED_EXEMPTIONS,
|
|
917
|
+
SCAN_DIRS,
|
|
918
|
+
SCAN_EXT,
|
|
919
|
+
SELF_TEST_FILE,
|
|
920
|
+
MAX_FILE_SIZE_BYTES,
|
|
921
|
+
};
|