session-orchestrator 3.22.0 → 3.24.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/.codex-plugin/plugin.json +1 -1
- package/.cursor/commands/autopilot-multi.md +14 -0
- package/.cursor/commands/autopilot.md +14 -0
- package/.cursor/commands/bootstrap.md +14 -0
- package/.cursor/commands/brainstorm.md +14 -0
- package/.cursor/commands/close.md +13 -0
- package/.cursor/commands/contract-version-bump.md +14 -0
- package/.cursor/commands/debug.md +14 -0
- package/.cursor/commands/discovery.md +14 -0
- package/.cursor/commands/dispatcher.md +14 -0
- package/.cursor/commands/eli5.md +14 -0
- package/.cursor/commands/eval.md +14 -0
- package/.cursor/commands/evolve.md +14 -0
- package/.cursor/commands/go.md +14 -0
- package/.cursor/commands/grill.md +14 -0
- package/.cursor/commands/harness-audit.md +13 -0
- package/.cursor/commands/journey-audit.md +14 -0
- package/.cursor/commands/memory-cleanup.md +14 -0
- package/.cursor/commands/persona-panel.md +14 -0
- package/.cursor/commands/plan.md +14 -0
- package/.cursor/commands/portfolio.md +14 -0
- package/.cursor/commands/reconcile.md +14 -0
- package/.cursor/commands/release.md +14 -0
- package/.cursor/commands/repo-audit.md +13 -0
- package/.cursor/commands/session.md +14 -0
- package/.cursor/commands/spinout.md +14 -0
- package/.cursor/commands/sunset-review.md +14 -0
- package/.cursor/commands/templates-ack.md +14 -0
- package/.cursor/commands/test.md +14 -0
- package/.cursor/hooks.json +60 -0
- package/.cursor/rules/000-session-orchestrator.mdc +8 -0
- package/.cursor/rules/010-session-workflow.mdc +9 -1
- package/.cursor/rules/020-quality-gates.mdc +1 -1
- package/.cursor/rules/030-wave-execution.mdc +1 -1
- package/.cursor/rules/050-plan.mdc +2 -2
- package/.cursor/rules/070-gitlab-ops.mdc +73 -57
- package/.cursor/rules/080-ecosystem-health.mdc +7 -7
- package/.cursor/skills/architecture/SKILL.md +13 -0
- package/.cursor/skills/autopilot/SKILL.md +12 -0
- package/.cursor/skills/bootstrap/SKILL.md +12 -0
- package/.cursor/skills/brainstorm/SKILL.md +13 -0
- package/.cursor/skills/claude-md-drift-check/SKILL.md +13 -0
- package/.cursor/skills/contract-version-bump/SKILL.md +12 -0
- package/.cursor/skills/convergence-monitoring/SKILL.md +12 -0
- package/.cursor/skills/daily/SKILL.md +12 -0
- package/.cursor/skills/debug/SKILL.md +13 -0
- package/.cursor/skills/discovery/SKILL.md +13 -0
- package/.cursor/skills/dispatcher/SKILL.md +13 -0
- package/.cursor/skills/docs-orchestrator/SKILL.md +13 -0
- package/.cursor/skills/domain-model/SKILL.md +13 -0
- package/.cursor/skills/ecosystem-health/SKILL.md +13 -0
- package/.cursor/skills/eli5/SKILL.md +13 -0
- package/.cursor/skills/eval/SKILL.md +12 -0
- package/.cursor/skills/evolve/SKILL.md +13 -0
- package/.cursor/skills/frontmatter-guard/SKILL.md +13 -0
- package/.cursor/skills/gitlab-ops/SKILL.md +13 -0
- package/.cursor/skills/gitlab-portfolio/SKILL.md +13 -0
- package/.cursor/skills/grill/SKILL.md +13 -0
- package/.cursor/skills/hook-development/SKILL.md +13 -0
- package/.cursor/skills/journey-audit/SKILL.md +13 -0
- package/.cursor/skills/mcp-builder/SKILL.md +13 -0
- package/.cursor/skills/memory-cleanup/SKILL.md +12 -0
- package/.cursor/skills/mode-selector/SKILL.md +13 -0
- package/.cursor/skills/npm-publish/SKILL.md +12 -0
- package/.cursor/skills/peekaboo-driver/SKILL.md +13 -0
- package/.cursor/skills/persona-panel/SKILL.md +12 -0
- package/.cursor/skills/plan/SKILL.md +13 -0
- package/.cursor/skills/playwright-driver/SKILL.md +13 -0
- package/.cursor/skills/quality-gates/SKILL.md +13 -0
- package/.cursor/skills/reconcile/SKILL.md +12 -0
- package/.cursor/skills/remote-offload/SKILL.md +13 -0
- package/.cursor/skills/repo-audit/SKILL.md +13 -0
- package/.cursor/skills/session-end/SKILL.md +13 -0
- package/.cursor/skills/session-plan/SKILL.md +13 -0
- package/.cursor/skills/session-start/SKILL.md +13 -0
- package/.cursor/skills/skill-creator/SKILL.md +13 -0
- package/.cursor/skills/spinout/SKILL.md +12 -0
- package/.cursor/skills/sunset-review/SKILL.md +13 -0
- package/.cursor/skills/test-runner/SKILL.md +13 -0
- package/.cursor/skills/tmux-layout/SKILL.md +13 -0
- package/.cursor/skills/ubiquitous-language/SKILL.md +13 -0
- package/.cursor/skills/using-orchestrator/SKILL.md +13 -0
- package/.cursor/skills/vault-mirror/SKILL.md +13 -0
- package/.cursor/skills/vault-sync/SKILL.md +13 -0
- package/.cursor/skills/wave-executor/SKILL.md +13 -0
- package/.cursor/skills/write-executable-plan/SKILL.md +13 -0
- package/.mcp.json +4 -1
- package/CHANGELOG.md +446 -0
- package/README.md +22 -17
- package/agents/AGENTS.md +23 -4
- package/agents/code-implementer.md +2 -1
- package/agents/db-specialist.md +2 -2
- package/agents/docs-writer.md +3 -1
- package/agents/eval-judge.md +1 -1
- package/agents/session-reviewer.md +7 -1
- package/agents/test-writer.md +2 -1
- package/agents/ui-developer.md +2 -1
- package/commands/bootstrap.md +2 -2
- package/commands/close.md +3 -1
- package/commands/go.md +1 -1
- package/commands/journey-audit.md +43 -0
- package/docs/USER-GUIDE.md +2 -2
- package/docs/ci-setup.md +194 -25
- package/docs/codex-setup.md +64 -0
- package/docs/components.md +7 -7
- package/docs/cursor-setup.md +26 -47
- package/docs/events-schema.md +120 -10
- package/docs/github-mirror-protection.md +197 -0
- package/docs/pi-setup.md +2 -0
- package/docs/rule-authoring.md +3 -1
- package/docs/scope-collision-guard.md +49 -2
- package/docs/session-config-reference.md +89 -9
- package/docs/session-config-template.md +38 -7
- package/docs/telemetry/telemetry-claims.md +11 -10
- package/docs/telemetry.md +52 -1
- package/hooks/_lib/atomic-json.mjs +111 -0
- package/hooks/_lib/lock-bootstrap.mjs +8 -4
- package/hooks/_lib/subagent-paths.mjs +143 -0
- package/hooks/_lib/vcs-create-matcher.mjs +397 -38
- package/hooks/cwd-change-restore.mjs +9 -29
- package/hooks/enforce-scope.mjs +93 -0
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +201 -20
- package/hooks/hooks-pi.json +1 -1
- package/hooks/hooks.json +2 -2
- package/hooks/on-session-end.mjs +486 -19
- package/hooks/on-session-start.mjs +263 -12
- package/hooks/on-stop.mjs +392 -24
- package/hooks/post-bash-write-verify.mjs +104 -4
- package/hooks/post-subagent-discovery-validator.mjs +182 -21
- package/hooks/post-tool-batch-wave-signal.mjs +165 -42
- package/hooks/post-tool-failure-corrective-context.mjs +9 -32
- package/hooks/pre-bash-issue-budget.mjs +117 -4
- package/hooks/pre-bash-memory-propose-audit.mjs +13 -7
- package/hooks/pre-bash-sessions-ledger-guard.mjs +159 -0
- package/hooks/pre-bash-staging-fence.mjs +4 -0
- package/hooks/pre-task-scope-disjoint.mjs +368 -35
- package/hooks/skill-invocation-telemetry.mjs +21 -10
- package/hooks/subagent-telemetry.mjs +11 -26
- package/monitors/monitors.json +6 -0
- package/package.json +1 -1
- package/pi/prompts/journey-audit.md +12 -0
- package/rules/_index.md +9 -1
- package/rules/always-on/ask-via-tool.md +62 -0
- package/rules/always-on/bash-harness-pitfalls.md +168 -0
- package/rules/always-on/build-value.md +47 -0
- package/rules/always-on/cross-session-messaging.md +59 -0
- package/rules/always-on/loop-and-monitor.md +221 -0
- package/rules/always-on/parallel-sessions.md +142 -12
- package/rules/always-on/receiving-review.md +108 -0
- package/rules/always-on/test-value.md +40 -0
- package/rules/always-on/verification-before-completion.md +77 -0
- package/scripts/archive-closed-prds.mjs +258 -18
- package/scripts/autopilot.mjs +31 -12
- package/scripts/backfill-abandoned-sessions.mjs +80 -11
- package/scripts/backfill-evidence-digest.mjs +376 -0
- package/scripts/cursor-install.mjs +89 -48
- package/scripts/emit-event.mjs +10 -2
- package/scripts/export-hw-learnings.mjs +143 -2
- package/scripts/express-path.mjs +299 -0
- package/scripts/generate-cursor-adapter.mjs +253 -0
- package/scripts/github-protection-audit.mjs +358 -0
- package/scripts/lib/auq/parse.mjs +5 -29
- package/scripts/lib/auto-dialectic.mjs +68 -0
- package/scripts/lib/autopilot/worktree-pipeline.mjs +318 -18
- package/scripts/lib/build-live-signals.mjs +49 -27
- package/scripts/lib/ci-status-banner.mjs +158 -11
- package/scripts/lib/cold-start-detector.mjs +23 -14
- package/scripts/lib/command-blocker.mjs +70 -0
- package/scripts/lib/config/block-header.mjs +55 -0
- package/scripts/lib/config/discovery-validator.mjs +7 -2
- package/scripts/lib/config/health-endpoints.mjs +383 -0
- package/scripts/lib/config/reconcile.mjs +79 -4
- package/scripts/lib/config/remote-hosts.mjs +233 -0
- package/scripts/lib/config/section-extractor.mjs +235 -36
- package/scripts/lib/config-schema.mjs +9 -1
- package/scripts/lib/config.mjs +87 -8
- package/scripts/lib/convergence-monitor.mjs +13 -2
- package/scripts/lib/cursor-hook-bridge.mjs +443 -0
- package/scripts/lib/dispatcher/cli.mjs +2 -2
- package/scripts/lib/dispatcher/enumerate.mjs +2 -17
- package/scripts/lib/events-schema.mjs +48 -0
- package/scripts/lib/events.mjs +238 -5
- package/scripts/lib/evolve/autonomy-verdict.mjs +9 -4
- package/scripts/lib/evolve/autopilot-effectiveness.mjs +18 -1
- package/scripts/lib/express-path.mjs +327 -0
- package/scripts/lib/file-lock.mjs +22 -4
- package/scripts/lib/gates/gate-full.mjs +81 -8
- package/scripts/lib/gates/gate-helpers.mjs +76 -15
- package/scripts/lib/git-config-drift.mjs +134 -5
- package/scripts/lib/gitlab-portfolio/cli.mjs +3 -15
- package/scripts/lib/harness-audit/categories/category1.mjs +17 -6
- package/scripts/lib/host-identity.mjs +247 -2
- package/scripts/lib/instruction-budget-guard.mjs +31 -1
- package/scripts/lib/issue-budget.mjs +229 -30
- package/scripts/lib/learnings/io.mjs +55 -10
- package/scripts/lib/learnings/schema.mjs +95 -28
- package/scripts/lib/lock-reaper.mjs +7 -1
- package/scripts/lib/locks/staging-fence-lock.mjs +5 -1
- package/scripts/lib/locks/state-md-lock.mjs +8 -1
- package/scripts/lib/memory-banner.mjs +25 -10
- package/scripts/lib/memory-paths.mjs +15 -6
- package/scripts/lib/mode-selector/scoring.mjs +53 -6
- package/scripts/lib/peer-discovery.mjs +20 -2
- package/scripts/lib/platform.mjs +72 -9
- package/scripts/lib/plugin-root.mjs +143 -19
- package/scripts/lib/project-hygiene.mjs +43 -3
- package/scripts/lib/quality-gate.mjs +271 -13
- package/scripts/lib/reconcile/emitter.mjs +87 -19
- package/scripts/lib/reconcile/engine.mjs +517 -18
- package/scripts/lib/reconcile/idempotency.mjs +102 -1
- package/scripts/lib/reconcile/renderer.mjs +148 -3
- package/scripts/lib/reconcile/sanitize.mjs +40 -17
- package/scripts/lib/reconcile/writer.mjs +415 -84
- package/scripts/lib/rule-loader.mjs +37 -2
- package/scripts/lib/rules-sync.mjs +51 -8
- package/scripts/lib/scope-gate.mjs +126 -0
- package/scripts/lib/session-close-backfill.mjs +427 -37
- package/scripts/lib/session-discovery.mjs +69 -5
- package/scripts/lib/session-end/phase-skip.mjs +38 -5
- package/scripts/lib/session-end/worktree-cleanup.mjs +154 -7
- package/scripts/lib/session-id.mjs +30 -14
- package/scripts/lib/session-identity/own-session.mjs +220 -0
- package/scripts/lib/session-lock.mjs +85 -30
- package/scripts/lib/session-schema/normalizer.mjs +70 -3
- package/scripts/lib/session-schema/validator.mjs +40 -0
- package/scripts/lib/session-start-probes.mjs +608 -0
- package/scripts/lib/session-transition.mjs +277 -0
- package/scripts/lib/sessions-canonical.mjs +446 -0
- package/scripts/lib/sessions-staleness-banner.mjs +124 -57
- package/scripts/lib/spiral-carryover.mjs +90 -9
- package/scripts/lib/state-md/frontmatter-mutators.mjs +41 -8
- package/scripts/lib/state-md/mission-status.mjs +350 -52
- package/scripts/lib/state-md/yaml-parser.mjs +145 -16
- package/scripts/lib/state-md.mjs +12 -2
- package/scripts/lib/telemetry/schema.mjs +74 -8
- package/scripts/lib/telemetry/sync.mjs +91 -16
- package/scripts/lib/tmux-layout/telemetry.mjs +14 -2
- package/scripts/lib/validate/check-agents.mjs +66 -0
- package/scripts/lib/validate/check-cursor-adapter.mjs +102 -0
- package/scripts/lib/validate/check-dead-bridge.mjs +24 -2
- package/scripts/lib/validate/check-doc-cli-commands.mjs +25 -65
- package/scripts/lib/validate/check-hooks-emit-event-guard.mjs +370 -0
- package/scripts/lib/validate/check-hooks-symmetry.mjs +29 -63
- package/scripts/lib/validate/check-playwright-mcp-canary.mjs +13 -22
- package/scripts/lib/validate/check-plugin-monitors.mjs +10 -4
- package/scripts/lib/validate/check-skill-script-paths.mjs +436 -0
- package/scripts/lib/validate/check-test-value-bans.mjs +165 -17
- package/scripts/lib/validate/check-untracked-test-deps.mjs +10 -0
- package/scripts/lib/validate/check-unwired-features.mjs +333 -32
- package/scripts/lib/validate/check-validator-registration.mjs +248 -0
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +6 -28
- package/scripts/lib/validate/markdown-fences.mjs +196 -0
- package/scripts/lib/validate/repo-files.mjs +275 -0
- package/scripts/lib/validate-vendored-rules.mjs +229 -7
- package/scripts/lib/vault-mirror/process.mjs +99 -43
- package/scripts/lib/vault-mirror/telemetry.mjs +210 -0
- package/scripts/lib/vault-staleness-banner.mjs +76 -6
- package/scripts/lib/vault-status/board-lock.mjs +185 -0
- package/scripts/lib/vault-status/board-writer.mjs +381 -141
- package/scripts/lib/vault-status/narrative-mirror.mjs +190 -27
- package/scripts/lib/wave-executor/foreign-dispatch.mjs +832 -0
- package/scripts/lib/wave-executor/remote-dispatch.mjs +504 -0
- package/scripts/lib/wave-resource-gate.mjs +127 -7
- package/scripts/lib/wave-transcript-tail.mjs +889 -0
- package/scripts/materialize-wave-scope.mjs +228 -15
- package/scripts/mcp-server.sh +11 -2
- package/scripts/memory-propose.mjs +132 -8
- package/scripts/parse-config.mjs +65 -0
- package/scripts/promote-vault-strict.mjs +4 -15
- package/scripts/site-numbers.mjs +36 -4
- package/scripts/token-audit.sh +9 -2
- package/scripts/validate-plugin.mjs +29 -0
- package/scripts/validate-wave-scope.mjs +67 -0
- package/scripts/vault-consolidate.mjs +3 -11
- package/scripts/vault-integration-watcher.mjs +2 -4
- package/scripts/vault-mirror.mjs +305 -51
- package/skills/_shared/monitor-patterns.md +31 -5
- package/skills/_shared/parallel-aware-auq.md +31 -2
- package/skills/_shared/parallel-aware-preamble.md +19 -4
- package/skills/_shared/platform-tools.md +11 -5
- package/skills/_shared/state-ownership.md +29 -2
- package/skills/autopilot/SKILL.md +5 -1
- package/skills/bootstrap/SKILL.md +3 -3
- package/skills/bootstrap/_shared-template.md +18 -10
- package/skills/bootstrap/deep-template.md +10 -6
- package/skills/bootstrap/fast-template.md +15 -8
- package/skills/bootstrap/standard-template.md +10 -6
- package/skills/claude-md-drift-check/checker.mjs +39 -11
- package/skills/contract-version-bump/SKILL.md +1 -1
- package/skills/dispatcher/SKILL.md +1 -1
- package/skills/ecosystem-health/SKILL.md +4 -1
- package/skills/ecosystem-health/wizard.md +5 -0
- package/skills/evolve/SKILL.md +38 -1
- package/skills/journey-audit/SKILL.md +270 -0
- package/skills/peekaboo-driver/SKILL.md +15 -3
- package/skills/persona-panel/SKILL.md +1 -1
- package/skills/reconcile/SKILL.md +46 -3
- package/skills/remote-offload/SKILL.md +89 -0
- package/skills/session-end/SKILL.md +17 -4
- package/skills/session-end/metrics-collection.md +7 -4
- package/skills/session-end/phase-3-6-tail.md +20 -9
- package/skills/session-end/phase-3-7a-recommendations.md +16 -2
- package/skills/session-plan/SKILL.md +6 -1
- package/skills/session-plan/wave-template.md +1 -0
- package/skills/session-start/SKILL.md +54 -17
- package/skills/session-start/phase-7-5-mode-selector.md +15 -3
- package/skills/session-start/phase-8-5-express-path.md +77 -12
- package/skills/vault-sync/validator.mjs +31 -0
- package/skills/wave-executor/SKILL.md +5 -3
- package/skills/wave-executor/circuit-breaker.md +34 -9
- package/skills/wave-executor/wave-loop.md +143 -22
- package/templates/_shared/journey-manifest.md +110 -0
- package/templates/_shared/rules/parallel-sessions.md +0 -77
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// check-cursor-adapter.mjs — Ensure Cursor-native commands, skills, and hooks stay wired.
|
|
3
|
+
// Usage: check-cursor-adapter.mjs <plugin-root>
|
|
4
|
+
// Exit 0 = all checks passed, 1 = at least one failure.
|
|
5
|
+
|
|
6
|
+
import { spawnSync } from 'node:child_process';
|
|
7
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
|
|
10
|
+
import { CURSOR_TO_CANONICAL_EVENT } from '../cursor-hook-bridge.mjs';
|
|
11
|
+
|
|
12
|
+
const pluginRoot = process.argv[2];
|
|
13
|
+
if (!pluginRoot) {
|
|
14
|
+
console.error('Usage: check-cursor-adapter.mjs <plugin-root>');
|
|
15
|
+
process.exit(1);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let passed = 0;
|
|
19
|
+
let failed = 0;
|
|
20
|
+
|
|
21
|
+
function pass(msg) { console.log(` PASS: ${msg}`); passed += 1; }
|
|
22
|
+
function fail(msg) { console.log(` FAIL: ${msg}`); failed += 1; }
|
|
23
|
+
|
|
24
|
+
console.log('--- Check 1: generated Cursor command and skill wrappers ---');
|
|
25
|
+
|
|
26
|
+
const generator = join(pluginRoot, 'scripts', 'generate-cursor-adapter.mjs');
|
|
27
|
+
if (!existsSync(generator)) {
|
|
28
|
+
fail('scripts/generate-cursor-adapter.mjs exists');
|
|
29
|
+
} else {
|
|
30
|
+
const result = spawnSync(process.execPath, [generator, '--check'], {
|
|
31
|
+
cwd: pluginRoot,
|
|
32
|
+
encoding: 'utf8',
|
|
33
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
34
|
+
});
|
|
35
|
+
if (result.status === 0) {
|
|
36
|
+
pass('.cursor/commands and .cursor/skills are up to date with commands/ and skills/');
|
|
37
|
+
} else {
|
|
38
|
+
const detail = ((result.stdout ?? '') + (result.stderr ?? '')).trim();
|
|
39
|
+
fail(`Cursor adapter wrappers are stale${detail ? `: ${detail}` : ''}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
console.log('');
|
|
44
|
+
console.log('--- Check 2: .cursor/hooks.json native manifest ---');
|
|
45
|
+
|
|
46
|
+
const hooksPath = join(pluginRoot, '.cursor', 'hooks.json');
|
|
47
|
+
if (!existsSync(hooksPath)) {
|
|
48
|
+
fail('.cursor/hooks.json exists');
|
|
49
|
+
} else {
|
|
50
|
+
let hooksJson;
|
|
51
|
+
try {
|
|
52
|
+
hooksJson = JSON.parse(readFileSync(hooksPath, 'utf8'));
|
|
53
|
+
pass('.cursor/hooks.json is valid JSON');
|
|
54
|
+
} catch (err) {
|
|
55
|
+
fail(`.cursor/hooks.json is not valid JSON: ${err.message}`);
|
|
56
|
+
hooksJson = null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (hooksJson) {
|
|
60
|
+
if (hooksJson.version === 1) {
|
|
61
|
+
pass('.cursor/hooks.json version is 1');
|
|
62
|
+
} else {
|
|
63
|
+
fail('.cursor/hooks.json version must be 1');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const declared = Object.keys(hooksJson.hooks || {});
|
|
67
|
+
const expected = Object.keys(CURSOR_TO_CANONICAL_EVENT);
|
|
68
|
+
const missing = expected.filter((event) => !declared.includes(event));
|
|
69
|
+
const extra = declared.filter((event) => !expected.includes(event));
|
|
70
|
+
if (missing.length === 0 && extra.length === 0) {
|
|
71
|
+
pass(`.cursor/hooks.json events match CURSOR_TO_CANONICAL_EVENT (${expected.length})`);
|
|
72
|
+
} else {
|
|
73
|
+
if (missing.length > 0) fail(`.cursor/hooks.json missing events: ${missing.join(', ')}`);
|
|
74
|
+
if (extra.length > 0) fail(`.cursor/hooks.json extra events: ${extra.join(', ')}`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const bridgeRef = 'scripts/lib/cursor-hook-bridge.mjs';
|
|
78
|
+
const allPointAtBridge = declared.every((event) => {
|
|
79
|
+
const entries = Array.isArray(hooksJson.hooks[event]) ? hooksJson.hooks[event] : [];
|
|
80
|
+
return entries.some((entry) => typeof entry.command === 'string' && entry.command.includes(bridgeRef) && entry.command.includes(`--event ${event}`));
|
|
81
|
+
});
|
|
82
|
+
if (allPointAtBridge) {
|
|
83
|
+
pass('.cursor/hooks.json commands invoke cursor-hook-bridge.mjs with --event');
|
|
84
|
+
} else {
|
|
85
|
+
fail('.cursor/hooks.json commands must invoke scripts/lib/cursor-hook-bridge.mjs --event <name>');
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
console.log('');
|
|
91
|
+
console.log('--- Check 3: Cursor hook bridge module ---');
|
|
92
|
+
|
|
93
|
+
const bridgePath = join(pluginRoot, 'scripts', 'lib', 'cursor-hook-bridge.mjs');
|
|
94
|
+
if (existsSync(bridgePath)) {
|
|
95
|
+
pass('scripts/lib/cursor-hook-bridge.mjs exists');
|
|
96
|
+
} else {
|
|
97
|
+
fail('scripts/lib/cursor-hook-bridge.mjs exists');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
console.log('');
|
|
101
|
+
console.log(`Results: ${passed} passed, ${failed} failed`);
|
|
102
|
+
process.exit(failed > 0 ? 1 : 0);
|
|
@@ -27,6 +27,7 @@ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
|
27
27
|
import path from 'node:path';
|
|
28
28
|
import { DETECTORS } from './dead-bridge-detectors.mjs';
|
|
29
29
|
import * as corpus from './dead-bridge-corpus.mjs';
|
|
30
|
+
import { listRepoFiles } from './repo-files.mjs';
|
|
30
31
|
|
|
31
32
|
const DEFAULT_EXTS = ['.mjs', '.md'];
|
|
32
33
|
|
|
@@ -90,10 +91,31 @@ function walk(absDir, exts) {
|
|
|
90
91
|
* }}
|
|
91
92
|
*/
|
|
92
93
|
export function buildRepoContext(pluginRoot) {
|
|
94
|
+
/**
|
|
95
|
+
* Enumerate `absDir` from the git INDEX when it lies inside the plugin
|
|
96
|
+
* root, falling back to the raw walk otherwise (#1143). The walk cannot see
|
|
97
|
+
* `.gitignore`, so a worktree created under this repo's own
|
|
98
|
+
* `.claude/worktrees/` convention — or any vendored/generated tree, since
|
|
99
|
+
* this walk carries no exclusion set at all — enters the detector corpus as
|
|
100
|
+
* repository content. A detector then reads a PEER checkout's copy of a
|
|
101
|
+
* skill or rule file and counts it as an independent bridge.
|
|
102
|
+
*
|
|
103
|
+
* The containment guard matters: a corpus entry outside the root has no
|
|
104
|
+
* index to consult, and an `ls-files` pathspec of `../..` is an error, not
|
|
105
|
+
* an empty answer.
|
|
106
|
+
* @param {string} absDir
|
|
107
|
+
* @param {string[] | null} exts
|
|
108
|
+
* @returns {string[]}
|
|
109
|
+
*/
|
|
110
|
+
const listUnder = (absDir, exts) => {
|
|
111
|
+
const rel = path.relative(pluginRoot, absDir);
|
|
112
|
+
if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) return walk(absDir, exts);
|
|
113
|
+
return listRepoFiles(pluginRoot, { dirs: [rel], exts });
|
|
114
|
+
};
|
|
93
115
|
return {
|
|
94
116
|
pluginRoot,
|
|
95
|
-
listMdFiles: (absDir) =>
|
|
96
|
-
listFiles: (absDir, exts = DEFAULT_EXTS) =>
|
|
117
|
+
listMdFiles: (absDir) => listUnder(absDir, ['.md']),
|
|
118
|
+
listFiles: (absDir, exts = DEFAULT_EXTS) => listUnder(absDir, exts),
|
|
97
119
|
readText: (absPath) => readFileSync(absPath, 'utf8'),
|
|
98
120
|
exists: (absPath) => existsSync(absPath),
|
|
99
121
|
};
|
|
@@ -62,10 +62,12 @@
|
|
|
62
62
|
* Verified against glab 1.91.0 / gh 2.86.0 (2026-08-15).
|
|
63
63
|
*/
|
|
64
64
|
|
|
65
|
-
import {
|
|
65
|
+
import { readFileSync } from 'node:fs';
|
|
66
66
|
import path from 'node:path';
|
|
67
67
|
import { spawnSync } from 'node:child_process';
|
|
68
68
|
import { pathToFileURL } from 'node:url';
|
|
69
|
+
import { listRepoFiles } from './repo-files.mjs';
|
|
70
|
+
import { SHELL_LANGS, forEachLine } from './markdown-fences.mjs';
|
|
69
71
|
|
|
70
72
|
/** Directories walked for documentation. Mirrors check-vcs-repo-flag.mjs. */
|
|
71
73
|
const SCAN_DIRS = Object.freeze([
|
|
@@ -79,12 +81,6 @@ const SCAN_DIRS = Object.freeze([
|
|
|
79
81
|
'templates',
|
|
80
82
|
]);
|
|
81
83
|
|
|
82
|
-
/** Never descend into these. */
|
|
83
|
-
const EXCLUDED_DIRS = Object.freeze(['node_modules', '.git', 'dist', 'build', 'coverage']);
|
|
84
|
-
|
|
85
|
-
/** Fence languages whose body is shell. Anything else is prose. */
|
|
86
|
-
const SHELL_LANGS = Object.freeze(new Set(['bash', 'sh', 'shell', 'console', 'zsh']));
|
|
87
|
-
|
|
88
84
|
/** The CLIs this check knows how to interrogate. */
|
|
89
85
|
const BINS = Object.freeze(['gh', 'glab']);
|
|
90
86
|
|
|
@@ -205,44 +201,23 @@ export function extractCandidates(body) {
|
|
|
205
201
|
const candidates = [];
|
|
206
202
|
/** @type {{line: number, text: string}[]} */
|
|
207
203
|
const shellLines = [];
|
|
208
|
-
/** @type {{marker: string, length: number, shell: boolean} | null} */
|
|
209
|
-
let fence = null;
|
|
210
204
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
const fenceMatch = raw.match(/^\s*(`{3,}|~{3,})\s*([A-Za-z0-9_+-]*)/);
|
|
215
|
-
if (fenceMatch) {
|
|
216
|
-
const marker = fenceMatch[1][0];
|
|
217
|
-
const length = fenceMatch[1].length;
|
|
218
|
-
const lang = fenceMatch[2].toLowerCase();
|
|
219
|
-
if (fence === null) {
|
|
220
|
-
fence = { marker, length, shell: SHELL_LANGS.has(lang) };
|
|
221
|
-
continue;
|
|
222
|
-
}
|
|
223
|
-
// A closing fence uses the same char, is at least as long, and has no info string.
|
|
224
|
-
if (marker === fence.marker && length >= fence.length && lang === '') {
|
|
225
|
-
fence = null;
|
|
226
|
-
continue;
|
|
227
|
-
}
|
|
228
|
-
// Otherwise it is fence content (a nested fence inside a wider one).
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
if (fence !== null) {
|
|
232
|
-
if (!fence.shell) continue;
|
|
205
|
+
forEachLine(body, (raw, { lineNumber, inFence, lang }) => {
|
|
206
|
+
if (inFence) {
|
|
207
|
+
if (!SHELL_LANGS.has(lang)) return;
|
|
233
208
|
const stripped = raw.replace(/^\s*[$❯>]\s+/, '');
|
|
234
|
-
if (/^\s*#/.test(stripped))
|
|
235
|
-
shellLines.push({ line:
|
|
236
|
-
|
|
209
|
+
if (/^\s*#/.test(stripped)) return;
|
|
210
|
+
shellLines.push({ line: lineNumber, text: stripped });
|
|
211
|
+
return;
|
|
237
212
|
}
|
|
238
213
|
|
|
239
214
|
for (const span of raw.matchAll(/`([^`]+)`/g)) {
|
|
240
215
|
const text = span[1].trim();
|
|
241
216
|
if (/^(?:gh|glab)\s/.test(text)) {
|
|
242
|
-
candidates.push({ line:
|
|
217
|
+
candidates.push({ line: lineNumber, text, channel: 'inline-span' });
|
|
243
218
|
}
|
|
244
219
|
}
|
|
245
|
-
}
|
|
220
|
+
});
|
|
246
221
|
|
|
247
222
|
for (const entry of joinContinuations(shellLines)) {
|
|
248
223
|
candidates.push({ line: entry.line, text: entry.text, channel: 'shell-fence' });
|
|
@@ -296,28 +271,6 @@ function probeHelp(bin, group, cache) {
|
|
|
296
271
|
return entry;
|
|
297
272
|
}
|
|
298
273
|
|
|
299
|
-
/**
|
|
300
|
-
* Recursively collect `.md` files.
|
|
301
|
-
*
|
|
302
|
-
* @param {string} directory
|
|
303
|
-
* @param {string[]} acc
|
|
304
|
-
* @returns {string[]}
|
|
305
|
-
*/
|
|
306
|
-
function walk(directory, acc = []) {
|
|
307
|
-
if (!existsSync(directory)) return acc;
|
|
308
|
-
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
309
|
-
if (entry.isSymbolicLink()) continue;
|
|
310
|
-
if (EXCLUDED_DIRS.includes(entry.name)) continue;
|
|
311
|
-
const fullPath = path.join(directory, entry.name);
|
|
312
|
-
if (entry.isDirectory()) {
|
|
313
|
-
walk(fullPath, acc);
|
|
314
|
-
continue;
|
|
315
|
-
}
|
|
316
|
-
if (entry.isFile() && path.extname(entry.name) === '.md') acc.push(fullPath);
|
|
317
|
-
}
|
|
318
|
-
return acc;
|
|
319
|
-
}
|
|
320
|
-
|
|
321
274
|
/** @param {string} text @returns {string} */
|
|
322
275
|
function clampSnippet(text) {
|
|
323
276
|
const flat = text.replace(/\s+/g, ' ').trim();
|
|
@@ -358,13 +311,20 @@ export function inspectDocCliCommands(pluginRoot) {
|
|
|
358
311
|
/** @type {string[]} */
|
|
359
312
|
let files;
|
|
360
313
|
try {
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
314
|
+
// The index, not the filesystem (#1143). A `readdirSync` walk cannot see
|
|
315
|
+
// `.gitignore`, so every ignored artefact under a scan root entered this
|
|
316
|
+
// census as if it were documentation: measured 2026-08-26 on a clean
|
|
317
|
+
// checkout with NO worktree present, 4 such files — `.claude/STATE.md`
|
|
318
|
+
// (per-session mutable state) and three gitignored `docs/specs/*.md`. A
|
|
319
|
+
// worktree under this repo's own `.claude/worktrees/` convention would
|
|
320
|
+
// add a complete second copy of every scanned doc on top of that.
|
|
321
|
+
// One enumeration covers both populations this check has always scanned:
|
|
322
|
+
// the SCAN_DIRS subtrees, and the repo-root `.md` files at depth 0.
|
|
323
|
+
const scanDirs = new Set(SCAN_DIRS);
|
|
324
|
+
files = listRepoFiles(pluginRoot, { exts: ['.md'] }).filter((absolute) => {
|
|
325
|
+
const segments = path.relative(pluginRoot, absolute).split(path.sep);
|
|
326
|
+
return segments.length === 1 || scanDirs.has(segments[0]);
|
|
327
|
+
});
|
|
368
328
|
} catch (error) {
|
|
369
329
|
findings.push({
|
|
370
330
|
kind: 'tool-error',
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* check-hooks-emit-event-guard.mjs — every `emitEvent(...)` call site under
|
|
4
|
+
* `hooks/**\/*.mjs` must sit lexically inside a `try {} catch {}`, because
|
|
5
|
+
* `emitEvent()` THROWS `EventValidationError` on a malformed record
|
|
6
|
+
* (`scripts/lib/events.mjs`, #1177). An unguarded call site turns a
|
|
7
|
+
* telemetry-shape defect into an uncaught exception inside a
|
|
8
|
+
* PreToolUse/PostToolUse/Stop/SessionEnd/SessionStart hook — several of
|
|
9
|
+
* which are deny-capable. Per `.claude/rules/` "moving a guard from
|
|
10
|
+
* exit-code signalling to stdout-JSON inverts its failure direction": an
|
|
11
|
+
* aborted hook process writes no `permissionDecision` envelope at all, which
|
|
12
|
+
* several harnesses read as fail-OPEN (no decision → default allow) rather
|
|
13
|
+
* than fail-closed. #1183.
|
|
14
|
+
*
|
|
15
|
+
* SCOPE. `hooks/` is out of the file-scope this checker's OWNING task may
|
|
16
|
+
* edit (see the dispatching wave's FILE-SCOPE) — this checker only REPORTS,
|
|
17
|
+
* it never fixes. Five pre-existing unguarded call sites were census'd at
|
|
18
|
+
* HEAD 2ccea0f2 (2026-09-02, GitLab #1183) and were BASELINED below; all
|
|
19
|
+
* five were fixed in the same wave that added the reason/staleness
|
|
20
|
+
* discipline described next (FX-C, MED-3), so `BASELINE_UNGUARDED` is EMPTY
|
|
21
|
+
* on arrival. It stays a `Map<key, reason>`, not a bare `Set`, so the NEXT
|
|
22
|
+
* addition carries a reason from day one — mirroring
|
|
23
|
+
* `check-unwired-features.mjs`'s `ALLOWLIST` precedent verbatim: "Add an
|
|
24
|
+
* entry to `ALLOWLIST` keyed by the FULL dotted key path, whose value is a
|
|
25
|
+
* non-empty reason naming the actual consumer ... Every entry needs a
|
|
26
|
+
* reason — an empty or whitespace-only one is itself reported
|
|
27
|
+
* (`allowlist-missing-reason`) ... The list also drains itself: an entry is
|
|
28
|
+
* reported as `allowlist-stale` both when its key has left every config
|
|
29
|
+
* surface AND when the key stops triggering a finding (i.e. it finally got
|
|
30
|
+
* wired), so a fixed key does not leave a permanent exemption behind." Any
|
|
31
|
+
* unguarded call NOT in the baseline is a genuine regression and FAILs the
|
|
32
|
+
* build; a baseline entry whose site is now guarded or gone FAILs as
|
|
33
|
+
* `baseline-stale`; an entry with an empty reason FAILs as
|
|
34
|
+
* `baseline-missing-reason`. A baselined-but-still-unguarded site (a valid,
|
|
35
|
+
* reasoned entry that still matches) reports as a WARN, never a FAIL, so
|
|
36
|
+
* this checker can ship blocking-by-default without going red on arrival.
|
|
37
|
+
*
|
|
38
|
+
* METHOD (BV-001.5 substitution). A real AST scope-walk via `@babel/parser`
|
|
39
|
+
* (`sourceType: 'module'`, `topLevelAwait` + `importMeta` plugins — the same
|
|
40
|
+
* combination `check-guard-requires-parity.mjs` already parses every hook
|
|
41
|
+
* with), not brace-depth counting over source text. Brace-depth counting is
|
|
42
|
+
* fooled by a `{`/`}` inside a string, template literal, regex literal or
|
|
43
|
+
* comment; a real parser is immune to that class entirely, and
|
|
44
|
+
* `@babel/parser` is already an installed `dependencies` entry (not just
|
|
45
|
+
* `devDependencies`) used for the identical domain — reasoning about the
|
|
46
|
+
* lexical nesting of `hooks/**\/*.mjs`.
|
|
47
|
+
*
|
|
48
|
+
* GUARD DEFINITION. An `emitEvent(...)` `CallExpression` counts as guarded
|
|
49
|
+
* when walking its AST ancestor chain OUTWARD reaches a `TryStatement` —
|
|
50
|
+
* entered via its `.block` property specifically (not `.handler` /
|
|
51
|
+
* `.finalizer`: sitting INSIDE a catch or finally block is not "guarded by"
|
|
52
|
+
* that try) AND carrying a non-null `.handler` (an actual `catch` clause — a
|
|
53
|
+
* bare `try {} finally {}` does not swallow the throw, so it doesn't count
|
|
54
|
+
* either) — BEFORE crossing a function boundary (`FunctionDeclaration`,
|
|
55
|
+
* `FunctionExpression`, `ArrowFunctionExpression`, `ObjectMethod`,
|
|
56
|
+
* `ClassMethod`, `ClassPrivateMethod`). Crossing a function boundary first
|
|
57
|
+
* means the call can only be guarded by ITS OWN function's try, never by an
|
|
58
|
+
* outer function's — this is what makes the textbook false-negative case
|
|
59
|
+
* (`await helper()` inside an outer `try`, where `helper()` itself calls
|
|
60
|
+
* `emitEvent()` unguarded) correctly report unguarded rather than a false
|
|
61
|
+
* negative from naive text-proximity matching.
|
|
62
|
+
*
|
|
63
|
+
* NAMED CEILING (BV-004): a hook file that fails to parse under the plugin
|
|
64
|
+
* set above is reported as a tool-error FAIL for that file — never silently
|
|
65
|
+
* skipped, because a parse failure hiding a finding is worse than a loud
|
|
66
|
+
* one. REVISIT if `hooks/` ever needs syntax outside `topLevelAwait` +
|
|
67
|
+
* `importMeta` (e.g. decorators) — this repo's own ESLint config would need
|
|
68
|
+
* the same addition first, so the two drift together, not silently.
|
|
69
|
+
*
|
|
70
|
+
* Usage: check-hooks-emit-event-guard.mjs <repo-root>
|
|
71
|
+
* Output: ` PASS: …` / ` WARN: …` / ` FAIL: …` lines (two leading
|
|
72
|
+
* spaces), then `Results: N passed, M failed`. Exit 0 = clean (WARNs do not
|
|
73
|
+
* fail the build), 1 = at least one NEW (non-baselined) finding, 2 = tool
|
|
74
|
+
* error.
|
|
75
|
+
*
|
|
76
|
+
* Import-safety: importing this module MUST NOT execute anything — the
|
|
77
|
+
* isMain guard at the bottom is the only side-effecting path.
|
|
78
|
+
*/
|
|
79
|
+
|
|
80
|
+
import { readFileSync } from 'node:fs';
|
|
81
|
+
import path from 'node:path';
|
|
82
|
+
import { pathToFileURL } from 'node:url';
|
|
83
|
+
import { parse } from '@babel/parser';
|
|
84
|
+
import { listRepoFiles } from './repo-files.mjs';
|
|
85
|
+
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// Baseline — Map<key, reason>, keyed on
|
|
88
|
+
// `<repo-relative-file>::<event-type-literal>` rather than a line number:
|
|
89
|
+
// `hooks/` churns under other waves in this repo's normal operation, and a
|
|
90
|
+
// line-number key would silently stop matching (or worse, mismatch a
|
|
91
|
+
// DIFFERENT call) on any unrelated edit above a baselined site. A static
|
|
92
|
+
// string-literal first argument (as every real emitEvent() call in this
|
|
93
|
+
// repo uses) makes the key stable.
|
|
94
|
+
//
|
|
95
|
+
// Empty on arrival (MED-3, FX-C, 2026-09-02): the five sites census'd at
|
|
96
|
+
// HEAD 2ccea0f2 (#1183) were guarded in the same wave that added this Map
|
|
97
|
+
// plus the baseline-stale / baseline-missing-reason detection below. A
|
|
98
|
+
// future addition MUST carry a non-empty reason naming the linked issue —
|
|
99
|
+
// an empty reason fails as `baseline-missing-reason`, and an entry whose
|
|
100
|
+
// site is later guarded (or removed) fails as `baseline-stale` rather than
|
|
101
|
+
// lingering as a silent, permanent WARN exemption.
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
const BASELINE_UNGUARDED = new Map();
|
|
105
|
+
|
|
106
|
+
const HOOKS_DIR_REL = 'hooks';
|
|
107
|
+
|
|
108
|
+
const FUNCTION_TYPES = new Set([
|
|
109
|
+
'FunctionDeclaration',
|
|
110
|
+
'FunctionExpression',
|
|
111
|
+
'ArrowFunctionExpression',
|
|
112
|
+
'ObjectMethod',
|
|
113
|
+
'ClassMethod',
|
|
114
|
+
'ClassPrivateMethod',
|
|
115
|
+
]);
|
|
116
|
+
|
|
117
|
+
/** AST metadata keys that are never worth descending into (no code nesting). */
|
|
118
|
+
const SKIP_KEYS = new Set([
|
|
119
|
+
'loc',
|
|
120
|
+
'start',
|
|
121
|
+
'end',
|
|
122
|
+
'extra',
|
|
123
|
+
'tokens',
|
|
124
|
+
'comments',
|
|
125
|
+
'errors',
|
|
126
|
+
'leadingComments',
|
|
127
|
+
'trailingComments',
|
|
128
|
+
'innerComments',
|
|
129
|
+
]);
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Parse a source module with the same syntax family used by this repository
|
|
133
|
+
* for `hooks/**\/*.mjs` (mirrors `check-guard-requires-parity.mjs`).
|
|
134
|
+
*
|
|
135
|
+
* @param {string} source
|
|
136
|
+
* @param {string} filename
|
|
137
|
+
* @returns {import('@babel/parser').ParseResult<import('@babel/types').File>}
|
|
138
|
+
*/
|
|
139
|
+
function parseModule(source, filename) {
|
|
140
|
+
return parse(source, {
|
|
141
|
+
sourceType: 'module',
|
|
142
|
+
sourceFilename: filename,
|
|
143
|
+
errorRecovery: false,
|
|
144
|
+
plugins: ['topLevelAwait', 'importMeta'],
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* @param {{node: any, key: string | number}[]} ancestors outer→inner
|
|
150
|
+
* @returns {boolean}
|
|
151
|
+
*/
|
|
152
|
+
function isGuardedByTry(ancestors) {
|
|
153
|
+
for (let i = ancestors.length - 1; i >= 0; i -= 1) {
|
|
154
|
+
const { node, key } = ancestors[i];
|
|
155
|
+
if (FUNCTION_TYPES.has(node.type)) return false; // own function's scope — stop here
|
|
156
|
+
if (node.type === 'TryStatement' && key === 'block' && node.handler !== null && node.handler !== undefined) return true;
|
|
157
|
+
}
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* @param {any} callNode a CallExpression node
|
|
163
|
+
* @returns {string | null} the first argument's literal string value, else null
|
|
164
|
+
*/
|
|
165
|
+
function eventTypeLiteral(callNode) {
|
|
166
|
+
const arg = callNode.arguments?.[0];
|
|
167
|
+
return arg && arg.type === 'StringLiteral' ? arg.value : null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Find every `emitEvent(...)` call site in `source` and classify each as
|
|
172
|
+
* guarded/unguarded per {@link isGuardedByTry}.
|
|
173
|
+
*
|
|
174
|
+
* @param {string} source
|
|
175
|
+
* @param {string} filename repo-relative path, used only for parse error messages
|
|
176
|
+
* @returns {{line: number, guarded: boolean, eventType: string | null}[]}
|
|
177
|
+
*/
|
|
178
|
+
export function findEmitEventCalls(source, filename) {
|
|
179
|
+
const ast = parseModule(source, filename);
|
|
180
|
+
const calls = [];
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* @param {any} node
|
|
184
|
+
* @param {{node: any, key: string | number}[]} ancestors
|
|
185
|
+
*/
|
|
186
|
+
function visit(node, ancestors) {
|
|
187
|
+
if (Array.isArray(node)) {
|
|
188
|
+
for (const item of node) visit(item, ancestors);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
if (!node || typeof node !== 'object' || typeof node.type !== 'string') return;
|
|
192
|
+
|
|
193
|
+
if (
|
|
194
|
+
node.type === 'CallExpression' &&
|
|
195
|
+
node.callee?.type === 'Identifier' &&
|
|
196
|
+
node.callee.name === 'emitEvent'
|
|
197
|
+
) {
|
|
198
|
+
calls.push({
|
|
199
|
+
line: node.loc?.start?.line ?? 0,
|
|
200
|
+
guarded: isGuardedByTry(ancestors),
|
|
201
|
+
eventType: eventTypeLiteral(node),
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
for (const [key, child] of Object.entries(node)) {
|
|
206
|
+
if (SKIP_KEYS.has(key)) continue;
|
|
207
|
+
if (child && typeof child === 'object') {
|
|
208
|
+
ancestors.push({ node, key });
|
|
209
|
+
visit(child, ancestors);
|
|
210
|
+
ancestors.pop();
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
visit(ast.program, []);
|
|
216
|
+
return calls;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* @typedef {{kind: 'unguarded', file: string, line: number, eventType: string | null, baselined: boolean}} UnguardedFinding
|
|
221
|
+
* @typedef {{kind: 'baseline-stale' | 'baseline-missing-reason', key: string, message: string}} BaselineFinding
|
|
222
|
+
* @typedef {UnguardedFinding | BaselineFinding} Finding
|
|
223
|
+
*/
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* @param {string} repoRoot
|
|
227
|
+
* @param {{baseline?: Map<string, string>}} [options] `baseline` defaults to
|
|
228
|
+
* the module-level `BASELINE_UNGUARDED` (empty in production); injectable
|
|
229
|
+
* so tests can exercise `baseline-stale` / `baseline-missing-reason`
|
|
230
|
+
* against a synthetic map without mutating the shipped baseline.
|
|
231
|
+
* @returns {{findings: Finding[], parseErrors: {file: string, message: string}[]}}
|
|
232
|
+
*/
|
|
233
|
+
export function scanHooksEmitEventGuard(repoRoot, { baseline = BASELINE_UNGUARDED } = {}) {
|
|
234
|
+
const files = listRepoFiles(repoRoot, { dirs: [HOOKS_DIR_REL], exts: ['mjs'] });
|
|
235
|
+
/** @type {Finding[]} */
|
|
236
|
+
const findings = [];
|
|
237
|
+
/** @type {{file: string, message: string}[]} */
|
|
238
|
+
const parseErrors = [];
|
|
239
|
+
/** @type {Set<string>} baseline keys that matched a real unguarded call site this scan */
|
|
240
|
+
const flagged = new Set();
|
|
241
|
+
|
|
242
|
+
for (const absFile of files) {
|
|
243
|
+
const rel = path.relative(repoRoot, absFile).split(path.sep).join('/');
|
|
244
|
+
let source;
|
|
245
|
+
try {
|
|
246
|
+
source = readFileSync(absFile, 'utf8');
|
|
247
|
+
} catch (err) {
|
|
248
|
+
parseErrors.push({ file: rel, message: `unreadable: ${err?.message ?? String(err)}` });
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
if (!source.includes('emitEvent(')) continue; // cheap textual pre-filter
|
|
252
|
+
|
|
253
|
+
let calls;
|
|
254
|
+
try {
|
|
255
|
+
calls = findEmitEventCalls(source, rel);
|
|
256
|
+
} catch (err) {
|
|
257
|
+
parseErrors.push({ file: rel, message: err?.message ?? String(err) });
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
for (const call of calls) {
|
|
262
|
+
if (call.guarded) continue;
|
|
263
|
+
const key = `${rel}::${call.eventType ?? `L${call.line}`}`;
|
|
264
|
+
const isBaselined = baseline.has(key);
|
|
265
|
+
if (isBaselined) {
|
|
266
|
+
flagged.add(key);
|
|
267
|
+
const reason = baseline.get(key);
|
|
268
|
+
if (String(reason ?? '').trim() === '') {
|
|
269
|
+
findings.push({
|
|
270
|
+
kind: 'baseline-missing-reason',
|
|
271
|
+
key,
|
|
272
|
+
message: 'baseline entry has no reason — name the linked issue or remove the entry',
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
findings.push({
|
|
277
|
+
kind: 'unguarded',
|
|
278
|
+
file: rel,
|
|
279
|
+
line: call.line,
|
|
280
|
+
eventType: call.eventType,
|
|
281
|
+
baselined: isBaselined,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Mirrors check-unwired-features.mjs's ALLOWLIST drain (see the header
|
|
287
|
+
// quote above): a baseline entry that no longer matches ANY unguarded call
|
|
288
|
+
// site this scan found — because the site is now guarded, or gone
|
|
289
|
+
// entirely — is stale and must be removed, not left as a permanent
|
|
290
|
+
// exemption.
|
|
291
|
+
for (const key of baseline.keys()) {
|
|
292
|
+
if (flagged.has(key)) continue;
|
|
293
|
+
findings.push({
|
|
294
|
+
kind: 'baseline-stale',
|
|
295
|
+
key,
|
|
296
|
+
message: 'baseline entry no longer matches an unguarded call site (guarded or removed) — remove the entry',
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return { findings, parseErrors };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ---------------------------------------------------------------------------
|
|
304
|
+
// CLI
|
|
305
|
+
// ---------------------------------------------------------------------------
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Run the check against a repo root, printing the validate-plugin line
|
|
309
|
+
* vocabulary.
|
|
310
|
+
*
|
|
311
|
+
* @param {string} repoRoot
|
|
312
|
+
* @param {{baseline?: Map<string, string>}} [options] forwarded to
|
|
313
|
+
* {@link scanHooksEmitEventGuard} — see its JSDoc for why this is
|
|
314
|
+
* injectable (test-only; the CLI entrypoint below never passes it).
|
|
315
|
+
* @returns {number} 0 = clean (baselined WARNs allowed), 1 = new finding(s), 2 = tool error
|
|
316
|
+
*/
|
|
317
|
+
export function runCheckHooksEmitEventGuard(repoRoot, options) {
|
|
318
|
+
console.log('--- Check: hooks emitEvent() try/catch guard (#1183) ---');
|
|
319
|
+
|
|
320
|
+
const { findings, parseErrors } = scanHooksEmitEventGuard(repoRoot, options);
|
|
321
|
+
|
|
322
|
+
if (parseErrors.length > 0) {
|
|
323
|
+
for (const e of parseErrors) console.log(` FAIL: ${e.file} — ${e.message}`);
|
|
324
|
+
console.log('');
|
|
325
|
+
console.log(`Results: 0 passed, ${parseErrors.length} failed`);
|
|
326
|
+
return 2;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const unguarded = findings.filter((f) => f.kind === 'unguarded');
|
|
330
|
+
const baselineIssues = findings.filter((f) => f.kind === 'baseline-stale' || f.kind === 'baseline-missing-reason');
|
|
331
|
+
|
|
332
|
+
const newUnguarded = unguarded.filter((f) => !f.baselined);
|
|
333
|
+
const baselinedUnguarded = unguarded.filter((f) => f.baselined);
|
|
334
|
+
|
|
335
|
+
if (newUnguarded.length === 0 && baselineIssues.length === 0) {
|
|
336
|
+
console.log(
|
|
337
|
+
baselinedUnguarded.length === 0
|
|
338
|
+
? ' PASS: every hooks/**/*.mjs emitEvent() call site is try/catch-guarded'
|
|
339
|
+
: ` PASS: no NEW unguarded emitEvent() call sites (${baselinedUnguarded.length} pre-existing, baselined — see #1183)`,
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
for (const f of baselinedUnguarded) {
|
|
343
|
+
console.log(
|
|
344
|
+
` WARN: ${f.file}:${f.line} — emitEvent('${f.eventType ?? '?'}') not inside a try/catch (pre-existing, baselined #1183 — out of this checker's file-scope to fix)`,
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
for (const f of newUnguarded) {
|
|
348
|
+
console.log(
|
|
349
|
+
` FAIL: ${f.file}:${f.line} — emitEvent('${f.eventType ?? '?'}') not inside a try/catch — a throw here aborts the hook process`,
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
for (const f of baselineIssues) {
|
|
353
|
+
console.log(` FAIL: ${f.key} — ${f.message} (${f.kind})`);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const failCount = newUnguarded.length + baselineIssues.length;
|
|
357
|
+
console.log('');
|
|
358
|
+
console.log(`Results: ${failCount === 0 ? 1 : 0} passed, ${failCount} failed`);
|
|
359
|
+
return failCount > 0 ? 1 : 0;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const isMain = import.meta.url === pathToFileURL(process.argv[1] || '').href;
|
|
363
|
+
if (isMain) {
|
|
364
|
+
const root = process.argv[2];
|
|
365
|
+
if (!root) {
|
|
366
|
+
console.error('Usage: check-hooks-emit-event-guard.mjs <repo-root>');
|
|
367
|
+
process.exit(2);
|
|
368
|
+
}
|
|
369
|
+
process.exit(runCheckHooksEmitEventGuard(path.resolve(root)));
|
|
370
|
+
}
|