session-orchestrator 5.3.0 → 5.5.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/.agents/skills/claude-md-drift-check/SKILL.md +1 -1
- package/.agents/skills/test-audit/SKILL.md +21 -0
- package/.agents/skills/test-audit/agents/openai.yaml +5 -0
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.codex-plugin/skills/claude-md-drift-check/SKILL.md +1 -1
- package/.codex-plugin/skills/test-audit/SKILL.md +21 -0
- package/.codex-plugin/skills/test-audit/agents/openai.yaml +5 -0
- package/.cursor/commands/test-audit.md +14 -0
- package/.cursor/rules/060-evolve.mdc +28 -13
- package/.cursor/skills/claude-md-drift-check/SKILL.md +1 -1
- package/.cursor/skills/test-audit/SKILL.md +12 -0
- package/.cursor-plugin/plugin.json +1 -1
- package/.orchestrator/policy/blocked-commands.json +1 -1
- package/AGENTS.md +9 -31
- package/CHANGELOG.md +162 -0
- package/README.md +14 -10
- package/docs/agent-authoring.md +2 -2
- package/docs/baseline.md +10 -1
- package/docs/ci-setup.md +154 -4
- package/docs/components.md +11 -11
- package/docs/events-schema.md +25 -19
- package/docs/install.md +14 -2
- package/docs/migration-v4.md +24 -17
- package/docs/persona-panel.md +5 -4
- package/docs/plugin-architecture-v3.md +1 -1
- package/docs/rule-authoring.md +27 -12
- package/docs/scope-collision-guard.md +40 -16
- package/docs/session-config-reference.md +61 -12
- package/docs/session-config-template.md +11 -2
- package/docs/vault-docs-architecture.md +8 -4
- package/hooks/_lib/hook-import-set.json +42 -29
- package/hooks/_lib/lock-bootstrap.mjs +118 -26
- package/hooks/config-protection.mjs +1 -1
- package/hooks/enforce-commands.mjs +28 -13
- package/hooks/enforce-scope.mjs +201 -23
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +5 -0
- package/hooks/hooks-pi.json +5 -0
- package/hooks/hooks.json +6 -1
- package/hooks/on-session-start.mjs +57 -2
- package/hooks/on-stop.mjs +70 -168
- package/hooks/post-bash-write-verify.mjs +98 -17
- package/hooks/post-edit-validate.mjs +15 -5
- package/hooks/post-tool-batch-wave-signal.mjs +32 -152
- package/hooks/pre-bash-memory-propose-audit.mjs +71 -10
- package/hooks/pre-bash-sessions-ledger-guard.mjs +28 -2
- package/hooks/pre-bash-staging-fence.mjs +29 -8
- package/hooks/pre-edit-sessions-ledger-guard.mjs +162 -0
- package/hooks/pre-task-scope-disjoint.mjs +1143 -279
- package/hooks/subagent-telemetry.mjs +96 -19
- package/hooks/wave-scope-commit-guard.mjs +168 -72
- package/package.json +2 -6
- package/pi/prompts/test-audit.md +12 -0
- package/rules/README.md +6 -1
- package/rules/_index.md +3 -3
- package/rules/always-on/bash-harness-pitfalls.md +7 -1
- package/rules/opt-in-domain/prompt-caching.md +5 -1
- package/rules/opt-in-stack/backend-data.md +9 -1
- package/rules/opt-in-stack/backend.md +7 -1
- package/rules/opt-in-stack/frontend.md +6 -1
- package/rules/opt-in-stack/security-web.md +7 -1
- package/rules/opt-in-stack/swift.md +5 -1
- package/scripts/apply-session-learnings.mjs +513 -0
- package/scripts/archive-closed-prds.mjs +40 -12
- package/scripts/autopilot.mjs +57 -54
- package/scripts/backfill-abandoned-sessions.mjs +81 -6
- package/scripts/backfill-learnings-from-vault.mjs +49 -22
- package/scripts/baseline-archetypes.mjs +1 -1
- package/scripts/check-package-manager.mjs +62 -5
- package/scripts/codex-install.mjs +3 -0
- package/scripts/cursor-install.mjs +47 -6
- package/scripts/emit-session.mjs +298 -2
- package/scripts/export-hw-learnings.mjs +40 -10
- package/scripts/fleet-checkin.mjs +170 -0
- package/scripts/lib/agent-frontmatter.mjs +3 -3
- package/scripts/lib/auto-dream.mjs +7 -6
- package/scripts/lib/autopilot/flags.mjs +6 -1
- package/scripts/lib/autopilot/loop.mjs +32 -13
- package/scripts/lib/autopilot/stall-sampler.mjs +13 -9
- package/scripts/lib/baseline-archetypes.mjs +41 -22
- package/scripts/lib/blocked-commands-policy.mjs +40 -0
- package/scripts/lib/common.mjs +41 -0
- package/scripts/lib/config/host-paths.mjs +79 -0
- package/scripts/lib/config/persona-gate-wave.mjs +3 -3
- package/scripts/lib/config/reaper.mjs +7 -6
- package/scripts/lib/config/vault-integration.mjs +151 -16
- package/scripts/lib/config-schema.mjs +13 -0
- package/scripts/lib/config.mjs +75 -10
- package/scripts/lib/eval/engine.mjs +13 -0
- package/scripts/lib/events-retention-banner.mjs +393 -0
- package/scripts/lib/events-rotation.mjs +21 -6
- package/scripts/lib/events.mjs +72 -5
- package/scripts/lib/fleet-protocol.mjs +288 -0
- package/scripts/lib/gates/gate-baseline.mjs +13 -3
- package/scripts/lib/gates/gate-full.mjs +7 -2
- package/scripts/lib/gates/gate-helpers.mjs +93 -2
- package/scripts/lib/gates/gate-incremental.mjs +23 -22
- package/scripts/lib/gates/gate-per-file.mjs +16 -7
- package/scripts/lib/hardening.mjs +1 -0
- package/scripts/lib/harness-audit/categories/category4.mjs +8 -6
- package/scripts/lib/instruction-budget-guard.mjs +77 -11
- package/scripts/lib/learnings/expiry-sweep.mjs +420 -22
- package/scripts/lib/learnings/io.mjs +274 -37
- package/scripts/lib/learnings/schema.mjs +163 -15
- package/scripts/lib/learnings/sizing-subject.mjs +2 -2
- package/scripts/lib/lock-reaper.mjs +38 -14
- package/scripts/lib/maintenance-due-banner.mjs +23 -8
- package/scripts/lib/memory-proposals/schema.mjs +11 -5
- package/scripts/lib/named-baseline-resolver.mjs +53 -28
- package/scripts/lib/named-vault-resolver.mjs +147 -5
- package/scripts/lib/orphan-reaper/defaults.mjs +41 -0
- package/scripts/lib/orphan-reaper/no-follow-append.mjs +87 -0
- package/scripts/lib/orphan-reaper/ps-snapshot.mjs +181 -0
- package/scripts/lib/orphan-reaper/reaper-audit.mjs +323 -0
- package/scripts/lib/orphan-reaper/reaper-decide.mjs +395 -0
- package/scripts/lib/orphan-reaper/scan-throttle.mjs +144 -0
- package/scripts/lib/orphan-reaper/scan.mjs +510 -0
- package/scripts/lib/orphan-reaper/trigger.mjs +173 -0
- package/scripts/lib/orphan-reaper.mjs +47 -1315
- package/scripts/lib/owner-yaml.mjs +104 -6
- package/scripts/lib/persona-panel/catalog-loader.mjs +1 -1
- package/scripts/lib/platform.mjs +206 -2
- package/scripts/lib/plugin-update-banner.mjs +3 -1
- package/scripts/lib/process-group.mjs +134 -22
- package/scripts/lib/project-hygiene.mjs +21 -1
- package/scripts/lib/quality-gate.mjs +45 -5
- package/scripts/lib/resource-probe.mjs +1 -1
- package/scripts/lib/rule-loader.mjs +100 -62
- package/scripts/lib/rules-sync.mjs +27 -12
- package/scripts/lib/scope-baseline.mjs +119 -29
- package/scripts/lib/scope-echo.mjs +7 -0
- package/scripts/lib/scope-gate.mjs +194 -27
- package/scripts/lib/session-close-backfill.mjs +129 -4
- package/scripts/lib/session-end/tail-runner.mjs +10 -5
- package/scripts/lib/session-lock-shape.mjs +6 -3
- package/scripts/lib/session-lock.mjs +320 -67
- package/scripts/lib/session-record-repair.mjs +183 -26
- package/scripts/lib/session-registry.mjs +67 -17
- package/scripts/lib/session-schema/constants.mjs +30 -4
- package/scripts/lib/session-schema/validator.mjs +28 -1
- package/scripts/lib/session-start-probes.mjs +242 -68
- package/scripts/lib/session-token-rollup.mjs +252 -54
- package/scripts/lib/sessions-canonical.mjs +100 -9
- package/scripts/lib/sessions-staleness-banner.mjs +3 -1
- package/scripts/lib/sh-quote.mjs +29 -0
- package/scripts/lib/subagents-schema.mjs +30 -3
- package/scripts/lib/tail-window.mjs +27 -3
- package/scripts/lib/telemetry/pricing.mjs +121 -71
- package/scripts/lib/telemetry/sync.mjs +17 -0
- package/scripts/lib/tests-src-ratio.mjs +215 -23
- package/scripts/lib/tmux-layout/layouts.mjs +1 -11
- package/scripts/lib/tmux-layout/telemetry-stats.mjs +12 -0
- package/scripts/lib/tmux-layout/tmux-shell.mjs +2 -11
- package/scripts/lib/ux-grill/reconcile.mjs +1 -1
- package/scripts/lib/validate/check-agents.mjs +1 -1
- package/scripts/lib/validate/check-hooks-symmetry.mjs +5 -0
- package/scripts/lib/validate/check-learnings-shell-writes.mjs +214 -0
- package/scripts/lib/validate/check-test-value-bans.mjs +388 -27
- package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
- package/scripts/lib/validate/check-unwired-features.mjs +1 -1
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +1 -1
- package/scripts/lib/validate-vendored-rules.mjs +206 -81
- package/scripts/lib/vault-mirror/auto-commit.mjs +5 -3
- package/scripts/lib/vault-mirror/namespace.mjs +47 -26
- package/scripts/lib/vault-mirror/process.mjs +350 -62
- package/scripts/lib/vault-mirror/pseudonym-map.mjs +14 -5
- package/scripts/lib/vault-mirror/render-learnings.mjs +66 -3
- package/scripts/lib/vault-mirror/render-sessions.mjs +28 -4
- package/scripts/lib/vault-mirror/telemetry.mjs +25 -11
- package/scripts/lib/vault-status/board-writer.mjs +53 -12
- package/scripts/lib/vault-status/narrative-mirror.mjs +40 -9
- package/scripts/lib/vcs-repo-spec.mjs +8 -0
- package/scripts/lib/wave-executor/dispatch-common.mjs +10 -18
- package/scripts/lib/wave-executor/foreign-dispatch.mjs +1 -1
- package/scripts/lib/wave-executor/remote-dispatch.mjs +1 -1
- package/scripts/lib/wave-resource-gate.mjs +34 -8
- package/scripts/materialize-wave-scope.mjs +7 -4
- package/scripts/memory-propose.mjs +18 -5
- package/scripts/release-session-lock.mjs +2 -1
- package/scripts/release.mjs +11 -3
- package/scripts/repair-invalid-sessions.mjs +37 -4
- package/scripts/run-migrate-v2-cross-repo.mjs +171 -31
- package/scripts/run-quality-gate.mjs +331 -307
- package/scripts/self-update.mjs +229 -0
- package/scripts/site-numbers.mjs +163 -5
- package/scripts/sweep-expired-learnings.mjs +603 -20
- package/scripts/sweep-expired-rules.mjs +2 -1
- package/scripts/validate-plugin.mjs +7 -0
- package/scripts/validate-wave-scope.mjs +21 -2
- package/scripts/vault-backfill.mjs +50 -37
- package/scripts/vault-integration-watcher.mjs +2 -1
- package/scripts/vault-mirror.mjs +238 -75
- package/scripts/wave-scope-binding.mjs +2 -1
- package/skills/_shared/config-reading.md +3 -3
- package/skills/_shared/fleet-protocol.md +134 -0
- package/skills/_shared/state-ownership.md +41 -12
- package/skills/autopilot/SKILL.md +25 -11
- package/skills/bootstrap/SKILL.md +6 -4
- package/skills/bootstrap/_shared-template.md +97 -2
- package/skills/bootstrap/fast-template.md +4 -1
- package/skills/bootstrap/private-contract.md +2 -1
- package/skills/bootstrap/public-fallback.md +6 -3
- package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +5 -3
- package/skills/bootstrap/references/bootstrap-retroactive-flow.md +2 -1
- package/skills/bootstrap/standard-template.md +2 -0
- package/skills/claude-md-drift-check/SKILL.md +10 -10
- package/skills/claude-md-drift-check/checker.mjs +26 -18
- package/skills/discovery/probes/ssot-code-diff.mjs +12 -6
- package/skills/discovery/probes/vault-narrative-staleness.mjs +6 -2
- package/skills/discovery/probes/vault-staleness.mjs +9 -3
- package/skills/docs-orchestrator/SKILL.md +12 -5
- package/skills/docs-orchestrator/audience-mapping.md +5 -3
- package/skills/evolve/SKILL.md +13 -4
- package/skills/evolve/references/evolve-analyze-mode.md +42 -18
- package/skills/memory-cleanup/SKILL.md +1 -1
- package/skills/persona-panel/SKILL.md +2 -2
- package/skills/persona-panel/persona-format.md +2 -2
- package/skills/persona-panel/presets/designer-lens.md +1 -1
- package/skills/persona-panel/presets/engineer-lens.md +1 -1
- package/skills/persona-panel/presets/pm-lens.md +1 -1
- package/skills/plan/mode-retro.md +19 -4
- package/skills/quality-gates/SKILL.md +6 -4
- package/skills/remote-offload/SKILL.md +4 -2
- package/skills/session-end/learning-patterns.md +23 -13
- package/skills/session-end/metrics-collection.md +6 -7
- package/skills/session-end/phase-3-2-docs-verification.md +7 -6
- package/skills/session-end/plan-verification.md +25 -9
- package/skills/session-end/references/phase-3-documentation-updates.md +13 -3
- package/skills/session-end/references/phase-4a-worktree-cleanup.md +2 -0
- package/skills/session-end/references/session-summary-template.md +1 -1
- package/skills/session-end/session-metrics-write.md +65 -68
- package/skills/session-plan/SKILL.md +0 -3
- package/skills/session-start/SKILL.md +8 -2
- package/skills/session-start/references/operations-contract.md +6 -0
- package/skills/session-start/references/phase-1-2-session-lock.md +32 -7
- package/skills/session-start/references/phase-1-7-vault-status-board.md +1 -1
- package/skills/session-start/references/phase-4-ssot-environment-check.md +10 -3
- package/skills/test-audit/SKILL.md +152 -0
- package/skills/test-audit/references/campaign.md +111 -0
- package/skills/test-audit/references/mutate.sh +50 -0
- package/skills/vault-mirror/SKILL.md +21 -11
- package/skills/wave-executor/SKILL.md +2 -2
- package/skills/wave-executor/circuit-breaker.md +1 -1
- package/skills/wave-executor/references/wave-executor-state-init.md +3 -0
- package/skills/wave-executor/references/wave-loop-dispatch.md +11 -1
- package/skills/wave-executor/references/wave-loop-review.md +4 -6
- package/skills/wave-executor/references/wave-loop-scope-manifest.md +13 -5
- package/templates/personas/README.md +5 -5
- package/templates/personas/accounting-compliance.v1.md +1 -1
- package/templates/personas/accounting-tax-advisor.v1.md +1 -1
- package/templates/personas/buyer-p1-cto.v1.md +1 -1
- package/templates/personas/buyer-p2-kanzlei.v1.md +1 -1
- package/templates/personas/buyer-p3-build.v1.md +1 -1
- package/templates/personas/buyer-p4-tech-veto.v1.md +1 -1
- package/templates/personas/buyer-p5-solo.v1.md +1 -1
- package/templates/personas/buyer-p6-ld.v1.md +1 -1
- package/templates/personas/klima-ai-expert.v1.md +1 -1
- package/templates/personas/klima-physicist.v1.md +1 -1
|
@@ -93,11 +93,27 @@
|
|
|
93
93
|
* pure input→output date function is out of scope by
|
|
94
94
|
* construction rather than by exception list.
|
|
95
95
|
*
|
|
96
|
-
* NOT flagged: blocks that control the clock (`now
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
96
|
+
* NOT flagged: blocks that control the clock (`now:`, `{ now }`
|
|
97
|
+
* or a positional `now` — wider than the seam proof, #1478;
|
|
98
|
+
* SCREAMING_CASE `NOW` / `FIXED_NOW` / `NOW_MS` count as
|
|
99
|
+
* clock names too, #1499 item 7 — or a fake timer that pins
|
|
100
|
+
* the DATE: `vi.setSystemTime(<date>)`,
|
|
101
|
+
* `vi.useFakeTimers({ now: <date> })`, also when called in
|
|
102
|
+
* the setup at module level or in a `describe` that
|
|
103
|
+
* encloses the block; a bare `vi.useFakeTimers()` installs
|
|
104
|
+
* at the real clock and pins nothing, #1499 item 8). A
|
|
105
|
+
* handover whose value IS the real clock is no control
|
|
106
|
+
* (#1487): inline (`{ now: Date.now() }`) or through a
|
|
107
|
+
* binding in the same block (`const now = new Date();
|
|
108
|
+
* fn(now)`) or in the shared setup — module level,
|
|
109
|
+
* `describe` body, `beforeEach` (#1489 item 16). A freeze
|
|
110
|
+
* does not exempt a block that hands over a real clock bound
|
|
111
|
+
* at collection time (module level, `describe` body): that
|
|
112
|
+
* value was read before any hook ran (#1499 item 9). Date
|
|
113
|
+
* literals in INPUT position (only `.toBe`/`.toEqual`/
|
|
114
|
+
* `.toStrictEqual` expected values are read), which leaves
|
|
115
|
+
* the passthrough class (input date === output date)
|
|
116
|
+
* untouched.
|
|
101
117
|
*
|
|
102
118
|
* Per-file opt-out: `// @test-value-bans-allowed` in the first 5 lines skips
|
|
103
119
|
* the file entirely (same convention as check-test-fixture-shapes.mjs's
|
|
@@ -335,18 +351,98 @@ const keyLiteralRe = (key) => new RegExp(`["'\\\\]+${key}["'\\\\]+\\s*:`);
|
|
|
335
351
|
* An absolute date pinned as the EXPECTED value of an equality assertion.
|
|
336
352
|
* Input-position date literals (`created_at: '2026-06-21T…'`) do not match —
|
|
337
353
|
* that is what keeps the passthrough class (input date === output date) out.
|
|
354
|
+
*
|
|
355
|
+
* CEILING (#1473): a fixed INPUT date that a production TTL ages out later is
|
|
356
|
+
* not flagged either, on purpose. Measured 2026-09-29 with a shim that moves
|
|
357
|
+
* `Date.now()` and `new Date()` forward in every vitest worker: the 34 test
|
|
358
|
+
* files (plus one fixture module) holding a fixed `created_at: '2026-05…09'`
|
|
359
|
+
* (1,211 tests) stayed green at +120, +365 and +3,650 days, so a rule keyed on
|
|
360
|
+
* fixed input dates would have reported false positives only. The one real
|
|
361
|
+
* bomb of that shape, the phase-skip fixture before 6670149c, was green at
|
|
362
|
+
* -60 days and red at +0 and +120 days under the same shim — and
|
|
363
|
+
* scanClockBombs() reaches that file since #1476 (alias imports are read) but
|
|
364
|
+
* stays silent on it: the file hands no clock argument to anything. REVISIT when a
|
|
365
|
+
* second fixed-input-date bomb lands on main: the class then recurs, and a
|
|
366
|
+
* clock-shift run of the suite is the check that finds it without guessing.
|
|
338
367
|
*/
|
|
339
368
|
const DATE_EXPECTATION =
|
|
340
369
|
/\.(?:toBe|toEqual|toStrictEqual)\(\s*(['"`])(\d{4}-\d{2}-\d{2}(?:[T ][^'"`]*)?)\1\s*\)/;
|
|
341
370
|
|
|
342
|
-
/**
|
|
343
|
-
|
|
371
|
+
/**
|
|
372
|
+
* A clock-named token not read off an object (`Date.now`, `opts.now`) — shared
|
|
373
|
+
* head of the patterns below. Group 1 is the name.
|
|
374
|
+
*
|
|
375
|
+
* SCREAMING_CASE module constants are clock names too (#1499 item 7): `NOW`,
|
|
376
|
+
* `NOW_MS`, `NOW_ISO`, and any `<WORD>_`-prefixed form (`FIXED_NOW`,
|
|
377
|
+
* `FROZEN_NOW`). An `i` flag would not reach them — `_` is a `\w`, so the
|
|
378
|
+
* lookbehind and the terminators below never see a `FIXED_NOW` boundary.
|
|
379
|
+
* Measured 2026-10-02 over 722 test files: `NOW` is handed over in 29 files,
|
|
380
|
+
* `FIXED_NOW` in 10, `NOW_MS` in 5, `NOW_ISO` in 2.
|
|
381
|
+
*/
|
|
382
|
+
const CLOCK_NAME = String.raw`(?<![.\w$])(now|nowMs|nowIso|clock|currentDate|(?:[A-Z][A-Z0-9]*_)*(?:NOW(?:_MS|_ISO)?|CLOCK|CURRENT_DATE))\s*`;
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Pass 1, the seam PROOF: a NAMED clock key (`{ now: … }`) — only that shape says
|
|
386
|
+
* the callee's API takes a clock, whatever value it is handed. Every line it
|
|
387
|
+
* matches also matches CONTROL_CLOCK_ARG, so a block that proves a seam is never
|
|
388
|
+
* flagged itself — UNLESS the value it hands over is the real clock (#1487):
|
|
389
|
+
* then the block proves the seam and is a bomb in the same breath.
|
|
390
|
+
*/
|
|
391
|
+
const SEAM_CLOCK_ARG = new RegExp(`${CLOCK_NAME}:`);
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Pass 2, is this block clock-CONTROLLED: any clock handover. Property shorthand
|
|
395
|
+
* (`{ now }`, `{ repoRoot, now }`) and a positional `now` count too; `Date.now()`
|
|
396
|
+
* and `const now =` do not. Wider is the safe direction here — it can only
|
|
397
|
+
* remove findings. Group 2 is the terminator: `:` means the name is a KEY and
|
|
398
|
+
* the handed-over value follows it; any other terminator means the name IS the
|
|
399
|
+
* value. A handover only counts when that value is not the real clock — see
|
|
400
|
+
* {@link handsOverControlledClock}.
|
|
401
|
+
*
|
|
402
|
+
* CEILING (#1478): pass 1 reads only the named form, because the wide form also
|
|
403
|
+
* turns helpers into seams — +20 seamed (file, id) pairs in 15 files
|
|
404
|
+
* (172 → 192, measured 2026-10-01 @ ffc5929f), e.g. `expectDeny` via a
|
|
405
|
+
* `{ manifestMtimeMs: now }` fixture. Cost: a file whose ONLY clock handover is
|
|
406
|
+
* `{ now }` or a positional `now` loses its seam proof and its bombs go unseen.
|
|
407
|
+
* REVISIT on the first B5 finding whose subject is a helper, or the first CI
|
|
408
|
+
* `test-value-bans` B5 finding the ±days clock-shift probe shows to be false.
|
|
409
|
+
*/
|
|
410
|
+
const CONTROL_CLOCK_ARG = new RegExp(String.raw`${CLOCK_NAME}(:|,|\}|\))`, 'g');
|
|
411
|
+
|
|
412
|
+
/** A read of the real wall clock: `Date.now()`, an argument-less `new Date()`, `performance.now()`. */
|
|
413
|
+
const REAL_CLOCK_READ = /\bDate\.now\(\)|\bnew Date\(\s*\)|\bperformance\.now\(\)/;
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* `const now = <rhs>` / `now = <rhs>` — a clock-named binding (not `==`, not an
|
|
417
|
+
* arrow `now =>`). Group 1 is the name, group 2 the right-hand side.
|
|
418
|
+
*/
|
|
419
|
+
const CLOCK_BINDING = new RegExp(String.raw`${CLOCK_NAME}=(?![=>])(.*)`);
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Freezing the global clock — equally valid control, but not a seam PROOF. Only
|
|
423
|
+
* the INSTALLERS count: `advanceTimersByTime` / `runAllTimers` move a clock some
|
|
424
|
+
* installer already faked and freeze nothing on their own, so a block that only
|
|
425
|
+
* advances timers is controlled exactly when an installer reaches it. Whether an
|
|
426
|
+
* installer pins the DATE is {@link pinsDate}'s call. Group 1 is the installer.
|
|
427
|
+
*/
|
|
428
|
+
const DATE_PIN_CALL = /\b(useFakeTimers|setSystemTime)\s*\(/g;
|
|
344
429
|
|
|
345
|
-
/**
|
|
346
|
-
|
|
430
|
+
/**
|
|
431
|
+
* Vitest aliases that resolve to the repo's own modules — source of truth is
|
|
432
|
+
* `resolve.alias` in vitest.config.mjs (today only `@lib`). CEILING: one entry
|
|
433
|
+
* per alias, kept by hand; REVISIT when vitest.config.mjs gains an alias — the
|
|
434
|
+
* parametrised B5 test reads that config and goes red until the key is listed
|
|
435
|
+
* here. Dynamic imports (`await import('@lib/…')`) are not read in either form.
|
|
436
|
+
*/
|
|
437
|
+
const ALIAS_PREFIXES = ['@lib/'];
|
|
347
438
|
|
|
348
|
-
/** `import { a, b as c } from './rel.mjs'` — SUT candidates live behind these. */
|
|
349
|
-
const
|
|
439
|
+
/** `import { a, b as c } from './rel.mjs'` or `'@lib/x.mjs'` — SUT candidates live behind these. */
|
|
440
|
+
const LOCAL_IMPORT = new RegExp(
|
|
441
|
+
String.raw`import\s+([^;]+?)\s+from\s+['"]((?:\.|` +
|
|
442
|
+
ALIAS_PREFIXES.map((p) => p.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')).join('|') +
|
|
443
|
+
String.raw`)[^'"]+)['"]`,
|
|
444
|
+
'g',
|
|
445
|
+
);
|
|
350
446
|
|
|
351
447
|
// ---------------------------------------------------------------------------
|
|
352
448
|
// File enumeration
|
|
@@ -564,16 +660,46 @@ function declaredHookSubjects(lines) {
|
|
|
564
660
|
* @returns {Array<{start: number, end: number}>} half-open [start, end) indices
|
|
565
661
|
*/
|
|
566
662
|
function testBlocks(lines) {
|
|
567
|
-
|
|
568
|
-
|
|
663
|
+
return callRanges(lines, /^(\s*)(?:it|test)(?:\.\w+)*\s*\(/, /^\s*(?:it|test|describe)(?:\.\w+)*\s*\(/);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* Split a file into `describe(` blocks, segmented like {@link testBlocks} but
|
|
668
|
+
* with no sibling fallback: a `describe` without its own same-indentation `});`
|
|
669
|
+
* runs to the end of the file. For its one consumer (B5's setup fake-timer
|
|
670
|
+
* scope) over-inclusion is NOT safe: a wider `describe` exempts more blocks, so
|
|
671
|
+
* a fake timer in it hides a sibling describe's time bomb — the false negative
|
|
672
|
+
* this scoping exists to remove. Ceiling (BV-004): two shapes still widen —
|
|
673
|
+
* a `describe` closed with options (`}, 10_000);`) runs to end of file, and a
|
|
674
|
+
* tagged-template `describe.each\`…\`` never matches the opener, so its timer
|
|
675
|
+
* reads as module level. Measured 2026-10-02 over 722 test files: 0 of the 13
|
|
676
|
+
* fake-timer files use either shape. Revisit when one appears in a fake-timer
|
|
677
|
+
* file, or when a B5 miss traces to one.
|
|
678
|
+
* @param {string[]} lines
|
|
679
|
+
* @returns {Array<{start: number, end: number}>} half-open [start, end) indices
|
|
680
|
+
*/
|
|
681
|
+
function describeBlocks(lines) {
|
|
682
|
+
return callRanges(lines, /^(\s*)describe(?:\.\w+)*\s*\(/, null);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* Shared segmenter: every line matching `opener` (group 1 = its indentation)
|
|
687
|
+
* opens a range that ends after the first later line closing at the SAME
|
|
688
|
+
* indentation (`});`), or before the first `sibling` line, or at end of file.
|
|
689
|
+
* @param {string[]} lines
|
|
690
|
+
* @param {RegExp} opener
|
|
691
|
+
* @param {RegExp|null} sibling
|
|
692
|
+
* @returns {Array<{start: number, end: number}>} half-open [start, end) indices
|
|
693
|
+
*/
|
|
694
|
+
function callRanges(lines, opener, sibling) {
|
|
569
695
|
const out = [];
|
|
570
696
|
for (let i = 0; i < lines.length; i++) {
|
|
571
|
-
const m =
|
|
697
|
+
const m = opener.exec(lines[i]);
|
|
572
698
|
if (!m) continue;
|
|
573
699
|
const closer = new RegExp(`^${m[1]}\\}\\)`);
|
|
574
700
|
let end = lines.length;
|
|
575
701
|
for (let j = i + 1; j < lines.length; j++) {
|
|
576
|
-
if (
|
|
702
|
+
if (sibling?.test(lines[j])) {
|
|
577
703
|
end = j;
|
|
578
704
|
break;
|
|
579
705
|
}
|
|
@@ -588,17 +714,17 @@ function testBlocks(lines) {
|
|
|
588
714
|
}
|
|
589
715
|
|
|
590
716
|
/**
|
|
591
|
-
* Identifiers this test file imports from the repo's OWN modules (relative
|
|
592
|
-
* specifiers). These are the subject-under-test candidates for B5;
|
|
593
|
-
* (`vitest`) and stdlib (`node:*`) imports are structurally excluded
|
|
594
|
-
* their specifiers
|
|
717
|
+
* Identifiers this test file imports from the repo's OWN modules (relative or
|
|
718
|
+
* vitest-alias specifiers). These are the subject-under-test candidates for B5;
|
|
719
|
+
* framework (`vitest`) and stdlib (`node:*`) imports are structurally excluded
|
|
720
|
+
* because their specifiers match neither form.
|
|
595
721
|
* @param {string} content
|
|
596
722
|
* @returns {string[]}
|
|
597
723
|
*/
|
|
598
724
|
function importedLocalIdentifiers(content) {
|
|
599
725
|
/** @type {Set<string>} */
|
|
600
726
|
const ids = new Set();
|
|
601
|
-
for (const m of content.matchAll(
|
|
727
|
+
for (const m of content.matchAll(LOCAL_IMPORT)) {
|
|
602
728
|
const clause = m[1];
|
|
603
729
|
// `{ a, b as c }` → c ; `x` / `* as ns` → x / ns
|
|
604
730
|
for (const part of clause.replace(/[{}]/g, ',').split(',')) {
|
|
@@ -612,6 +738,144 @@ function importedLocalIdentifiers(content) {
|
|
|
612
738
|
return [...ids];
|
|
613
739
|
}
|
|
614
740
|
|
|
741
|
+
/**
|
|
742
|
+
* The clock-named bindings in these lines (`const now = Date.now()`,
|
|
743
|
+
* `now = new Date('2026-01-01')`): per name, whether ANY binding reads the REAL
|
|
744
|
+
* clock and whether ANY binding does not. Handing a real-clock binding over is
|
|
745
|
+
* reading the wall clock with an extra step, not controlling it.
|
|
746
|
+
*
|
|
747
|
+
* CEILING (#1487): a real-clock value laundered through a non-clock name
|
|
748
|
+
* (`const t = Date.now(); fn({ now: t })`) still counts as control. REVISIT
|
|
749
|
+
* when a clock-shift run of the suite finds a bomb fed by that route.
|
|
750
|
+
* @param {string[]} live non-comment lines
|
|
751
|
+
* @returns {Map<string, {real: boolean, other: boolean}>}
|
|
752
|
+
*/
|
|
753
|
+
function clockBindings(live) {
|
|
754
|
+
/** @type {Map<string, {real: boolean, other: boolean}>} */
|
|
755
|
+
const out = new Map();
|
|
756
|
+
for (const line of live) {
|
|
757
|
+
const m = CLOCK_BINDING.exec(line);
|
|
758
|
+
if (!m) continue;
|
|
759
|
+
const seen = out.get(m[1]) ?? { real: false, other: false };
|
|
760
|
+
if (REAL_CLOCK_READ.test(m[2])) seen.real = true;
|
|
761
|
+
else seen.other = true;
|
|
762
|
+
out.set(m[1], seen);
|
|
763
|
+
}
|
|
764
|
+
return out;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
/**
|
|
768
|
+
* The clock-named names a block hands over as the REAL clock: its own real-clock
|
|
769
|
+
* bindings, plus the shared setup's (module level, `describe` body,
|
|
770
|
+
* `beforeEach`) for every name the block does not rebind itself (#1489 item 16:
|
|
771
|
+
* read block-wise only, `const now = Date.now()` at module level counted as
|
|
772
|
+
* control and kept a genuine bomb silent).
|
|
773
|
+
*
|
|
774
|
+
* A setup name counts only when EVERY setup binding of it reads the real clock.
|
|
775
|
+
* Setup is file-wide, not per-`describe`, so a name bound to the real clock in
|
|
776
|
+
* one `beforeEach` and to a fixed date in another is ambiguous for any given
|
|
777
|
+
* block — read as control, the scan's precision-first direction.
|
|
778
|
+
* @param {Map<string, {real: boolean, other: boolean}>} own the block's bindings
|
|
779
|
+
* @param {Map<string, {real: boolean, other: boolean}>} setup the shared setup's bindings
|
|
780
|
+
* @returns {Set<string>}
|
|
781
|
+
*/
|
|
782
|
+
function realClockNames(own, setup) {
|
|
783
|
+
/** @type {Set<string>} */
|
|
784
|
+
const names = new Set();
|
|
785
|
+
for (const [name, b] of own) if (b.real) names.add(name);
|
|
786
|
+
for (const [name, b] of setup) if (b.real && !b.other && !own.has(name)) names.add(name);
|
|
787
|
+
return names;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/**
|
|
791
|
+
* The value expression starting at `from`, up to the first `,` or closing
|
|
792
|
+
* bracket at nesting depth 0 (`now: new Date(x), other` → `new Date(x)`).
|
|
793
|
+
* Brackets inside string literals are not tracked; a cut-short value can only
|
|
794
|
+
* hide a real-clock read, which leaves the block counted as controlled — the
|
|
795
|
+
* pre-#1487 behaviour, never a new finding.
|
|
796
|
+
*/
|
|
797
|
+
function valueExpressionAt(line, from) {
|
|
798
|
+
let depth = 0;
|
|
799
|
+
for (let i = from; i < line.length; i++) {
|
|
800
|
+
const c = line[i];
|
|
801
|
+
if (c === '(' || c === '[' || c === '{') depth++;
|
|
802
|
+
else if (c === ')' || c === ']' || c === '}') {
|
|
803
|
+
if (depth === 0) return line.slice(from, i);
|
|
804
|
+
depth--;
|
|
805
|
+
} else if (c === ',' && depth === 0) return line.slice(from, i);
|
|
806
|
+
}
|
|
807
|
+
return line.slice(from);
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/**
|
|
811
|
+
* True when this line hands over a clock that is NOT the real one: a clock-named
|
|
812
|
+
* handover (CONTROL_CLOCK_ARG) whose value neither reads the wall clock inline
|
|
813
|
+
* (`{ now: Date.now() }`) nor names a real-clock binding in the block's scope
|
|
814
|
+
* (`const now = Date.now(); fn(now)`, see `realClockNames`). #1487: before this,
|
|
815
|
+
* both shapes counted as control and kept a genuine time bomb silent.
|
|
816
|
+
* @param {string} line
|
|
817
|
+
* @param {Set<string>} realClock names bound to the real clock in this block's scope
|
|
818
|
+
*/
|
|
819
|
+
function handsOverControlledClock(line, realClock) {
|
|
820
|
+
return handoverValues(line).some((v) => !REAL_CLOCK_READ.test(v) && !namesAny(v, realClock));
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
/**
|
|
824
|
+
* The values this line hands over as a clock (CONTROL_CLOCK_ARG): after a
|
|
825
|
+
* `name:` key the expression that follows it, otherwise the name itself.
|
|
826
|
+
* @param {string} line
|
|
827
|
+
* @returns {string[]}
|
|
828
|
+
*/
|
|
829
|
+
function handoverValues(line) {
|
|
830
|
+
return [...line.matchAll(CONTROL_CLOCK_ARG)].map((m) =>
|
|
831
|
+
m[2] === ':' ? valueExpressionAt(line, m.index + m[0].length) : m[1],
|
|
832
|
+
);
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
/**
|
|
836
|
+
* True when `value` names one of `names` as a whole identifier.
|
|
837
|
+
* @param {string} value
|
|
838
|
+
* @param {Iterable<string>} names
|
|
839
|
+
*/
|
|
840
|
+
function namesAny(value, names) {
|
|
841
|
+
return [...names].some((n) => new RegExp(String.raw`(?<![.\w$])${n}(?![\w$])`).test(value));
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
/**
|
|
845
|
+
* True when this line freezes the calendar DATE, not just the timers (#1499
|
|
846
|
+
* item 8). vitest 4.1 installs `useFakeTimers()` at `this._fakingDate ||
|
|
847
|
+
* Date.now()` — the real clock unless a date was set first — so only
|
|
848
|
+
* `setSystemTime(<date>)` or a `useFakeTimers` config naming `now` pins a day;
|
|
849
|
+
* `{ toFake: ['setTimeout'] }` leaves Date real. A pin whose value reads the wall
|
|
850
|
+
* clock pins nothing: inline (`setSystemTime(new Date())`, `{ now: Date.now() }`)
|
|
851
|
+
* or through a clock-named binding the scanner knows reads it
|
|
852
|
+
* (`const now = new Date(); vi.setSystemTime(now)`, `useFakeTimers({ now })` —
|
|
853
|
+
* review of ac3ded47). An argument the line cannot read — a variable, a config
|
|
854
|
+
* spread over lines, a quoted key — counts as a pin: precision first, as
|
|
855
|
+
* everywhere in B5. CEILING: a pin value laundered through a NON-clock name
|
|
856
|
+
* (`const t = new Date(); vi.setSystemTime(t)`) still counts — the
|
|
857
|
+
* {@link clockBindings} ceiling.
|
|
858
|
+
* @param {string} line
|
|
859
|
+
* @param {Set<string>} realClock names bound to the real clock in this line's scope
|
|
860
|
+
*/
|
|
861
|
+
function pinsDate(line, realClock) {
|
|
862
|
+
for (const m of line.matchAll(DATE_PIN_CALL)) {
|
|
863
|
+
const arg = valueExpressionAt(line, m.index + m[0].length).trim();
|
|
864
|
+
let value = arg;
|
|
865
|
+
if (m[1] === 'useFakeTimers') {
|
|
866
|
+
if (arg === '') continue;
|
|
867
|
+
if (!arg.startsWith('{') || !arg.endsWith('}')) return true;
|
|
868
|
+
const key = /(?<![\w$])now(?![\w$])\s*([:,}])?/.exec(arg);
|
|
869
|
+
if (!key) continue;
|
|
870
|
+
if (!key[1]) return true;
|
|
871
|
+
// `now:` hands over the expression after it; shorthand `{ now }` hands over the binding `now`.
|
|
872
|
+
value = key[1] === ':' ? valueExpressionAt(arg, key.index + key[0].length) : 'now';
|
|
873
|
+
}
|
|
874
|
+
if (!REAL_CLOCK_READ.test(value) && !namesAny(value, realClock)) return true;
|
|
875
|
+
}
|
|
876
|
+
return false;
|
|
877
|
+
}
|
|
878
|
+
|
|
615
879
|
/** True when `id` is invoked anywhere in these lines. */
|
|
616
880
|
function callsIdentifier(lines, id) {
|
|
617
881
|
const re = new RegExp(`\\b${id}\\s*\\(`);
|
|
@@ -623,8 +887,9 @@ function callsIdentifier(lines, id) {
|
|
|
623
887
|
*
|
|
624
888
|
* Two passes over the file's `it`/`test` blocks:
|
|
625
889
|
* 1. PROVE the seam — an imported id called from a block that also hands over
|
|
626
|
-
*
|
|
627
|
-
* `now` parameter because the function reads the clock on its
|
|
890
|
+
* a NAMED clock key (SEAM_CLOCK_ARG) is clock-seamed. A public API only
|
|
891
|
+
* grows a `now` parameter because the function reads the clock on its
|
|
892
|
+
* main path.
|
|
628
893
|
* 2. FLAG — in blocks with NO clock control at all, any equality assertion
|
|
629
894
|
* pinning an absolute date against such a subject is a time bomb.
|
|
630
895
|
*
|
|
@@ -642,33 +907,129 @@ function scanClockBombs(relPath, content, lines) {
|
|
|
642
907
|
const sutIds = importedLocalIdentifiers(content);
|
|
643
908
|
if (sutIds.length === 0) return findings;
|
|
644
909
|
|
|
645
|
-
const
|
|
910
|
+
const ranges = testBlocks(lines);
|
|
911
|
+
|
|
912
|
+
// The setup every block shares (helpers, constants, `describe` bodies,
|
|
913
|
+
// beforeEach): where an echo may come from, and where a clock may be bound or
|
|
914
|
+
// frozen for the blocks below it. Never a sibling test — its assertions,
|
|
915
|
+
// titles and clock literals say nothing about this block's inputs.
|
|
916
|
+
// CEILING (BV-004): a block without its own same-indentation `});` (a
|
|
917
|
+
// `}, 10_000);` close, an expression-bodied arrow) runs on to the next sibling
|
|
918
|
+
// and takes the setup lines in between out of sharedSetup. Both directions
|
|
919
|
+
// follow: a swallowed `now = Date.now()` hides a finding, a swallowed fake
|
|
920
|
+
// timer adds one. Measured 2026-10-02 @ 46e6695b over 722 test files: 221 of
|
|
921
|
+
// 15,644 blocks lack the closer, 1 of them swallows a beforeAll/beforeEach,
|
|
922
|
+
// none a fake-timer install; 0 blocks are one-line its. REVISIT when a
|
|
923
|
+
// swallowed setup line binds or freezes a clock — then end such blocks on
|
|
924
|
+
// their own close instead of the sibling (testBlocks() also feeds B2/B3).
|
|
925
|
+
/** @type {Set<number>} */
|
|
926
|
+
const inBlock = new Set();
|
|
927
|
+
for (const { start, end } of ranges) for (let q = start; q < end; q++) inBlock.add(q);
|
|
928
|
+
const sharedSetup = lines.filter((_, q) => !inBlock.has(q));
|
|
929
|
+
const setupLive = sharedSetup.filter((l) => !isCommentLine(l));
|
|
930
|
+
const setupBindings = clockBindings(setupLive);
|
|
931
|
+
// A clock frozen in setup (`beforeEach(() => vi.setSystemTime(…))`) controls
|
|
932
|
+
// every block it reaches, including a `now = Date.now()` bound after it — and
|
|
933
|
+
// it reaches only the blocks vitest runs it for: all of them from module level,
|
|
934
|
+
// only its own `describe`'s from inside one. A fake timer in a SIBLING
|
|
935
|
+
// `describe` exempted every block in the file until 2026-10-02 and kept a
|
|
936
|
+
// genuine bomb silent (review of 3206ab24). `null` = module level.
|
|
937
|
+
const describes = describeBlocks(lines);
|
|
938
|
+
/**
|
|
939
|
+
* Innermost `describe` holding line q — ranges nest, so the latest start that
|
|
940
|
+
* still holds it — or `null` at module level.
|
|
941
|
+
* @param {number} q
|
|
942
|
+
*/
|
|
943
|
+
const innermostDescribe = (q) => {
|
|
944
|
+
const holders = describes.filter((d) => d.start <= q && q < d.end);
|
|
945
|
+
return holders.length > 0 ? holders[holders.length - 1] : null;
|
|
946
|
+
};
|
|
947
|
+
const setupRealClock = realClockNames(new Map(), setupBindings);
|
|
948
|
+
/** @type {Array<{start: number, end: number}|null>} */
|
|
949
|
+
const setupTimerScopes = [];
|
|
950
|
+
lines.forEach((l, q) => {
|
|
951
|
+
if (inBlock.has(q) || isCommentLine(l) || !pinsDate(l, setupRealClock)) return;
|
|
952
|
+
setupTimerScopes.push(innermostDescribe(q));
|
|
953
|
+
});
|
|
954
|
+
/** @param {number} start */
|
|
955
|
+
const setupFreezes = (start) =>
|
|
956
|
+
setupTimerScopes.some((d) => d === null || (d.start < start && start < d.end));
|
|
957
|
+
// A real-clock name bound by a statement of the module itself or of a
|
|
958
|
+
// `describe` body is read at COLLECTION time, before vitest runs any hook
|
|
959
|
+
// (#1499 item 9). No freeze, in the setup or in the block, reaches that value,
|
|
960
|
+
// so a block handing it over is not exempted by one. Every other setup binding
|
|
961
|
+
// is DEFERRED — a hook callback, or a helper function's body (`validRecord()`,
|
|
962
|
+
// `writeLock()`), which runs when called, after any freeze: 12 of the 13 setup
|
|
963
|
+
// real-clock bindings outside a hook were such helper locals (measured
|
|
964
|
+
// 2026-10-02 over 722 test files, review of ac3ded47). A name qualifies only
|
|
965
|
+
// with no deferred binding at all — anything else reads as control, the
|
|
966
|
+
// precision-first direction. "Statement of" is read from indentation: column 0
|
|
967
|
+
// at module level, the enclosing `describe`'s column + 2 inside one (the
|
|
968
|
+
// prettier indent of this repo). A misread only moves names OUT of the set: a
|
|
969
|
+
// deeper or other indent style counts as deferred, and a hook range that
|
|
970
|
+
// over-extends (a one-line `beforeEach(…);`) does the same. CEILINGS: a
|
|
971
|
+
// one-line helper (`function f() { const now = Date.now(); … }`) at
|
|
972
|
+
// `describe`-body indent counts as collection time; a freeze that itself runs
|
|
973
|
+
// at collection time (a module-level `vi.setSystemTime` above the binding) is
|
|
974
|
+
// not read. Measured 2026-10-02 over 722 test files: 1 collection-time binding
|
|
975
|
+
// (tests/hooks/lock-reconcile.test.mjs:24), 0 installers outside a hook or test
|
|
976
|
+
// block. REVISIT on the first B5 finding traced to either shape.
|
|
977
|
+
const hooks = callRanges(lines, /^(\s*)(?:before|after)(?:Each|All)\s*\(/, null);
|
|
978
|
+
const indentOf = (/** @type {string} */ l) => /^\s*/.exec(l)[0].length;
|
|
979
|
+
/** @param {number} q */
|
|
980
|
+
const atCollection = (q) => {
|
|
981
|
+
if (hooks.some((h) => h.start <= q && q < h.end)) return false;
|
|
982
|
+
const d = innermostDescribe(q);
|
|
983
|
+
return indentOf(lines[q]) === (d ? indentOf(lines[d.start]) + 2 : 0);
|
|
984
|
+
};
|
|
985
|
+
const deferredBindings = clockBindings(
|
|
986
|
+
lines.filter((l, q) => !inBlock.has(q) && !isCommentLine(l) && !atCollection(q)),
|
|
987
|
+
);
|
|
988
|
+
const collectionClock = [...setupBindings]
|
|
989
|
+
.filter(([name, b]) => b.real && !b.other && !deferredBindings.has(name))
|
|
990
|
+
.map(([name]) => name);
|
|
991
|
+
|
|
992
|
+
const blocks = ranges.map(({ start, end }) => {
|
|
646
993
|
const body = lines.slice(start, end);
|
|
647
994
|
const live = body.filter((l) => !isCommentLine(l));
|
|
995
|
+
const own = clockBindings(live);
|
|
996
|
+
const realClock = realClockNames(own, setupBindings);
|
|
997
|
+
const unfrozen = collectionClock.filter((name) => !own.has(name));
|
|
998
|
+
const handsOverUnfrozen = live.some((l) => handoverValues(l).some((v) => namesAny(v, unfrozen)));
|
|
648
999
|
return {
|
|
649
1000
|
start,
|
|
650
1001
|
body,
|
|
651
|
-
|
|
652
|
-
|
|
1002
|
+
provesSeam: live.some((l) => SEAM_CLOCK_ARG.test(l)),
|
|
1003
|
+
hasClockArg: live.some((l) => handsOverControlledClock(l, realClock)),
|
|
1004
|
+
freezesDate: !handsOverUnfrozen && (setupFreezes(start) || live.some((l) => pinsDate(l, realClock))),
|
|
653
1005
|
};
|
|
654
1006
|
});
|
|
655
1007
|
|
|
656
1008
|
/** @type {Set<string>} */
|
|
657
1009
|
const seamed = new Set();
|
|
658
1010
|
for (const b of blocks) {
|
|
659
|
-
if (!b.
|
|
1011
|
+
if (!b.provesSeam) continue;
|
|
660
1012
|
for (const id of sutIds) if (callsIdentifier(b.body, id)) seamed.add(id);
|
|
661
1013
|
}
|
|
662
1014
|
if (seamed.size === 0) return findings;
|
|
663
1015
|
|
|
664
1016
|
for (const b of blocks) {
|
|
665
|
-
if (b.hasClockArg || b.
|
|
1017
|
+
if (b.hasClockArg || b.freezesDate) continue;
|
|
666
1018
|
const subject = [...seamed].find((id) => callsIdentifier(b.body, id));
|
|
667
1019
|
if (!subject) continue;
|
|
1020
|
+
const echoSources = [...b.body, ...sharedSetup].filter(
|
|
1021
|
+
(l) => !isCommentLine(l) && !DATE_EXPECTATION.test(l),
|
|
1022
|
+
);
|
|
668
1023
|
b.body.forEach((line, k) => {
|
|
669
1024
|
if (isCommentLine(line)) return;
|
|
670
1025
|
const m = DATE_EXPECTATION.exec(line);
|
|
671
1026
|
if (!m) return;
|
|
1027
|
+
// Echo: the pinned value is also an input of this block or its shared setup,
|
|
1028
|
+
// not a clock reading (the CEILING above keeps fixed INPUT dates unflagged on
|
|
1029
|
+
// purpose). CEILING: a real bomb whose date also stands in its own block or
|
|
1030
|
+
// the shared setup (its title, a trailing comment, a helper fixture) is
|
|
1031
|
+
// swallowed; REVISIT when a clock-shift run finds a bomb this scan missed.
|
|
1032
|
+
if (echoSources.some((l) => l.includes(m[2]))) return;
|
|
672
1033
|
findings.push({
|
|
673
1034
|
file: relPath,
|
|
674
1035
|
line: b.start + k + 1,
|
|
@@ -74,7 +74,7 @@
|
|
|
74
74
|
* a backlog, printed as an aggregate like S4 and enumerated under `--list`.
|
|
75
75
|
*
|
|
76
76
|
* S6 carries no camelCase fallback ON PURPOSE: adding one re-hides both reaper
|
|
77
|
-
* keys, whose camelCase names exist in `orphan-reaper.mjs` as that module's own
|
|
77
|
+
* keys, whose camelCase names exist in `orphan-reaper/defaults.mjs` as that module's own
|
|
78
78
|
* defaults. A name is not a read.
|
|
79
79
|
*
|
|
80
80
|
* S2 applies to TOP-LEVEL keys only — a nested key reaches code through its
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
* a string literal counts only when the command STARTS the literal (after
|
|
42
42
|
* optional whitespace) AND the literal carries no `${` interpolation. That
|
|
43
43
|
* second half is what separates
|
|
44
|
-
* `' glab ci status --
|
|
44
|
+
* `' glab ci status --branch main'` (a real command handed to a
|
|
45
45
|
* shell) from `` `glab note add spawn error: ${err}` `` (a log message).
|
|
46
46
|
* 4. **The first token after the CLI must be a REAL top-level subcommand** of
|
|
47
47
|
* that CLI, measured from `gh --help` / `glab --help` (gh 2.86.0,
|