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,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scripts/lib/validate/repo-files.mjs
|
|
3
|
+
*
|
|
4
|
+
* File enumeration for the `scripts/lib/validate/` scanners — TWO exports,
|
|
5
|
+
* because the population a scanner needs genuinely splits in two:
|
|
6
|
+
*
|
|
7
|
+
* listRepoFiles() — what git TRACKS. The right default for any scanner
|
|
8
|
+
* asking "what does this repository contain?".
|
|
9
|
+
* listOnDiskFiles() — what the filesystem HOLDS, minus a named exclusion
|
|
10
|
+
* set. For the scanners whose oracle is deliberately
|
|
11
|
+
* "exists on disk" — `check-untracked-test-deps.mjs`
|
|
12
|
+
* states it outright: "untracked = ignored OR (exists on
|
|
13
|
+
* disk AND not in git ls-files)". That check needs BOTH
|
|
14
|
+
* halves and diffs them; collapsing them into one
|
|
15
|
+
* enumerator would delete its subject.
|
|
16
|
+
*
|
|
17
|
+
* Why this module exists (#1143). A scanner that walks with `readdirSync`
|
|
18
|
+
* cannot see `.gitignore`, so every gitignored artefact under a scan root
|
|
19
|
+
* enters its census as if it were repository content. Two populations arrive
|
|
20
|
+
* that way, and only the first is widely known:
|
|
21
|
+
*
|
|
22
|
+
* 1. A git worktree created inside the repo. This repo's own convention is
|
|
23
|
+
* `.claude/worktrees/<name>` (gitignored, `.gitignore:20`), which drops a
|
|
24
|
+
* COMPLETE second checkout into any root-anchored walk. Measured by
|
|
25
|
+
* `check-unwired-features.mjs` (see its EXCLUDED_DIRS comment) with one
|
|
26
|
+
* peer worktree present: +755 `.md` and +1209 `.mjs` files, 133 MB — and,
|
|
27
|
+
* worse than the cost, the peer's copy of a rule file was counted as an
|
|
28
|
+
* INDEPENDENT document, so a finding cited two sources where one exists.
|
|
29
|
+
*
|
|
30
|
+
* 2. Ordinary gitignored content, present with no worktree at all. Measured
|
|
31
|
+
* 2026-08-26 on a clean checkout of this repo (`.claude/worktrees/` empty,
|
|
32
|
+
* `find .claude/worktrees -mindepth 1 | wc -l` → 0): the `.md` corpus of
|
|
33
|
+
* `check-doc-cli-commands.mjs` was 4 files wider on disk than in the index
|
|
34
|
+
* — `.claude/STATE.md` (per-session mutable state) and three
|
|
35
|
+
* `docs/specs/*.md` (gitignored design notes). So the exposure is LIVE,
|
|
36
|
+
* not merely latent on the worktree case.
|
|
37
|
+
*
|
|
38
|
+
* The index is also the cheaper oracle: one `git ls-files -z` beats a
|
|
39
|
+
* recursive `readdirSync` over the same subtree, and it never descends into
|
|
40
|
+
* `node_modules` at all.
|
|
41
|
+
*
|
|
42
|
+
* Both exports return ABSOLUTE paths, sorted, and never throw for a missing
|
|
43
|
+
* directory (it contributes zero files). Symlinks are skipped in the walk —
|
|
44
|
+
* following them can leave the root entirely, and a symlinked file is not
|
|
45
|
+
* independent content.
|
|
46
|
+
*
|
|
47
|
+
* @module scripts/lib/validate/repo-files
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
import { existsSync, readdirSync, realpathSync, statSync } from 'node:fs';
|
|
51
|
+
import path from 'node:path';
|
|
52
|
+
import { execFileSync } from 'node:child_process';
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Environment handed to `git`. An allowlist rather than `process.env`: an
|
|
56
|
+
* inherited `GIT_DIR` / `GIT_WORK_TREE` (set by any hook that spawned us)
|
|
57
|
+
* would silently re-point `ls-files` at a DIFFERENT repository, and the
|
|
58
|
+
* result would look like a plausible file list. Same allowlist as
|
|
59
|
+
* `check-banner-parity.mjs`, whose enumeration pattern this lifts.
|
|
60
|
+
*/
|
|
61
|
+
const GIT_ENV_ALLOWLIST = Object.freeze(['PATH', 'HOME', 'LANG', 'LC_ALL', 'TMPDIR', 'TZ']);
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Directory names excluded from every filesystem walk in this module.
|
|
65
|
+
*
|
|
66
|
+
* `worktrees` is the #1143 entry and is here for a MEASURED reason, not by
|
|
67
|
+
* analogy to `node_modules` (see the module header). The other four are the
|
|
68
|
+
* usual generated/vendored trees; excluding them is what makes the walk
|
|
69
|
+
* fallback comparable in cost to the `ls-files` path it replaces.
|
|
70
|
+
*
|
|
71
|
+
* NOT exhaustive by intent: this is the exclusion set for a walk that has
|
|
72
|
+
* already lost `.gitignore`. The fix for a new gitignored tree is to use
|
|
73
|
+
* `listRepoFiles()`, not to grow this list.
|
|
74
|
+
*/
|
|
75
|
+
export const EXCLUDED_DIRS = Object.freeze([
|
|
76
|
+
'node_modules',
|
|
77
|
+
'.git',
|
|
78
|
+
'worktrees',
|
|
79
|
+
'dist',
|
|
80
|
+
'coverage',
|
|
81
|
+
]);
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* `realpathSync` that degrades to its input. Used only to compare two paths
|
|
85
|
+
* for identity, where an unresolvable path can never equal a resolvable one.
|
|
86
|
+
* @param {string} target
|
|
87
|
+
* @returns {string}
|
|
88
|
+
*/
|
|
89
|
+
function safeRealpath(target) {
|
|
90
|
+
try {
|
|
91
|
+
return realpathSync(target);
|
|
92
|
+
} catch {
|
|
93
|
+
return target;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** @returns {NodeJS.ProcessEnv} the allowlisted git environment */
|
|
98
|
+
function gitEnv() {
|
|
99
|
+
/** @type {NodeJS.ProcessEnv} */
|
|
100
|
+
const env = {};
|
|
101
|
+
for (const key of GIT_ENV_ALLOWLIST) {
|
|
102
|
+
if (process.env[key] !== undefined) env[key] = process.env[key];
|
|
103
|
+
}
|
|
104
|
+
return env;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* True when `root` is the top level of a git working tree — the precondition
|
|
109
|
+
* for trusting `git ls-files` to describe it.
|
|
110
|
+
*
|
|
111
|
+
* The realpath comparison is load-bearing: inside a SUBDIRECTORY of a repo
|
|
112
|
+
* (or inside a worktree of one), `rev-parse --show-toplevel` succeeds and
|
|
113
|
+
* names a DIFFERENT directory, and `ls-files` run there would enumerate a
|
|
114
|
+
* corpus that is not the caller's root.
|
|
115
|
+
*
|
|
116
|
+
* @param {string} root absolute path
|
|
117
|
+
* @param {NodeJS.ProcessEnv} [env] allowlisted git environment (default: {@link gitEnv})
|
|
118
|
+
* @returns {boolean}
|
|
119
|
+
*/
|
|
120
|
+
export function isGitToplevel(root, env = gitEnv()) {
|
|
121
|
+
try {
|
|
122
|
+
const toplevel = execFileSync('git', ['rev-parse', '--show-toplevel'], {
|
|
123
|
+
cwd: root,
|
|
124
|
+
encoding: 'utf8',
|
|
125
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
126
|
+
env,
|
|
127
|
+
}).trim();
|
|
128
|
+
return Boolean(toplevel) && safeRealpath(toplevel) === safeRealpath(root);
|
|
129
|
+
} catch {
|
|
130
|
+
return false; // not a git checkout, or no git binary
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Normalise the `exts` option into a predicate.
|
|
136
|
+
* @param {string[] | null | undefined} exts
|
|
137
|
+
* @returns {(absolute: string) => boolean}
|
|
138
|
+
*/
|
|
139
|
+
function extFilter(exts) {
|
|
140
|
+
if (!exts || exts.length === 0) return () => true;
|
|
141
|
+
const set = new Set(exts.map((e) => (e.startsWith('.') ? e : `.${e}`)));
|
|
142
|
+
return (absolute) => set.has(path.extname(absolute));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Recursive `readdirSync` walk under `absDir`.
|
|
147
|
+
*
|
|
148
|
+
* Defensive by design: an unreadable sub-directory is skipped rather than
|
|
149
|
+
* thrown, so one bad entry never aborts a whole census. A directory whose
|
|
150
|
+
* basename is in `exclude` is not descended into.
|
|
151
|
+
*
|
|
152
|
+
* @param {string} absDir
|
|
153
|
+
* @param {(absolute: string) => boolean} matches
|
|
154
|
+
* @param {Set<string>} exclude directory BASENAMES to skip
|
|
155
|
+
* @param {string[]} acc
|
|
156
|
+
* @returns {string[]}
|
|
157
|
+
*/
|
|
158
|
+
function walk(absDir, matches, exclude, acc = []) {
|
|
159
|
+
let entries;
|
|
160
|
+
try {
|
|
161
|
+
if (!existsSync(absDir) || !statSync(absDir).isDirectory()) return acc;
|
|
162
|
+
entries = readdirSync(absDir, { withFileTypes: true });
|
|
163
|
+
} catch {
|
|
164
|
+
return acc;
|
|
165
|
+
}
|
|
166
|
+
for (const entry of entries) {
|
|
167
|
+
// A symlink can leave the root entirely, and a symlinked file is not
|
|
168
|
+
// independent content — skip both directions.
|
|
169
|
+
if (entry.isSymbolicLink()) continue;
|
|
170
|
+
const full = path.join(absDir, entry.name);
|
|
171
|
+
if (entry.isDirectory()) {
|
|
172
|
+
if (exclude.has(entry.name)) continue;
|
|
173
|
+
walk(full, matches, exclude, acc);
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
if (entry.isFile() && matches(full)) acc.push(full);
|
|
177
|
+
}
|
|
178
|
+
return acc;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Resolve the `dirs` option to absolute directories under `root`.
|
|
183
|
+
* `'.'` (or an empty list) means the root itself.
|
|
184
|
+
* @param {string} root
|
|
185
|
+
* @param {string[] | undefined} dirs
|
|
186
|
+
* @returns {string[]}
|
|
187
|
+
*/
|
|
188
|
+
function resolveDirs(root, dirs) {
|
|
189
|
+
if (!dirs || dirs.length === 0) return [root];
|
|
190
|
+
return dirs.map((d) => (d === '.' ? root : path.join(root, d)));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Files git TRACKS under `dirs` — the repository's own account of its content.
|
|
195
|
+
*
|
|
196
|
+
* `git ls-files` is authoritative and honours `.gitignore` for free. When
|
|
197
|
+
* `root` is not the top level of a git checkout (a tarball export, a tmpdir
|
|
198
|
+
* fixture, a vendored copy), this falls back to a filesystem walk carrying the
|
|
199
|
+
* SAME exclusion set as {@link listOnDiskFiles} — a fallback that walked
|
|
200
|
+
* naively would reintroduce the exact bug this function exists to remove.
|
|
201
|
+
*
|
|
202
|
+
* @param {string} root absolute repository root
|
|
203
|
+
* @param {{dirs?: string[], exts?: string[] | null}} [options]
|
|
204
|
+
* `dirs` repo-relative directories to scan (default: the whole root);
|
|
205
|
+
* `exts` extensions to keep, with or without the dot (default: every file).
|
|
206
|
+
* @returns {string[]} absolute paths, sorted, deduplicated
|
|
207
|
+
*/
|
|
208
|
+
export function listRepoFiles(root, options = {}) {
|
|
209
|
+
const { dirs, exts = null } = options;
|
|
210
|
+
const matches = extFilter(exts);
|
|
211
|
+
const env = gitEnv();
|
|
212
|
+
|
|
213
|
+
if (isGitToplevel(root, env)) {
|
|
214
|
+
const pathspecs = dirs && dirs.length > 0 ? dirs.filter((d) => d !== '.') : [];
|
|
215
|
+
try {
|
|
216
|
+
const out = execFileSync('git', ['ls-files', '-z', '--', ...pathspecs], {
|
|
217
|
+
cwd: root,
|
|
218
|
+
encoding: 'utf8',
|
|
219
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
220
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
221
|
+
env,
|
|
222
|
+
});
|
|
223
|
+
return out
|
|
224
|
+
.split('\0')
|
|
225
|
+
.filter(Boolean)
|
|
226
|
+
.map((rel) => path.join(root, rel))
|
|
227
|
+
.filter(matches)
|
|
228
|
+
// A tracked path can be absent from the working tree (sparse checkout,
|
|
229
|
+
// a deletion staged elsewhere). A scanner that then read it would
|
|
230
|
+
// report a tool-error for a file nobody removed.
|
|
231
|
+
.filter((absolute) => {
|
|
232
|
+
try {
|
|
233
|
+
return statSync(absolute).isFile();
|
|
234
|
+
} catch {
|
|
235
|
+
return false;
|
|
236
|
+
}
|
|
237
|
+
})
|
|
238
|
+
.sort();
|
|
239
|
+
} catch {
|
|
240
|
+
// fall through to the walk — a git that answered rev-parse but failed
|
|
241
|
+
// ls-files leaves us with no index to trust.
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const exclude = new Set(EXCLUDED_DIRS);
|
|
246
|
+
const found = new Set();
|
|
247
|
+
for (const dir of resolveDirs(root, dirs)) {
|
|
248
|
+
for (const file of walk(dir, matches, exclude)) found.add(file);
|
|
249
|
+
}
|
|
250
|
+
return [...found].sort();
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Files the FILESYSTEM holds under `dirs`, minus {@link EXCLUDED_DIRS}.
|
|
255
|
+
*
|
|
256
|
+
* Use this only when "exists on disk" is genuinely the oracle — i.e. when the
|
|
257
|
+
* point is to find files git does NOT track. Every other scanner wants
|
|
258
|
+
* {@link listRepoFiles}.
|
|
259
|
+
*
|
|
260
|
+
* @param {string} root absolute repository root
|
|
261
|
+
* @param {{dirs?: string[], exts?: string[] | null, exclude?: string[]}} [options]
|
|
262
|
+
* `exclude` REPLACES the default exclusion set when given; pass
|
|
263
|
+
* `[...EXCLUDED_DIRS, 'extra']` to extend it rather than to swap it.
|
|
264
|
+
* @returns {string[]} absolute paths, sorted, deduplicated
|
|
265
|
+
*/
|
|
266
|
+
export function listOnDiskFiles(root, options = {}) {
|
|
267
|
+
const { dirs, exts = null, exclude = EXCLUDED_DIRS } = options;
|
|
268
|
+
const matches = extFilter(exts);
|
|
269
|
+
const excludeSet = new Set(exclude);
|
|
270
|
+
const found = new Set();
|
|
271
|
+
for (const dir of resolveDirs(root, dirs)) {
|
|
272
|
+
for (const file of walk(dir, matches, excludeSet)) found.add(file);
|
|
273
|
+
}
|
|
274
|
+
return [...found].sort();
|
|
275
|
+
}
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
*/
|
|
36
36
|
|
|
37
37
|
import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs';
|
|
38
|
-
import { join, resolve, relative } from 'node:path';
|
|
38
|
+
import { join, resolve, relative, basename } from 'node:path';
|
|
39
39
|
import { fileURLToPath } from 'node:url';
|
|
40
40
|
import { execFileSync } from 'node:child_process';
|
|
41
41
|
import { createRequire } from 'node:module';
|
|
@@ -237,6 +237,200 @@ function getTargetFileList(targetRoot) {
|
|
|
237
237
|
return files;
|
|
238
238
|
}
|
|
239
239
|
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
// Vendoring sanitizer (issue #1098)
|
|
242
|
+
//
|
|
243
|
+
// Lives here rather than in rules-sync.mjs because it has this module's exact
|
|
244
|
+
// shape — "judge one rule file, return findings" — and because rules-sync.mjs
|
|
245
|
+
// already imports from here, so the move adds no import edge and no cycle.
|
|
246
|
+
// rules-sync.mjs re-exports `scanVendoringLeaks` for its existing importers.
|
|
247
|
+
// ---------------------------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Path roots that exist in the PLUGIN repo but are not part of what a consumer
|
|
251
|
+
* repo receives. A rule that cites `scripts/lib/foo.mjs` reads fine here and
|
|
252
|
+
* dangles the moment the file is vendored.
|
|
253
|
+
*
|
|
254
|
+
* Deliberately a small, named list rather than "every top-level directory":
|
|
255
|
+
* the detector below additionally requires the cited path to resolve to a real
|
|
256
|
+
* FILE inside pluginRoot, which is what separates a plugin-internal citation
|
|
257
|
+
* from a consumer-repo path that merely shares a prefix (`docs/api.md`,
|
|
258
|
+
* `tests/` as a generic directory reference, `scripts/smoke-test.ts` in a code
|
|
259
|
+
* sample). Revisit if the plugin grows another top-level source directory that
|
|
260
|
+
* rules cite.
|
|
261
|
+
*/
|
|
262
|
+
const REPO_LOCAL_PATH_ROOTS = ['scripts', 'hooks', 'skills', 'docs', 'tests'];
|
|
263
|
+
|
|
264
|
+
// Leading boundary excludes `react-hooks/exhaustive-deps` and
|
|
265
|
+
// `templates/shared/hooks/x.sh` — a root only counts at a token boundary.
|
|
266
|
+
const REPO_LOCAL_PATH_RE = new RegExp(
|
|
267
|
+
`(?<![\\w./-])((?:${REPO_LOCAL_PATH_ROOTS.join('|')})/[\\w./-]*\\w)`,
|
|
268
|
+
'g',
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
const MD_REFERENCE_RE = /[\w][\w.-]*\.md/g;
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* `*.md` names a `## See Also` section may legitimately cite even though
|
|
275
|
+
* `rules/_index.md` does not register them: they are project-root instruction
|
|
276
|
+
* files every consumer repo has, not rules that travel through the sync.
|
|
277
|
+
*/
|
|
278
|
+
const NON_RULE_MD_REFERENCES = new Set(['CLAUDE.md', 'AGENTS.md', 'README.md']);
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* 1-based line number of `index` within `content`.
|
|
282
|
+
* @param {string} content
|
|
283
|
+
* @param {number} index
|
|
284
|
+
* @returns {number}
|
|
285
|
+
*/
|
|
286
|
+
function lineOf(content, index) {
|
|
287
|
+
return content.slice(0, index).split('\n').length;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Extracts the body of the trailing `## See Also` section, with the offset at
|
|
292
|
+
* which it starts. Returns null when the file has no such section.
|
|
293
|
+
* @param {string} content
|
|
294
|
+
* @returns {{ body: string, offset: number } | null}
|
|
295
|
+
*/
|
|
296
|
+
function extractSeeAlso(content) {
|
|
297
|
+
const m = /^##\s+See Also\s*$/m.exec(content);
|
|
298
|
+
if (!m) return null;
|
|
299
|
+
const start = m.index + m[0].length;
|
|
300
|
+
const next = /^##\s+/m.exec(content.slice(start));
|
|
301
|
+
const end = next !== null ? start + next.index : content.length;
|
|
302
|
+
return { body: content.slice(start, end), offset: start };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* True when `token` (a repo-relative path taken from rule prose) stays inside
|
|
307
|
+
* `pluginRoot` once resolved. `REPO_LOCAL_PATH_RE`'s character class admits
|
|
308
|
+
* `..`, so `scripts/../../../../etc/passwd` reaches this function; without the
|
|
309
|
+
* containment check its `statSync` would walk outside the plugin root and
|
|
310
|
+
* report a host file as a vendoring leak. Same shape as
|
|
311
|
+
* `git-config-drift.mjs`'s `hooksPathIsTracked` containment guard.
|
|
312
|
+
* @param {string} pluginRoot
|
|
313
|
+
* @param {string} token
|
|
314
|
+
* @returns {boolean}
|
|
315
|
+
*/
|
|
316
|
+
function tokenStaysInside(pluginRoot, token) {
|
|
317
|
+
const abs = resolve(pluginRoot, token);
|
|
318
|
+
const rel = relative(resolve(pluginRoot), abs);
|
|
319
|
+
return rel !== '' && !rel.startsWith('..') && !/^([a-zA-Z]:)?[\\/]/.test(rel);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Report-only vendoring sanitizer (issue #1098).
|
|
324
|
+
*
|
|
325
|
+
* Scans one rule file for the two ways a rule that reads correctly INSIDE this
|
|
326
|
+
* plugin repo breaks once it is vendored into a consumer repo:
|
|
327
|
+
*
|
|
328
|
+
* - `repo-local-path` — a `scripts/…`, `hooks/…`, `skills/…`, `docs/…` or
|
|
329
|
+
* `tests/…` citation that resolves to a real file under `pluginRoot`. The
|
|
330
|
+
* consumer has no such file, so the citation dangles.
|
|
331
|
+
* - `unresolvable-see-also` — a `## See Also` entry naming a `*.md` rule that
|
|
332
|
+
* `rules/_index.md` does not register, so it is never vendored alongside.
|
|
333
|
+
*
|
|
334
|
+
* **This function never rewrites content.** Silent stripping would change the
|
|
335
|
+
* meaning of a rule at vendoring time, invisibly to both the author and the
|
|
336
|
+
* consumer; the finding is reported and a human decides. `@your-org/*`
|
|
337
|
+
* placeholders are deliberately NOT flagged — they are the documented
|
|
338
|
+
* placeholder convention for consumer-supplied package scopes, not a leak.
|
|
339
|
+
*
|
|
340
|
+
* @param {object} opts
|
|
341
|
+
* @param {string} opts.content - raw rule file content (never modified)
|
|
342
|
+
* @param {string} opts.relPath - manifest-relative path, used as `file`
|
|
343
|
+
* @param {string} opts.pluginRoot - repo root the repo-local check resolves against
|
|
344
|
+
* @param {Set<string>|null} [opts.manifestBasenames] - basenames registered in
|
|
345
|
+
* `rules/_index.md`; when null the `unresolvable-see-also` check is skipped
|
|
346
|
+
* @returns {Array<{file: string, line: number, kind: 'repo-local-path'|'unresolvable-see-also', text: string}>}
|
|
347
|
+
*/
|
|
348
|
+
export function scanVendoringLeaks({ content, relPath, pluginRoot, manifestBasenames = null }) {
|
|
349
|
+
const findings = [];
|
|
350
|
+
|
|
351
|
+
REPO_LOCAL_PATH_RE.lastIndex = 0;
|
|
352
|
+
const seenPaths = new Set();
|
|
353
|
+
let m;
|
|
354
|
+
while ((m = REPO_LOCAL_PATH_RE.exec(content)) !== null) {
|
|
355
|
+
const token = m[1];
|
|
356
|
+
if (seenPaths.has(token)) continue;
|
|
357
|
+
let isFile;
|
|
358
|
+
try {
|
|
359
|
+
// Containment first: never stat a path that escapes pluginRoot.
|
|
360
|
+
if (!tokenStaysInside(pluginRoot, token)) continue;
|
|
361
|
+
const abs = join(pluginRoot, token);
|
|
362
|
+
isFile = existsSync(abs) && statSync(abs).isFile();
|
|
363
|
+
} catch {
|
|
364
|
+
isFile = false;
|
|
365
|
+
}
|
|
366
|
+
if (!isFile) continue;
|
|
367
|
+
seenPaths.add(token);
|
|
368
|
+
findings.push({
|
|
369
|
+
file: relPath,
|
|
370
|
+
line: lineOf(content, m.index),
|
|
371
|
+
kind: 'repo-local-path',
|
|
372
|
+
text: token,
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (manifestBasenames) {
|
|
377
|
+
const seeAlso = extractSeeAlso(content);
|
|
378
|
+
if (seeAlso) {
|
|
379
|
+
const ownBasename = basename(relPath);
|
|
380
|
+
const seenRefs = new Set();
|
|
381
|
+
MD_REFERENCE_RE.lastIndex = 0;
|
|
382
|
+
let r;
|
|
383
|
+
while ((r = MD_REFERENCE_RE.exec(seeAlso.body)) !== null) {
|
|
384
|
+
const ref = r[0];
|
|
385
|
+
const refBase = basename(ref);
|
|
386
|
+
if (refBase === ownBasename || seenRefs.has(refBase)) continue;
|
|
387
|
+
if (NON_RULE_MD_REFERENCES.has(refBase)) continue;
|
|
388
|
+
if (manifestBasenames.has(refBase)) continue;
|
|
389
|
+
seenRefs.add(refBase);
|
|
390
|
+
findings.push({
|
|
391
|
+
file: relPath,
|
|
392
|
+
line: lineOf(content, seeAlso.offset + r.index),
|
|
393
|
+
kind: 'unresolvable-see-also',
|
|
394
|
+
text: ref,
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
return findings;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Reads `<pluginRoot>/rules/_index.md` and returns the set of rule basenames it
|
|
405
|
+
* registers, for `scanVendoringLeaks()`'s `unresolvable-see-also` check.
|
|
406
|
+
* Returns `null` when the manifest is absent or unreadable — the caller then
|
|
407
|
+
* skips that check rather than reporting every See-Also entry as a leak.
|
|
408
|
+
*
|
|
409
|
+
* The bullet shape is the one `rules-sync.mjs`'s `parseIndex()` parses; it is
|
|
410
|
+
* matched here with a local regex rather than imported, because `rules-sync.mjs`
|
|
411
|
+
* imports from THIS module and an import back would create a module-load cycle
|
|
412
|
+
* (same reasoning as the `PLUGIN_HEADER_PREFIX` copy above).
|
|
413
|
+
*
|
|
414
|
+
* @param {string} pluginRoot
|
|
415
|
+
* @returns {Set<string>|null}
|
|
416
|
+
*/
|
|
417
|
+
export function readManifestBasenames(pluginRoot) {
|
|
418
|
+
const indexPath = join(pluginRoot, 'rules', '_index.md');
|
|
419
|
+
let indexContent;
|
|
420
|
+
try {
|
|
421
|
+
indexContent = readFileSync(indexPath, 'utf8');
|
|
422
|
+
} catch {
|
|
423
|
+
return null;
|
|
424
|
+
}
|
|
425
|
+
const names = new Set();
|
|
426
|
+
const bulletRe = /^-\s+`([^`]+\.md)`/gm;
|
|
427
|
+
let m;
|
|
428
|
+
while ((m = bulletRe.exec(indexContent)) !== null) {
|
|
429
|
+
names.add(basename(m[1]));
|
|
430
|
+
}
|
|
431
|
+
return names;
|
|
432
|
+
}
|
|
433
|
+
|
|
240
434
|
// ---------------------------------------------------------------------------
|
|
241
435
|
// Public API
|
|
242
436
|
// ---------------------------------------------------------------------------
|
|
@@ -396,13 +590,21 @@ export function validateRuleContent({ content, relPath, targetRoot = null, requi
|
|
|
396
590
|
* Validates every `*.md` rule file under `dir`, recursively (skips dotfiles
|
|
397
591
|
* and `_index.md`).
|
|
398
592
|
*
|
|
593
|
+
* When `pluginRoot` is given, every scanned file is additionally passed through
|
|
594
|
+
* `scanVendoringLeaks()` and the findings are collected into the additive
|
|
595
|
+
* `sanitizer[]` array (issue #1098). Report-only, exactly as in `syncRules()`:
|
|
596
|
+
* a finding contributes to neither `errorCount` nor `warnCount`, so it can
|
|
597
|
+
* never change this function's `ok` verdict or its CLI's exit code.
|
|
598
|
+
*
|
|
399
599
|
* @param {object} opts
|
|
400
600
|
* @param {string} opts.dir - absolute path to a directory of rule files
|
|
401
601
|
* @param {string|null} [opts.targetRoot] - forwarded to validateRuleContent
|
|
402
602
|
* @param {boolean} [opts.requireProvenance] - forwarded to validateRuleContent
|
|
403
|
-
* @
|
|
603
|
+
* @param {string|null} [opts.pluginRoot] - when provided, enables the vendoring
|
|
604
|
+
* sanitizer scan against this plugin root
|
|
605
|
+
* @returns {{ ok: boolean, files: Array<{ file: string, violations: RuleViolation[] }>, errorCount: number, warnCount: number, sanitizer: Array<{file: string, line: number, kind: string, text: string}> }}
|
|
404
606
|
*/
|
|
405
|
-
export function validateRulesDir({ dir, targetRoot = null, requireProvenance = false }) {
|
|
607
|
+
export function validateRulesDir({ dir, targetRoot = null, requireProvenance = false, pluginRoot = null }) {
|
|
406
608
|
function collectRuleFiles(absDir) {
|
|
407
609
|
const files = [];
|
|
408
610
|
for (const entry of readdirSync(absDir, { withFileTypes: true })) {
|
|
@@ -420,9 +622,12 @@ export function validateRulesDir({ dir, targetRoot = null, requireProvenance = f
|
|
|
420
622
|
const entries = collectRuleFiles(dir).sort();
|
|
421
623
|
|
|
422
624
|
const files = [];
|
|
625
|
+
const sanitizer = [];
|
|
423
626
|
let errorCount = 0;
|
|
424
627
|
let warnCount = 0;
|
|
425
628
|
|
|
629
|
+
const manifestBasenames = pluginRoot ? readManifestBasenames(pluginRoot) : null;
|
|
630
|
+
|
|
426
631
|
for (const name of entries) {
|
|
427
632
|
const filePath = join(dir, name);
|
|
428
633
|
const content = readFileSync(filePath, 'utf8');
|
|
@@ -432,9 +637,15 @@ export function validateRulesDir({ dir, targetRoot = null, requireProvenance = f
|
|
|
432
637
|
else if (v.severity === 'warn') warnCount++;
|
|
433
638
|
}
|
|
434
639
|
files.push({ file: name, violations });
|
|
640
|
+
|
|
641
|
+
if (pluginRoot) {
|
|
642
|
+
sanitizer.push(
|
|
643
|
+
...scanVendoringLeaks({ content, relPath: name, pluginRoot, manifestBasenames }),
|
|
644
|
+
);
|
|
645
|
+
}
|
|
435
646
|
}
|
|
436
647
|
|
|
437
|
-
return { ok: errorCount === 0, files, errorCount, warnCount };
|
|
648
|
+
return { ok: errorCount === 0, files, errorCount, warnCount, sanitizer };
|
|
438
649
|
}
|
|
439
650
|
|
|
440
651
|
// ── CLI ───────────────────────────────────────────────────────────────────────
|
|
@@ -480,13 +691,17 @@ if (isMain) {
|
|
|
480
691
|
|
|
481
692
|
if (args.includes('--help') || args.includes('-h')) {
|
|
482
693
|
process.stdout.write(
|
|
483
|
-
'Usage: validate-vendored-rules.mjs --dir <rulesDir> [--target-root <repo>] [--require-provenance] [--json] [--mode hard|warn]\n',
|
|
694
|
+
'Usage: validate-vendored-rules.mjs --dir <rulesDir> [--target-root <repo>] [--plugin-root <dir>] [--require-provenance] [--json] [--mode hard|warn]\n',
|
|
484
695
|
);
|
|
485
696
|
process.exit(0);
|
|
486
697
|
}
|
|
487
698
|
|
|
488
699
|
const dirArg = getArg('--dir');
|
|
489
700
|
const targetRoot = getArg('--target-root') ?? null;
|
|
701
|
+
// Enables the report-only vendoring sanitizer (#1098). Never affects the
|
|
702
|
+
// exit code — findings are surfaced, the operator decides.
|
|
703
|
+
const pluginRootArg = getArg('--plugin-root');
|
|
704
|
+
const pluginRoot = pluginRootArg ? resolve(pluginRootArg) : null;
|
|
490
705
|
const requireProvenance = args.includes('--require-provenance');
|
|
491
706
|
const jsonOutput = args.includes('--json');
|
|
492
707
|
const mode = getArg('--mode') ?? 'hard';
|
|
@@ -510,16 +725,23 @@ if (isMain) {
|
|
|
510
725
|
|
|
511
726
|
let result;
|
|
512
727
|
try {
|
|
513
|
-
result = validateRulesDir({ dir, targetRoot, requireProvenance });
|
|
728
|
+
result = validateRulesDir({ dir, targetRoot, requireProvenance, pluginRoot });
|
|
514
729
|
} catch (err) {
|
|
515
730
|
process.stderr.write(`validate-vendored-rules: error: failed to read --dir ${dir}: ${err.message}\n`);
|
|
516
731
|
process.exit(2);
|
|
517
732
|
}
|
|
518
733
|
|
|
519
734
|
if (jsonOutput) {
|
|
520
|
-
process.stdout.write(
|
|
735
|
+
process.stdout.write(
|
|
736
|
+
JSON.stringify({ mode, dir, targetRoot, pluginRoot, requireProvenance, ...result }, null, 2) + '\n',
|
|
737
|
+
);
|
|
521
738
|
} else {
|
|
522
739
|
process.stdout.write(formatSummary(result, dir) + '\n');
|
|
740
|
+
for (const f of result.sanitizer) {
|
|
741
|
+
process.stderr.write(
|
|
742
|
+
`validate-vendored-rules: sanitizer ${f.kind} ${f.file}:${f.line} — ${f.text}\n`,
|
|
743
|
+
);
|
|
744
|
+
}
|
|
523
745
|
}
|
|
524
746
|
|
|
525
747
|
const exitCode = result.errorCount > 0 && mode === 'hard' ? 1 : 0;
|