@opengsd/gsd-core 1.10.0 → 1.11.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/agents/gsd-debug-session-manager.md +11 -0
- package/agents/gsd-doc-synthesizer.md +2 -4
- package/agents/gsd-executor.md +5 -5
- package/agents/gsd-mempalace-curator.md +5 -2
- package/agents/gsd-phase-researcher.md +20 -1
- package/agents/gsd-plan-checker.md +37 -0
- package/agents/gsd-planner.md +44 -46
- package/agents/gsd-user-profiler.md +3 -0
- package/agents/gsd-verifier.md +12 -3
- package/bin/install.js +841 -971
- package/bin/lib/ui-safety-gate.cjs +2 -0
- package/commands/gsd/code-review.md +1 -1
- package/commands/gsd/execute-phase.md +1 -1
- package/commands/gsd/map-codebase.md +1 -1
- package/commands/gsd/mempalace-capture.md +1 -1
- package/commands/gsd/mempalace-recall.md +1 -1
- package/commands/gsd/new-milestone.md +1 -1
- package/commands/gsd/quick.md +1 -1
- package/commands/gsd/review-backlog.md +2 -1
- package/commands/gsd/verify-work.md +1 -1
- package/gsd-core/bin/gsd-tools.cjs +469 -88
- package/gsd-core/bin/lib/active-workstream-store.cjs +138 -22
- package/gsd-core/bin/lib/agent-install-check.cjs +230 -32
- package/gsd-core/bin/lib/api-coverage.cjs +3 -5
- package/gsd-core/bin/lib/artifacts.cjs +3 -0
- package/gsd-core/bin/lib/assumption-delta.cjs +2 -4
- package/gsd-core/bin/lib/audit-command-router.cjs +9 -2
- package/gsd-core/bin/lib/audit.cjs +876 -240
- package/gsd-core/bin/lib/broken-windows.cjs +1 -1
- package/gsd-core/bin/lib/capability-consent.cjs +149 -15
- package/gsd-core/bin/lib/capability-lifecycle.cjs +45 -0
- package/gsd-core/bin/lib/capability-registry.cjs +575 -101
- package/gsd-core/bin/lib/capability-source.cjs +92 -0
- package/gsd-core/bin/lib/capability-trust.cjs +444 -25
- package/gsd-core/bin/lib/capability-validator.cjs +495 -22
- package/gsd-core/bin/lib/capability-writer.cjs +3 -2
- package/gsd-core/bin/lib/check-command-router.cjs +71 -37
- package/gsd-core/bin/lib/claude-orchestration.cjs +56 -3
- package/gsd-core/bin/lib/codex-agent-toml.cjs +329 -0
- package/gsd-core/bin/lib/command-aliases.cjs +22 -0
- package/gsd-core/bin/lib/command-roster.cjs +44 -1
- package/gsd-core/bin/lib/commands.cjs +651 -86
- package/gsd-core/bin/lib/commonjs-marker.cjs +12 -6
- package/gsd-core/bin/lib/complexity-trigger.cjs +1172 -0
- package/gsd-core/bin/lib/config-loader.cjs +75 -0
- package/gsd-core/bin/lib/config.cjs +10 -1
- package/gsd-core/bin/lib/core-utils.cjs +127 -29
- package/gsd-core/bin/lib/decisions.cjs +23 -0
- package/gsd-core/bin/lib/fallow-runner.cjs +20 -44
- package/gsd-core/bin/lib/frontmatter.cjs +155 -20
- package/gsd-core/bin/lib/gap-checker.cjs +68 -7
- package/gsd-core/bin/lib/git-base-branch.cjs +102 -0
- package/gsd-core/bin/lib/gsd2-import.cjs +10 -1
- package/gsd-core/bin/lib/health-diagnostic-rules/agent-install.cjs +101 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/config-validation.cjs +348 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/consistency.cjs +145 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/install-surface-shadowing.cjs +98 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/milestone-archive-hygiene.cjs +100 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +222 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +265 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/root-existence.cjs +161 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +303 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs +173 -0
- package/gsd-core/bin/lib/health-diagnostic-types.cjs +68 -0
- package/gsd-core/bin/lib/health-diagnostic.cjs +431 -0
- package/gsd-core/bin/lib/host-runtime-detection.cjs +134 -0
- package/gsd-core/bin/lib/init.cjs +321 -129
- package/gsd-core/bin/lib/install-effort-resolver.cjs +73 -30
- package/gsd-core/bin/lib/install-engine.cjs +745 -258
- package/gsd-core/bin/lib/install-fs-adapter.cjs +262 -0
- package/gsd-core/bin/lib/install-model-override-resolver.cjs +203 -0
- package/gsd-core/bin/lib/install-profiles.cjs +134 -57
- package/gsd-core/bin/lib/install-scope.cjs +270 -0
- package/gsd-core/bin/lib/install-shadow-report.cjs +385 -0
- package/gsd-core/bin/lib/installed-surface-resolver.cjs +381 -0
- package/gsd-core/bin/lib/installer-migrations.cjs +138 -31
- package/gsd-core/bin/lib/io.cjs +10 -0
- package/gsd-core/bin/lib/markdown-sectionizer.cjs +2 -1
- package/gsd-core/bin/lib/markdown-table.cjs +133 -20
- package/gsd-core/bin/lib/milestone-lock.cjs +248 -0
- package/gsd-core/bin/lib/milestone.cjs +754 -70
- package/gsd-core/bin/lib/model-catalog.cjs +59 -1
- package/gsd-core/bin/lib/model-resolver.cjs +183 -40
- package/gsd-core/bin/lib/normalize-test-command.cjs +1 -1
- package/gsd-core/bin/lib/pattern.cjs +122 -0
- package/gsd-core/bin/lib/phase-estimation.cjs +1 -1
- package/gsd-core/bin/lib/phase-id.cjs +444 -36
- package/gsd-core/bin/lib/phase-lifecycle.cjs +28 -3
- package/gsd-core/bin/lib/phase-locator.cjs +125 -18
- package/gsd-core/bin/lib/phase.cjs +646 -143
- package/gsd-core/bin/lib/plan-dependency-graph.cjs +72 -1
- package/gsd-core/bin/lib/plan-drift-guard.cjs +120 -0
- package/gsd-core/bin/lib/plan-scan.cjs +86 -2
- package/gsd-core/bin/lib/planning-scope.cjs +31 -0
- package/gsd-core/bin/lib/planning-snapshot.cjs +890 -0
- package/gsd-core/bin/lib/planning-workspace.cjs +56 -6
- package/gsd-core/bin/lib/probe-core.cjs +1 -1
- package/gsd-core/bin/lib/profile-output.cjs +1 -1
- package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +740 -0
- package/gsd-core/bin/lib/retired-artifact-cleanup.cjs +11 -6
- package/gsd-core/bin/lib/review-lane-descriptor.cjs +13 -4
- package/gsd-core/bin/lib/review-lane-invocation.cjs +30 -0
- package/gsd-core/bin/lib/review-lane-runner.cjs +421 -66
- package/gsd-core/bin/lib/review-reviewer-selection.cjs +13 -18
- package/gsd-core/bin/lib/roadmap-command-router.cjs +34 -0
- package/gsd-core/bin/lib/roadmap-parser.cjs +943 -184
- package/gsd-core/bin/lib/roadmap-upgrade.cjs +37 -10
- package/gsd-core/bin/lib/roadmap.cjs +385 -94
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +608 -46
- package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +14 -2
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +426 -55
- package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +3 -2
- package/gsd-core/bin/lib/runtime-homes.cjs +69 -3
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +115 -3
- package/gsd-core/bin/lib/runtime-name-policy.cjs +3 -1
- package/gsd-core/bin/lib/runtime-slash.cjs +27 -9
- package/gsd-core/bin/lib/security.cjs +104 -5
- package/gsd-core/bin/lib/shell-command-projection.cjs +275 -3
- package/gsd-core/bin/lib/smart-entry.cjs +142 -22
- package/gsd-core/bin/lib/state-command-router.cjs +5 -1
- package/gsd-core/bin/lib/state-document.cjs +152 -8
- package/gsd-core/bin/lib/state-transition.cjs +371 -117
- package/gsd-core/bin/lib/state.cjs +1794 -357
- package/gsd-core/bin/lib/surface.cjs +23 -9
- package/gsd-core/bin/lib/text-lines.cjs +80 -0
- package/gsd-core/bin/lib/token-scanner.cjs +76 -0
- package/gsd-core/bin/lib/uat-predicate.cjs +9 -3
- package/gsd-core/bin/lib/uat.cjs +399 -56
- package/gsd-core/bin/lib/ui-frontend-evidence.cjs +157 -0
- package/gsd-core/bin/lib/ui-safety-gate.cjs +14 -5
- package/gsd-core/bin/lib/unusable-input.cjs +24 -0
- package/gsd-core/bin/lib/update-context.cjs +8 -2
- package/gsd-core/bin/lib/user-artifact-staging.cjs +705 -0
- package/gsd-core/bin/lib/validate.cjs +20 -6
- package/gsd-core/bin/lib/vendor/README.md +37 -0
- package/gsd-core/bin/lib/vendor/re2js.cjs +6480 -0
- package/gsd-core/bin/lib/vendor/re2js.d.cts +938 -0
- package/gsd-core/bin/lib/verification-command-router.cjs +2 -1
- package/gsd-core/bin/lib/verification.cjs +258 -8
- package/gsd-core/bin/lib/verify.cjs +368 -888
- package/gsd-core/bin/lib/workstream-inventory-builder.cjs +53 -32
- package/gsd-core/bin/lib/workstream-inventory.cjs +63 -10
- package/gsd-core/bin/lib/workstream.cjs +2 -2
- package/gsd-core/bin/lib/worktree-safety.cjs +176 -9
- package/gsd-core/bin/shared/config-defaults.manifest.json +1 -0
- package/gsd-core/bin/shared/config-schema.manifest.json +7 -1
- package/gsd-core/references/agent-contracts.md +43 -26
- package/gsd-core/references/checkpoints.md +2 -2
- package/gsd-core/references/context-budget.md +1 -1
- package/gsd-core/references/dispatch-isolation-gate.md +138 -0
- package/gsd-core/references/doc-conflict-engine.md +1 -1
- package/gsd-core/references/execute-mvp-tdd.md +3 -3
- package/gsd-core/references/execute-phase-between-wave-reset.md +6 -2
- package/gsd-core/references/execute-phase-context-guard.md +1 -1
- package/gsd-core/references/execute-phase-response-language.md +1 -1
- package/gsd-core/references/execute-phase-wave-guard.md +6 -2
- package/gsd-core/references/gate-prompts.md +1 -1
- package/gsd-core/references/git-planning-commit.md +2 -1
- package/gsd-core/references/loop-hook-dispatch.md +39 -2
- package/gsd-core/references/model-profiles.md +12 -4
- package/gsd-core/references/mvp-concepts.md +9 -9
- package/gsd-core/references/planner-guidance.md +3 -9
- package/gsd-core/references/planner-preconditions.md +1 -1
- package/gsd-core/references/planner-reviews.md +1 -1
- package/gsd-core/references/planning-config.md +8 -6
- package/gsd-core/references/revision-loop.md +1 -1
- package/gsd-core/references/specless-probe-fallback.md +1 -1
- package/gsd-core/references/universal-anti-patterns.md +3 -3
- package/gsd-core/references/verifier-phase-gates.md +192 -0
- package/gsd-core/references/verify-mvp-mode.md +1 -1
- package/gsd-core/references/workstream-flag.md +22 -6
- package/gsd-core/templates/discussion-log.md +1 -1
- package/gsd-core/templates/phase-prompt.md +2 -4
- package/gsd-core/templates/state.md +4 -4
- package/gsd-core/templates/verification-report.md +9 -1
- package/gsd-core/workflows/ai-integration-phase.md +9 -11
- package/gsd-core/workflows/autonomous.md +1 -1
- package/gsd-core/workflows/cleanup.md +62 -3
- package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +13 -3
- package/gsd-core/workflows/code-review-fix.md +37 -10
- package/gsd-core/workflows/code-review.md +38 -12
- package/gsd-core/workflows/complete-milestone.md +141 -18
- package/gsd-core/workflows/debug.md +7 -5
- package/gsd-core/workflows/diagnose-issues.md +35 -9
- package/gsd-core/workflows/discuss-phase/modes/chain.md +2 -1
- package/gsd-core/workflows/discuss-phase/modes/default.md +1 -1
- package/gsd-core/workflows/discuss-phase-assumptions.md +2 -1
- package/gsd-core/workflows/edit-phase.md +26 -1
- package/gsd-core/workflows/eval-review.md +3 -5
- package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +31 -6
- package/gsd-core/workflows/execute-phase/steps/per-plan-executor-routing.md +77 -0
- package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +2 -0
- package/gsd-core/workflows/execute-phase.md +38 -50
- package/gsd-core/workflows/execute-plan.md +36 -4
- package/gsd-core/workflows/explore.md +131 -4
- package/gsd-core/workflows/fast.md +10 -2
- package/gsd-core/workflows/health.md +73 -4
- package/gsd-core/workflows/import.md +4 -4
- package/gsd-core/workflows/ingest-docs.md +5 -5
- package/gsd-core/workflows/mvp-phase.md +6 -3
- package/gsd-core/workflows/new-milestone.md +14 -9
- package/gsd-core/workflows/new-project.md +14 -14
- package/gsd-core/workflows/next.md +12 -0
- package/gsd-core/workflows/plan-phase.md +41 -17
- package/gsd-core/workflows/plan-review-convergence.md +50 -2
- package/gsd-core/workflows/progress.md +34 -6
- package/gsd-core/workflows/quick/steps/plan-checker-loop.md +4 -4
- package/gsd-core/workflows/quick/steps/quick-verification.md +27 -6
- package/gsd-core/workflows/quick/steps/research-phase.md +2 -2
- package/gsd-core/workflows/quick.md +35 -15
- package/gsd-core/workflows/review.md +26 -5
- package/gsd-core/workflows/secure-phase.md +1 -1
- package/gsd-core/workflows/session-report.md +2 -1
- package/gsd-core/workflows/settings.md +66 -2
- package/gsd-core/workflows/ship.md +104 -44
- package/gsd-core/workflows/spec-phase.md +30 -12
- package/gsd-core/workflows/sync-skills.md +63 -8
- package/gsd-core/workflows/transition.md +46 -11
- package/gsd-core/workflows/ui-phase.md +5 -5
- package/gsd-core/workflows/ui-review.md +2 -2
- package/gsd-core/workflows/update.md +1 -1
- package/gsd-core/workflows/validate-phase.md +1 -1
- package/gsd-core/workflows/verify-work.md +9 -7
- package/hooks/dist/gsd-agent-isolation-guard.js +103 -14
- package/hooks/dist/gsd-check-update-worker.js +56 -13
- package/hooks/dist/gsd-check-update.js +19 -1
- package/hooks/dist/gsd-cursor-pre-tool.js +0 -3
- package/hooks/dist/gsd-cursor-subagent-start.js +77 -2
- package/hooks/dist/gsd-cursor-subagent-stop.js +3 -2
- package/hooks/dist/gsd-prompt-guard.js +21 -20
- package/hooks/dist/gsd-read-injection-scanner.js +38 -24
- package/hooks/dist/gsd-statusline.js +18 -0
- package/hooks/dist/gsd-update-banner.js +22 -1
- package/hooks/dist/gsd-workflow-guard.js +134 -36
- package/hooks/dist/lib/git-cmd.js +92 -59
- package/hooks/dist/lib/injection-patterns.js +45 -0
- package/hooks/dist/lib/isolation-deny-reason.js +39 -0
- package/hooks/dist/lib/isolation-sentinel.js +9 -0
- package/hooks/gsd-agent-isolation-guard.js +103 -14
- package/hooks/gsd-check-update-worker.js +56 -13
- package/hooks/gsd-check-update.js +19 -1
- package/hooks/gsd-cursor-pre-tool.js +0 -3
- package/hooks/gsd-cursor-subagent-start.js +77 -2
- package/hooks/gsd-cursor-subagent-stop.js +3 -2
- package/hooks/gsd-prompt-guard.js +21 -20
- package/hooks/gsd-read-injection-scanner.js +38 -24
- package/hooks/gsd-statusline.js +18 -0
- package/hooks/gsd-update-banner.js +22 -1
- package/hooks/gsd-workflow-guard.js +134 -36
- package/hooks/lib/git-cmd.js +92 -59
- package/hooks/lib/injection-patterns.js +45 -0
- package/hooks/lib/isolation-deny-reason.js +39 -0
- package/hooks/lib/isolation-sentinel.js +9 -0
- package/package.json +21 -9
- package/pi/gsd.cjs +19 -5
- package/scripts/baselines/planning-prompt-drift-baseline.json +4 -0
- package/scripts/baselines/planning-snapshot-bypass-baseline.json +12 -0
- package/scripts/baselines/unreachable-guard-drift-baseline.json +4 -0
- package/scripts/changeset/lint.cjs +60 -5
- package/scripts/check-alias-drift.cjs +7 -43
- package/scripts/check-contract-drift.cjs +297 -0
- package/scripts/ci-test-scope.cjs +19 -2
- package/scripts/command-contract-helpers.cjs +903 -1
- package/scripts/gen-adr-index.cjs +728 -38
- package/scripts/gen-capability-registry.cjs +3 -15
- package/scripts/gen-context-index.cjs +2 -11
- package/scripts/gen-health-docs.cjs +390 -0
- package/scripts/gen-inventory-manifest.cjs +50 -4
- package/scripts/gen-loop-host-contract.cjs +4 -24
- package/scripts/gen-registry.cjs +3 -14
- package/scripts/lib/alias-drift-families.cjs +46 -0
- package/scripts/lib/drift-scan.cjs +278 -0
- package/scripts/lint-allow-test-rule-refs.allowlist.json +1 -26
- package/scripts/lint-allow-test-rule-refs.effective-ceiling.json +4 -0
- package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +3 -0
- package/scripts/lint-canary-version-leak.cjs +73 -0
- package/scripts/lint-command-contract.cjs +96 -13
- package/scripts/lint-completion-predicate-drift.cjs +933 -0
- package/scripts/lint-completion-ratio-drift.cjs +214 -0
- package/scripts/lint-default-flip-documentation.cjs +193 -0
- package/scripts/lint-eslint-glob-coverage.allowlist.json +34 -0
- package/scripts/lint-eslint-glob-coverage.cjs +340 -0
- package/scripts/lint-frontmatter-scalar-broad-grep.cjs +237 -0
- package/scripts/lint-health-diagnostic-rule-table.cjs +404 -0
- package/scripts/lint-hooks-runtime-build-seam.cjs +262 -0
- package/scripts/lint-milestone-window-drift.cjs +468 -0
- package/scripts/lint-phase-enumeration-drift.cjs +479 -0
- package/scripts/lint-plan-count-drift.cjs +318 -0
- package/scripts/lint-planning-artifact-writer-drift.cjs +398 -0
- package/scripts/lint-planning-prompt-drift.cjs +434 -0
- package/scripts/lint-planning-snapshot-bypass-drift.cjs +544 -0
- package/scripts/lint-regression-test-names.cjs +15 -13
- package/scripts/lint-removed-but-needed.cjs +320 -0
- package/scripts/lint-state-field-drift.cjs +805 -0
- package/scripts/lint-state-write-path-drift.cjs +1045 -0
- package/scripts/lint-test-file-count.allowlist.json +21 -10
- package/scripts/lint-unreachable-guard-drift.cjs +843 -0
- package/scripts/lint-vendored-deps.cjs +124 -0
- package/scripts/pr-changed-files.cjs +63 -0
- package/scripts/pr-template-policy.cjs +14 -4
- package/scripts/prompt-injection-scan.sh +25 -0
- package/scripts/require-issue-link-policy.cjs +192 -0
- package/scripts/state-write-path-drift-baseline.json +19 -0
- package/scripts/sync-runtime-launcher.cjs +2 -4
- package/skills/gsd-autonomous/SKILL.md +0 -1
- package/skills/gsd-code-review/SKILL.md +1 -1
- package/skills/gsd-execute-phase/SKILL.md +1 -2
- package/skills/gsd-map-codebase/SKILL.md +1 -1
- package/skills/gsd-mempalace-capture/SKILL.md +1 -1
- package/skills/gsd-mempalace-recall/SKILL.md +1 -1
- package/skills/gsd-new-milestone/SKILL.md +1 -1
- package/skills/gsd-next/SKILL.md +0 -1
- package/skills/gsd-plan-phase/SKILL.md +0 -1
- package/skills/gsd-progress/SKILL.md +0 -1
- package/skills/gsd-quick/SKILL.md +1 -1
- package/skills/gsd-review-backlog/SKILL.md +2 -1
- package/skills/gsd-stats/SKILL.md +0 -1
- package/skills/gsd-verify-work/SKILL.md +1 -1
- package/vscode/package.json +1 -1
- package/gsd-core/workflows/discovery-phase.md +0 -298
- package/gsd-core/workflows/plan-milestone-gaps.md +0 -281
- package/gsd-core/workflows/verify-phase.md +0 -574
- package/scripts/affected-tests-lib.cjs +0 -554
- package/scripts/lint-allow-test-rule-refs.cjs +0 -162
- package/scripts/run-affected-tests.cjs +0 -7
- package/scripts/run-tests.cjs +0 -1051
|
@@ -8,12 +8,12 @@
|
|
|
8
8
|
* boundary moved. The core.cjs re-export spine was retired in epic #1267;
|
|
9
9
|
* callers import phase-id helpers from phase-id.cjs directly.
|
|
10
10
|
*
|
|
11
|
-
* Dependencies:
|
|
11
|
+
* Dependencies:
|
|
12
|
+
* - ./pattern.cjs (escapeRegex — #3212 Phase 1 seam; this module is no
|
|
13
|
+
* longer the owner of pattern-escaping, only a consumer)
|
|
12
14
|
*/
|
|
15
|
+
const pattern_cjs_1 = require("./pattern.cjs");
|
|
13
16
|
// ─── Phase-id helpers ─────────────────────────────────────────────────────────
|
|
14
|
-
function escapeRegex(value) {
|
|
15
|
-
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
16
|
-
}
|
|
17
17
|
// project_code values start with an uppercase letter (e.g. PROJ, APP_CODE);
|
|
18
18
|
// leading underscores are not valid project codes per .planning/config.json.
|
|
19
19
|
const PROJECT_CODE_PREFIX_STRIP_RE = /^[A-Z][A-Z0-9_]*-(?=\d)/;
|
|
@@ -48,6 +48,22 @@ const OPTIONAL_PHASE_TAG_SOURCE = '(?:\\s*\\([^)\\n]{0,200}\\))?';
|
|
|
48
48
|
// (scripts/lint-phase-id-drift.cjs) fails CI if a literal re-derivation is
|
|
49
49
|
// introduced outside this module without a `// phase-id-owner:` justification.
|
|
50
50
|
const PHASE_NUMBER_TOKEN_SOURCE = '\\d+[A-Z]?(?:\\.\\d+)*';
|
|
51
|
+
// #2528 review: the CASE-FLEXIBLE renderings of the two sources above, for call
|
|
52
|
+
// sites that scan directory names (where a project code or a variant suffix may
|
|
53
|
+
// legitimately be lowercase) and therefore cannot use a case-sensitive class.
|
|
54
|
+
//
|
|
55
|
+
// They live HERE, beside the sources they widen, because the alternative in use
|
|
56
|
+
// was `SOURCE.replaceAll('A-Z', 'A-Za-z')` at the consuming site — a derivation
|
|
57
|
+
// that depends on the owner rendering that exact literal. It passes
|
|
58
|
+
// lint-phase-id-drift.cjs (no literal copy of the grammar), but the day this
|
|
59
|
+
// module expresses the same class any other way (`[[:upper:]]`, a named
|
|
60
|
+
// fragment, an escaped range) the replaceAll silently no-ops and the consumer
|
|
61
|
+
// quietly narrows to uppercase-only — the failure is a NON-match, so nothing
|
|
62
|
+
// throws and no test that only feeds uppercase input notices. Deriving it once,
|
|
63
|
+
// where the source is defined, makes that impossible: a rename here is a
|
|
64
|
+
// compile-visible change, not a silent behavior change three modules away.
|
|
65
|
+
const CASE_FLEXIBLE_PROJECT_CODE_PREFIX_SOURCE = OPTIONAL_PROJECT_CODE_PREFIX_SOURCE.replaceAll('A-Z', 'A-Za-z');
|
|
66
|
+
const CASE_FLEXIBLE_PHASE_NUMBER_TOKEN_SOURCE = PHASE_NUMBER_TOKEN_SOURCE.replaceAll('A-Z', 'A-Za-z');
|
|
51
67
|
// #2232: the canonical CONTINUATION-segment grammar — a dash-separated segment
|
|
52
68
|
// that extends a phase token (a zero-padded sub-phase or plan number, e.g. the
|
|
53
69
|
// "01" in "02-01-setup"). getPhaseDirFromPhaseId writes these zero-padded to
|
|
@@ -59,7 +75,8 @@ const PHASE_NUMBER_TOKEN_SOURCE = '\\d+[A-Z]?(?:\\.\\d+)*';
|
|
|
59
75
|
// trailing grammar (letter suffixes, dotted sub-phases, segment boundaries).
|
|
60
76
|
// POLICY (locked by boundary tests): sub-phase/plan numbers ≥100 are out of the
|
|
61
77
|
// dir-token grammar — the LEADING phase number stays unbounded (`\d+`), only
|
|
62
|
-
// continuation segments
|
|
78
|
+
// continuation segments begin with a two-digit run; consuming sites retain
|
|
79
|
+
// their established suffix and boundary grammar. Shared from here so the five #2043
|
|
63
80
|
// call sites cannot drift independently (see scripts/lint-phase-id-drift.cjs).
|
|
64
81
|
const PHASE_CONTINUATION_SEGMENT_SOURCE = '\\d{2}(?!\\d)';
|
|
65
82
|
const PHASE_CONTINUATION_SEGMENT_PREFIX_RE = new RegExp(`^${PHASE_CONTINUATION_SEGMENT_SOURCE}`);
|
|
@@ -124,7 +141,8 @@ const BRACKET_CANONICAL_NUMERIC_SOURCE = '(?:[1-9]\\d{2,}|\\d{2})';
|
|
|
124
141
|
const BRACKET_PHASE_TOKEN_SOURCE = `\\d+[A-Z]?` +
|
|
125
142
|
`(?:-${BRACKET_CANONICAL_NUMERIC_SOURCE}(?!\\d))?` +
|
|
126
143
|
`(?:\\.${BRACKET_CANONICAL_NUMERIC_SOURCE}(?!\\d))?` +
|
|
127
|
-
`(?:-${PHASE_CONTINUATION_SEGMENT_SOURCE})
|
|
144
|
+
`(?:-${PHASE_CONTINUATION_SEGMENT_SOURCE})?` +
|
|
145
|
+
`(?=-|$)`;
|
|
128
146
|
// A phase HEADING intro under bracket is either a `[...]` bracket (optionally
|
|
129
147
|
// followed by a `Phase ` label) or a bare `Phase ` label; a bare number is NOT
|
|
130
148
|
// a phase-heading intro. The `[^\]]{1,200}` bound mirrors the existing
|
|
@@ -355,20 +373,24 @@ function phaseMarkdownRegexSource(phaseNum) {
|
|
|
355
373
|
const subParts = milestoneSegments[2].slice(1).split('-');
|
|
356
374
|
const subFragments = subParts.map(s => {
|
|
357
375
|
const unpadded = s.replace(/^0+/, '') || '0';
|
|
358
|
-
return `0*${escapeRegex(unpadded)}`;
|
|
376
|
+
return `0*${(0, pattern_cjs_1.escapeRegex)(unpadded)}`;
|
|
359
377
|
});
|
|
360
378
|
const suffix = milestoneSegments[3] || '';
|
|
361
|
-
const suffixFragment = suffix ? escapeRegex(suffix) : '';
|
|
362
|
-
return `0*${escapeRegex(majorUnpadded)}-${subFragments.join('-')}${suffixFragment}`;
|
|
379
|
+
const suffixFragment = suffix ? (0, pattern_cjs_1.escapeRegex)(suffix) : '';
|
|
380
|
+
return `0*${(0, pattern_cjs_1.escapeRegex)(majorUnpadded)}-${subFragments.join('-')}${suffixFragment}`;
|
|
363
381
|
}
|
|
364
382
|
// Plain numeric phase: 1, 01, 12A, 12.1
|
|
365
383
|
const match = stripped.match(/^0*(\d+)([A-Z])?((?:\.\d+)*)$/i);
|
|
384
|
+
// #3212: escapeRegex now requires a string (the seam owns coercion policy,
|
|
385
|
+
// not this module) — String(...) here preserves this function's own
|
|
386
|
+
// pre-existing `unknown` acceptance for callers that pass a non-string
|
|
387
|
+
// phaseNum through to this fallback branch.
|
|
366
388
|
if (!match)
|
|
367
|
-
return escapeRegex(phaseNum);
|
|
389
|
+
return (0, pattern_cjs_1.escapeRegex)(String(phaseNum));
|
|
368
390
|
const integer = match[1].replace(/^0+/, '') || '0';
|
|
369
|
-
const letter = match[2] ? escapeRegex(match[2]) : '';
|
|
370
|
-
const decimal = match[3] ? escapeRegex(match[3]) : '';
|
|
371
|
-
return `0*${escapeRegex(integer)}${letter}${decimal}`;
|
|
391
|
+
const letter = match[2] ? (0, pattern_cjs_1.escapeRegex)(match[2]) : '';
|
|
392
|
+
const decimal = match[3] ? (0, pattern_cjs_1.escapeRegex)(match[3]) : '';
|
|
393
|
+
return `0*${(0, pattern_cjs_1.escapeRegex)(integer)}${letter}${decimal}`;
|
|
372
394
|
}
|
|
373
395
|
/**
|
|
374
396
|
* #3599: when the caller passed a project-code-prefixed ID like `PROJ-42`,
|
|
@@ -378,7 +400,7 @@ function phaseMarkdownRegexSourceExact(phaseNum) {
|
|
|
378
400
|
const raw = String(phaseNum);
|
|
379
401
|
if (!hasProjectCodePrefix(raw))
|
|
380
402
|
return null;
|
|
381
|
-
return escapeRegex(raw);
|
|
403
|
+
return (0, pattern_cjs_1.escapeRegex)(raw);
|
|
382
404
|
}
|
|
383
405
|
function comparePhaseNum(a, b) {
|
|
384
406
|
// Strip optional project_code prefix before comparing
|
|
@@ -436,27 +458,18 @@ function comparePhaseNum(a, b) {
|
|
|
436
458
|
return 0;
|
|
437
459
|
}
|
|
438
460
|
/**
|
|
439
|
-
*
|
|
461
|
+
* Segmentation core shared by `extractPhaseToken` (the token VALUE) and
|
|
462
|
+
* `isPhaseArtifact` (the DERIVABILITY check — #3511). Factored out so the two
|
|
463
|
+
* questions ("what is this dir's token" and "did a real token exist at all")
|
|
464
|
+
* can never diverge — see CLAUDE.md's "Generative Fix Divergence" note: this
|
|
465
|
+
* is exactly a shared parser between two parallel surfaces.
|
|
466
|
+
*
|
|
467
|
+
* Returns `tokenSegments.length === 0` iff dirName's own leading segment
|
|
468
|
+
* carries no phase-number token (the `extractPhaseToken` dirName-unchanged
|
|
469
|
+
* fallback) — i.e. the directory name itself does not start with a digit or a
|
|
470
|
+
* short letter+digit prefix, so no reliable phase token can be read from it.
|
|
440
471
|
*/
|
|
441
|
-
function
|
|
442
|
-
// #612 bracket dir form `{CODE}.{MM}-{PP}[.{SS}]-slug` → phase token `PP[.SS]`.
|
|
443
|
-
// GATED on convention === 'bracket' (mirrors getMilestoneFromPhaseId's READING-B
|
|
444
|
-
// decision above). A bracket dir `{CODE}.{MM}-{PP}` is string-INDISTINGUISHABLE
|
|
445
|
-
// from the legacy #2043/#1324 letter-prefixed-decimal family (`P0.3-2`,
|
|
446
|
-
// `P0.12-34`) whenever the project code ends in a digit, so NO string-only
|
|
447
|
-
// discriminator can separate the two conventions — auto-detecting here silently
|
|
448
|
-
// reinterpreted `P0.3-2` → `2` (was `P0.3-2`), a byte-identical-read regression
|
|
449
|
-
// on this CRITICAL 6-caller helper (ADR-2121). Requiring an explicit convention
|
|
450
|
-
// signal keeps every existing (convention-less) call site byte-identical to
|
|
451
|
-
// prior behaviour — see the #2043 numeric-tail characterization in
|
|
452
|
-
// tests/phase-id.test.cjs — while keeping the helper pure (optional param, no
|
|
453
|
-
// config read). The captured token is dot-only (`PP[.SS]`); the milestone↔phase
|
|
454
|
-
// hyphen and any trailing plan/slug are excluded.
|
|
455
|
-
if (convention === 'bracket') {
|
|
456
|
-
const bracketDir = dirName.match(/^[A-Z][A-Z0-9_]*\.\d+-(\d+(?:\.\d+)?)/);
|
|
457
|
-
if (bracketDir)
|
|
458
|
-
return bracketDir[1];
|
|
459
|
-
}
|
|
472
|
+
function derivePhaseTokenSegments(dirName) {
|
|
460
473
|
const codePrefixMatch = dirName.match(PROJECT_CODE_PREFIX_CAPTURE_RE_I);
|
|
461
474
|
let prefix = '';
|
|
462
475
|
let rest = dirName;
|
|
@@ -493,18 +506,295 @@ function extractPhaseToken(dirName, convention) {
|
|
|
493
506
|
break;
|
|
494
507
|
}
|
|
495
508
|
}
|
|
496
|
-
else if (
|
|
509
|
+
else if ((firstLetterPrefixed && /^\d/.test(seg)) ||
|
|
510
|
+
(!firstLetterPrefixed && isPhaseContinuationSegment(seg))) {
|
|
497
511
|
tokenSegments.push(seg);
|
|
498
512
|
}
|
|
499
513
|
else {
|
|
500
514
|
break;
|
|
501
515
|
}
|
|
502
516
|
}
|
|
517
|
+
return { prefix, tokenSegments, firstLetterPrefixed };
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* Extract the phase token from a directory name.
|
|
521
|
+
*/
|
|
522
|
+
function extractPhaseToken(dirName, convention) {
|
|
523
|
+
// #612 bracket dir form `{CODE}.{MM}-{PP}[.{SS}]-slug` → phase token `PP[.SS]`.
|
|
524
|
+
// GATED on convention === 'bracket' (mirrors getMilestoneFromPhaseId's READING-B
|
|
525
|
+
// decision above). A bracket dir `{CODE}.{MM}-{PP}` is string-INDISTINGUISHABLE
|
|
526
|
+
// from the legacy #2043/#1324 letter-prefixed-decimal family (`P0.3-2`,
|
|
527
|
+
// `P0.12-34`) whenever the project code ends in a digit, so NO string-only
|
|
528
|
+
// discriminator can separate the two conventions — auto-detecting here silently
|
|
529
|
+
// reinterpreted `P0.3-2` → `2` (was `P0.3-2`), a byte-identical-read regression
|
|
530
|
+
// on this CRITICAL 6-caller helper (ADR-2121). Requiring an explicit convention
|
|
531
|
+
// signal keeps every existing (convention-less) call site byte-identical to
|
|
532
|
+
// prior behaviour — see the #2043 numeric-tail characterization in
|
|
533
|
+
// tests/phase-id.test.cjs — while keeping the helper pure (optional param, no
|
|
534
|
+
// config read). The captured token is dot-only (`PP[.SS]`); the milestone↔phase
|
|
535
|
+
// hyphen and any trailing plan/slug are excluded.
|
|
536
|
+
if (convention === 'bracket') {
|
|
537
|
+
const bracketDir = dirName.match(/^[A-Z][A-Z0-9_]*\.\d+-(\d+(?:\.\d+)?)/);
|
|
538
|
+
if (bracketDir)
|
|
539
|
+
return bracketDir[1];
|
|
540
|
+
}
|
|
541
|
+
const { prefix, tokenSegments, firstLetterPrefixed } = derivePhaseTokenSegments(dirName);
|
|
503
542
|
if (tokenSegments.length === 0) {
|
|
504
543
|
return dirName;
|
|
505
544
|
}
|
|
545
|
+
// #2528 (re-review): the tokenizer deliberately does NOT try to tell a 2-digit
|
|
546
|
+
// slug word ("24" of "24/7 Autonomy") from a genuine zero-padded continuation
|
|
547
|
+
// ("24" of sub-phase 10.24) — by width alone they are the same string, the gap
|
|
548
|
+
// between #2043's 1-digit and #2232's ≥3-digit guards, and no LOCAL signal
|
|
549
|
+
// separates them. An earlier revision of this fix rewound the token when the
|
|
550
|
+
// segment that stopped the scan was a 1-digit word, which reads
|
|
551
|
+
// "10-24-7-autonomy" correctly but silently re-tokenizes the equally real
|
|
552
|
+
// "10-24-7-zip" (sub-phase 10.24 named "7-Zip …") from "10-24" to "10" — it
|
|
553
|
+
// trades the reported ambiguity for the symmetric one a level down, on a
|
|
554
|
+
// CRITICAL 15-caller chokepoint whose output feeds query-less derivations
|
|
555
|
+
// (STATE.md phase counts, W007, the #2562 key surface).
|
|
556
|
+
//
|
|
557
|
+
// So the token stays the LITERAL reading of the name, and disambiguation lives
|
|
558
|
+
// ONE layer up, in matchPhaseDirs, where a QUERY exists to disambiguate
|
|
559
|
+
// against: a bare-integer lookup falls back to the directory's leading digit
|
|
560
|
+
// run and resolves "10-24-7-autonomy" for "10" without touching what the
|
|
561
|
+
// directory's own token is. That is the same bounded mechanism the
|
|
562
|
+
// "05-80-20-cleanup" shape already uses — one rule for the whole
|
|
563
|
+
// digit-leading-slug family instead of two overlapping ones.
|
|
564
|
+
//
|
|
565
|
+
// A generated slug is lowercase. If the owner admitted a two-digit prefix
|
|
566
|
+
// from a digit+letter slug segment ("10x", "25abc"), remove only that final
|
|
567
|
+
// segment. Uppercase suffixes remain available to the established plan-ID
|
|
568
|
+
// grammar, and dotted continuations remain intact.
|
|
569
|
+
if (!firstLetterPrefixed &&
|
|
570
|
+
tokenSegments.length > 1 &&
|
|
571
|
+
/^\d{2}[a-z][a-z0-9]*$/.test(tokenSegments[tokenSegments.length - 1])) {
|
|
572
|
+
tokenSegments.pop();
|
|
573
|
+
}
|
|
506
574
|
return prefix + tokenSegments.join('-');
|
|
507
575
|
}
|
|
576
|
+
/**
|
|
577
|
+
* #3511 (reworked — adversarial review found the membership rule wrong in
|
|
578
|
+
* approach, not just detail): predicate for AGGREGATE phase-directory scans
|
|
579
|
+
* (every matching file contributes, e.g.
|
|
580
|
+
* uat-predicate/phase.cts/state.cts/uat.cts/audit.cts's `*-UAT.md` /
|
|
581
|
+
* `*-VERIFICATION.md` scans) — answers "does fileName belong to THIS phase"
|
|
582
|
+
* so a stray, cross-phase, or ad-hoc file (`04-VERIFICATION.md` sitting in
|
|
583
|
+
* phase 03's directory) cannot contribute its status to phase 03. This is
|
|
584
|
+
* deliberately NOT `resolveVerificationFile` (`src/verification.cts`,
|
|
585
|
+
* #3357/#3492) — that resolver answers a SINGLE-PICK question ("which one
|
|
586
|
+
* candidate is THE report") for a phase dir already known to hold one; this
|
|
587
|
+
* answers a per-file membership question for a scan that must fold in EVERY
|
|
588
|
+
* match. See "Reconciliation" below — the two do NOT fully agree.
|
|
589
|
+
*
|
|
590
|
+
* THE ORIGINAL BUG: files are named by `normalizePhaseName`
|
|
591
|
+
* (`cmdScaffold`, `src/commands.cts` — PADDED, project-code-STRIPPED), while
|
|
592
|
+
* this predicate read the directory's OWN token via the literal, unpadded,
|
|
593
|
+
* project-code-CARRYING `extractPhaseToken(phaseDirName)`. Two different
|
|
594
|
+
* normalizations of the same phase number, so a literal
|
|
595
|
+
* `startsWith(token + '-')` excluded a phase's own artifacts whenever they
|
|
596
|
+
* disagreed: `CK-01-foundation` (token `CK-01`, file `01-VERIFICATION.md`),
|
|
597
|
+
* `1-unpadded` (token `1`, file `01-VERIFICATION.md`), and the #2528
|
|
598
|
+
* digit-leading-slug family `05-80-20-cleanup` / `10-24-7-autonomy` (token
|
|
599
|
+
* over-absorbs past the digit run `cmdScaffold` actually writes: `05-80-20`
|
|
600
|
+
* vs the real `05-UAT.md`).
|
|
601
|
+
*
|
|
602
|
+
* THE FIX: build the set of every phase-number READING this directory could
|
|
603
|
+
* plausibly resolve to elsewhere in the module, then check fileName against
|
|
604
|
+
* ALL of them — reusing the exact readings `matchPhaseDirs` /
|
|
605
|
+
* `phaseNumberForMatch` (#2528, below) already carry for directory
|
|
606
|
+
* RESOLUTION, so this membership check can never diverge from what "the
|
|
607
|
+
* directory for phase N" means elsewhere in the module (no third
|
|
608
|
+
* normalization — CLAUDE.md's Generative Fix Divergence class):
|
|
609
|
+
* 1. the literal token (`extractPhaseToken(phaseDirName)` — still correct
|
|
610
|
+
* for the common case and for genuine decimal / letter-suffixed
|
|
611
|
+
* sub-phase dirs);
|
|
612
|
+
* 2. the same token read off the project-code-STRIPPED name
|
|
613
|
+
* (`stripProjectCodePrefix` — the exact fallback `phaseTokenMatches`
|
|
614
|
+
* already applies, #612/#1324);
|
|
615
|
+
* 3. the directory's own LEADING DIGIT RUN on the stripped name
|
|
616
|
+
* (`LEADING_DIGIT_RUN_RE` — the #2528 bare-integer-fallback reading,
|
|
617
|
+
* the one that actually matches what `cmdScaffold` writes for the
|
|
618
|
+
* digit-leading-slug family); and
|
|
619
|
+
* 4. each of (1)-(3) additionally passed through `normalizePhaseName`,
|
|
620
|
+
* since files always carry the PADDED form and directories often do
|
|
621
|
+
* not (`1-unpadded` vs `01-...`).
|
|
622
|
+
* A file belongs when it starts with any candidate + `-` OR any candidate +
|
|
623
|
+
* `.`, compared case-insensitively (matching `phaseTokenMatches`' own rule —
|
|
624
|
+
* review item 8: `03A-VERIFICATION.md` vs `03a-foo`). This is a PREFIX check,
|
|
625
|
+
* not a full-token equality — `03-01-SUMMARY.md` (phase 03, plan 01) must
|
|
626
|
+
* still match dir `03-foo` on candidate `03`, even though
|
|
627
|
+
* `extractPhaseToken('03-01-SUMMARY.md')` would (wrongly, for this purpose)
|
|
628
|
+
* read `03-01` as a mis-absorbed 2-digit continuation.
|
|
629
|
+
*
|
|
630
|
+
* DOTTED SUB-PHASE CONTINUATION: the dot arm of the check exists because this
|
|
631
|
+
* module's own token grammar (`PHASE_NUMBER_TOKEN_SOURCE`) admits a dotted
|
|
632
|
+
* sub-phase continuation (`(?:\.\d+)*`) alongside dash-continuations — a
|
|
633
|
+
* sub-phase artifact `01.1-CONTEXT.md` is `01`'s own file, written into `01`'s
|
|
634
|
+
* directory, not a stray from a different phase. A dash-only prefix check
|
|
635
|
+
* excluded it (`01.1-` does not start with `01-`), which is over-exclusion:
|
|
636
|
+
* the dangerous direction for an aggregate scan whose fail-safes above all
|
|
637
|
+
* default to inclusion when membership is unclear. Widening dash-only to
|
|
638
|
+
* dash-OR-dot only ever ADDS a match a candidate already earned; it cannot
|
|
639
|
+
* newly admit a file whose leading digits differ from `candidate`, so it
|
|
640
|
+
* cannot resolve a genuinely different phase's artifact (`02.1-...` still
|
|
641
|
+
* fails every `01`-rooted candidate).
|
|
642
|
+
*
|
|
643
|
+
* BRACKET CONVENTION (review item 7): a letter-prefixed-decimal dir
|
|
644
|
+
* (`P0.3-2-slug`) is string-INDISTINGUISHABLE from a bracket-dir token
|
|
645
|
+
* (`extractPhaseToken` above, gated on `convention === 'bracket'`) without an
|
|
646
|
+
* explicit convention signal — and this predicate is never given one: none
|
|
647
|
+
* of its 9 call sites thread `convention`/config through today. Rather than
|
|
648
|
+
* guess a reading it cannot know is active and risk excluding the phase's OWN
|
|
649
|
+
* artifact (the exact defect class this rework exists to fix), this family
|
|
650
|
+
* (`firstLetterPrefixed` dirs) falls into the same include-everything
|
|
651
|
+
* fail-safe as the zero-segment case below — a documented, deliberate
|
|
652
|
+
* widening (it also stops excluding a genuine stray from a DIFFERENT
|
|
653
|
+
* letter-prefixed-decimal phase, narrowly) accepted in trade for never
|
|
654
|
+
* dropping the phase's own report. Convention-aware scoping for this family
|
|
655
|
+
* is deferred to whenever a call site actually threads `convention` through.
|
|
656
|
+
*
|
|
657
|
+
* FAIL-SAFE (#3511, unchanged): when dirName's own leading segment carries no
|
|
658
|
+
* phase-number token at all (`derivePhaseTokenSegments` finds zero segments —
|
|
659
|
+
* the same condition `extractPhaseToken` treats as "return dirName
|
|
660
|
+
* unchanged"), no reliable token exists to scope against. Excluding on an
|
|
661
|
+
* unreliable token would make an aggregate gate silently PERMISSIVE in the
|
|
662
|
+
* wrong direction — dropping the phase's own real blockers — which is worse
|
|
663
|
+
* than the cross-phase-contamination bug this predicate exists to fix.
|
|
664
|
+
* Instead every file is treated as belonging to the phase (returns `true`
|
|
665
|
+
* unconditionally), matching pre-fix (unscoped) behaviour for that directory.
|
|
666
|
+
*
|
|
667
|
+
* FIX 2 — bare `VERIFICATION.md` / `UAT.md` (no dash, no token of its own):
|
|
668
|
+
* `derivePhaseTokenSegments(fileName)` also finds zero segments for these —
|
|
669
|
+
* the file carries no phase number to compare against anything. Directory
|
|
670
|
+
* containment is the only signal available for a token-less file, and it is
|
|
671
|
+
* sufficient: every call site passes `fs.readdirSync` results for ONE
|
|
672
|
+
* specific phase dir, so a token-less candidate already reaching this
|
|
673
|
+
* predicate (past each call site's own verification/UAT suffix pre-filter)
|
|
674
|
+
* is, by construction, that phase's own
|
|
675
|
+
* listing. Returns `true` unconditionally, same as the dir-side fail-safe.
|
|
676
|
+
*
|
|
677
|
+
* RECONCILIATION WITH resolveVerificationFile (#3357/#3492/#3511) — the two
|
|
678
|
+
* surfaces now AGREE. `resolveVerificationFile`'s fallback (`verification.cts`,
|
|
679
|
+
* "Fallback" step in its own docblock) filters its dashed candidates through
|
|
680
|
+
* THIS predicate — `isPhaseArtifact(f, phaseDirName)` — before picking
|
|
681
|
+
* alphabetically-first, via a new `phaseDirName` option every call site
|
|
682
|
+
* threads in (the same basename each already derives for `phaseToken`). So a
|
|
683
|
+
* stray cross-phase file can no longer win the single-pick fallback either:
|
|
684
|
+
* it is excluded there for the identical reason it is excluded from the
|
|
685
|
+
* aggregate scans here — membership, not canonical shape. The fail-safes stay
|
|
686
|
+
* aligned too: when this predicate cannot determine membership for a
|
|
687
|
+
* directory (returns `true` unconditionally — see FAIL-SAFE above),
|
|
688
|
+
* `resolveVerificationFile`'s filter is a no-op and its fallback degrades to
|
|
689
|
+
* the original pre-#3357 "alphabetically first of ALL dashed candidates"
|
|
690
|
+
* behavior, exactly as it always did for that directory shape.
|
|
691
|
+
*/
|
|
692
|
+
function isPhaseArtifact(fileName, phaseDirName) {
|
|
693
|
+
const { tokenSegments, firstLetterPrefixed } = derivePhaseTokenSegments(phaseDirName);
|
|
694
|
+
if (tokenSegments.length === 0)
|
|
695
|
+
return true;
|
|
696
|
+
const literalToken = extractPhaseToken(phaseDirName);
|
|
697
|
+
const strippedDir = stripProjectCodePrefix(phaseDirName);
|
|
698
|
+
const strippedToken = strippedDir !== phaseDirName ? extractPhaseToken(strippedDir) : literalToken;
|
|
699
|
+
const leadingRunMatch = strippedDir.match(LEADING_DIGIT_RUN_RE);
|
|
700
|
+
const rawCandidates = [literalToken, strippedToken, leadingRunMatch?.[1]].filter((t) => Boolean(t));
|
|
701
|
+
// Each reading is compared in BOTH its padded and de-padded form: files are
|
|
702
|
+
// written padded by `normalizePhaseName` (`cmdScaffold`) while directories
|
|
703
|
+
// are often not (`1-unpadded`), and legacy trees carry the reverse pairing.
|
|
704
|
+
// De-padding is numeric-only — a token with a letter suffix or a dotted
|
|
705
|
+
// sub-phase (`03A`, `03.1`) has no meaningful de-padded form and is left
|
|
706
|
+
// alone, so this only ever ADDS a reading and can never drop one.
|
|
707
|
+
const depad = (t) => (/^\d+$/.test(t) ? String(Number(t)) : t);
|
|
708
|
+
const candidates = new Set(rawCandidates
|
|
709
|
+
.flatMap(t => [t, normalizePhaseName(t), depad(t)])
|
|
710
|
+
.map(t => t.toUpperCase()));
|
|
711
|
+
const fileUpper = fileName.toUpperCase();
|
|
712
|
+
for (const candidate of candidates) {
|
|
713
|
+
// A dotted sub-phase segment (e.g. `01.1-CONTEXT.md`) is a legitimate
|
|
714
|
+
// continuation of `candidate` per this module's own token grammar
|
|
715
|
+
// (PHASE_NUMBER_TOKEN_SOURCE admits `(?:\.\d+)*`), so it belongs to
|
|
716
|
+
// `candidate`'s own directory just as a dash-continuation does. Inclusion
|
|
717
|
+
// is the safe direction for these aggregate scans (see FAIL-SAFE above) —
|
|
718
|
+
// widening a dash-only check to dash-OR-dot never drops a genuine match,
|
|
719
|
+
// it only stops wrongly excluding one.
|
|
720
|
+
//
|
|
721
|
+
// Accepted separator class after a matched candidate: `-`, `.`, or `_`.
|
|
722
|
+
// The underscore was added for state.cts's `cmdStateValidate` S006/S007
|
|
723
|
+
// scan, whose own pre-filter is deliberately broader than the dashed
|
|
724
|
+
// grammar every other call site uses (`.includes('VERIFICATION')`, no
|
|
725
|
+
// dash required — see the WARNING-4 comment there), so it admits names
|
|
726
|
+
// like `03_VERIFICATION.md`. Before this predicate accepted `_` as a
|
|
727
|
+
// boundary, such a file failed the `-`/`.`-only check here even though
|
|
728
|
+
// its digits matched `candidate` exactly, and `scopeToPhase` dropped it —
|
|
729
|
+
// a real same-phase verification report reported as absent. Widening the
|
|
730
|
+
// separator class only ever EXTENDS a candidate whose digits already
|
|
731
|
+
// match exactly; it cannot admit a genuinely different phase's file,
|
|
732
|
+
// since the candidate comparison itself is unchanged.
|
|
733
|
+
if (fileUpper.startsWith(`${candidate}-`) ||
|
|
734
|
+
fileUpper.startsWith(`${candidate}.`) ||
|
|
735
|
+
fileUpper.startsWith(`${candidate}_`))
|
|
736
|
+
return true;
|
|
737
|
+
}
|
|
738
|
+
// FIX 2: token-less filename (bare "VERIFICATION.md"/"UAT.md") — containment
|
|
739
|
+
// in this phase's own directory listing is sufficient.
|
|
740
|
+
if (derivePhaseTokenSegments(fileName).tokenSegments.length === 0)
|
|
741
|
+
return true;
|
|
742
|
+
// Bracket-convention ambiguity fail-safe — see docblock above.
|
|
743
|
+
if (firstLetterPrefixed)
|
|
744
|
+
return true;
|
|
745
|
+
return false;
|
|
746
|
+
}
|
|
747
|
+
/**
|
|
748
|
+
* #3511: scope `fileNames` to the subset that passes
|
|
749
|
+
* `isPhaseArtifact(fileName, phaseDirName)`. The single seam every
|
|
750
|
+
* phase-directory scan routes through, so the membership rule has ONE owner.
|
|
751
|
+
*
|
|
752
|
+
* AN EMPTY RESULT IS A REAL ANSWER — deliberately, and this is the hard-won
|
|
753
|
+
* part. An earlier revision of this helper carried an extra rule ("scoping
|
|
754
|
+
* must never turn a non-empty set into an empty one": if the filter removed
|
|
755
|
+
* every file, return the unfiltered input). It was added to rescue a
|
|
756
|
+
* directory whose basename merely PARSES phase-shaped —
|
|
757
|
+
* `gsd-651-broad-grep-a1b2`, an `mkdtemp`-style fixture name that
|
|
758
|
+
* `extractPhaseToken` reads as project code `gsd` + phase `651` (the capture
|
|
759
|
+
* regex is case-INSENSITIVE) — holding only `01-bg-VERIFICATION.md`, which
|
|
760
|
+
* the filter then dropped, yielding an empty set indistinguishable from "no
|
|
761
|
+
* report exists".
|
|
762
|
+
*
|
|
763
|
+
* That rescue was wrong, and no local rule can make it right: a directory
|
|
764
|
+
* whose own name says phase 651 holding only a file that says phase 01 is
|
|
765
|
+
* STRING-INDISTINGUISHABLE from `03-foo/` holding only `04-VERIFICATION.md`
|
|
766
|
+
* — the exact cross-phase stray #3511 exists to exclude. Keeping the rule
|
|
767
|
+
* meant a real phase directory holding only a MISFILED report would resolve
|
|
768
|
+
* to it and publish another phase's `passed` as its own: the reported bug, in
|
|
769
|
+
* its single most damaging form. `missing` is the correct answer when a
|
|
770
|
+
* phase's own report is genuinely absent, and every caller already has a
|
|
771
|
+
* `missing`/`null` branch for it.
|
|
772
|
+
*
|
|
773
|
+
* The over-exclusion that rule was reaching for is instead handled where it
|
|
774
|
+
* is actually determinable, inside `isPhaseArtifact`: the zero-token dir
|
|
775
|
+
* fail-safe, the `firstLetterPrefixed` bracket-ambiguity fail-safe, the
|
|
776
|
+
* token-less-filename rule, and the multi-reading candidate set (literal /
|
|
777
|
+
* project-code-stripped / leading-digit-run, each also padded AND de-padded)
|
|
778
|
+
* that covers every normalization a phase directory and its files can
|
|
779
|
+
* legitimately disagree on. A file excluded after all of those genuinely
|
|
780
|
+
* names a different phase.
|
|
781
|
+
*
|
|
782
|
+
* SITE DISCIPLINE: every aggregate-scan call site (`uat.cts`,
|
|
783
|
+
* `uat-predicate.cts`, `phase.cts`, `audit.cts`, `state.cts`,
|
|
784
|
+
* `core-utils.cts`'s `getPhaseFileStats` — #3511 BLOCKER-2 — and
|
|
785
|
+
* `init.cts`'s two phase-info-projection sites — #3511 BLOCKER-3, both of
|
|
786
|
+
* which scope the raw listing once up front and reuse it for every bare
|
|
787
|
+
* `.find()`/`.some()` artifact predicate: context/research/UAT/reviews/
|
|
788
|
+
* patterns) and `resolveVerificationFile`'s single-pick fallback
|
|
789
|
+
* (`verification.cts`) MUST route through this helper rather than calling
|
|
790
|
+
* `isPhaseArtifact` in a filter position directly, so the rule cannot be
|
|
791
|
+
* re-derived per site (CLAUDE.md's Generative Fix Divergence class).
|
|
792
|
+
* `isPhaseArtifact` stays exported for single-item membership questions and
|
|
793
|
+
* its own unit tests.
|
|
794
|
+
*/
|
|
795
|
+
function scopeToPhase(fileNames, phaseDirName) {
|
|
796
|
+
return fileNames.filter((f) => isPhaseArtifact(f, phaseDirName));
|
|
797
|
+
}
|
|
508
798
|
/**
|
|
509
799
|
* Check if a directory name's phase token matches the normalized phase exactly.
|
|
510
800
|
*/
|
|
@@ -520,6 +810,119 @@ function phaseTokenMatches(dirName, normalized) {
|
|
|
520
810
|
}
|
|
521
811
|
return false;
|
|
522
812
|
}
|
|
813
|
+
/**
|
|
814
|
+
* #2528: the LEADING DIGIT RUN of a directory name — the fragment the
|
|
815
|
+
* bare-integer fallback selects on, and the one `phaseNumberForMatch` then
|
|
816
|
+
* displays. Named (per this module's convention of naming grammar fragments
|
|
817
|
+
* rather than inlining them) because the two sites must not drift: selecting on
|
|
818
|
+
* one run and displaying another would resolve a directory and then label it
|
|
819
|
+
* with a number that never matched.
|
|
820
|
+
*
|
|
821
|
+
* `LEADING_DIGIT_RUN_RE` anchors a trailing `-`-or-end so the run is a whole
|
|
822
|
+
* segment; `_PREFIX` is the same run without that boundary, for reading the run
|
|
823
|
+
* back off a name already known to match.
|
|
824
|
+
*/
|
|
825
|
+
const LEADING_DIGIT_RUN_SOURCE = '\\d+';
|
|
826
|
+
const LEADING_DIGIT_RUN_RE = new RegExp(`^(${LEADING_DIGIT_RUN_SOURCE})(?:-|$)`);
|
|
827
|
+
const LEADING_DIGIT_RUN_PREFIX_RE = new RegExp(`^${LEADING_DIGIT_RUN_SOURCE}`);
|
|
828
|
+
const BARE_INTEGER_RE = new RegExp(`^${LEADING_DIGIT_RUN_SOURCE}$`);
|
|
829
|
+
/** Strip leading zeros for numeric-equality compare, keeping a lone "0". */
|
|
830
|
+
const unpad = (digits) => digits.replace(/^0+(?=\d)/, '');
|
|
831
|
+
/**
|
|
832
|
+
* #2528: the CANONICAL phase-directory match selection — the one rule every
|
|
833
|
+
* directory-resolution path (the shared locator plus the `find-phase` and
|
|
834
|
+
* `phase-plan-index` command scans) applies to a candidate dir list. Extracted
|
|
835
|
+
* here because the surrounding scan/ambiguity/shaping code exists per site and
|
|
836
|
+
* had already diverged; the selection itself must not.
|
|
837
|
+
*
|
|
838
|
+
* Two passes:
|
|
839
|
+
* 1. PRIMARY — exact token match (`phaseTokenMatches`), unchanged behavior.
|
|
840
|
+
* 2. BARE-INTEGER FALLBACK — only when the primary pass matched NOTHING and
|
|
841
|
+
* the query is a bare integer, re-filter by each directory's own LEADING
|
|
842
|
+
* digit run (zero-padded compare). This catches digit-leading slug shapes
|
|
843
|
+
* the tokenizer cannot disambiguate from genuine sub-phase segments
|
|
844
|
+
* (e.g. "05-80-20-cleanup", phase 5 named "80/20 Cleanup", whose token
|
|
845
|
+
* "05-80-20" is byte-identical in shape to a real deep-decomposition dir).
|
|
846
|
+
* The fallback can only turn a silent not-found into a resolution or into
|
|
847
|
+
* a surfaced ambiguity (callers keep their #2237 multi-match guards) —
|
|
848
|
+
* never override a primary match.
|
|
849
|
+
*
|
|
850
|
+
* SCOPE, precisely (#2528 re-review). Non-bare QUERIES ("46-6", "12A",
|
|
851
|
+
* "PROJ-42") never enter the fallback, so nothing changes about how a
|
|
852
|
+
* deep-decomposition or letter-suffix lookup is asked. What DOES change is the
|
|
853
|
+
* DIRECTORY side: a bare query now reaches directories the tokenizer classified
|
|
854
|
+
* as multi-segment, and a genuine sub-phase directory has exactly that shape.
|
|
855
|
+
* So `5` against a lone `05-01-auth` resolves (phase_number "05", phase_name
|
|
856
|
+
* "01-auth") where it previously found nothing.
|
|
857
|
+
*
|
|
858
|
+
* That widening is DELIBERATE and it is irreducible from directory names alone.
|
|
859
|
+
* `05-01-auth` (sub-phase 5.1) and `30-12-factor-refactor` (phase 30 named
|
|
860
|
+
* "12-Factor Refactor") are the same string shape — `NN-NN-<slug>` — and the
|
|
861
|
+
* discriminator that would separate them, "is the second segment a valid decimal
|
|
862
|
+
* sub-phase", accepts both (`5.1` and `30.12` are equally well-formed). Any rule
|
|
863
|
+
* strong enough to exclude `05-01-auth` also excludes `30-12-factor-refactor`,
|
|
864
|
+
* which is the defect #2528 exists to fix. The tie is therefore broken in favour
|
|
865
|
+
* of resolving, and the consequence is bounded on the side that matters: when
|
|
866
|
+
* BOTH readings have a directory (`05-01-auth` + `05-02-api`) the result is two
|
|
867
|
+
* matches. `tests/phase-resolution-parity.test.cjs` pins both directions: the
|
|
868
|
+
* lone-directory resolution and the two-directory refusal.
|
|
869
|
+
*
|
|
870
|
+
* WHAT IS SHARED IS SELECTION, NOT AMBIGUITY POLICY. This function is the one
|
|
871
|
+
* owner of "which directories does this query name". What a caller does with
|
|
872
|
+
* two of them stays the caller's own decision, and the callers split in two
|
|
873
|
+
* tiers on purpose:
|
|
874
|
+
*
|
|
875
|
+
* REFUSE on `matches.length > 1` — `searchPhaseInDir`, `cmdFindPhase`,
|
|
876
|
+
* `cmdPhasePlanIndex`, `cmdPhaseRemove`. These either act destructively or
|
|
877
|
+
* answer "which phase is this", so guessing is worse than reporting the
|
|
878
|
+
* candidates (#2237).
|
|
879
|
+
*
|
|
880
|
+
* TAKE `matches[0]` — `cmdPhasesList`, `cmdInitManager`, `cmdRoadmapAnalyze`,
|
|
881
|
+
* `cmdVerifySchemaDrift`, `detectVerifyFailed`. Each read a directory to
|
|
882
|
+
* DECORATE a row they are already emitting; each used `.find()` before this
|
|
883
|
+
* PR, so first-match is their prior behavior preserved verbatim, and each is
|
|
884
|
+
* order-stable because the directory list is sorted and this function filters
|
|
885
|
+
* without reordering.
|
|
886
|
+
*
|
|
887
|
+
* The honest caveat on that second tier: the bare-number fallback makes
|
|
888
|
+
* multi-match newly REACHABLE for inputs that previously found nothing, so those
|
|
889
|
+
* five can now silently pick one of several candidates where they used to report
|
|
890
|
+
* not-found. That is a widening of an existing first-match rule, not a new rule
|
|
891
|
+
* — but it is a widening, and promoting any of them to refusal is a UX decision
|
|
892
|
+
* about their own output, not a change to selection, so it does not belong here.
|
|
893
|
+
*
|
|
894
|
+
* `usedBareFallback` tells callers to derive the displayed phase number from
|
|
895
|
+
* the directory's leading digit run instead of `extractPhaseToken` (whose
|
|
896
|
+
* token for these dirs is the mis-absorbed multi-segment form).
|
|
897
|
+
*/
|
|
898
|
+
function matchPhaseDirs(dirs, normalized) {
|
|
899
|
+
const primary = dirs.filter(d => phaseTokenMatches(d, normalized));
|
|
900
|
+
if (primary.length > 0)
|
|
901
|
+
return { matches: primary, usedBareFallback: false };
|
|
902
|
+
const bare = String(normalized);
|
|
903
|
+
if (!BARE_INTEGER_RE.test(bare))
|
|
904
|
+
return { matches: primary, usedBareFallback: false };
|
|
905
|
+
const want = unpad(bare);
|
|
906
|
+
const fallback = dirs.filter(d => {
|
|
907
|
+
const m = stripProjectCodePrefix(d).match(LEADING_DIGIT_RUN_RE);
|
|
908
|
+
return m !== null && unpad(m[1]) === want;
|
|
909
|
+
});
|
|
910
|
+
return { matches: fallback, usedBareFallback: fallback.length > 0 };
|
|
911
|
+
}
|
|
912
|
+
/**
|
|
913
|
+
* #2528: the display phase number for a directory selected by matchPhaseDirs.
|
|
914
|
+
* Primary matches keep the extracted token; bare-fallback matches use the
|
|
915
|
+
* directory's leading digit run (the whole point of the fallback is that the
|
|
916
|
+
* extracted token is wrong for these dirs).
|
|
917
|
+
*/
|
|
918
|
+
function phaseNumberForMatch(dirName, usedBareFallback) {
|
|
919
|
+
if (!usedBareFallback)
|
|
920
|
+
return extractPhaseToken(dirName);
|
|
921
|
+
const stripped = stripProjectCodePrefix(dirName);
|
|
922
|
+
const prefix = dirName.slice(0, dirName.length - stripped.length);
|
|
923
|
+
const m = stripped.match(LEADING_DIGIT_RUN_PREFIX_RE);
|
|
924
|
+
return m ? prefix + m[0] : extractPhaseToken(dirName);
|
|
925
|
+
}
|
|
523
926
|
// ─── Canonical phase KEY surface (#2562) ─────────────────────────────────────
|
|
524
927
|
//
|
|
525
928
|
// A phase "key" is the padding-, case- and project-code-insensitive identity of
|
|
@@ -699,10 +1102,11 @@ function roadmapPhaseLookupSources(phaseNum) {
|
|
|
699
1102
|
return [...new Set(sources)];
|
|
700
1103
|
}
|
|
701
1104
|
module.exports = {
|
|
702
|
-
escapeRegex,
|
|
703
1105
|
OPTIONAL_PROJECT_CODE_PREFIX_SOURCE,
|
|
704
1106
|
OPTIONAL_PHASE_TAG_SOURCE,
|
|
705
1107
|
PHASE_NUMBER_TOKEN_SOURCE,
|
|
1108
|
+
CASE_FLEXIBLE_PROJECT_CODE_PREFIX_SOURCE,
|
|
1109
|
+
CASE_FLEXIBLE_PHASE_NUMBER_TOKEN_SOURCE,
|
|
706
1110
|
PHASE_CONTINUATION_SEGMENT_SOURCE,
|
|
707
1111
|
isPhaseContinuationSegment,
|
|
708
1112
|
BRACKET_PHASE_TOKEN_SOURCE,
|
|
@@ -720,7 +1124,11 @@ module.exports = {
|
|
|
720
1124
|
phaseMarkdownRegexSourceExact,
|
|
721
1125
|
comparePhaseNum,
|
|
722
1126
|
extractPhaseToken,
|
|
1127
|
+
isPhaseArtifact,
|
|
1128
|
+
scopeToPhase,
|
|
723
1129
|
phaseTokenMatches,
|
|
1130
|
+
matchPhaseDirs,
|
|
1131
|
+
phaseNumberForMatch,
|
|
724
1132
|
phaseKeyFromToken,
|
|
725
1133
|
phaseKeyFromDir,
|
|
726
1134
|
phaseKeyFromProse,
|
|
@@ -22,8 +22,12 @@
|
|
|
22
22
|
*/
|
|
23
23
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
24
24
|
exports.deriveProgressFromRoadmap = deriveProgressFromRoadmap;
|
|
25
|
+
exports.clampPercentFromFraction = clampPercentFromFraction;
|
|
25
26
|
exports.clampPercent = clampPercent;
|
|
26
27
|
const markdown_table_cjs_1 = require("./markdown-table.cjs");
|
|
28
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-id.cjs is an export= CommonJS module
|
|
29
|
+
const phaseIdMod = require("./phase-id.cjs");
|
|
30
|
+
const { isSentinelPhaseId } = phaseIdMod;
|
|
27
31
|
/**
|
|
28
32
|
* Derive completed_phases, total_phases, and total_plans from ROADMAP content.
|
|
29
33
|
* Root cause fix for issue #4 — see gen-phase-lifecycle.mjs for full documentation.
|
|
@@ -81,10 +85,11 @@ function deriveProgressFromRoadmap(roadmapContent) {
|
|
|
81
85
|
const allRows = table.rows;
|
|
82
86
|
const completed = allRows.filter((r) => /^complete$/i.test((r['Status'] ?? '').trim())).length;
|
|
83
87
|
completedPhases = completed > 0 ? completed : null;
|
|
84
|
-
// Data rows only (exclude
|
|
88
|
+
// Data rows only (exclude sentinel phases 0 and 999.x).
|
|
89
|
+
// #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
|
|
85
90
|
const dataRows = allRows.filter((r) => {
|
|
86
91
|
const phase = (r['Phase'] ?? '').trim();
|
|
87
|
-
return /^\d/.test(phase) &&
|
|
92
|
+
return /^\d/.test(phase) && !isSentinelPhaseId(phase);
|
|
88
93
|
});
|
|
89
94
|
totalPhases = dataRows.length > 0 ? dataRows.length : null;
|
|
90
95
|
let totalPlansSum = 0;
|
|
@@ -98,12 +103,32 @@ function deriveProgressFromRoadmap(roadmapContent) {
|
|
|
98
103
|
}
|
|
99
104
|
return { completedPhases, totalPhases, totalPlans };
|
|
100
105
|
}
|
|
106
|
+
/**
|
|
107
|
+
* Compute progress percent clamped to 100 from an already-computed FRACTION.
|
|
108
|
+
*
|
|
109
|
+
* ADR-3180 Decision 7 (#3180): the completion-RATIO derivation has exactly one
|
|
110
|
+
* owner, and this is its kernel — the single place the `fraction -> integer
|
|
111
|
+
* percent` rounding and the 100 ceiling are expressed. `clampPercent` below is
|
|
112
|
+
* the count-shaped entry point and delegates here; a caller that already holds a
|
|
113
|
+
* fraction (rather than a completed/total pair) calls this directly instead of
|
|
114
|
+
* re-deriving `Math.min(100, Math.round(f * 100))` locally.
|
|
115
|
+
*
|
|
116
|
+
* Enforced by `scripts/lint-completion-ratio-drift.cjs`.
|
|
117
|
+
*/
|
|
118
|
+
function clampPercentFromFraction(fraction) {
|
|
119
|
+
return Math.min(100, Math.round(fraction * 100));
|
|
120
|
+
}
|
|
101
121
|
/**
|
|
102
122
|
* Compute progress percent clamped to 100.
|
|
103
123
|
* Root cause fix for issue #4 — see gen-phase-lifecycle.mjs for full documentation.
|
|
124
|
+
*
|
|
125
|
+
* A non-positive (or absent) denominator yields `0` — "nothing to complete" is
|
|
126
|
+
* reported as 0%, never as 100%. Every `.planning/` completion percentage in this
|
|
127
|
+
* codebase routes through here (ADR-3180 Decision 7); the `total > 0 ? ... : 0`
|
|
128
|
+
* ternary that used to precede each inline copy IS this function's first line.
|
|
104
129
|
*/
|
|
105
130
|
function clampPercent(completed, total) {
|
|
106
131
|
if (!total || total <= 0)
|
|
107
132
|
return 0;
|
|
108
|
-
return
|
|
133
|
+
return clampPercentFromFraction(completed / total);
|
|
109
134
|
}
|