@soleur/opencode 0.0.0-stage → 3.354.2
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/agents.manifest.json +476 -0
- package/.claude-plugin/plugin.json +42 -0
- package/README.md +44 -2
- package/agents/community/.gitkeep +0 -0
- package/agents/engineering/cto.md +87 -0
- package/agents/engineering/design/ddd-architect.md +95 -0
- package/agents/engineering/discovery/agent-finder.md +214 -0
- package/agents/engineering/discovery/functional-discovery.md +182 -0
- package/agents/engineering/infra/infra-security.md +151 -0
- package/agents/engineering/infra/platform-strategist.md +109 -0
- package/agents/engineering/infra/terraform-architect.md +171 -0
- package/agents/engineering/prompt-engineer.md +37 -0
- package/agents/engineering/research/best-practices-researcher.md +115 -0
- package/agents/engineering/research/framework-docs-researcher.md +93 -0
- package/agents/engineering/research/git-history-analyzer.md +53 -0
- package/agents/engineering/research/learnings-researcher.md +285 -0
- package/agents/engineering/research/repo-research-analyst.md +118 -0
- package/agents/engineering/review/agent-native-reviewer.md +279 -0
- package/agents/engineering/review/architecture-strategist.md +61 -0
- package/agents/engineering/review/code-quality-analyst.md +92 -0
- package/agents/engineering/review/code-simplicity-reviewer.md +109 -0
- package/agents/engineering/review/data-integrity-guardian.md +82 -0
- package/agents/engineering/review/data-migration-expert.md +98 -0
- package/agents/engineering/review/deployment-verification-agent.md +183 -0
- package/agents/engineering/review/dhh-rails-reviewer.md +46 -0
- package/agents/engineering/review/kieran-rails-reviewer.md +87 -0
- package/agents/engineering/review/legacy-code-expert.md +55 -0
- package/agents/engineering/review/observability-coverage-reviewer.md +147 -0
- package/agents/engineering/review/pattern-recognition-specialist.md +59 -0
- package/agents/engineering/review/performance-oracle.md +130 -0
- package/agents/engineering/review/security-sentinel.md +167 -0
- package/agents/engineering/review/semgrep-sast.md +36 -0
- package/agents/engineering/review/test-design-reviewer.md +150 -0
- package/agents/engineering/review/user-impact-reviewer.md +70 -0
- package/agents/engineering/workflow/pr-comment-resolver.md +69 -0
- package/agents/finance/budget-analyst.md +29 -0
- package/agents/finance/cfo.md +44 -0
- package/agents/finance/financial-reporter.md +30 -0
- package/agents/finance/revenue-analyst.md +30 -0
- package/agents/legal/clo.md +115 -0
- package/agents/legal/legal-compliance-auditor.md +110 -0
- package/agents/legal/legal-document-generator.md +134 -0
- package/agents/marketing/analytics-analyst.md +25 -0
- package/agents/marketing/brand-architect.md +181 -0
- package/agents/marketing/cmo.md +76 -0
- package/agents/marketing/conversion-optimizer.md +19 -0
- package/agents/marketing/copywriter.md +20 -0
- package/agents/marketing/fact-checker.md +70 -0
- package/agents/marketing/growth-strategist.md +145 -0
- package/agents/marketing/paid-media-strategist.md +28 -0
- package/agents/marketing/pricing-strategist.md +17 -0
- package/agents/marketing/programmatic-seo-specialist.md +25 -0
- package/agents/marketing/retention-strategist.md +23 -0
- package/agents/marketing/seo-aeo-analyst.md +164 -0
- package/agents/operations/coo.md +50 -0
- package/agents/operations/ops-advisor.md +78 -0
- package/agents/operations/ops-provisioner.md +101 -0
- package/agents/operations/ops-research.md +52 -0
- package/agents/operations/service-automator.md +278 -0
- package/agents/product/business-validator.md +206 -0
- package/agents/product/competitive-intelligence.md +89 -0
- package/agents/product/cpo.md +63 -0
- package/agents/product/design/ux-design-lead.md +209 -0
- package/agents/product/spec-flow-analyzer.md +122 -0
- package/agents/sales/cro.md +44 -0
- package/agents/sales/deal-architect.md +29 -0
- package/agents/sales/outbound-strategist.md +29 -0
- package/agents/sales/pipeline-analyst.md +29 -0
- package/agents/support/cco.md +48 -0
- package/agents/support/community-manager.md +570 -0
- package/agents/support/ticket-triage.md +70 -0
- package/bin/soleur-opencode.js +29 -0
- package/commands/go.md +660 -0
- package/commands/help.md +273 -0
- package/commands/sync.md +977 -0
- package/commands/version.md +103 -0
- package/hooks/antigravity-hooks.json +84 -0
- package/hooks/antigravity-session-start.sh +15 -0
- package/hooks/bare-root-cwd-guard.sh +131 -0
- package/hooks/browser-snapshot-credential-guard.sh +200 -0
- package/hooks/codex-session-start.sh +9 -0
- package/hooks/compaction-state.sh +525 -0
- package/hooks/destructive-command-guard.sh +1387 -0
- package/hooks/devin-session-start.sh +90 -0
- package/hooks/ext-filter-detect.sh +171 -0
- package/hooks/hooks.json +133 -0
- package/hooks/lib/hook-tool-kind.sh +24 -0
- package/hooks/lib/log-rotation.sh +176 -0
- package/hooks/lib/shell-argv.pl +576 -0
- package/hooks/memory-backstop.sh +1114 -0
- package/hooks/operator-stage-approval.sh +391 -0
- package/hooks/stop-hook.sh +367 -0
- package/hooks/unkept-promise-hook.sh +383 -0
- package/hooks/version-banner.sh +127 -0
- package/hooks/welcome-hook.sh +50 -0
- package/install.js +29 -0
- package/lib/agent-registry.ts +154 -0
- package/lib/c4-canonical-cli.mjs +34 -0
- package/lib/c4-canonical.mjs +46 -0
- package/lib/c4-from-components.ts +554 -0
- package/lib/go-routing.ts +81 -0
- package/lib/grok-inspect-contract.ts +311 -0
- package/lib/harness-model-map.ts +101 -0
- package/lib/harness-parity.ts +726 -0
- package/lib/harness-tool-map.ts +180 -0
- package/lib/harness.ts +716 -0
- package/lib/heartbeat-live-reconcile.ts +1153 -0
- package/lib/heartbeat-manifest.ts +361 -0
- package/lib/kb-coverage.ts +252 -0
- package/lib/pr-merge-poll.ts +103 -0
- package/lib/workflow-fidelity.ts +388 -0
- package/opencode/INSTRUCTIONS.md +171 -0
- package/opencode/agents/engineering/cto.md +10 -0
- package/opencode/agents/engineering/design/ddd-architect.md +10 -0
- package/opencode/agents/engineering/discovery/agent-finder.md +10 -0
- package/opencode/agents/engineering/discovery/functional-discovery.md +10 -0
- package/opencode/agents/engineering/infra/infra-security.md +10 -0
- package/opencode/agents/engineering/infra/platform-strategist.md +10 -0
- package/opencode/agents/engineering/infra/terraform-architect.md +10 -0
- package/opencode/agents/engineering/prompt-engineer.md +10 -0
- package/opencode/agents/engineering/research/best-practices-researcher.md +10 -0
- package/opencode/agents/engineering/research/framework-docs-researcher.md +10 -0
- package/opencode/agents/engineering/research/git-history-analyzer.md +10 -0
- package/opencode/agents/engineering/research/learnings-researcher.md +10 -0
- package/opencode/agents/engineering/research/repo-research-analyst.md +10 -0
- package/opencode/agents/engineering/review/agent-native-reviewer.md +10 -0
- package/opencode/agents/engineering/review/architecture-strategist.md +10 -0
- package/opencode/agents/engineering/review/code-quality-analyst.md +10 -0
- package/opencode/agents/engineering/review/code-simplicity-reviewer.md +10 -0
- package/opencode/agents/engineering/review/data-integrity-guardian.md +10 -0
- package/opencode/agents/engineering/review/data-migration-expert.md +10 -0
- package/opencode/agents/engineering/review/deployment-verification-agent.md +10 -0
- package/opencode/agents/engineering/review/dhh-rails-reviewer.md +10 -0
- package/opencode/agents/engineering/review/kieran-rails-reviewer.md +10 -0
- package/opencode/agents/engineering/review/legacy-code-expert.md +10 -0
- package/opencode/agents/engineering/review/observability-coverage-reviewer.md +10 -0
- package/opencode/agents/engineering/review/pattern-recognition-specialist.md +10 -0
- package/opencode/agents/engineering/review/performance-oracle.md +10 -0
- package/opencode/agents/engineering/review/security-sentinel.md +10 -0
- package/opencode/agents/engineering/review/semgrep-sast.md +10 -0
- package/opencode/agents/engineering/review/test-design-reviewer.md +10 -0
- package/opencode/agents/engineering/review/user-impact-reviewer.md +10 -0
- package/opencode/agents/engineering/workflow/pr-comment-resolver.md +10 -0
- package/opencode/agents/finance/budget-analyst.md +10 -0
- package/opencode/agents/finance/cfo.md +10 -0
- package/opencode/agents/finance/financial-reporter.md +10 -0
- package/opencode/agents/finance/revenue-analyst.md +10 -0
- package/opencode/agents/legal/clo.md +10 -0
- package/opencode/agents/legal/legal-compliance-auditor.md +10 -0
- package/opencode/agents/legal/legal-document-generator.md +10 -0
- package/opencode/agents/marketing/analytics-analyst.md +10 -0
- package/opencode/agents/marketing/brand-architect.md +10 -0
- package/opencode/agents/marketing/cmo.md +10 -0
- package/opencode/agents/marketing/conversion-optimizer.md +10 -0
- package/opencode/agents/marketing/copywriter.md +10 -0
- package/opencode/agents/marketing/fact-checker.md +10 -0
- package/opencode/agents/marketing/growth-strategist.md +10 -0
- package/opencode/agents/marketing/paid-media-strategist.md +10 -0
- package/opencode/agents/marketing/pricing-strategist.md +10 -0
- package/opencode/agents/marketing/programmatic-seo-specialist.md +10 -0
- package/opencode/agents/marketing/retention-strategist.md +10 -0
- package/opencode/agents/marketing/seo-aeo-analyst.md +10 -0
- package/opencode/agents/operations/coo.md +10 -0
- package/opencode/agents/operations/ops-advisor.md +10 -0
- package/opencode/agents/operations/ops-provisioner.md +10 -0
- package/opencode/agents/operations/ops-research.md +10 -0
- package/opencode/agents/operations/service-automator.md +10 -0
- package/opencode/agents/product/business-validator.md +10 -0
- package/opencode/agents/product/competitive-intelligence.md +10 -0
- package/opencode/agents/product/cpo.md +10 -0
- package/opencode/agents/product/design/ux-design-lead.md +10 -0
- package/opencode/agents/product/spec-flow-analyzer.md +10 -0
- package/opencode/agents/sales/cro.md +10 -0
- package/opencode/agents/sales/deal-architect.md +10 -0
- package/opencode/agents/sales/outbound-strategist.md +10 -0
- package/opencode/agents/sales/pipeline-analyst.md +10 -0
- package/opencode/agents/support/cco.md +10 -0
- package/opencode/agents/support/community-manager.md +10 -0
- package/opencode/agents/support/ticket-triage.md +10 -0
- package/opencode/commands/go.md +10 -0
- package/opencode/commands/help.md +10 -0
- package/opencode/commands/sync.md +10 -0
- package/opencode/commands/version.md +11 -0
- package/opencode/opencode.json +8 -0
- package/opencode/plugin.ts +818 -0
- package/package.json +36 -4
- package/scripts/admin-merge-ready.sh +545 -0
- package/scripts/admin-merge-ready.test.sh +753 -0
- package/scripts/affected-tests.sh +908 -0
- package/scripts/affected-tests.test.sh +1797 -0
- package/scripts/alpha-metrics.sh +78 -0
- package/scripts/alpha-metrics.test.sh +115 -0
- package/scripts/ancestor-signal-helpers.baseline.txt +16 -0
- package/scripts/audit-flag-flip.sh +107 -0
- package/scripts/check-red-on-main.sh +316 -0
- package/scripts/ci-head-verdict.sh +411 -0
- package/scripts/cloud-detect.sh +193 -0
- package/scripts/delegation-verify.sh +657 -0
- package/scripts/delegation-verify.test.sh +1424 -0
- package/scripts/deploy-arm.sh +475 -0
- package/scripts/domain-model-drift.sh +419 -0
- package/scripts/emit-decision.sh +144 -0
- package/scripts/emit-decision.test.sh +144 -0
- package/scripts/ext-filter-divergence-corpus.txt +15 -0
- package/scripts/ext-filter-divergence.sh +189 -0
- package/scripts/generate-c4-from-components.ts +442 -0
- package/scripts/grok-fidelity-gate.sh +68 -0
- package/scripts/grok-pre-push-gate.sh +232 -0
- package/scripts/harness-discovery-smoke.ts +906 -0
- package/scripts/harness-parity-census.ts +92 -0
- package/scripts/inngest-luks-alert-armed-probe.ts +29 -0
- package/scripts/install-opencode-adapter.ts +509 -0
- package/scripts/lib/affected-tests/common.sh +365 -0
- package/scripts/lib/affected-tests/go.sh +157 -0
- package/scripts/lib/affected-tests/js.sh +188 -0
- package/scripts/lib/affected-tests/nx-turbo.sh +98 -0
- package/scripts/lib/affected-tests/python.sh +185 -0
- package/scripts/lib/context-sanitize.sh +49 -0
- package/scripts/lib/domain-model-lib.sh +180 -0
- package/scripts/lib/ext-filter-grammar.tsv +34 -0
- package/scripts/lib/operator-script.sh +1236 -0
- package/scripts/lib/proc.sh +580 -0
- package/scripts/lib/session-state.sh +748 -0
- package/scripts/lib/tmp-classify.sh +843 -0
- package/scripts/lib/wrapper-boundary.sh +47 -0
- package/scripts/lint-ratchet.py +1547 -0
- package/scripts/monitor-pr-checks.sh +441 -0
- package/scripts/pipeline-tally.sh +756 -0
- package/scripts/pipeline-tally.test.sh +740 -0
- package/scripts/plugin-version.sh +528 -0
- package/scripts/precommit-guard.sh +115 -0
- package/scripts/precommit-guard.test.sh +254 -0
- package/scripts/reconcile-live-heartbeats.ts +705 -0
- package/scripts/render-c4-model.sh +217 -0
- package/scripts/resolve-git-root.sh +55 -0
- package/scripts/resolve-regenerable-conflicts.sh +363 -0
- package/scripts/resolve-regenerable-conflicts.test.sh +780 -0
- package/scripts/ruff-requirements.txt +22 -0
- package/scripts/run-in-pid-namespace.sh +98 -0
- package/scripts/run-in-pid-namespace.test.sh +290 -0
- package/scripts/scan-ancestor-signal-helpers.py +556 -0
- package/scripts/scan-ancestor-signal-helpers.test.sh +670 -0
- package/scripts/setup-ralph-loop.sh +238 -0
- package/scripts/sync-cursor-name-map.ts +316 -0
- package/scripts/sync-grok-agent-compat.ts +188 -0
- package/scripts/sync-npm-package.ts +165 -0
- package/scripts/sync-opencode-adapter.ts +498 -0
- package/scripts/sync-pr-behind.sh +647 -0
- package/scripts/sync-pr-behind.test.sh +252 -0
- package/scripts/taste-profile-update.sh +180 -0
- package/scripts/taste-profile-update.test.sh +167 -0
- package/scripts/write-budget-marker.sh +23 -0
- package/scripts/write-kb-coverage.ts +113 -0
- package/skills/admin-ip-refresh/SKILL.md +81 -0
- package/skills/admin-ip-refresh/references/admin-ip-refresh-procedure.md +201 -0
- package/skills/agent-browser/SKILL.md +698 -0
- package/skills/agent-browser/scripts/playwright-mcp-profile-slot.sh +304 -0
- package/skills/agent-browser/scripts/playwright-mcp-redact-proxy.py +964 -0
- package/skills/agent-browser/scripts/redact-a11y-snapshot.py +443 -0
- package/skills/agent-browser/test/fixtures/capture-playwright-mcp-fixtures.py +217 -0
- package/skills/agent-browser/test/fixtures/fake-passthrough-proxy.py +24 -0
- package/skills/agent-browser/test/fixtures/fake-playwright-mcp.py +131 -0
- package/skills/agent-browser/test/fixtures/make-proxy-mutant.py +48 -0
- package/skills/agent-browser/test/fixtures/odd-shapes/dialog-link-injection.json +10 -0
- package/skills/agent-browser/test/fixtures/odd-shapes/empty-content.json +5 -0
- package/skills/agent-browser/test/fixtures/odd-shapes/error-and-result.json +4 -0
- package/skills/agent-browser/test/fixtures/odd-shapes/error-code-bool.json +6 -0
- package/skills/agent-browser/test/fixtures/odd-shapes/error-code-string.json +6 -0
- package/skills/agent-browser/test/fixtures/odd-shapes/error-message-tree.json +6 -0
- package/skills/agent-browser/test/fixtures/odd-shapes/error-no-data.json +6 -0
- package/skills/agent-browser/test/fixtures/odd-shapes/error-string.json +4 -0
- package/skills/agent-browser/test/fixtures/odd-shapes/error-with-data.json +9 -0
- package/skills/agent-browser/test/fixtures/odd-shapes/evaluate-escaped-object.json +10 -0
- package/skills/agent-browser/test/fixtures/odd-shapes/invalid-utf8.json +4 -0
- package/skills/agent-browser/test/fixtures/odd-shapes/link-result.json +10 -0
- package/skills/agent-browser/test/fixtures/odd-shapes/list-two-results.json +6 -0
- package/skills/agent-browser/test/fixtures/odd-shapes/meta-in-result.json +13 -0
- package/skills/agent-browser/test/fixtures/odd-shapes/no-content.json +5 -0
- package/skills/agent-browser/test/fixtures/odd-shapes/quoted-key.json +10 -0
- package/skills/agent-browser/test/fixtures/odd-shapes/resource-block.json +18 -0
- package/skills/agent-browser/test/fixtures/odd-shapes/result-and-method.json +11 -0
- package/skills/agent-browser/test/fixtures/odd-shapes/result-string.json +3 -0
- package/skills/agent-browser/test/fixtures/odd-shapes/run-code-escaped-one-row.json +10 -0
- package/skills/agent-browser/test/fixtures/odd-shapes/run-code-escaped.json +10 -0
- package/skills/agent-browser/test/fixtures/odd-shapes/structured-content.json +13 -0
- package/skills/agent-browser/test/fixtures/odd-shapes/tools-list-no-tools.json +5 -0
- package/skills/agent-browser/test/fixtures/odd-shapes/tree-no-credential.json +10 -0
- package/skills/agent-browser/test/fixtures/odd-shapes/two-text-blocks.json +14 -0
- package/skills/agent-browser/test/fixtures/odd-shapes/unparsable-line.json +3 -0
- package/skills/agent-browser/test/fixtures/odd-shapes/value-contains-brace.json +10 -0
- package/skills/agent-browser/test/fixtures/odd-shapes/value-contains-link-substring.json +10 -0
- package/skills/agent-browser/test/fixtures/playwright-mcp-0.0.78/close.json +12 -0
- package/skills/agent-browser/test/fixtures/playwright-mcp-0.0.78/evaluate.json +12 -0
- package/skills/agent-browser/test/fixtures/playwright-mcp-0.0.78/find.json +12 -0
- package/skills/agent-browser/test/fixtures/playwright-mcp-0.0.78/framing.json +6 -0
- package/skills/agent-browser/test/fixtures/playwright-mcp-0.0.78/initialize.json +14 -0
- package/skills/agent-browser/test/fixtures/playwright-mcp-0.0.78/navigate-link.json +12 -0
- package/skills/agent-browser/test/fixtures/playwright-mcp-0.0.78/navigate.json +12 -0
- package/skills/agent-browser/test/fixtures/playwright-mcp-0.0.78/roots-list-request.json +5 -0
- package/skills/agent-browser/test/fixtures/playwright-mcp-0.0.78/screenshot.json +17 -0
- package/skills/agent-browser/test/fixtures/playwright-mcp-0.0.78/snapshot-filename.json +12 -0
- package/skills/agent-browser/test/fixtures/playwright-mcp-0.0.78/snapshot-meta-json.json +12 -0
- package/skills/agent-browser/test/fixtures/playwright-mcp-0.0.78/snapshot-meta-raw.json +12 -0
- package/skills/agent-browser/test/fixtures/playwright-mcp-0.0.78/snapshot.json +12 -0
- package/skills/agent-browser/test/fixtures/playwright-mcp-0.0.78/tools-list.json +827 -0
- package/skills/agent-browser/test/fixtures/proxy-session-driver.py +138 -0
- package/skills/agent-browser/test/fixtures/session-facts.py +102 -0
- package/skills/agent-browser/test/playwright-mcp-lifetime.test.sh +1620 -0
- package/skills/agent-browser/test/playwright-mcp-redact-proxy.test.sh +1599 -0
- package/skills/agent-browser/test/redact-a11y-snapshot.test.sh +527 -0
- package/skills/agent-native-architecture/SKILL.md +479 -0
- package/skills/agent-native-architecture/references/action-parity-discipline.md +430 -0
- package/skills/agent-native-architecture/references/agent-execution-patterns.md +483 -0
- package/skills/agent-native-architecture/references/agent-native-testing.md +605 -0
- package/skills/agent-native-architecture/references/architecture-patterns.md +493 -0
- package/skills/agent-native-architecture/references/dynamic-context-injection.md +358 -0
- package/skills/agent-native-architecture/references/files-universal-interface.md +314 -0
- package/skills/agent-native-architecture/references/from-primitives-to-domain-tools.md +381 -0
- package/skills/agent-native-architecture/references/mcp-tool-design.md +524 -0
- package/skills/agent-native-architecture/references/mobile-patterns.md +897 -0
- package/skills/agent-native-architecture/references/product-implications.md +478 -0
- package/skills/agent-native-architecture/references/refactoring-to-prompt-native.md +342 -0
- package/skills/agent-native-architecture/references/self-modification.md +294 -0
- package/skills/agent-native-architecture/references/shared-workspace-architecture.md +700 -0
- package/skills/agent-native-architecture/references/system-prompt-design.md +263 -0
- package/skills/agent-native-audit/SKILL.md +300 -0
- package/skills/agent-native-audit/workflows/agent-native-audit.workflow.js +423 -0
- package/skills/andrew-kane-gem-writer/SKILL.md +189 -0
- package/skills/andrew-kane-gem-writer/references/database-adapters.md +231 -0
- package/skills/andrew-kane-gem-writer/references/module-organization.md +121 -0
- package/skills/andrew-kane-gem-writer/references/rails-integration.md +183 -0
- package/skills/andrew-kane-gem-writer/references/resources.md +126 -0
- package/skills/andrew-kane-gem-writer/references/testing-patterns.md +261 -0
- package/skills/architecture/SKILL.md +436 -0
- package/skills/architecture/references/adr-template.md +154 -0
- package/skills/architecture/references/likec4-reference.md +136 -0
- package/skills/architecture/references/nfr-reference.md +126 -0
- package/skills/archive-kb/SKILL.md +86 -0
- package/skills/archive-kb/scripts/archive-kb.sh +300 -0
- package/skills/archive-kb/test/archive-kb-partial-run.test.sh +276 -0
- package/skills/atdd-developer/SKILL.md +65 -0
- package/skills/brainstorm/SKILL.md +701 -0
- package/skills/brainstorm/references/brainstorm-brand-workshop.md +153 -0
- package/skills/brainstorm/references/brainstorm-domain-config.md +63 -0
- package/skills/brainstorm/references/brainstorm-validation-workshop.md +65 -0
- package/skills/brainstorm/references/ui-surface-terms.md +42 -0
- package/skills/brainstorm-techniques/SKILL.md +231 -0
- package/skills/brainstorm-techniques/references/decision-principles.md +150 -0
- package/skills/brainstorm-techniques/references/phase-boundaries.md +103 -0
- package/skills/brainstorm-techniques/references/standing-delegation.md +407 -0
- package/skills/brainstorm-techniques/references/technical-ruling-delegation.md +174 -0
- package/skills/campaign-calendar/SKILL.md +145 -0
- package/skills/cf-token-scope/SKILL.md +164 -0
- package/skills/cf-token-scope/references/widen-playbook.md +114 -0
- package/skills/cf-token-scope/scripts/cf-token-scope.sh +253 -0
- package/skills/changelog/SKILL.md +142 -0
- package/skills/code-to-prd/SKILL.md +66 -0
- package/skills/code-to-prd/references/banner-template.md +12 -0
- package/skills/code-to-prd/references/prd-template.md +41 -0
- package/skills/code-to-prd/scripts/code-to-prd.sh +634 -0
- package/skills/code-to-prd/test/code-to-prd.test.sh +447 -0
- package/skills/code-to-prd/test/fixture/.env.example +7 -0
- package/skills/code-to-prd/test/fixture/app/about/page.tsx +11 -0
- package/skills/code-to-prd/test/fixture/app/api/health/route.ts +10 -0
- package/skills/code-to-prd/test/fixture/app/page.tsx +14 -0
- package/skills/code-to-prd/test/fixture/next.config.mjs +3 -0
- package/skills/code-to-prd/test/fixture/package.json +6 -0
- package/skills/cohort-status/SKILL.md +62 -0
- package/skills/community/SKILL.md +210 -0
- package/skills/community/scripts/bsky-community.sh +644 -0
- package/skills/community/scripts/bsky-setup.sh +631 -0
- package/skills/community/scripts/community-router.sh +93 -0
- package/skills/community/scripts/discord-community.sh +487 -0
- package/skills/community/scripts/discord-setup.sh +784 -0
- package/skills/community/scripts/github-community.sh +706 -0
- package/skills/community/scripts/hn-community.sh +233 -0
- package/skills/community/scripts/lib/hmac-sha1-b64.sh +126 -0
- package/skills/community/scripts/linkedin-community.sh +750 -0
- package/skills/community/scripts/linkedin-setup.sh +758 -0
- package/skills/community/scripts/x-community.sh +890 -0
- package/skills/community/scripts/x-setup.sh +713 -0
- package/skills/community/test/community-argv.test.sh +2922 -0
- package/skills/community/test/github-community.test.sh +981 -0
- package/skills/competitive-analysis/SKILL.md +69 -0
- package/skills/competitive-analysis/references/peer-plugin-audit.md +218 -0
- package/skills/compound/SKILL.md +673 -0
- package/skills/compound/scripts/token-efficiency-report.sh +334 -0
- package/skills/compound/test/phase-16.test.sh +379 -0
- package/skills/compound-capture/SKILL.md +791 -0
- package/skills/compound-capture/assets/critical-pattern-template.md +37 -0
- package/skills/compound-capture/assets/resolution-template.md +116 -0
- package/skills/compound-capture/references/yaml-schema.md +67 -0
- package/skills/compound-capture/schema.yaml +181 -0
- package/skills/constraint-scaffold/SKILL.md +175 -0
- package/skills/constraint-scaffold/references/boundary-readme.template +35 -0
- package/skills/constraint-scaffold/references/constraint-gates-workflow.template +69 -0
- package/skills/constraint-scaffold/references/depcruise-config.template +230 -0
- package/skills/constraint-scaffold/references/fix-constraints-stage-a.template +240 -0
- package/skills/constraint-scaffold/references/fix-constraints-stage-b.template +313 -0
- package/skills/constraint-scaffold/references/shared-runner.template +135 -0
- package/skills/constraint-scaffold/scripts/constraint-scaffold.sh +589 -0
- package/skills/constraint-scaffold/test/bite-proof.test.sh +1338 -0
- package/skills/constraint-scaffold/test/boundary.test.sh +734 -0
- package/skills/constraint-scaffold/test/emit-fix-constraints.test.sh +193 -0
- package/skills/constraint-scaffold/test/generator.test.sh +93 -0
- package/skills/constraint-scaffold/test/parity.test.sh +285 -0
- package/skills/content-writer/SKILL.md +242 -0
- package/skills/content-writer/scripts/blog-jargon-scan.sh +42 -0
- package/skills/cron-delete/SKILL.md +56 -0
- package/skills/cron-list/SKILL.md +54 -0
- package/skills/deepen-plan/SKILL.md +937 -0
- package/skills/deepen-plan/workflows/deepen-plan.workflow.js +488 -0
- package/skills/deploy/SKILL.md +115 -0
- package/skills/deploy/references/hetzner-setup.md +67 -0
- package/skills/deploy/scripts/deploy.sh +37 -0
- package/skills/deploy-docs/SKILL.md +95 -0
- package/skills/dhh-rails-style/SKILL.md +203 -0
- package/skills/dhh-rails-style/references/architecture.md +717 -0
- package/skills/dhh-rails-style/references/controllers.md +323 -0
- package/skills/dhh-rails-style/references/frontend.md +556 -0
- package/skills/dhh-rails-style/references/gems.md +316 -0
- package/skills/dhh-rails-style/references/models.md +388 -0
- package/skills/dhh-rails-style/references/testing.md +354 -0
- package/skills/discord-content/SKILL.md +126 -0
- package/skills/docs-site/SKILL.md +214 -0
- package/skills/drain-labeled-backlog/SKILL.md +203 -0
- package/skills/drain-labeled-backlog/scripts/group-by-area.sh +208 -0
- package/skills/drain-labeled-backlog/workflows/drain-labeled-backlog.workflow.js +433 -0
- package/skills/drain-prs/SKILL.md +143 -0
- package/skills/drain-prs/scripts/triage-prs.sh +192 -0
- package/skills/drain-prs/test/argv-ceiling.test.sh +183 -0
- package/skills/dspy-ruby/SKILL.md +634 -0
- package/skills/dspy-ruby/assets/config-template.rb +359 -0
- package/skills/dspy-ruby/assets/module-template.rb +326 -0
- package/skills/dspy-ruby/assets/signature-template.rb +143 -0
- package/skills/dspy-ruby/references/core-concepts.md +268 -0
- package/skills/dspy-ruby/references/optimization.md +623 -0
- package/skills/dspy-ruby/references/providers.md +335 -0
- package/skills/eval-harness/README.md +194 -0
- package/skills/eval-harness/SKILL.md +157 -0
- package/skills/eval-harness/enums/go-routes.json +13 -0
- package/skills/eval-harness/enums/incident-threshold.json +5 -0
- package/skills/eval-harness/enums/lane.json +5 -0
- package/skills/eval-harness/enums/tool-selection.json +20 -0
- package/skills/eval-harness/enums/triage-levels.json +5 -0
- package/skills/eval-harness/gated-skills.json +34 -0
- package/skills/eval-harness/models.generated.json +5 -0
- package/skills/eval-harness/promptfooconfig-go-routing.yaml +35 -0
- package/skills/eval-harness/promptfooconfig-incident-threshold.yaml +32 -0
- package/skills/eval-harness/promptfooconfig-lane-inference.yaml +33 -0
- package/skills/eval-harness/promptfooconfig-ticket-triage.yaml +34 -0
- package/skills/eval-harness/promptfooconfig-tool-selection.yaml +33 -0
- package/skills/eval-harness/prompts/go-baseline.txt +6 -0
- package/skills/eval-harness/prompts/go-skill.txt +21 -0
- package/skills/eval-harness/prompts/incident-threshold-baseline.txt +6 -0
- package/skills/eval-harness/prompts/incident-threshold-skill.txt +12 -0
- package/skills/eval-harness/prompts/lane-inference-baseline.txt +6 -0
- package/skills/eval-harness/prompts/lane-inference-skill.txt +15 -0
- package/skills/eval-harness/prompts/tool-selection-baseline.txt +19 -0
- package/skills/eval-harness/prompts/tool-selection-skill.txt +22 -0
- package/skills/eval-harness/prompts/triage-baseline.txt +7 -0
- package/skills/eval-harness/prompts/triage-skill.txt +10 -0
- package/skills/eval-harness/scripts/eval-gate.cjs +318 -0
- package/skills/eval-harness/scripts/extract-block.cjs +87 -0
- package/skills/eval-harness/scripts/gate-classification.cjs +28 -0
- package/skills/eval-harness/scripts/gen-models.sh +41 -0
- package/skills/eval-harness/scripts/gen-skill-prompt.cjs +177 -0
- package/skills/eval-harness/scripts/measure-classification.cjs +30 -0
- package/skills/eval-harness/scripts/parse-label.cjs +93 -0
- package/skills/eval-harness/scripts/verdict.cjs +132 -0
- package/skills/eval-harness/tasks/go-routing.jsonl +8 -0
- package/skills/eval-harness/tasks/incident-threshold.jsonl +7 -0
- package/skills/eval-harness/tasks/lane-inference.jsonl +7 -0
- package/skills/eval-harness/tasks/ticket-triage.jsonl +6 -0
- package/skills/eval-harness/tasks/tool-selection.jsonl +15 -0
- package/skills/eval-harness/test/eval-gate.test.sh +132 -0
- package/skills/eval-harness/test/extract-block.test.sh +87 -0
- package/skills/eval-harness/test/gate-classification.test.sh +77 -0
- package/skills/eval-harness/test/gen-models.test.sh +75 -0
- package/skills/eval-harness/test/measure-classification.test.sh +74 -0
- package/skills/eval-harness/test/parse-label.test.sh +129 -0
- package/skills/eval-harness/test/registry-completeness.test.sh +109 -0
- package/skills/eval-harness/test/verdict.test.sh +123 -0
- package/skills/every-style-editor/SKILL.md +142 -0
- package/skills/every-style-editor/references/EVERY_WRITE_STYLE.md +529 -0
- package/skills/feature-tweet/SKILL.md +199 -0
- package/skills/feature-video/SKILL.md +412 -0
- package/skills/feature-video/scripts/check_deps.sh +281 -0
- package/skills/file-todos/SKILL.md +267 -0
- package/skills/file-todos/assets/todo-template.md +166 -0
- package/skills/fix-issue/SKILL.md +278 -0
- package/skills/fix-issue/references/agent-authored-exclusion.md +120 -0
- package/skills/fix-issue/references/exclude-label-jq-snippet.md +97 -0
- package/skills/flag-bootstrap/SETUP.md +142 -0
- package/skills/flag-create/SKILL.md +145 -0
- package/skills/flag-create/scripts/create.sh +273 -0
- package/skills/flag-delete/SKILL.md +167 -0
- package/skills/flag-delete/scripts/delete.sh +288 -0
- package/skills/flag-list/SKILL.md +99 -0
- package/skills/flag-list/scripts/list.sh +275 -0
- package/skills/flag-set-role/SKILL.md +229 -0
- package/skills/flag-set-role/scripts/flip.sh +774 -0
- package/skills/frontend-anti-slop/SKILL.md +106 -0
- package/skills/frontend-anti-slop/references/anti-patterns.md +199 -0
- package/skills/frontend-anti-slop/references/slop-rules.md +65 -0
- package/skills/frontend-anti-slop/scripts/tier1-scan.ts +525 -0
- package/skills/frontend-design/SKILL.md +77 -0
- package/skills/gdpr-gate/NOTICE +136 -0
- package/skills/gdpr-gate/SKILL.md +341 -0
- package/skills/gdpr-gate/references/fields.md +160 -0
- package/skills/gdpr-gate/references/layers/api-layer.md +355 -0
- package/skills/gdpr-gate/references/layers/auth-sessions.md +290 -0
- package/skills/gdpr-gate/references/layers/data-in-transit.md +331 -0
- package/skills/gdpr-gate/references/layers/data-lifecycle.md +341 -0
- package/skills/gdpr-gate/references/layers/frontend.md +249 -0
- package/skills/gdpr-gate/references/layers/testing-seeding.md +256 -0
- package/skills/gdpr-gate/references/leakage-vectors.md +248 -0
- package/skills/gdpr-gate/references/legacy/legal-consent-v1-prose.md +48 -0
- package/skills/gdpr-gate/references/legal-consent.md +140 -0
- package/skills/gdpr-gate/references/non-negotiables.md +60 -0
- package/skills/gdpr-gate/scripts/gdpr-gate.sh +271 -0
- package/skills/gdpr-gate/scripts/notice-frontmatter.sh +337 -0
- package/skills/gdpr-gate/scripts/path-denylist.txt +39 -0
- package/skills/gdpr-gate/scripts/repo-scan.sh +158 -0
- package/skills/gdpr-gate/scripts/vendor-drift-classify.sh +120 -0
- package/skills/gdpr-gate/scripts/vendor-pin-integrity.sh +237 -0
- package/skills/gemini-imagegen/SKILL.md +283 -0
- package/skills/gemini-imagegen/requirements.txt +5 -0
- package/skills/gemini-imagegen/scripts/_error_handling.py +146 -0
- package/skills/gemini-imagegen/scripts/compose_images.py +150 -0
- package/skills/gemini-imagegen/scripts/edit_image.py +137 -0
- package/skills/gemini-imagegen/scripts/gemini_images.py +256 -0
- package/skills/gemini-imagegen/scripts/generate_image.py +147 -0
- package/skills/gemini-imagegen/scripts/multi_turn_chat.py +223 -0
- package/skills/gemini-imagegen/scripts/test_error_handling.py +179 -0
- package/skills/git-worktree/SKILL.md +534 -0
- package/skills/git-worktree/scripts/git-repo-readiness-diag.sh +98 -0
- package/skills/git-worktree/scripts/worktree-manager.sh +4773 -0
- package/skills/git-worktree/test/create-from-origin-main.test.sh +116 -0
- package/skills/git-worktree/test/lease-protects-active.test.sh +1056 -0
- package/skills/git-worktree/test/no-repo-fail-loud.test.sh +88 -0
- package/skills/git-worktree/test/orphan-reaper-honest-count.test.sh +379 -0
- package/skills/git-worktree/test/reap-archive-persistence.test.sh +725 -0
- package/skills/git-worktree/test/stale-lock-sweep.test.sh +176 -0
- package/skills/go/SKILL.md +54 -0
- package/skills/growth/SKILL.md +164 -0
- package/skills/harvest-debt/SKILL.md +78 -0
- package/skills/harvest-debt/scripts/harvest-debt.sh +114 -0
- package/skills/heal-skill/SKILL.md +173 -0
- package/skills/help/SKILL.md +46 -0
- package/skills/incident/NOTICE +39 -0
- package/skills/incident/SKILL.md +395 -0
- package/skills/incident/scripts/dry-run.sh +448 -0
- package/skills/incident/scripts/redact-engine.py +412 -0
- package/skills/incident/scripts/redact-sentinel.sh +44 -0
- package/skills/incident/templates/pir.md +139 -0
- package/skills/incident/test/fixtures/dry-run-incident.json +26 -0
- package/skills/incident/test/fixtures/dry-run-secret-leak.json +26 -0
- package/skills/incident/test/fixtures/legacy-bash-scanner.sh +82 -0
- package/skills/incident/test/fixtures/positive-corpus.md +87 -0
- package/skills/incident/test/redact-sentinel.test.sh +1420 -0
- package/skills/invoice/SKILL.md +206 -0
- package/skills/kb-glossary/SKILL.md +71 -0
- package/skills/kb-glossary/references/glossary-format.md +111 -0
- package/skills/kb-glossary/references/rejected-request-register.md +115 -0
- package/skills/kb-search/SKILL.md +276 -0
- package/skills/kb-search/scripts/kb-search-cache.sh +123 -0
- package/skills/legal-audit/SKILL.md +161 -0
- package/skills/legal-generate/NOTICE +242 -0
- package/skills/legal-generate/SKILL.md +244 -0
- package/skills/legal-generate/references/templates/advisor-agreement/template.md +12 -0
- package/skills/legal-generate/references/templates/business-associate-agreement/template.md +112 -0
- package/skills/legal-generate/references/templates/cookie-notice/template.md +82 -0
- package/skills/legal-generate/references/templates/dpa-global/template.md +439 -0
- package/skills/legal-generate/references/templates/dpa-us/template.md +222 -0
- package/skills/legal-generate/references/templates/employee-offer-letter/template.md +75 -0
- package/skills/legal-generate/references/templates/master-services-agreement/template.md +252 -0
- package/skills/legal-generate/references/templates/mutual-nda/template.md +66 -0
- package/skills/legal-generate/references/templates/one-way-nda/template.md +38 -0
- package/skills/legal-generate/references/templates/privacy-policy-gdpr/template.md +427 -0
- package/skills/legal-generate/references/templates/privacy-policy-us/template.md +313 -0
- package/skills/legal-generate/references/templates/terms-of-use/template.md +75 -0
- package/skills/legal-generate/scripts/strip-vendor-credit.sh +96 -0
- package/skills/linear-fetch/SKILL.md +247 -0
- package/skills/linear-fetch/scripts/assert-no-linear-telemetry.sh +66 -0
- package/skills/linear-fetch/scripts/redact-linear-urls.sh +51 -0
- package/skills/linear-fetch/scripts/render-caller-template.sh +36 -0
- package/skills/linear-fetch/test/assert-no-linear-telemetry.test.sh +113 -0
- package/skills/linear-fetch/test/parity.test.sh +112 -0
- package/skills/linear-fetch/test/persist-safe-integration.test.sh +191 -0
- package/skills/linear-fetch/test/redact-linear-urls.test.sh +226 -0
- package/skills/merge-pr/SKILL.md +670 -0
- package/skills/model-launch-review/SKILL.md +255 -0
- package/skills/model-launch-review/scripts/audit-models.sh +501 -0
- package/skills/one-shot/SKILL.md +316 -0
- package/skills/one-shot/references/token-discipline.md +74 -0
- package/skills/operator-bootstrap/SKILL.md +412 -0
- package/skills/operator-bootstrap/template.sh +439 -0
- package/skills/operator-digest/SKILL.md +287 -0
- package/skills/operator-digest/assets/operator-digest.workflow.yml +175 -0
- package/skills/operator-digest/scripts/digest-scrub.sh +119 -0
- package/skills/operator-digest/scripts/provision-operator-digest-repo.sh +147 -0
- package/skills/operator-rephrase/SKILL.md +98 -0
- package/skills/pencil-setup/SKILL.md +201 -0
- package/skills/pencil-setup/scripts/check_deps.sh +556 -0
- package/skills/pencil-setup/scripts/copy_adapter.sh +80 -0
- package/skills/pencil-setup/scripts/package-lock.json +1172 -0
- package/skills/pencil-setup/scripts/package.json +14 -0
- package/skills/pencil-setup/scripts/pencil-error-enrichment.mjs +21 -0
- package/skills/pencil-setup/scripts/pencil-mcp-adapter.mjs +733 -0
- package/skills/pencil-setup/scripts/pencil-response-classify.mjs +38 -0
- package/skills/pencil-setup/scripts/pencil-save-gate.mjs +26 -0
- package/skills/pencil-setup/scripts/sanitize-filename.mjs +25 -0
- package/skills/plan/SKILL.md +1140 -0
- package/skills/plan/references/differential-migration-testing.md +185 -0
- package/skills/plan/references/plan-community-discovery.md +42 -0
- package/skills/plan/references/plan-founder-check.md +137 -0
- package/skills/plan/references/plan-functional-overlap.md +25 -0
- package/skills/plan/references/plan-host-fanout.md +51 -0
- package/skills/plan/references/plan-issue-templates.md +779 -0
- package/skills/plan/references/plan-network-outage-checklist.md +131 -0
- package/skills/plan/references/plan-scope-check.md +126 -0
- package/skills/plan/references/plan-sharp-edges.md +331 -0
- package/skills/plan/references/plan-signoff-lifecycle.md +12 -0
- package/skills/plan/references/webapp-cwv-observability.md +143 -0
- package/skills/plan-review/SKILL.md +91 -0
- package/skills/plan-review/lib/named-panel.mjs +58 -0
- package/skills/plan-review/workflows/plan-review.workflow.js +477 -0
- package/skills/postmerge/SKILL.md +642 -0
- package/skills/postmerge/references/deploy-status-debugging.md +141 -0
- package/skills/postmerge/references/graceful-degradation.md +20 -0
- package/skills/postmerge/references/sentry-error-count-delta.md +137 -0
- package/skills/preflight/SKILL.md +1553 -0
- package/skills/preflight/references/check-13-founder-check.md +286 -0
- package/skills/preflight/scripts/founder-check.py +1640 -0
- package/skills/preflight/scripts/parse-form-a.awk +107 -0
- package/skills/preflight/scripts/probe-verb-gate.sh +108 -0
- package/skills/product-roadmap/SKILL.md +357 -0
- package/skills/product-roadmap/scripts/roadmap-reconcile.sh +327 -0
- package/skills/provision-cloudflare/SKILL.md +67 -0
- package/skills/provision-cloudflare/scripts/provision-cloudflare.sh +271 -0
- package/skills/provision-doppler/SKILL.md +58 -0
- package/skills/provision-doppler/scripts/provision-doppler.sh +358 -0
- package/skills/provision-github/SKILL.md +59 -0
- package/skills/provision-github/scripts/provision-github.sh +300 -0
- package/skills/provision-hetzner/SKILL.md +76 -0
- package/skills/provision-hetzner/scripts/provision-hetzner.sh +262 -0
- package/skills/provision-hetzner/test/fixture/knowledge-base/legal/tenant-dpa-register.md +26 -0
- package/skills/provision-hetzner/test/fixture/provision-hetzner-dry-run.golden +34 -0
- package/skills/provision-hetzner/test/provision-hetzner-characterization.test.sh +227 -0
- package/skills/qa/SKILL.md +263 -0
- package/skills/qa/references/browser-preflight.md +22 -0
- package/skills/questionnaire-generate/SKILL.md +328 -0
- package/skills/questionnaire-generate/references/questionnaire.template +60 -0
- package/skills/rclone/SKILL.md +162 -0
- package/skills/rclone/scripts/check_setup.sh +63 -0
- package/skills/release-announce/SKILL.md +65 -0
- package/skills/release-docs/SKILL.md +92 -0
- package/skills/reproduce-bug/SKILL.md +358 -0
- package/skills/resolve-debt/SKILL.md +85 -0
- package/skills/resolve-debt/scripts/resolve-debt.py +584 -0
- package/skills/resolve-parallel/SKILL.md +45 -0
- package/skills/resolve-parallel/workflows/resolve-parallel.workflow.js +415 -0
- package/skills/resolve-pr-parallel/SKILL.md +68 -0
- package/skills/resolve-pr-parallel/workflows/resolve-pr-parallel.workflow.js +419 -0
- package/skills/resolve-todo-parallel/SKILL.md +57 -0
- package/skills/resolve-todo-parallel/workflows/resolve-todo-parallel.workflow.js +398 -0
- package/skills/review/SKILL.md +1620 -0
- package/skills/review/references/anti-slop-scanner-hook.md +40 -0
- package/skills/review/references/defect-classes-migrations.md +9 -0
- package/skills/review/references/generated-input-oracle-surfaces.md +15 -0
- package/skills/review/references/review-e2e-testing.md +116 -0
- package/skills/review/references/review-evidence-comment.md +36 -0
- package/skills/review/references/review-todo-structure.md +165 -0
- package/skills/review/references/risk-tier-and-fix-rounds.md +97 -0
- package/skills/review/references/semgrep-custom-rules.yaml +174 -0
- package/skills/review/references/ultrathink-phases.md +98 -0
- package/skills/review/references/wfs.md +6 -0
- package/skills/review/scripts/emit-review-trailer.sh +499 -0
- package/skills/review/scripts/ensure-semgrep.sh +99 -0
- package/skills/review/scripts/fix-round-seats.sh +197 -0
- package/skills/review/test/emit-review-trailer.test.sh +387 -0
- package/skills/review/test/ensure-semgrep.test.sh +111 -0
- package/skills/review/test/fix-round-seats.test.sh +237 -0
- package/skills/review/workflows/README.md +147 -0
- package/skills/review/workflows/review.workflow.js +667 -0
- package/skills/schedule/SKILL.md +796 -0
- package/skills/seo-aeo/SKILL.md +139 -0
- package/skills/seo-aeo/scripts/validate-csp.sh +233 -0
- package/skills/seo-aeo/scripts/validate-seo.sh +262 -0
- package/skills/ship/SKILL.md +3023 -0
- package/skills/ship/references/adr-ordinal-collision.md +47 -0
- package/skills/ship/references/affected-gate-contract.md +115 -0
- package/skills/ship/references/ci-run-diagnosis.md +29 -0
- package/skills/ship/references/ci-workflow-authoring.md +68 -0
- package/skills/ship/references/cleanup-merged-archival.md +39 -0
- package/skills/ship/references/counsel-review-clo-attestation.md +38 -0
- package/skills/ship/references/cto-ruling-armed-push.md +5 -0
- package/skills/ship/references/decision-challenges-render.md +71 -0
- package/skills/ship/references/delegated-act-authoring-form.md +7 -0
- package/skills/ship/references/detached-battery-verdict.md +13 -0
- package/skills/ship/references/epic-close-sweep.md +62 -0
- package/skills/ship/references/feature-tweet-draft.md +42 -0
- package/skills/ship/references/followthrough-issue-body-template.md +47 -0
- package/skills/ship/references/followthrough-stub-template.sh +92 -0
- package/skills/ship/references/merge-queue-dequeue.md +39 -0
- package/skills/ship/references/operator-holds.md +179 -0
- package/skills/ship/references/pipeline-tally-render.md +43 -0
- package/skills/ship/references/pr-overlap.md +153 -0
- package/skills/ship/references/queue-mode.md +7 -0
- package/skills/ship/references/ready-run-wait.md +36 -0
- package/skills/ship/references/red-on-main-quarantine.md +110 -0
- package/skills/ship/references/register-update-pr-pattern.md +88 -0
- package/skills/ship/references/settle-then-admin-merge.md +130 -0
- package/skills/ship/references/supabase-column-probe.md +13 -0
- package/skills/ship/scripts/auto-close-scan.sh +76 -0
- package/skills/ship/scripts/battery-owed.sh +403 -0
- package/skills/ship/scripts/hold-gate-eval.sh +410 -0
- package/skills/ship/scripts/net-issue-flow.sh +665 -0
- package/skills/ship/scripts/pr-overlap-preflight.sh +483 -0
- package/skills/ship/scripts/ship-pir-action-items-gate.sh +246 -0
- package/skills/ship/scripts/technical-ruling-gate.sh +444 -0
- package/skills/skill-creator/SKILL.md +299 -0
- package/skills/skill-creator/references/api-security.md +248 -0
- package/skills/skill-creator/references/authoring-levers.md +110 -0
- package/skills/skill-creator/references/be-clear-and-direct.md +587 -0
- package/skills/skill-creator/references/best-practices.md +431 -0
- package/skills/skill-creator/references/common-patterns.md +639 -0
- package/skills/skill-creator/references/core-principles.md +193 -0
- package/skills/skill-creator/references/executable-code.md +188 -0
- package/skills/skill-creator/references/iteration-and-testing.md +509 -0
- package/skills/skill-creator/references/official-spec.md +193 -0
- package/skills/skill-creator/references/recommended-structure.md +180 -0
- package/skills/skill-creator/references/skill-structure.md +396 -0
- package/skills/skill-creator/references/using-scripts.md +122 -0
- package/skills/skill-creator/references/using-templates.md +120 -0
- package/skills/skill-creator/references/workflows-and-validation.md +541 -0
- package/skills/skill-creator/scripts/init_skill.py +322 -0
- package/skills/skill-creator/scripts/package_skill.py +110 -0
- package/skills/skill-creator/scripts/quick_validate.py +64 -0
- package/skills/skill-creator/templates/router-skill.md +73 -0
- package/skills/skill-creator/templates/simple-skill.md +34 -0
- package/skills/skill-creator/workflows/add-reference.md +96 -0
- package/skills/skill-creator/workflows/add-script.md +104 -0
- package/skills/skill-creator/workflows/add-template.md +83 -0
- package/skills/skill-creator/workflows/add-workflow.md +128 -0
- package/skills/skill-creator/workflows/audit-skill.md +159 -0
- package/skills/skill-creator/workflows/create-domain-expertise-skill.md +623 -0
- package/skills/skill-creator/workflows/create-new-skill.md +207 -0
- package/skills/skill-creator/workflows/get-guidance.md +134 -0
- package/skills/skill-creator/workflows/upgrade-to-router.md +170 -0
- package/skills/skill-creator/workflows/verify-skill.md +226 -0
- package/skills/skill-security-scan/SKILL.md +145 -0
- package/skills/skill-security-scan/references/disclaimer.md +21 -0
- package/skills/skill-security-scan/references/first-party-allowlist.yaml +38 -0
- package/skills/skill-security-scan/references/override-artifact-schema.json +68 -0
- package/skills/skill-security-scan/references/override-mechanism.md +171 -0
- package/skills/skill-security-scan/references/redirect-domains.yaml +27 -0
- package/skills/skill-security-scan/references/regex-patterns.md +136 -0
- package/skills/skill-security-scan/references/rules/code-exec.yaml +70 -0
- package/skills/skill-security-scan/references/rules/filesystem-boundary.yaml +29 -0
- package/skills/skill-security-scan/references/rules/manifest.yaml +44 -0
- package/skills/skill-security-scan/references/rules/supply-chain.yaml +36 -0
- package/skills/skill-security-scan/references/scan-meta-schema.json +63 -0
- package/skills/skill-security-scan/references/test-fixtures/clean-soleur-style.skill.md +23 -0
- package/skills/skill-security-scan/references/test-fixtures/clean-third-party.skill.md +26 -0
- package/skills/skill-security-scan/references/test-fixtures/malicious-codeexec.skill.md +23 -0
- package/skills/skill-security-scan/references/test-fixtures/malicious-curl-pipe-bash.skill.md +69 -0
- package/skills/skill-security-scan/references/test-fixtures/malicious-prompt-injection.skill.md +17 -0
- package/skills/skill-security-scan/references/test-fixtures/malicious-telemetry-beacon.skill.md +23 -0
- package/skills/skill-security-scan/references/typosquat-targets.yaml +65 -0
- package/skills/skill-security-scan/scripts/check-codeexec.sh +41 -0
- package/skills/skill-security-scan/scripts/check-filesystem-boundary.sh +19 -0
- package/skills/skill-security-scan/scripts/check-prompt-injection.sh +95 -0
- package/skills/skill-security-scan/scripts/check-supply-chain.sh +219 -0
- package/skills/skill-security-scan/scripts/check-telemetry-surface.sh +126 -0
- package/skills/skill-security-scan/scripts/lib.sh +197 -0
- package/skills/skill-security-scan/scripts/parse-override.sh +231 -0
- package/skills/skill-security-scan/scripts/run-scan.sh +342 -0
- package/skills/skill-security-scan/scripts/run-self-test.sh +95 -0
- package/skills/skill-security-scan/test/argv-ceiling.test.sh +180 -0
- package/skills/skill-security-scan/test/meta-dir-reap.test.sh +411 -0
- package/skills/skill-security-scan/test/parse-override.test.sh +607 -0
- package/skills/skill-security-scan/test/redact-pii-ghs-shape.test.sh +251 -0
- package/skills/social-distribute/SKILL.md +485 -0
- package/skills/spec-templates/SKILL.md +274 -0
- package/skills/sync/SKILL.md +49 -0
- package/skills/test-browser/SKILL.md +464 -0
- package/skills/test-browser/references/feature-map.md +161 -0
- package/skills/test-fix-loop/SKILL.md +159 -0
- package/skills/test-fix-loop/references/determinism-checklist.md +154 -0
- package/skills/triage/SKILL.md +402 -0
- package/skills/trigger-cron/SKILL.md +136 -0
- package/skills/trigger-cron/scripts/trigger.sh +198 -0
- package/skills/user-set-role/SKILL.md +147 -0
- package/skills/user-set-role/scripts/set-role.sh +218 -0
- package/skills/user-story-writer/SKILL.md +77 -0
- package/skills/ux-audit/SKILL.md +251 -0
- package/skills/ux-audit/references/finding.schema.json +62 -0
- package/skills/ux-audit/references/route-list.yaml +95 -0
- package/skills/ux-audit/scripts/bot-fixture.ts +270 -0
- package/skills/ux-audit/scripts/bot-signin.ts +135 -0
- package/skills/ux-audit/scripts/dedup-hash.ts +46 -0
- package/skills/verification-scaffold/SKILL.md +73 -0
- package/skills/verification-scaffold/references/gitleaks.toml.template +13 -0
- package/skills/verification-scaffold/references/guardrail-audit.md +156 -0
- package/skills/verification-scaffold/references/guards/block-stash-hook.sh.template +72 -0
- package/skills/verification-scaffold/references/guards/commit-on-main-hook.sh.template +82 -0
- package/skills/verification-scaffold/references/guards/orphan-test-check.sh.template +102 -0
- package/skills/verification-scaffold/references/guards/precommit-guard.sh.template +115 -0
- package/skills/verification-scaffold/references/guards/secret-scan-hook.sh.template +176 -0
- package/skills/verification-scaffold/references/guards/verification-gates.yml.template +49 -0
- package/skills/verification-scaffold/references/guards/wrapper-boundary.sh.template +47 -0
- package/skills/verification-scaffold/references/guards.tsv +21 -0
- package/skills/verification-scaffold/references/verification-readme.template +55 -0
- package/skills/verification-scaffold/scripts/verification-scaffold.sh +785 -0
- package/skills/verification-scaffold/test/bite-proof.test.sh +159 -0
- package/skills/verification-scaffold/test/generator.test.sh +319 -0
- package/skills/verification-scaffold/test/manifest.test.sh +144 -0
- package/skills/work/SKILL.md +1480 -0
- package/skills/work/references/end-of-work-resume.md +7 -0
- package/skills/work/references/hook-test-main-cwd.md +24 -0
- package/skills/work/references/key-principles.md +83 -0
- package/skills/work/references/property-testing.md +122 -0
- package/skills/work/references/sql-security-definer-rpc-scaffold.sql +78 -0
- package/skills/work/references/work-agent-teams.md +97 -0
- package/skills/work/references/work-lifecycle-parallel.md +147 -0
- package/skills/work/references/work-lockfile-bumps.md +97 -0
- package/skills/work/references/work-scratch-sandboxes.md +212 -0
- package/skills/work/references/work-subagent-fanout.md +61 -0
- package/skills/work/references/work-test-harness-sharp-edges.md +73 -0
- package/skills/xcode-test/SKILL.md +360 -0
|
@@ -0,0 +1,1547 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Shrink-only lint ratchet: ruff over tracked Python, eslint no-unused-vars over test dirs.
|
|
3
|
+
|
|
4
|
+
python3 plugins/soleur/scripts/lint-ratchet.py <scope> [--check | --update | --list] [--base <ref>]
|
|
5
|
+
|
|
6
|
+
Soleur scopes: ``ruff`` and ``eslint-test-dirs`` (see SCOPES); they run only from the Soleur checkout.
|
|
7
|
+
User-repository scopes (PR3b): ``user-ruff`` runs the pinned ruff over the CALLING repository with the
|
|
8
|
+
user's own ruff configuration and a baseline at ``.lint-ratchet/ruff.baseline.txt``; ``auto`` decides
|
|
9
|
+
whether that repository opted in (a tracked root ``ruff.toml``, ``.ruff.toml`` or ``pyproject.toml``
|
|
10
|
+
with a ``[tool.ruff`` table AND a baseline file in the working tree, committed or not) and then runs
|
|
11
|
+
``user-ruff --check``. Nothing is installed or run for a repository that did not opt in: ``auto`` prints
|
|
12
|
+
one line and exits 0 (outside CI, or in CI once a merge base resolves). ``user-ruff --update`` is the
|
|
13
|
+
adoption step and also writes the per-user ruff cache.
|
|
14
|
+
Design record: ADR-290; tracker #10331.
|
|
15
|
+
|
|
16
|
+
A baseline file lists the findings that existed when the scope was introduced. Each entry is
|
|
17
|
+
``path|rule|anchor|n``: ``anchor`` is a 12-hex fingerprint of the whitespace-normalised source line
|
|
18
|
+
of the finding (the line text is never stored) and ``n`` the 1-based ordinal among identical
|
|
19
|
+
``(path, rule, anchor)`` triples, so two identical lines are two entries. ``--check`` enforces
|
|
20
|
+
|
|
21
|
+
1. the baseline file exists (and is neither a symlink nor carries duplicate lines),
|
|
22
|
+
2. the tool ran and its report parsed, and (Soleur scopes) every tracked in-scope file was actually
|
|
23
|
+
scanned: a tracked file the tool silently skipped is a failure, not a smaller census. The user scopes
|
|
24
|
+
count the files ruff listed instead and warn about tracked symlinks and untracked files,
|
|
25
|
+
3. (Soleur scopes) the scan saw enough files (a floor per scope); the user scopes have no floor, a scan of
|
|
26
|
+
zero files is a WARN and never an OK,
|
|
27
|
+
4. the baseline EQUALS the live census: a new finding fails, and so does a stale entry,
|
|
28
|
+
5. per ``(path, rule)`` the baseline never holds more entries than at the merge base, after
|
|
29
|
+
applying ``git diff -M`` renames, so the baseline can only shrink. The user scopes lift this only
|
|
30
|
+
when the toolchain fingerprint (ruff pin + tracked ruff configs) differs from the merge base's.
|
|
31
|
+
|
|
32
|
+
Exit codes: 0 clean; 1 drift or vacuity; 2 infrastructure (tool missing or crashed, unparsable
|
|
33
|
+
output, hash mismatch, baseline absent or unsafe, no merge base under ``CI``, any unexpected error).
|
|
34
|
+
In the user scopes a content-caused exit 2 carries the stable prefix ``REPO-CONTENT:`` so a caller can
|
|
35
|
+
tell a defect in the repository from a defect in the toolchain.
|
|
36
|
+
|
|
37
|
+
Counting is waiver-proof in the Soleur scopes (ruff ``--ignore-noqa``, eslint ``--no-inline-config``; the
|
|
38
|
+
user scopes honour the user's own ``# noqa``) and no tool is ever run in a fix mode: the files under scan include test fixtures that are test INPUT.
|
|
39
|
+
|
|
40
|
+
Library contract (PR3b): ``check(config, root, base, env)`` takes a plain dict with ``name``,
|
|
41
|
+
``baseline``, ``floor``, ``run`` (``run(root) -> (findings, file_count)``, may raise ``Vacuous``,
|
|
42
|
+
``InfraError`` or ``Skip``), ``remedy``, and optionally ``script``, ``recovery_cmd``,
|
|
43
|
+
``waiver_note``, ``ladder``, ``header`` (a callable giving the toolchain header line, which turns on the
|
|
44
|
+
fingerprint rule), ``repo_content`` and ``header_recovery``. ``Skip`` propagates to the caller.
|
|
45
|
+
|
|
46
|
+
Accepted residual of the fingerprint rule: an agent can edit the ruff config, run ``--update`` and go
|
|
47
|
+
green. The control is review of the config diff; the rule makes it visible (``CONFIG CHANGED``).
|
|
48
|
+
|
|
49
|
+
Residual: anchors are read from the working tree, so a partially staged commit can disagree with
|
|
50
|
+
the index; the full-tree CI run is the backstop.
|
|
51
|
+
|
|
52
|
+
Stdlib only and 3.9-safe: lefthook runs this on contributors' macOS system Python.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
from __future__ import annotations
|
|
56
|
+
|
|
57
|
+
import argparse
|
|
58
|
+
import contextlib
|
|
59
|
+
import hashlib
|
|
60
|
+
import json
|
|
61
|
+
import os
|
|
62
|
+
import platform
|
|
63
|
+
import re
|
|
64
|
+
import shlex
|
|
65
|
+
import shutil
|
|
66
|
+
import stat
|
|
67
|
+
import subprocess
|
|
68
|
+
import sys
|
|
69
|
+
import tempfile
|
|
70
|
+
import time
|
|
71
|
+
from pathlib import Path
|
|
72
|
+
from typing import NamedTuple
|
|
73
|
+
|
|
74
|
+
if sys.version_info < (3, 9):
|
|
75
|
+
sys.stderr.write("lint-ratchet: needs python>=3.9\n")
|
|
76
|
+
sys.exit(2)
|
|
77
|
+
|
|
78
|
+
# The Soleur scopes (ruff, eslint-test-dirs) always run against the repository this script is vendored
|
|
79
|
+
# in: REPO_ROOT is derived from the script location (plugins/soleur/scripts/), never from the cwd.
|
|
80
|
+
# A copy placed shallower than four directories (a bare vendor dir) has no such root: the Soleur scopes
|
|
81
|
+
# refuse there (build_config), the user scopes never need it.
|
|
82
|
+
_ANCESTORS = Path(__file__).resolve().parents
|
|
83
|
+
REPO_ROOT = _ANCESTORS[3] if len(_ANCESTORS) > 3 else None
|
|
84
|
+
SCRIPT_REL = "plugins/soleur/scripts/lint-ratchet.py"
|
|
85
|
+
RUFF_REQUIREMENTS = Path(__file__).resolve().parent / "ruff-requirements.txt"
|
|
86
|
+
ESLINT_CONFIG = "apps/web-platform/eslint.test-dirs.config.mjs"
|
|
87
|
+
ESLINT_BIN = "apps/web-platform/node_modules/.bin/eslint"
|
|
88
|
+
ESLINT_EXTS = (".js", ".mjs", ".cjs", ".jsx", ".ts", ".tsx", ".mts", ".cts")
|
|
89
|
+
# Directory names ruff.toml (extend-exclude) deliberately does not scan, wherever they sit in the
|
|
90
|
+
# path; anything else a tracked file is missing from ruff's own file list for is a silent skip and
|
|
91
|
+
# fails the scan.
|
|
92
|
+
EXCLUDED_DIRS = ("knowledge-base",)
|
|
93
|
+
|
|
94
|
+
# User scopes (PR3b). These pathspecs are deliberately NOT read from SCOPES: the user scopes must give the
|
|
95
|
+
# same verdict whatever the Soleur tables hold.
|
|
96
|
+
USER_SCOPES = ("user-ruff", "auto")
|
|
97
|
+
USER_BASELINE = ".lint-ratchet/ruff.baseline.txt"
|
|
98
|
+
USER_PY_PATHSPECS = ("*.py", "*.pyi", "*.pyw")
|
|
99
|
+
USER_RUFF_CONFIGS = ("ruff.toml", ".ruff.toml")
|
|
100
|
+
USER_PYPROJECT = "pyproject.toml"
|
|
101
|
+
MAX_CONFIG_BYTES = 1 << 20
|
|
102
|
+
REPO_CONTENT = "REPO-CONTENT: "
|
|
103
|
+
HEADER_PREFIX = "# toolchain: "
|
|
104
|
+
CONFIG_CHANGED = "CONFIG CHANGED vs merge base"
|
|
105
|
+
# Fully qualified: a bare ``origin/main`` is resolved by git as a tag first, and tags travel with a clone.
|
|
106
|
+
BASE_LADDER = ("refs/remotes/origin/HEAD", "refs/remotes/origin/main", "refs/remotes/origin/master",
|
|
107
|
+
"refs/remotes/upstream/main", "refs/remotes/upstream/master", "refs/heads/main", "refs/heads/master")
|
|
108
|
+
# The only ruff failures where the pinned version is a plausible cause.
|
|
109
|
+
PIN_HINT_RE = re.compile(r"(?i)(required[- ]version|unknown rule selector|unknown (field|key|variant|option))")
|
|
110
|
+
MAX_BASELINE_BYTES = 16 << 20
|
|
111
|
+
# Per launch; Windows' command line is the tight limit, POSIX has 1 MiB (macOS) or more.
|
|
112
|
+
ARGV_BUDGET = 24000 if sys.platform == "win32" else 100000
|
|
113
|
+
TOTAL_RUN_BUDGET = 1800 # seconds across every chunked ruff launch of one run
|
|
114
|
+
NOTICE_LIMIT = 25
|
|
115
|
+
LINE_LIMIT = 600
|
|
116
|
+
FIELD_LIMIT = 200
|
|
117
|
+
# Variables that redirect git to another repository, index or object store, or change how a pathspec is read.
|
|
118
|
+
GIT_SCRUB = ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_COMMON_DIR", "GIT_OBJECT_DIRECTORY",
|
|
119
|
+
"GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_NAMESPACE", "GIT_PREFIX", "GIT_LITERAL_PATHSPECS",
|
|
120
|
+
"GIT_GLOB_PATHSPECS", "GIT_NOGLOB_PATHSPECS", "GIT_ICASE_PATHSPECS", "GIT_QUARANTINE_PATH",
|
|
121
|
+
"GIT_REPLACE_REF_BASE", "GIT_GRAFT_FILE", "GIT_SHALLOW_FILE")
|
|
122
|
+
RULE_PIN_RE = re.compile(r"ruff ([0-9][0-9A-Za-z.]*)")
|
|
123
|
+
GIT_TIMEOUT = 120
|
|
124
|
+
RUN_TIMEOUT = 900
|
|
125
|
+
PIP_TIMEOUT = 300
|
|
126
|
+
|
|
127
|
+
ENTRY_RE = re.compile(r"^([^|]+)\|([^|]+)\|([0-9a-f]{12})\|([0-9]+)$")
|
|
128
|
+
LINE_SPLIT_RE = re.compile(r"\r\n|\r|\n")
|
|
129
|
+
# Invisible or direction-changing characters, listed as code points so this source never holds a raw
|
|
130
|
+
# line separator: line/paragraph separators, zero-width and bidi controls, invisible operators,
|
|
131
|
+
# the soft hyphen, the Mongolian vowel separator, the byte-order mark, the Unicode tag block and variation
|
|
132
|
+
# selectors (an ASCII-smuggling channel into a model's context) and the blank hangul/braille fillers.
|
|
133
|
+
INVISIBLE_CODEPOINTS = (0x00ad, 0x034f, 0x061c, 0x115f, 0x1160, 0x180e, 0x2028, 0x2029, 0x2060, 0x2061, 0x2062,
|
|
134
|
+
0x2063, 0x2064, 0x206a, 0x206b, 0x206c, 0x206d, 0x206e, 0x206f, 0x2800, 0x3164, 0xfeff,
|
|
135
|
+
0xffa0) + tuple(range(0x200b, 0x2010)) + tuple(range(0x202a, 0x202f)) \
|
|
136
|
+
+ tuple(range(0x2066, 0x206a)) + tuple(range(0xfe00, 0xfe10)) + tuple(range(0xfff9, 0xfffd)) \
|
|
137
|
+
+ tuple(range(0xe0000, 0xe0080)) + tuple(range(0xe0100, 0xe01f0)) \
|
|
138
|
+
+ (0x17b4, 0x17b5, 0x2065) + tuple(range(0x180b, 0x180e)) + tuple(range(0x1bca0, 0x1bca4)) \
|
|
139
|
+
+ tuple(range(0x1d173, 0x1d17b))
|
|
140
|
+
CONTROL_RE = re.compile("[\x00-\x1f\x7f-\x9f" + "".join(chr(c) for c in INVISIBLE_CODEPOINTS) + "]")
|
|
141
|
+
# pip output that means the REQUIREMENTS are wrong or the wheel does not match its hash: never a
|
|
142
|
+
# skip, because a skipped integrity failure is exactly how a poisoned index would get through.
|
|
143
|
+
PIP_INTEGRITY_MARKERS = (
|
|
144
|
+
"DO NOT MATCH THE HASHES",
|
|
145
|
+
"Hashes are required",
|
|
146
|
+
"Invalid requirement",
|
|
147
|
+
"Could not open requirements file",
|
|
148
|
+
"must be pinned",
|
|
149
|
+
"In --require-hashes mode",
|
|
150
|
+
)
|
|
151
|
+
# Only a positively identified reachability failure may be skipped locally; any other pip
|
|
152
|
+
# failure is infrastructure, so a reworded integrity message cannot turn into a silent skip.
|
|
153
|
+
PIP_NETWORK_MARKERS = (
|
|
154
|
+
"Could not fetch URL",
|
|
155
|
+
"Temporary failure in name resolution",
|
|
156
|
+
"Name or service not known",
|
|
157
|
+
"Network is unreachable",
|
|
158
|
+
"No route to host",
|
|
159
|
+
"Max retries exceeded",
|
|
160
|
+
"timed out",
|
|
161
|
+
"Connection",
|
|
162
|
+
"nodename nor servname",
|
|
163
|
+
"NameResolutionError",
|
|
164
|
+
"NewConnectionError",
|
|
165
|
+
"Failed to establish a new connection",
|
|
166
|
+
"Failed to resolve",
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
REMEDY_RUFF = (
|
|
170
|
+
"fix the code: SIM115 use a context manager (with open(...)), S110/S112 handle or log the "
|
|
171
|
+
"exception instead of passing, F401 remove the import, F841 use the value or prefix it with _"
|
|
172
|
+
)
|
|
173
|
+
REMEDY_USER = ("fix the finding; changing your ruff configuration or running --update to make this pass needs the "
|
|
174
|
+
"user's explicit OK; the baseline is yours and may only shrink")
|
|
175
|
+
WAIVER_NOTE_SOLEUR = ("inline waivers (# noqa, eslint-disable) are NOT honoured here, and --update must not be used "
|
|
176
|
+
"to admit a new finding")
|
|
177
|
+
WAIVER_NOTE_USER = "inline waivers (# noqa) are honoured here, and --update must not be used to admit a new finding"
|
|
178
|
+
REMEDY_ESLINT = (
|
|
179
|
+
"remove the unused binding or prefix it with _ (arguments and variables only; for an unused "
|
|
180
|
+
"catch parameter write catch {})"
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
SCOPES = {
|
|
184
|
+
"ruff": {
|
|
185
|
+
"baseline": "scripts/lint-ratchet-ruff.baseline.txt",
|
|
186
|
+
"floor": 55,
|
|
187
|
+
"scope": ("*.py", "*.pyi", "*.pyw"),
|
|
188
|
+
"remedy": REMEDY_RUFF,
|
|
189
|
+
},
|
|
190
|
+
"eslint-test-dirs": {
|
|
191
|
+
"baseline": "scripts/lint-ratchet-eslint-test-dirs.baseline.txt",
|
|
192
|
+
"floor": 105,
|
|
193
|
+
"scope": (
|
|
194
|
+
"test/**",
|
|
195
|
+
"plugins/soleur/test/**",
|
|
196
|
+
"plugins/soleur/skills/*/test/**",
|
|
197
|
+
"scripts/*.test.mjs",
|
|
198
|
+
),
|
|
199
|
+
"remedy": REMEDY_ESLINT,
|
|
200
|
+
},
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class InfraError(Exception):
|
|
205
|
+
"""The tool, its output, the baseline or git could not be trusted: exit 2."""
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
class ContentError(InfraError):
|
|
209
|
+
"""The repository's own content (config, baseline) made the run impossible: exit 2, REPO-CONTENT prefix."""
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class Vacuous(Exception):
|
|
213
|
+
"""The scan would have looked at nothing: exit 1."""
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class Skip(Exception):
|
|
217
|
+
"""The tool is unobtainable outside CI: print a loud SKIP and exit 0."""
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class Finding(NamedTuple):
|
|
221
|
+
path: str
|
|
222
|
+
rule: str
|
|
223
|
+
row: int
|
|
224
|
+
message: str
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# --------------------------------------------------------------------------- process helpers
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _exec(run, argv, timeout, label=None, **kwargs):
|
|
231
|
+
"""Run a child process with a bound, UTF-8 text capture, mapping spawn failures to InfraError."""
|
|
232
|
+
kwargs.setdefault("capture_output", True)
|
|
233
|
+
kwargs.setdefault("encoding", "utf-8")
|
|
234
|
+
kwargs.setdefault("errors", "replace")
|
|
235
|
+
name = label or Path(str(argv[0])).name
|
|
236
|
+
try:
|
|
237
|
+
return run(argv, timeout=timeout, **kwargs)
|
|
238
|
+
except subprocess.TimeoutExpired:
|
|
239
|
+
raise InfraError("{} timed out after {}s".format(name, timeout))
|
|
240
|
+
except OSError as exc:
|
|
241
|
+
raise InfraError("could not run {}: {}".format(name, exc))
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _clean(text: str) -> str:
|
|
245
|
+
"""Strip terminal control characters from anything echoed from a file name or tool message."""
|
|
246
|
+
return CONTROL_RE.sub(" ", text)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _clip(text: str, limit: int = FIELD_LIMIT) -> str:
|
|
250
|
+
"""Clean and bound a repository-controlled string before it is echoed (a file name has no length cap)."""
|
|
251
|
+
text = _clean(text)
|
|
252
|
+
return text if len(text) <= limit else text[:limit - 3] + "..."
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _is_ci(env) -> bool:
|
|
256
|
+
value = (env if env is not None else os.environ).get("CI", "")
|
|
257
|
+
return value.strip().lower() not in ("", "0", "false", "no")
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
# --------------------------------------------------------------------------- entries
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def normalise(text: str) -> str:
|
|
264
|
+
return " ".join(text.split())
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def anchor_of(text: str) -> str:
|
|
268
|
+
return hashlib.sha256(normalise(text).encode("utf-8")).hexdigest()[:12]
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def format_entry(path: str, rule: str, anchor: str, n: int) -> str:
|
|
272
|
+
return "{}|{}|{}|{}".format(path, rule, anchor, n)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def parse_baseline(text: str) -> list:
|
|
276
|
+
entries = []
|
|
277
|
+
seen = set()
|
|
278
|
+
for number, raw in enumerate(LINE_SPLIT_RE.split(text), start=1):
|
|
279
|
+
line = raw.strip()
|
|
280
|
+
if not line or line.startswith("#"):
|
|
281
|
+
continue
|
|
282
|
+
if line.startswith(("<<<<<<<", "=======", ">>>>>>>")):
|
|
283
|
+
raise ContentError("baseline line {} is a merge-conflict marker: take the base branch's copy of the "
|
|
284
|
+
"baseline and re-run --update".format(number))
|
|
285
|
+
match = ENTRY_RE.match(line)
|
|
286
|
+
if not match:
|
|
287
|
+
raise ContentError("baseline line {} is not 'path|rule|anchor|n': {!r}".format(number, _clip(line, 80)))
|
|
288
|
+
entry = (match.group(1), match.group(2), match.group(3), int(match.group(4)))
|
|
289
|
+
if entry in seen:
|
|
290
|
+
raise ContentError("baseline line {} duplicates an earlier entry (re-run --update): {!r}".format(
|
|
291
|
+
number, _clip(line, 80)))
|
|
292
|
+
seen.add(entry)
|
|
293
|
+
entries.append(entry)
|
|
294
|
+
return entries
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def render_baseline(name: str, entries: list, *, recovery=None, header=None) -> str:
|
|
298
|
+
recovery = recovery or "python3 {} {}".format(SCRIPT_REL, name)
|
|
299
|
+
# The user form belongs to the one config build_user_config builds ("user-ruff"); "auto" resolves to it
|
|
300
|
+
# before any render, so it never reaches here.
|
|
301
|
+
if name == "user-ruff":
|
|
302
|
+
# User baseline: machine-independent text (no absolute path).
|
|
303
|
+
lines = [
|
|
304
|
+
"# lint-ratchet baseline (ruff) for this repository; engine: the Soleur plugin, design in ADR-290.",
|
|
305
|
+
"# Shrink-only: this file always equals the live census, and per (path, rule) it may never hold more",
|
|
306
|
+
"# entries than the merge base, except when the toolchain line below changed (a ruff pin or config",
|
|
307
|
+
"# change; that run prints CONFIG CHANGED so the config diff gets reviewed). Entry: path|rule|anchor|n,",
|
|
308
|
+
"# where anchor is a 12-hex fingerprint of the normalised source line (the line is never stored).",
|
|
309
|
+
"# List the live findings: {} --list".format(recovery),
|
|
310
|
+
"# After fixing findings: {} --update (it refuses to add, except as above)".format(recovery),
|
|
311
|
+
"# Outside Claude Code, replace ${CLAUDE_PLUGIN_ROOT} with the plugin's install directory.",
|
|
312
|
+
"# After a merge conflict, take the base branch's copy of this file and re-run --update.",
|
|
313
|
+
]
|
|
314
|
+
if header:
|
|
315
|
+
lines.append(header)
|
|
316
|
+
return "\n".join(lines + [format_entry(*entry) for entry in sorted(entries)]) + "\n"
|
|
317
|
+
# Wording is a property of the two Soleur scopes, whose committed baselines are empty by ADR-290; the
|
|
318
|
+
# shrink-only guard rejects any entry against an empty merge-base copy. A Soleur scope that legitimately
|
|
319
|
+
# holds entries must change this header and its pin test.
|
|
320
|
+
header = [
|
|
321
|
+
"# lint-ratchet scope '{}' is a zero-findings gate (design in ADR-290): "
|
|
322
|
+
"fix findings, never baseline them.".format(name),
|
|
323
|
+
"# List the live findings: {} --list".format(recovery),
|
|
324
|
+
"# After fixing findings: {} --update (it refuses to add)".format(recovery),
|
|
325
|
+
"# After a merge conflict, take the base branch's copy of this file and re-run --update.",
|
|
326
|
+
]
|
|
327
|
+
body = [format_entry(*entry) for entry in sorted(entries)]
|
|
328
|
+
return "\n".join(header + body) + "\n"
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _check_path(path: str) -> None:
|
|
332
|
+
"""Refuse a path the entry format cannot round-trip: --update would write a baseline --check rejects forever."""
|
|
333
|
+
if "|" in path or "\n" in path or "\r" in path:
|
|
334
|
+
raise ContentError("path {!r} contains '|' or a newline; the baseline format cannot carry it".format(
|
|
335
|
+
_clip(path)))
|
|
336
|
+
if path != path.strip() or path.startswith(("#", "<<<<<<<", "=======", ">>>>>>>")):
|
|
337
|
+
raise ContentError("path {!r} starts with whitespace, '#' or a conflict marker, or ends with whitespace; "
|
|
338
|
+
"the baseline format cannot carry it".format(_clip(path)))
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def census_pairs(findings: list, root) -> list:
|
|
342
|
+
"""Return ``[(entry, finding)]`` sorted by entry; symlinked paths are skipped."""
|
|
343
|
+
root = Path(root)
|
|
344
|
+
lines_cache = {}
|
|
345
|
+
counts = {}
|
|
346
|
+
pairs = []
|
|
347
|
+
for finding in sorted(findings, key=lambda f: (f.path, f.row, f.rule)):
|
|
348
|
+
_check_path(finding.path)
|
|
349
|
+
full = root / finding.path
|
|
350
|
+
if full.is_symlink():
|
|
351
|
+
continue
|
|
352
|
+
if finding.path not in lines_cache:
|
|
353
|
+
# Findings arrive sorted by path, so only the current file's lines are ever needed.
|
|
354
|
+
lines_cache.clear()
|
|
355
|
+
try:
|
|
356
|
+
text = full.read_text(encoding="utf-8", errors="replace")
|
|
357
|
+
lines_cache[finding.path] = LINE_SPLIT_RE.split(text)
|
|
358
|
+
except OSError:
|
|
359
|
+
lines_cache[finding.path] = []
|
|
360
|
+
lines = lines_cache[finding.path]
|
|
361
|
+
text = lines[finding.row - 1] if 1 <= finding.row <= len(lines) else ""
|
|
362
|
+
anchor = anchor_of(text)
|
|
363
|
+
key = (finding.path, finding.rule, anchor)
|
|
364
|
+
counts[key] = counts.get(key, 0) + 1
|
|
365
|
+
pairs.append(((finding.path, finding.rule, anchor, counts[key]), finding))
|
|
366
|
+
return sorted(pairs, key=lambda pair: pair[0])
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def census(findings: list, root) -> list:
|
|
370
|
+
return [entry for entry, _ in census_pairs(findings, root)]
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
# --------------------------------------------------------------------------- adapters
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _relative(filename: str, root) -> str:
|
|
377
|
+
root = Path(root)
|
|
378
|
+
if not os.path.isabs(filename):
|
|
379
|
+
filename = os.path.join(str(root), filename)
|
|
380
|
+
for base in (str(root), None):
|
|
381
|
+
if base is None:
|
|
382
|
+
base = os.path.realpath(str(root)) # only on the fallback: it is a syscall per path otherwise
|
|
383
|
+
rel = os.path.relpath(filename, base)
|
|
384
|
+
if rel != ".." and not rel.startswith(".." + os.sep):
|
|
385
|
+
return rel
|
|
386
|
+
raise InfraError("tool reported a path outside the repository: {!r}".format(_clip(filename)))
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def adapt_ruff(text: str, root) -> list:
|
|
390
|
+
try:
|
|
391
|
+
report = json.loads(text)
|
|
392
|
+
except ValueError as exc:
|
|
393
|
+
raise InfraError("ruff printed output that is not JSON: {}".format(exc))
|
|
394
|
+
if not isinstance(report, list):
|
|
395
|
+
raise InfraError("ruff's JSON report is not a list")
|
|
396
|
+
findings = []
|
|
397
|
+
for item in report:
|
|
398
|
+
try:
|
|
399
|
+
code = item.get("code")
|
|
400
|
+
path = _relative(item["filename"], root)
|
|
401
|
+
if not code:
|
|
402
|
+
raise ContentError("ruff could not parse {} ({}); a file that did not parse hides its findings".format(
|
|
403
|
+
_clip(path), _clip(str(item.get("message", "syntax error")), 80)))
|
|
404
|
+
findings.append(Finding(path, code, int(item["location"]["row"]), item.get("message", "")))
|
|
405
|
+
except (KeyError, TypeError, AttributeError, ValueError) as exc:
|
|
406
|
+
raise InfraError("ruff report item has an unexpected shape ({}: {})".format(type(exc).__name__, exc))
|
|
407
|
+
return findings
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def adapt_eslint(text: str, root, tracked=None, seen=None) -> tuple:
|
|
411
|
+
try:
|
|
412
|
+
report = json.loads(text)
|
|
413
|
+
except ValueError as exc:
|
|
414
|
+
raise InfraError("eslint printed output that is not JSON: {}".format(exc))
|
|
415
|
+
if not isinstance(report, list):
|
|
416
|
+
raise InfraError("eslint's JSON report is not a list")
|
|
417
|
+
findings = []
|
|
418
|
+
file_count = 0
|
|
419
|
+
for result in report:
|
|
420
|
+
try:
|
|
421
|
+
path = _relative(result["filePath"], root)
|
|
422
|
+
if tracked is not None and path not in tracked:
|
|
423
|
+
continue
|
|
424
|
+
file_count += 1
|
|
425
|
+
if seen is not None:
|
|
426
|
+
seen.add(path)
|
|
427
|
+
for message in result.get("messages", []):
|
|
428
|
+
if message.get("fatal") or result.get("fatalErrorCount"):
|
|
429
|
+
raise InfraError("eslint could not parse {} ({}); a file that did not parse hides its "
|
|
430
|
+
"findings".format(path, message.get("message", "fatal error")))
|
|
431
|
+
findings.append(Finding(path, message.get("ruleId") or "(eslint)", int(message.get("line", 0)),
|
|
432
|
+
message.get("message", "")))
|
|
433
|
+
except (KeyError, TypeError, AttributeError, ValueError) as exc:
|
|
434
|
+
raise InfraError("eslint report item has an unexpected shape ({}: {})".format(type(exc).__name__, exc))
|
|
435
|
+
return findings, file_count
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
# --------------------------------------------------------------------------- git
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _git(root, args: list, env=None) -> subprocess.CompletedProcess:
|
|
442
|
+
return _exec(subprocess.run, ["git", "-C", str(root), "-c", "core.fsmonitor=false"] + list(args), GIT_TIMEOUT,
|
|
443
|
+
env=env)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def tracked_files(root, pathspecs, env=None) -> list:
|
|
447
|
+
result = _git(root, ["ls-files", "-z", "--"] + list(pathspecs), env)
|
|
448
|
+
if result.returncode != 0:
|
|
449
|
+
raise InfraError("git ls-files failed: {}".format(result.stderr.strip()[:200]))
|
|
450
|
+
return [name for name in result.stdout.split("\0") if name]
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def resolve_base(root, ref, env=None):
|
|
454
|
+
"""Return the SHA of the base to compare against, or None when it cannot be resolved."""
|
|
455
|
+
if ref is None:
|
|
456
|
+
result = _git(root, ["merge-base", "origin/main", "HEAD"], env)
|
|
457
|
+
else:
|
|
458
|
+
if ref.startswith("-"):
|
|
459
|
+
raise InfraError("--base {!r} looks like an option, not a ref".format(ref))
|
|
460
|
+
result = _git(root, ["rev-parse", "--verify", "--end-of-options", ref + "^{commit}"], env)
|
|
461
|
+
return result.stdout.strip() if result.returncode == 0 else None
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def resolve_base_ladder(root, ref, env=None, baseline=None) -> tuple:
|
|
465
|
+
"""User-mode base resolution: ``(sha | None, notes)``.
|
|
466
|
+
|
|
467
|
+
An explicit ``--base`` is compared at its merge base with HEAD, like the default. Without one the ladder
|
|
468
|
+
walks the fully qualified rungs; with ``baseline`` it prefers the first rung whose merge base already
|
|
469
|
+
carries that file, so a stale ``origin/HEAD`` left at a pre-adoption commit cannot read as an adoption.
|
|
470
|
+
"""
|
|
471
|
+
if ref is not None:
|
|
472
|
+
tip = resolve_base(root, ref, env)
|
|
473
|
+
if tip is None:
|
|
474
|
+
return None, []
|
|
475
|
+
merged = _git(root, ["merge-base", tip, "HEAD"], env)
|
|
476
|
+
return (merged.stdout.strip() if merged.returncode == 0 else tip), []
|
|
477
|
+
first, carrying = None, []
|
|
478
|
+
for rung in BASE_LADDER:
|
|
479
|
+
# The exact ref: git falls back to a TAG named like a missing ref, and tags travel with a clone.
|
|
480
|
+
if _git(root, ["show-ref", "--verify", "--quiet", rung], env).returncode != 0:
|
|
481
|
+
continue
|
|
482
|
+
verified = _git(root, ["rev-parse", "--verify", "--quiet", "--end-of-options", rung + "^{commit}"], env)
|
|
483
|
+
if verified.returncode != 0:
|
|
484
|
+
continue
|
|
485
|
+
merged = _git(root, ["merge-base", verified.stdout.strip(), "HEAD"], env)
|
|
486
|
+
if merged.returncode != 0:
|
|
487
|
+
continue
|
|
488
|
+
sha = merged.stdout.strip()
|
|
489
|
+
notes = []
|
|
490
|
+
if rung.startswith("refs/heads/"):
|
|
491
|
+
notes.append("WARN: using local {} as merge base".format(rung.rsplit("/", 1)[-1]))
|
|
492
|
+
if baseline is None:
|
|
493
|
+
return sha, notes
|
|
494
|
+
if baseline_at(root, sha, baseline, env) is not None:
|
|
495
|
+
carrying.append((sha, notes, rung.startswith("refs/heads/")))
|
|
496
|
+
else:
|
|
497
|
+
first = first or (sha, notes)
|
|
498
|
+
if carrying:
|
|
499
|
+
# Rungs can disagree (a stale origin/HEAD next to a current origin/main): judge growth against the
|
|
500
|
+
# NEWEST merge base that carries the baseline, the strictest reference.
|
|
501
|
+
# A local branch only counts when no remote rung carries the baseline: on main with unpushed commits the
|
|
502
|
+
# local branch is the newest, and judging growth against your own unpushed work would always pass.
|
|
503
|
+
pool = [c for c in carrying if not c[2]] or carrying
|
|
504
|
+
best = pool[0]
|
|
505
|
+
for candidate in pool[1:]:
|
|
506
|
+
if candidate[0] != best[0] and _git(root, ["merge-base", "--is-ancestor", best[0], candidate[0]],
|
|
507
|
+
env).returncode == 0:
|
|
508
|
+
best = candidate
|
|
509
|
+
return best[0], best[1]
|
|
510
|
+
if first is not None:
|
|
511
|
+
return first
|
|
512
|
+
shallow = _git(root, ["rev-parse", "--is-shallow-repository"], env)
|
|
513
|
+
if shallow.returncode == 0 and shallow.stdout.strip() == "true":
|
|
514
|
+
return None, ["shallow clone: fetch more history (fetch-depth: 0) so the shrink-only comparison can run"]
|
|
515
|
+
return None, []
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def rename_map(root, sha: str, env=None) -> dict:
|
|
519
|
+
"""Map old -> new for renames between ``sha`` and the working tree (staged renames included)."""
|
|
520
|
+
result = _git(root, ["diff", "-M30%", "--no-ext-diff", "--no-textconv", "-z", "--name-status", "--diff-filter=R",
|
|
521
|
+
sha, "--"], env)
|
|
522
|
+
if result.returncode != 0:
|
|
523
|
+
raise InfraError("git diff failed: {}".format(result.stderr.strip()[:200]))
|
|
524
|
+
tokens = result.stdout.split("\0")
|
|
525
|
+
return dict(zip(tokens[1::3], tokens[2::3]))
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def baseline_at(root, sha: str, path: str, env=None):
|
|
529
|
+
"""Return the file's text at ``sha``, or None only when the commit genuinely has no such path."""
|
|
530
|
+
listed = _git(root, ["ls-tree", "--name-only", sha, "--", path], env)
|
|
531
|
+
if listed.returncode != 0:
|
|
532
|
+
raise InfraError("git ls-tree {} failed: {}".format(sha[:12], listed.stderr.strip()[:200]))
|
|
533
|
+
if not listed.stdout.strip():
|
|
534
|
+
return None
|
|
535
|
+
shown = _git(root, ["show", "{}:{}".format(sha, path)], env)
|
|
536
|
+
if shown.returncode != 0:
|
|
537
|
+
raise InfraError("git show {}:{} failed: {}".format(sha[:12], path, shown.stderr.strip()[:200]))
|
|
538
|
+
return shown.stdout
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def _counts(entries: list, renames=None) -> dict:
|
|
542
|
+
counts = {}
|
|
543
|
+
for path, rule, _anchor, _n in entries:
|
|
544
|
+
key = ((renames or {}).get(path, path), rule)
|
|
545
|
+
counts[key] = counts.get(key, 0) + 1
|
|
546
|
+
return counts
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def growth(head_entries: list, base_entries: list, renames=None) -> list:
|
|
550
|
+
head, base = _counts(head_entries), _counts(base_entries, renames)
|
|
551
|
+
return sorted((p, r, n, base.get((p, r), 0)) for (p, r), n in head.items() if n > base.get((p, r), 0))
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
# --------------------------------------------------------------------------- check / update
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def _prefix(config: dict) -> str:
|
|
558
|
+
return "lint-ratchet({}): ".format(config["name"])
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def _recovery(config: dict) -> str:
|
|
562
|
+
return config.get("recovery_cmd") or "python3 {} {}".format(SCRIPT_REL, config["name"])
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def _content_prefix(config: dict, exc) -> str:
|
|
566
|
+
"""The line prefix for an exit-2 error: the user scopes mark content-caused ones."""
|
|
567
|
+
if config.get("repo_content") and isinstance(exc, ContentError):
|
|
568
|
+
return _prefix(config) + REPO_CONTENT
|
|
569
|
+
return _prefix(config)
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def header_of(text):
|
|
573
|
+
"""The ``# toolchain:`` line of a baseline text, or None (unknown)."""
|
|
574
|
+
match = re.search(r"(?:^|[\r\n])(" + re.escape(HEADER_PREFIX) + r"[^\r\n]*)", text or "")
|
|
575
|
+
return match.group(1).strip() if match else None
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def _pin_of(header):
|
|
579
|
+
match = RULE_PIN_RE.search(header or "")
|
|
580
|
+
return match.group(1) if match else None
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def _version_key(version):
|
|
584
|
+
return tuple(int(part) if part.isdigit() else 0 for part in re.split(r"[.]", version or ""))
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def _older(live_header, stored_header) -> bool:
|
|
588
|
+
"""True when the baseline was written by a newer ruff than this plugin carries."""
|
|
589
|
+
live_pin, stored_pin = _pin_of(live_header), _pin_of(stored_header)
|
|
590
|
+
return bool(live_pin and stored_pin and _version_key(stored_pin) > _version_key(live_pin))
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def _exempt(base_header, head_header, live_header) -> bool:
|
|
594
|
+
"""Growth is exempt only when the toolchain really changed since the merge base: the merge-base header
|
|
595
|
+
differs from the working-tree header AND the working-tree header equals the live fingerprint. A plugin
|
|
596
|
+
OLDER than the merge-base baseline's ruff never earns the exemption: that is skew, not a config change."""
|
|
597
|
+
return bool(base_header and head_header and live_header and base_header != head_header
|
|
598
|
+
and head_header == live_header and not _older(live_header, base_header))
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def _baseline_file(root: Path, config: dict) -> Path:
|
|
602
|
+
"""The baseline path, refused when it, its directory or a parent is a symlink or when it is not a bounded
|
|
603
|
+
regular file (it is read and rewritten; a FIFO would hang the tool)."""
|
|
604
|
+
path = root / config["baseline"]
|
|
605
|
+
current = root
|
|
606
|
+
for part in Path(config["baseline"]).parts:
|
|
607
|
+
current = current / part
|
|
608
|
+
if current.is_symlink():
|
|
609
|
+
raise ContentError("baseline {} is (or sits under) a symlink; refusing to read or write through "
|
|
610
|
+
"it".format(config["baseline"]))
|
|
611
|
+
try:
|
|
612
|
+
info = os.lstat(str(path))
|
|
613
|
+
except FileNotFoundError:
|
|
614
|
+
return path
|
|
615
|
+
except OSError as exc:
|
|
616
|
+
raise ContentError("could not stat baseline {}: {}".format(config["baseline"], exc.strerror or
|
|
617
|
+
type(exc).__name__))
|
|
618
|
+
if not stat.S_ISREG(info.st_mode):
|
|
619
|
+
raise ContentError("baseline {} is not a regular file; refusing to read it".format(config["baseline"]))
|
|
620
|
+
if info.st_size > MAX_BASELINE_BYTES:
|
|
621
|
+
raise ContentError("baseline {} is over 16 MiB; refusing to read it".format(config["baseline"]))
|
|
622
|
+
return path
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def _read_baseline(path: Path) -> str:
|
|
626
|
+
try:
|
|
627
|
+
return path.read_text(encoding="utf-8")
|
|
628
|
+
except UnicodeDecodeError:
|
|
629
|
+
raise ContentError("baseline {} is not valid UTF-8; take the base branch's copy of it and re-run "
|
|
630
|
+
"--update".format(path.name))
|
|
631
|
+
except OSError as exc:
|
|
632
|
+
raise ContentError("could not read baseline {}: {}".format(path.name, exc.strerror or type(exc).__name__))
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def _write_baseline(path: Path, text: str) -> None:
|
|
636
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
637
|
+
fd, tmp_name = tempfile.mkstemp(dir=str(path.parent), prefix="{}.tmp-".format(path.name))
|
|
638
|
+
try:
|
|
639
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
640
|
+
handle.write(text)
|
|
641
|
+
# mkstemp makes the file 0600; a tracked file should carry the mode the user's umask gives a new file.
|
|
642
|
+
umask = os.umask(0)
|
|
643
|
+
os.umask(umask)
|
|
644
|
+
os.chmod(tmp_name, 0o666 & ~umask)
|
|
645
|
+
os.replace(tmp_name, str(path))
|
|
646
|
+
finally:
|
|
647
|
+
if os.path.lexists(tmp_name):
|
|
648
|
+
os.unlink(tmp_name)
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def _base_for(config, root, base, env) -> tuple:
|
|
652
|
+
"""``(sha | None, notes)``: the user scopes walk the ladder, the Soleur scopes use origin/main."""
|
|
653
|
+
if config.get("ladder"):
|
|
654
|
+
return resolve_base_ladder(root, base, env, baseline=config["baseline"])
|
|
655
|
+
return resolve_base(root, base, env), []
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def _delta(config, root, head_entries, head_text, base, env, out: list) -> tuple:
|
|
659
|
+
"""Return ``(exit_code, reason)``; ``reason`` says why the comparison did not run, or ``"compared"``."""
|
|
660
|
+
ci = _is_ci(env)
|
|
661
|
+
pre = _prefix(config)
|
|
662
|
+
sha, notes = _base_for(config, root, base, env)
|
|
663
|
+
out.extend(pre + note for note in notes)
|
|
664
|
+
if sha is None:
|
|
665
|
+
if ci:
|
|
666
|
+
out.append(pre + "no merge base could be resolved under CI; refusing to skip the shrink-only comparison")
|
|
667
|
+
return 2, "no-base"
|
|
668
|
+
out.append(pre + "warning: merge base unresolvable here; the shrink-only comparison was skipped")
|
|
669
|
+
return 0, "no-base"
|
|
670
|
+
base_text = baseline_at(root, sha, config["baseline"], env)
|
|
671
|
+
if base_text is None:
|
|
672
|
+
script = config.get("script")
|
|
673
|
+
base_script = baseline_at(root, sha, script, env) if (ci and script) else None
|
|
674
|
+
if base_script is not None and re.search(r'"{}"\s*:\s*\{{'.format(re.escape(config["name"])), base_script):
|
|
675
|
+
out.append(pre + "the baseline is missing at the merge base {} although the ratchet script there already "
|
|
676
|
+
"defines this scope: a baseline moved or renamed in this diff cannot be compared".format(
|
|
677
|
+
sha[:12]))
|
|
678
|
+
return 2, "introduced"
|
|
679
|
+
out.append(pre + "baseline introduced by this diff (absent at {}); shrink-only comparison skipped".format(
|
|
680
|
+
sha[:12]))
|
|
681
|
+
return 0, "introduced"
|
|
682
|
+
if config.get("header"):
|
|
683
|
+
if _exempt(header_of(base_text), header_of(head_text), config["header"](root)):
|
|
684
|
+
out.append(pre + CONFIG_CHANGED + ": shrink-only not enforced; review the config diff")
|
|
685
|
+
return 0, "config-changed"
|
|
686
|
+
grew = growth(head_entries, parse_baseline(base_text), rename_map(root, sha, env))
|
|
687
|
+
for path, rule, now, before in grew[:NOTICE_LIMIT]:
|
|
688
|
+
out.append(pre + "baseline grew for {} [{}]: {} entries now, {} at the merge base".format(
|
|
689
|
+
_clip(path), _clip(rule, 60), now, before))
|
|
690
|
+
if len(grew) > NOTICE_LIMIT:
|
|
691
|
+
out.append(pre + "... and {} more (path, rule) pairs grew".format(len(grew) - NOTICE_LIMIT))
|
|
692
|
+
if grew:
|
|
693
|
+
out.append(pre + "the baseline may only shrink; fix the finding instead of admitting it")
|
|
694
|
+
if config.get("header") and _older(config["header"](root), header_of(base_text)):
|
|
695
|
+
out.append(pre + "the baseline was written by a newer ruff ({}) than this plugin carries: update the "
|
|
696
|
+
"plugin before judging growth".format(_pin_of(header_of(base_text))))
|
|
697
|
+
return 1, "compared"
|
|
698
|
+
return 0, "compared"
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def _more(pre: str, total: int, label: str, config: dict) -> list:
|
|
702
|
+
"""The pointer line after a capped class of report lines (an unbounded flood is read in full by the agent)."""
|
|
703
|
+
if total <= NOTICE_LIMIT:
|
|
704
|
+
return []
|
|
705
|
+
return [pre + "... and {} more {} (the full list: `{} --list`)".format(
|
|
706
|
+
total - NOTICE_LIMIT, label, _recovery(config))]
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def check(config: dict, root, base=None, env=None) -> tuple:
|
|
710
|
+
root = Path(root)
|
|
711
|
+
pre = _prefix(config)
|
|
712
|
+
out = []
|
|
713
|
+
try:
|
|
714
|
+
baseline_path = _baseline_file(root, config)
|
|
715
|
+
except InfraError as exc:
|
|
716
|
+
return 2, [_content_prefix(config, exc) + str(exc)]
|
|
717
|
+
if not baseline_path.exists():
|
|
718
|
+
return 2, [pre + "baseline {} is missing; create it with {} --update".format(
|
|
719
|
+
config["baseline"], _recovery(config))]
|
|
720
|
+
try:
|
|
721
|
+
# A baseline the repository cannot be judged against is decided before any tool is provisioned or run.
|
|
722
|
+
baseline_text = _read_baseline(baseline_path)
|
|
723
|
+
baseline_entries = parse_baseline(baseline_text)
|
|
724
|
+
except InfraError as exc:
|
|
725
|
+
return 2, [_content_prefix(config, exc) + str(exc)]
|
|
726
|
+
try:
|
|
727
|
+
findings, file_count = config["run"](root)
|
|
728
|
+
except Vacuous as exc:
|
|
729
|
+
return 1, [pre + "vacuous scan: {}".format(exc)]
|
|
730
|
+
except InfraError as exc:
|
|
731
|
+
return 2, [_content_prefix(config, exc) + str(exc)]
|
|
732
|
+
if file_count < config["floor"]:
|
|
733
|
+
return 1, [pre + "the scan saw only {} files, below the floor of {}; a scan of (almost) nothing "
|
|
734
|
+
"cannot report green".format(file_count, config["floor"])]
|
|
735
|
+
try:
|
|
736
|
+
pairs = census_pairs(findings, root)
|
|
737
|
+
except InfraError as exc:
|
|
738
|
+
return 2, [_content_prefix(config, exc) + str(exc)]
|
|
739
|
+
live = {entry: finding for entry, finding in pairs}
|
|
740
|
+
known = set(baseline_entries)
|
|
741
|
+
new = [entry for entry in sorted(live) if entry not in known]
|
|
742
|
+
stale = [entry for entry in sorted(known) if entry not in live]
|
|
743
|
+
changed_keys = {(e[0], e[1]) for e in new} & {(e[0], e[1]) for e in stale}
|
|
744
|
+
new_lines, stale_lines = [], []
|
|
745
|
+
for entry in new[:NOTICE_LIMIT]:
|
|
746
|
+
finding = live[entry]
|
|
747
|
+
new_lines.append(pre + "NEW {}:{} {} {}".format(_clip(finding.path), finding.row, _clip(finding.rule, 60),
|
|
748
|
+
_clip(finding.message)))
|
|
749
|
+
for entry in stale[:NOTICE_LIMIT]:
|
|
750
|
+
text = _clean(format_entry(_clip(entry[0]), _clip(entry[1], 60), entry[2], entry[3]))
|
|
751
|
+
if (entry[0], entry[1]) in changed_keys:
|
|
752
|
+
stale_lines.append(pre + "STALE {} -- a line carrying this finding changed; if the finding itself is "
|
|
753
|
+
"unchanged run `{} --update` (it refuses real growth)".format(text, _recovery(config)))
|
|
754
|
+
else:
|
|
755
|
+
stale_lines.append(pre + "STALE {} -- fixed: run `{} --update` and commit the baseline. After a merge "
|
|
756
|
+
"conflict take the base branch's copy of the baseline and re-run --update.".format(
|
|
757
|
+
text, _recovery(config)))
|
|
758
|
+
out.extend(new_lines + _more(pre, len(new), "NEW findings", config))
|
|
759
|
+
out.extend(stale_lines + _more(pre, len(stale), "STALE entries", config))
|
|
760
|
+
if new or stale:
|
|
761
|
+
if new:
|
|
762
|
+
out.append(pre + "remedy: " + config["remedy"])
|
|
763
|
+
out.append(pre + config.get("waiver_note", WAIVER_NOTE_SOLEUR))
|
|
764
|
+
return 1, out
|
|
765
|
+
try:
|
|
766
|
+
code, reason = _delta(config, root, list(live), baseline_text, base, env, out)
|
|
767
|
+
except InfraError as exc:
|
|
768
|
+
return 2, out + [_content_prefix(config, exc) + str(exc)]
|
|
769
|
+
if code == 0 and config.get("header"):
|
|
770
|
+
return _user_verdict(config, root, out, baseline_text, len(live), file_count, reason)
|
|
771
|
+
if code == 0:
|
|
772
|
+
out.append(pre + "OK ({} findings baselined, {} files scanned{})".format(
|
|
773
|
+
len(live), file_count, "" if reason == "compared" else "; shrink-only comparison skipped"))
|
|
774
|
+
return code, out
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
def _user_verdict(config, root, out, baseline_text, findings, file_count, reason) -> tuple:
|
|
778
|
+
"""User-mode green path: a WARN, never an OK, when the census was not compared or the toolchain moved. A
|
|
779
|
+
baseline introduced by this diff is an OK whose second line says the shrink-only comparison starts at merge."""
|
|
780
|
+
pre = _prefix(config)
|
|
781
|
+
if reason == "config-changed":
|
|
782
|
+
return 0, out
|
|
783
|
+
live_header, stored = config["header"](root), header_of(baseline_text)
|
|
784
|
+
stale = stored != live_header
|
|
785
|
+
if stale:
|
|
786
|
+
if _older(live_header, stored):
|
|
787
|
+
out.append(pre + "plugin older than the baseline's ruff {}: update the plugin".format(_pin_of(stored)))
|
|
788
|
+
else:
|
|
789
|
+
out.append(pre + "fingerprint stale: run --update to refresh (ask the user first)")
|
|
790
|
+
if file_count == 0:
|
|
791
|
+
out.insert(0, "WARN " + pre + "no Python files were scanned (0 files in ruff's listing); nothing to ratchet. "
|
|
792
|
+
"If that is unexpected, check the exclude settings of your ruff configuration")
|
|
793
|
+
elif reason == "no-base":
|
|
794
|
+
out.insert(0, "WARN " + pre + "census matches the baseline ({} findings, {} files) but the shrink-only "
|
|
795
|
+
"comparison was not performed (no merge base)".format(findings, file_count))
|
|
796
|
+
elif stale:
|
|
797
|
+
out.insert(0, "WARN " + pre + "census matches the baseline ({} findings, {} files) but the ruff config or "
|
|
798
|
+
"pin changed since it was written, so shrink-only is not judged against the new toolchain until "
|
|
799
|
+
"the baseline is refreshed".format(findings, file_count))
|
|
800
|
+
else:
|
|
801
|
+
out.insert(0, pre + "OK ({} findings baselined, {} files scanned)".format(findings, file_count))
|
|
802
|
+
return 0, out
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
def update(config: dict, root, base=None, env=None) -> tuple:
|
|
806
|
+
root = Path(root)
|
|
807
|
+
pre = _prefix(config)
|
|
808
|
+
try:
|
|
809
|
+
path = _baseline_file(root, config)
|
|
810
|
+
findings, file_count = config["run"](root)
|
|
811
|
+
if file_count < config["floor"]:
|
|
812
|
+
return 1, [pre + "the scan saw only {} files, below the floor of {}; refusing to write a baseline from "
|
|
813
|
+
"a scan of (almost) nothing".format(file_count, config["floor"])]
|
|
814
|
+
if config.get("header") and file_count == 0 and path.exists():
|
|
815
|
+
return 1, [pre + "ruff listed no files, so --update would rewrite the baseline to 0 entries; check the "
|
|
816
|
+
"exclude settings of your ruff configuration (refusing)"]
|
|
817
|
+
entries = census(findings, root)
|
|
818
|
+
sha, _notes = _base_for(config, root, base, env)
|
|
819
|
+
reference, exempt = None, False
|
|
820
|
+
if sha is None and path.exists():
|
|
821
|
+
return 2, [pre + "no merge base could be resolved, so the shrink-only guard cannot run; fetch "
|
|
822
|
+
"origin/main or pass --base <ref> (--base HEAD takes the committed baseline as the reference)"]
|
|
823
|
+
if sha is None:
|
|
824
|
+
# Deleting the baseline first must not lift the guard: the committed copy is the reference.
|
|
825
|
+
if config.get("header") and _git(root, ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"],
|
|
826
|
+
env).returncode == 0:
|
|
827
|
+
reference = baseline_at(root, "HEAD", config["baseline"], env)
|
|
828
|
+
else:
|
|
829
|
+
reference = baseline_at(root, sha, config["baseline"], env)
|
|
830
|
+
if reference is not None and config.get("header"):
|
|
831
|
+
live_header, base_header = config["header"](root), header_of(reference)
|
|
832
|
+
if _older(live_header, base_header):
|
|
833
|
+
return 2, [pre + "plugin older than the baseline's ruff {}: update the plugin; not rewriting the "
|
|
834
|
+
"baseline with an older ruff".format(_pin_of(base_header))]
|
|
835
|
+
# The same predicate check() applies once the live header is written.
|
|
836
|
+
exempt = _exempt(base_header, live_header, live_header)
|
|
837
|
+
if reference is not None and not exempt:
|
|
838
|
+
grew = growth(entries, parse_baseline(reference), rename_map(root, sha or "HEAD", env))
|
|
839
|
+
if grew:
|
|
840
|
+
lines = [pre + "refusing to write: {} [{}] would hold {} entries, {} at the merge base".format(
|
|
841
|
+
_clip(p), _clip(r, 60), now, before) for p, r, now, before in grew[:NOTICE_LIMIT]]
|
|
842
|
+
return 1, lines + [pre + "fix the new findings instead of baselining them"]
|
|
843
|
+
except Vacuous as exc:
|
|
844
|
+
return 1, [pre + "vacuous scan: {}".format(exc)]
|
|
845
|
+
except InfraError as exc:
|
|
846
|
+
return 2, [_content_prefix(config, exc) + str(exc)]
|
|
847
|
+
header = config["header"](root) if config.get("header") else None
|
|
848
|
+
try:
|
|
849
|
+
_write_baseline(path, render_baseline(config["name"], entries,
|
|
850
|
+
recovery=config.get("header_recovery") or _recovery(config), header=header))
|
|
851
|
+
except OSError as exc:
|
|
852
|
+
return 2, [_content_prefix(config, ContentError("")) + "could not write {}: {}".format(
|
|
853
|
+
config["baseline"], exc.strerror or type(exc).__name__)]
|
|
854
|
+
lines = [pre + "wrote {} entries to {}".format(len(entries), config["baseline"])]
|
|
855
|
+
if config.get("header"):
|
|
856
|
+
lines[0] += "; commit it"
|
|
857
|
+
ignored = _git(root, ["check-ignore", "-q", "--", config["baseline"]], env)
|
|
858
|
+
if ignored.returncode == 0:
|
|
859
|
+
lines.append(pre + "{} is git-ignored, so it would never reach the base branch and the shrink-only "
|
|
860
|
+
"guard stays dark; remove the ignore rule (this tool never edits your .gitignore)".format(
|
|
861
|
+
config["baseline"]))
|
|
862
|
+
return 0, lines
|
|
863
|
+
|
|
864
|
+
|
|
865
|
+
def list_live(config: dict, root) -> tuple:
|
|
866
|
+
try:
|
|
867
|
+
findings, _count = config["run"](Path(root))
|
|
868
|
+
except Vacuous as exc:
|
|
869
|
+
return 1, [_prefix(config) + "vacuous scan: {}".format(exc)]
|
|
870
|
+
except InfraError as exc:
|
|
871
|
+
return 2, [_content_prefix(config, exc) + str(exc)]
|
|
872
|
+
lines = ["{}:{} {} {}".format(f.path, f.row, f.rule, f.message)
|
|
873
|
+
for f in sorted(findings, key=lambda f: (f.path, f.row, f.rule))]
|
|
874
|
+
return 0, lines
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
# --------------------------------------------------------------------------- tools
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
def tool_argv(name: str, root, files: list, ruff_path, user=False) -> list:
|
|
881
|
+
if name == "ruff" and user:
|
|
882
|
+
# The user's own configuration decides the rules and their waivers; --no-fix-only is load-bearing
|
|
883
|
+
# because fix-only = true in a config otherwise rewrites the tree and hides the findings.
|
|
884
|
+
return [str(ruff_path), "check", "--no-cache", "--no-fix", "--no-fix-only", "--force-exclude",
|
|
885
|
+
"--output-format", "json", "--"] + list(files)
|
|
886
|
+
if name == "ruff":
|
|
887
|
+
return [str(ruff_path), "check", "--config", "ruff.toml", "--ignore-noqa", "--no-cache", "--no-fix",
|
|
888
|
+
"--force-exclude", "--output-format", "json", "--"] + list(files)
|
|
889
|
+
return [str(Path(root) / ESLINT_BIN), "--config", ESLINT_CONFIG, "--no-inline-config",
|
|
890
|
+
"--no-error-on-unmatched-pattern", "-f", "json"] + list(SCOPES["eslint-test-dirs"]["scope"])
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
def show_files_argv(ruff_path, files: list, user=False) -> list:
|
|
894
|
+
if user:
|
|
895
|
+
return [str(ruff_path), "check", "--no-cache", "--force-exclude", "--show-files", "--"] + list(files)
|
|
896
|
+
return [str(ruff_path), "check", "--config", "ruff.toml", "--no-cache", "--force-exclude", "--show-files",
|
|
897
|
+
"--"] + list(files)
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
def run_ruff(root, env=None, ruff_path=None, run=subprocess.run) -> tuple:
|
|
901
|
+
root = Path(root)
|
|
902
|
+
files = [name for name in tracked_files(root, SCOPES["ruff"]["scope"], env)
|
|
903
|
+
if (root / name).exists() and not (root / name).is_symlink()]
|
|
904
|
+
if not files:
|
|
905
|
+
raise Vacuous("no tracked Python files; refusing to invoke ruff (with no file arguments it lints the "
|
|
906
|
+
"current directory)")
|
|
907
|
+
exe = ruff_path if ruff_path is not None else ensure_ruff(RUFF_REQUIREMENTS, env=dict(env or os.environ))
|
|
908
|
+
listing = _exec(run, show_files_argv(exe, files), RUN_TIMEOUT, cwd=str(root), env=env)
|
|
909
|
+
if listing.returncode != 0:
|
|
910
|
+
raise InfraError("ruff --show-files failed (rc {}): {}".format(listing.returncode, listing.stderr.strip()[:300]))
|
|
911
|
+
listed = {_relative(line.strip(), root) for line in listing.stdout.splitlines() if line.strip()}
|
|
912
|
+
skipped = sorted(name for name in files if name not in listed and not set(Path(name).parts) & set(EXCLUDED_DIRS))
|
|
913
|
+
if skipped:
|
|
914
|
+
raise InfraError("ruff skipped {} tracked file(s) it was asked to lint (a default exclude such as dist/ or "
|
|
915
|
+
"venv/ matched): {}".format(len(skipped), ", ".join(_clean(n) for n in skipped[:5])))
|
|
916
|
+
result = _exec(run, tool_argv("ruff", root, files, exe), RUN_TIMEOUT, cwd=str(root), env=env)
|
|
917
|
+
if result.returncode not in (0, 1):
|
|
918
|
+
raise InfraError("ruff exited {}: {}".format(result.returncode, result.stderr.strip()[:300]))
|
|
919
|
+
failed = [line.strip() for line in result.stderr.splitlines() if "Failed to" in line]
|
|
920
|
+
if failed:
|
|
921
|
+
raise InfraError("ruff could not lint a file (a file it cannot read hides its findings): {}".format(
|
|
922
|
+
_clean(failed[0])[:200]))
|
|
923
|
+
return adapt_ruff(result.stdout, root), len(listed)
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
def run_eslint(root, env=None, run=subprocess.run) -> tuple:
|
|
927
|
+
root = Path(root)
|
|
928
|
+
binary = root / ESLINT_BIN
|
|
929
|
+
if not binary.exists():
|
|
930
|
+
raise InfraError("{} is missing; recover with: npm ci --ignore-scripts --prefix apps/web-platform".format(
|
|
931
|
+
ESLINT_BIN))
|
|
932
|
+
result = _exec(run, tool_argv("eslint-test-dirs", root, [], None), RUN_TIMEOUT, cwd=str(root), env=env)
|
|
933
|
+
if result.returncode not in (0, 1):
|
|
934
|
+
raise InfraError("eslint exited {}: {}".format(result.returncode, result.stderr.strip()[:300]))
|
|
935
|
+
tracked = {n for n in tracked_files(root, (".",), env) if (root / n).exists() and not (root / n).is_symlink()}
|
|
936
|
+
seen = set()
|
|
937
|
+
findings, file_count = adapt_eslint(result.stdout, root, tracked, seen)
|
|
938
|
+
scope = [":(glob)" + g for g in SCOPES["eslint-test-dirs"]["scope"]]
|
|
939
|
+
expected = {n for n in tracked_files(root, scope, env)
|
|
940
|
+
if n.endswith(ESLINT_EXTS) and "node_modules/" not in n and n in tracked}
|
|
941
|
+
missing = sorted(expected - seen)
|
|
942
|
+
if missing:
|
|
943
|
+
raise InfraError("eslint did not report {} tracked in-scope file(s) (an ignore or config gap hides their "
|
|
944
|
+
"findings): {}".format(len(missing), ", ".join(_clean(n) for n in missing[:5])))
|
|
945
|
+
return findings, file_count
|
|
946
|
+
|
|
947
|
+
|
|
948
|
+
# --------------------------------------------------------------------------- ruff provisioning
|
|
949
|
+
|
|
950
|
+
|
|
951
|
+
def requirements_pin(path) -> str:
|
|
952
|
+
text = Path(path).read_text(encoding="utf-8")
|
|
953
|
+
match = re.search(r"^ruff==([0-9][0-9A-Za-z.]*)", text, re.MULTILINE)
|
|
954
|
+
if not match:
|
|
955
|
+
raise InfraError("{} does not pin ruff==<version>".format(path))
|
|
956
|
+
return match.group(1)
|
|
957
|
+
|
|
958
|
+
|
|
959
|
+
def pip_argv(target, requirements) -> list:
|
|
960
|
+
return [sys.executable, "-I", "-m", "pip", "install", "--isolated", "--no-input", "--no-deps",
|
|
961
|
+
"--disable-pip-version-check", "--index-url", "https://pypi.org/simple", "--only-binary=:all:",
|
|
962
|
+
"--require-hashes", "--target", str(target), "-r", str(requirements), "--quiet"]
|
|
963
|
+
|
|
964
|
+
|
|
965
|
+
def pip_env(env) -> dict:
|
|
966
|
+
return {k: v for k, v in dict(env).items() if not k.startswith(("PIP_", "PYTHON"))}
|
|
967
|
+
|
|
968
|
+
|
|
969
|
+
def _private_cache_dir(root):
|
|
970
|
+
"""Return ``root`` if it is a private directory of this user, creating it 0700; else None."""
|
|
971
|
+
root = Path(root)
|
|
972
|
+
try:
|
|
973
|
+
try:
|
|
974
|
+
info = os.lstat(str(root))
|
|
975
|
+
except FileNotFoundError:
|
|
976
|
+
root.parent.mkdir(parents=True, exist_ok=True)
|
|
977
|
+
with contextlib.suppress(FileExistsError):
|
|
978
|
+
os.mkdir(str(root), 0o700)
|
|
979
|
+
info = os.lstat(str(root))
|
|
980
|
+
except OSError:
|
|
981
|
+
return None
|
|
982
|
+
if not stat.S_ISDIR(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o022:
|
|
983
|
+
return None
|
|
984
|
+
return root
|
|
985
|
+
|
|
986
|
+
|
|
987
|
+
OFFLINE_STAMP = "offline-stamp"
|
|
988
|
+
OFFLINE_WINDOW = 600
|
|
989
|
+
STALE_INSTALL_AGE = 3600
|
|
990
|
+
|
|
991
|
+
|
|
992
|
+
def _sweep_installs(base: Path) -> None:
|
|
993
|
+
"""Remove ``install-*`` temp dirs an interrupted run left behind (up to ~24 MB each)."""
|
|
994
|
+
now = time.time()
|
|
995
|
+
for entry in base.glob("install-*"):
|
|
996
|
+
with contextlib.suppress(OSError):
|
|
997
|
+
if now - entry.stat().st_mtime > STALE_INSTALL_AGE:
|
|
998
|
+
shutil.rmtree(str(entry), ignore_errors=True)
|
|
999
|
+
|
|
1000
|
+
|
|
1001
|
+
def ensure_ruff(requirements, env, run=subprocess.run, cache_root=None, fallback_root=None, recovery=None) -> Path:
|
|
1002
|
+
version = requirements_pin(requirements)
|
|
1003
|
+
recovery = recovery or "python3 {} ruff --check".format(SCRIPT_REL)
|
|
1004
|
+
env = dict(env)
|
|
1005
|
+
if not hasattr(os, "getuid"):
|
|
1006
|
+
raise InfraError("unsupported platform: this tool needs a POSIX system (native Windows is not supported)")
|
|
1007
|
+
if cache_root is None:
|
|
1008
|
+
xdg = env.get("XDG_CACHE_HOME")
|
|
1009
|
+
if xdg and not os.path.isabs(xdg):
|
|
1010
|
+
xdg = None # a relative cache home would put the install under the caller's working directory
|
|
1011
|
+
home = xdg or os.path.join(env.get("HOME") or os.path.expanduser("~"), ".cache")
|
|
1012
|
+
# A directory of our own: the shared `soleur` cache dir is created by other tools under the
|
|
1013
|
+
# user's umask and is often group-writable, which this check (rightly) refuses.
|
|
1014
|
+
cache_root = Path(home) / "soleur-lint-ratchet"
|
|
1015
|
+
if fallback_root is None:
|
|
1016
|
+
fallback_root = Path(tempfile.gettempdir()) / "soleur-lint-ratchet-{}".format(os.getuid())
|
|
1017
|
+
base = _private_cache_dir(cache_root) or _private_cache_dir(fallback_root)
|
|
1018
|
+
if base is None:
|
|
1019
|
+
raise InfraError("no private cache directory is usable for ruff ({} and {} are not owned by this user with "
|
|
1020
|
+
"mode 0700)".format(cache_root, fallback_root))
|
|
1021
|
+
digest = hashlib.sha256(Path(requirements).read_bytes()).hexdigest()[:12]
|
|
1022
|
+
final = base / "ruff-{}-{}-{}".format(digest, sys.platform, platform.machine() or "unknown")
|
|
1023
|
+
exe = final / "bin" / "ruff"
|
|
1024
|
+
if not exe.exists():
|
|
1025
|
+
stamp = base / OFFLINE_STAMP
|
|
1026
|
+
if not _is_ci(env) and stamp.exists() and time.time() - stamp.stat().st_mtime < OFFLINE_WINDOW:
|
|
1027
|
+
raise Skip("ruff could not be installed (the package index was unreachable moments ago; not retrying "
|
|
1028
|
+
"for {} minutes, or delete {} to retry now); recover with: {}".format(
|
|
1029
|
+
OFFLINE_WINDOW // 60, shlex.quote(str(stamp)), recovery))
|
|
1030
|
+
_sweep_installs(base)
|
|
1031
|
+
print("lint-ratchet: installing ruff {} (first run only)".format(version), file=sys.stderr)
|
|
1032
|
+
target = Path(tempfile.mkdtemp(prefix="install-", dir=str(base)))
|
|
1033
|
+
argv = pip_argv(target, requirements)
|
|
1034
|
+
if not _is_ci(env):
|
|
1035
|
+
# Bounded locally: an unreachable index must cost seconds, not pip's default ~100 s of retries.
|
|
1036
|
+
argv = list(argv) + ["--retries", "1", "--timeout", "15"]
|
|
1037
|
+
try:
|
|
1038
|
+
result = _exec(run, argv, PIP_TIMEOUT, label="pip", env=pip_env(env))
|
|
1039
|
+
except InfraError:
|
|
1040
|
+
shutil.rmtree(str(target), ignore_errors=True)
|
|
1041
|
+
raise
|
|
1042
|
+
if result.returncode != 0:
|
|
1043
|
+
shutil.rmtree(str(target), ignore_errors=True)
|
|
1044
|
+
output = "{}\n{}".format(result.stdout or "", result.stderr or "")
|
|
1045
|
+
if any(marker in output for marker in PIP_INTEGRITY_MARKERS):
|
|
1046
|
+
raise InfraError("ruff install failed an integrity check: {}".format(output.strip()[-300:]))
|
|
1047
|
+
reason = output.strip().splitlines()[-1][:200] if output.strip() else "pip exited {}".format(
|
|
1048
|
+
result.returncode)
|
|
1049
|
+
if _is_ci(env):
|
|
1050
|
+
raise InfraError("ruff could not be installed under CI: {}".format(reason))
|
|
1051
|
+
if any(marker in output for marker in PIP_NETWORK_MARKERS):
|
|
1052
|
+
with contextlib.suppress(OSError):
|
|
1053
|
+
stamp.touch()
|
|
1054
|
+
raise Skip("ruff could not be installed ({}); recover with: {}".format(reason, recovery))
|
|
1055
|
+
if "No module named pip" in output:
|
|
1056
|
+
raise InfraError("pip is not available for {}: install it (python3-pip) or run from a virtualenv".format(
|
|
1057
|
+
sys.executable))
|
|
1058
|
+
raise InfraError("ruff install failed for an unrecognised reason: {}".format(reason))
|
|
1059
|
+
try:
|
|
1060
|
+
os.rename(str(target), str(final))
|
|
1061
|
+
except OSError:
|
|
1062
|
+
if not final.exists():
|
|
1063
|
+
raise
|
|
1064
|
+
shutil.rmtree(str(target), ignore_errors=True)
|
|
1065
|
+
if not exe.exists():
|
|
1066
|
+
raise InfraError("ruff cache {} exists without bin/ruff; delete it and retry: rm -rf {}".format(final, final))
|
|
1067
|
+
probe = _exec(run, [str(exe), "--version"], 60)
|
|
1068
|
+
if probe.stdout.strip() != "ruff {}".format(version):
|
|
1069
|
+
raise InfraError("installed ruff ({}) reports {!r}, the pin is {!r}; delete {} and retry: rm -rf {}".format(
|
|
1070
|
+
exe, probe.stdout.strip(), "ruff " + version, final, final))
|
|
1071
|
+
return exe
|
|
1072
|
+
|
|
1073
|
+
|
|
1074
|
+
# --------------------------------------------------------------------------- user scopes (PR3b)
|
|
1075
|
+
|
|
1076
|
+
|
|
1077
|
+
class UserState(NamedTuple):
|
|
1078
|
+
kind: str
|
|
1079
|
+
code: int
|
|
1080
|
+
lines: list
|
|
1081
|
+
warnings: list
|
|
1082
|
+
|
|
1083
|
+
|
|
1084
|
+
def user_env(env) -> dict:
|
|
1085
|
+
"""The environment every user-mode child sees: no variable that redirects git to another repository or
|
|
1086
|
+
changes how a pathspec is read, and git's messages in English (the skip classification reads them)."""
|
|
1087
|
+
cleaned = {k: v for k, v in dict(env).items() if k not in GIT_SCRUB}
|
|
1088
|
+
cleaned["LC_ALL"] = "C"
|
|
1089
|
+
cleaned["LANGUAGE"] = "C"
|
|
1090
|
+
cleaned["GIT_NO_REPLACE_OBJECTS"] = "1" # refs/replace/* is repository content; never let it rewrite history we judge
|
|
1091
|
+
return cleaned
|
|
1092
|
+
|
|
1093
|
+
|
|
1094
|
+
def resolve_root(env=None) -> Path:
|
|
1095
|
+
"""The toplevel of the repository the CALLER stands in. Not a repository is a ``Skip``."""
|
|
1096
|
+
pinned = user_env(env if env is not None else os.environ)
|
|
1097
|
+
result = _exec(subprocess.run, ["git", "-c", "core.fsmonitor=false", "rev-parse", "--show-toplevel"], GIT_TIMEOUT,
|
|
1098
|
+
label="git", env=pinned)
|
|
1099
|
+
if result.returncode != 0:
|
|
1100
|
+
message = result.stderr.strip()
|
|
1101
|
+
if message.startswith("fatal: not a git repository"):
|
|
1102
|
+
raise Skip("not a git repository")
|
|
1103
|
+
if message.startswith("fatal: this operation must be run in a work tree"):
|
|
1104
|
+
raise InfraError("this is a bare repository; run lint-ratchet from a worktree of it")
|
|
1105
|
+
raise InfraError("git rev-parse --show-toplevel failed: {}".format(_clip(message)))
|
|
1106
|
+
return Path(result.stdout.strip())
|
|
1107
|
+
|
|
1108
|
+
|
|
1109
|
+
def _config_files(root, problems=None) -> dict:
|
|
1110
|
+
"""Existing ruff config candidates at the repository root (name -> text).
|
|
1111
|
+
|
|
1112
|
+
An unsafe one (symlink, not a regular file, over 1 MiB, unreadable) raises ``ContentError``, or is appended
|
|
1113
|
+
to ``problems`` and skipped when a list is given so the caller can decide whether it matters.
|
|
1114
|
+
"""
|
|
1115
|
+
found = {}
|
|
1116
|
+
for name in USER_RUFF_CONFIGS + (USER_PYPROJECT,):
|
|
1117
|
+
path = Path(root) / name
|
|
1118
|
+
if not os.path.lexists(str(path)):
|
|
1119
|
+
continue
|
|
1120
|
+
try:
|
|
1121
|
+
try:
|
|
1122
|
+
info = os.lstat(str(path))
|
|
1123
|
+
if stat.S_ISLNK(info.st_mode) or not stat.S_ISREG(info.st_mode):
|
|
1124
|
+
raise ContentError("{} is a symlink or not a regular file; refusing to run ruff against "
|
|
1125
|
+
"it".format(name))
|
|
1126
|
+
if info.st_size > MAX_CONFIG_BYTES:
|
|
1127
|
+
raise ContentError("{} is over 1 MiB; refusing to run ruff against it".format(name))
|
|
1128
|
+
found[name] = path.read_text(encoding="utf-8", errors="replace")
|
|
1129
|
+
except OSError as exc:
|
|
1130
|
+
raise ContentError("could not read {}: {}".format(name, exc.strerror or type(exc).__name__))
|
|
1131
|
+
except ContentError as exc:
|
|
1132
|
+
if problems is None:
|
|
1133
|
+
raise
|
|
1134
|
+
problems.append(str(exc))
|
|
1135
|
+
return found
|
|
1136
|
+
|
|
1137
|
+
|
|
1138
|
+
# A table header, tolerating the TOML spellings a hand-written file uses: inner spaces, quoted keys.
|
|
1139
|
+
_RUFF_HEADER_RE = re.compile(r"""^[ \t]*\[\[?[ \t]*["']?tool["']?[ \t]*\.[ \t]*["']?ruff["']?(?=[ \t.\]"'])""",
|
|
1140
|
+
re.MULTILINE)
|
|
1141
|
+
_TABLE_HEADER_RE = re.compile(r"^[ \t]*\[\[?[^\[\]\n]*\]\]?[ \t]*(#.*)?$")
|
|
1142
|
+
|
|
1143
|
+
|
|
1144
|
+
def _has_ruff_table(text: str) -> bool:
|
|
1145
|
+
return _RUFF_HEADER_RE.search(text) is not None
|
|
1146
|
+
|
|
1147
|
+
|
|
1148
|
+
def _ruff_tables(text: str) -> str:
|
|
1149
|
+
"""The ``[tool.ruff*]`` tables of a pyproject.toml, header lines and bodies."""
|
|
1150
|
+
keep, out = False, []
|
|
1151
|
+
for line in LINE_SPLIT_RE.split(text):
|
|
1152
|
+
if _TABLE_HEADER_RE.match(line):
|
|
1153
|
+
keep = _RUFF_HEADER_RE.match(line) is not None
|
|
1154
|
+
if keep:
|
|
1155
|
+
out.append(line)
|
|
1156
|
+
return "\n".join(out).rstrip("\n")
|
|
1157
|
+
|
|
1158
|
+
|
|
1159
|
+
CONFIG_GLOBS = tuple(":(glob)**/" + name for name in USER_RUFF_CONFIGS + (USER_PYPROJECT,))
|
|
1160
|
+
|
|
1161
|
+
|
|
1162
|
+
def _tracked_configs(root, env=None) -> dict:
|
|
1163
|
+
"""Every TRACKED ruff config, root and nested, as ``path -> text``: ruff applies the nearest one to each
|
|
1164
|
+
file, so a nested one decides findings the root one never sees. A pyproject.toml counts only through its
|
|
1165
|
+
``[tool.ruff*]`` tables. A nested symlink contributes its target name only."""
|
|
1166
|
+
configs = {}
|
|
1167
|
+
for name in tracked_files(root, CONFIG_GLOBS, env):
|
|
1168
|
+
path = Path(root) / name
|
|
1169
|
+
nested = "/" in name
|
|
1170
|
+
if not os.path.lexists(str(path)):
|
|
1171
|
+
continue # tracked but deleted in the tree: it contributes nothing to the live toolchain
|
|
1172
|
+
try:
|
|
1173
|
+
info = os.lstat(str(path))
|
|
1174
|
+
except OSError as exc:
|
|
1175
|
+
raise ContentError("could not stat {}: {}".format(name, exc.strerror or type(exc).__name__))
|
|
1176
|
+
if stat.S_ISLNK(info.st_mode):
|
|
1177
|
+
if not nested:
|
|
1178
|
+
raise ContentError("{} is a symlink or not a regular file; refusing to run ruff against it".format(
|
|
1179
|
+
name))
|
|
1180
|
+
content = "symlink:" + os.readlink(str(path))
|
|
1181
|
+
# A dangling or oversized target contributes its link text only; ruff would fail on it and say so.
|
|
1182
|
+
if os.path.isfile(str(path)) and os.path.getsize(str(path)) <= MAX_CONFIG_BYTES:
|
|
1183
|
+
try:
|
|
1184
|
+
content += "\0" + Path(os.path.realpath(str(path))).read_text(encoding="utf-8", errors="replace")
|
|
1185
|
+
except OSError as exc:
|
|
1186
|
+
raise ContentError("could not read {}: {}".format(_clip(name), exc.strerror or type(exc).__name__))
|
|
1187
|
+
configs[name] = content
|
|
1188
|
+
continue
|
|
1189
|
+
if not stat.S_ISREG(info.st_mode):
|
|
1190
|
+
raise ContentError("{} is not a regular file; refusing to run ruff against it".format(_clip(name)))
|
|
1191
|
+
if info.st_size > MAX_CONFIG_BYTES:
|
|
1192
|
+
# An oversized pyproject.toml matters only if it carries a ruff table; look at its head, not all of it.
|
|
1193
|
+
with open(str(path), "rb") as handle:
|
|
1194
|
+
head = handle.read(MAX_CONFIG_BYTES).decode("utf-8", "replace")
|
|
1195
|
+
if name.rsplit("/", 1)[-1] == USER_PYPROJECT and not _has_ruff_table(head):
|
|
1196
|
+
continue
|
|
1197
|
+
raise ContentError("{} is over 1 MiB; refusing to run ruff against it".format(_clip(name)))
|
|
1198
|
+
try:
|
|
1199
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
1200
|
+
except OSError as exc:
|
|
1201
|
+
raise ContentError("could not read {}: {}".format(name, exc.strerror or type(exc).__name__))
|
|
1202
|
+
if name.rsplit("/", 1)[-1] == USER_PYPROJECT:
|
|
1203
|
+
if not _has_ruff_table(text):
|
|
1204
|
+
continue
|
|
1205
|
+
text = _ruff_tables(text)
|
|
1206
|
+
configs[name] = text
|
|
1207
|
+
return configs
|
|
1208
|
+
|
|
1209
|
+
|
|
1210
|
+
def toolchain_header(root, env=None) -> str:
|
|
1211
|
+
"""The baseline header line: the pinned ruff plus a hash of every tracked ruff configuration."""
|
|
1212
|
+
digest = hashlib.sha256()
|
|
1213
|
+
configs = _tracked_configs(root, env)
|
|
1214
|
+
for name in sorted(configs):
|
|
1215
|
+
digest.update(name.encode("utf-8", "replace") + b"\0" + configs[name].encode("utf-8", "replace") + b"\0")
|
|
1216
|
+
return "{}ruff {} ; config sha256:{}".format(HEADER_PREFIX, requirements_pin(RUFF_REQUIREMENTS),
|
|
1217
|
+
digest.hexdigest()[:16])
|
|
1218
|
+
|
|
1219
|
+
|
|
1220
|
+
def _script_cmd(scope: str, mode: str) -> str:
|
|
1221
|
+
return "python3 {} {} {}".format(shlex.quote(str(Path(__file__).resolve())), scope, mode)
|
|
1222
|
+
|
|
1223
|
+
|
|
1224
|
+
def _is_soleur_checkout(root) -> bool:
|
|
1225
|
+
"""True only when the RUNNING script is the very file inside this repository's plugin tree, reached with
|
|
1226
|
+
no symlink on the way: a symlink a pull request plants at the script path must not switch the ratchet off."""
|
|
1227
|
+
top = os.path.realpath(str(root))
|
|
1228
|
+
current = top
|
|
1229
|
+
for part in Path(SCRIPT_REL).parts:
|
|
1230
|
+
current = os.path.join(current, part)
|
|
1231
|
+
if os.path.islink(current):
|
|
1232
|
+
return False
|
|
1233
|
+
return os.path.realpath(__file__) == os.path.join(top, SCRIPT_REL)
|
|
1234
|
+
|
|
1235
|
+
|
|
1236
|
+
def _is_untracked_ruff_config(root, name) -> bool:
|
|
1237
|
+
base = name.rsplit("/", 1)[-1]
|
|
1238
|
+
if base in USER_RUFF_CONFIGS:
|
|
1239
|
+
return True
|
|
1240
|
+
if base != USER_PYPROJECT:
|
|
1241
|
+
return False
|
|
1242
|
+
try:
|
|
1243
|
+
with open(str(Path(root) / name), "rb") as handle:
|
|
1244
|
+
return _has_ruff_table(handle.read(MAX_CONFIG_BYTES).decode("utf-8", "replace"))
|
|
1245
|
+
except OSError:
|
|
1246
|
+
return False
|
|
1247
|
+
|
|
1248
|
+
|
|
1249
|
+
def _scan_notices(root, env, pre) -> list:
|
|
1250
|
+
"""What an OK would otherwise hide: tracked .py symlinks and untracked .py files are not scanned."""
|
|
1251
|
+
notices = []
|
|
1252
|
+
links = [n for n in tracked_files(root, USER_PY_PATHSPECS, env) if (Path(root) / n).is_symlink()]
|
|
1253
|
+
if links:
|
|
1254
|
+
notices.append(pre + "WARN: {} tracked .py symlink(s) are not scanned: {}".format(
|
|
1255
|
+
len(links), ", ".join(_clip(n, 80) for n in links[:3])))
|
|
1256
|
+
others = _git(root, ["ls-files", "-z", "--others", "--exclude-standard", "--"] + list(USER_PY_PATHSPECS)
|
|
1257
|
+
+ list(CONFIG_GLOBS), env)
|
|
1258
|
+
if others.returncode == 0:
|
|
1259
|
+
names = [n for n in others.stdout.split("\0") if n]
|
|
1260
|
+
configs = [n for n in names if _is_untracked_ruff_config(root, n)]
|
|
1261
|
+
python = [n for n in names if n.rsplit("/", 1)[-1] not in USER_RUFF_CONFIGS + (USER_PYPROJECT,)]
|
|
1262
|
+
if python:
|
|
1263
|
+
notices.append(pre + "WARN: {} untracked .py file(s) are not scanned until they are added to git: "
|
|
1264
|
+
"{}".format(len(python), ", ".join(_clip(n, 80) for n in python[:3])))
|
|
1265
|
+
if configs:
|
|
1266
|
+
notices.append(pre + "WARN: {} untracked ruff config(s) apply here but not in CI or in the fingerprint: "
|
|
1267
|
+
"{}".format(len(configs), ", ".join(_clip(n, 80) for n in configs[:3])))
|
|
1268
|
+
return notices
|
|
1269
|
+
|
|
1270
|
+
|
|
1271
|
+
UNADOPT = ("restore it, or un-adopt on purpose with git rm -r .lint-ratchet (this check stays red on that branch "
|
|
1272
|
+
"until the deletion merges)")
|
|
1273
|
+
|
|
1274
|
+
|
|
1275
|
+
def classify_user(root, base, env) -> UserState:
|
|
1276
|
+
"""Decide the user-mode state without running or provisioning ruff; used by every user verb."""
|
|
1277
|
+
root = Path(root)
|
|
1278
|
+
pre = "lint-ratchet(user-ruff): "
|
|
1279
|
+
if _is_soleur_checkout(root):
|
|
1280
|
+
return UserState("SOLEUR", 0, ["lint-ratchet: Soleur repository: scopes run via lefthook and CI; nothing to "
|
|
1281
|
+
"ratchet here"], [])
|
|
1282
|
+
problems = []
|
|
1283
|
+
files = _config_files(root, problems)
|
|
1284
|
+
tracked = set(tracked_files(root, USER_RUFF_CONFIGS + (USER_PYPROJECT,), env))
|
|
1285
|
+
has_config = any(name in tracked for name in USER_RUFF_CONFIGS if name in files) or (
|
|
1286
|
+
USER_PYPROJECT in tracked and USER_PYPROJECT in files and _has_ruff_table(files[USER_PYPROJECT]))
|
|
1287
|
+
unsafe_baseline = None
|
|
1288
|
+
try:
|
|
1289
|
+
baseline_path = _baseline_file(root, {"baseline": USER_BASELINE})
|
|
1290
|
+
except ContentError as exc:
|
|
1291
|
+
baseline_path, unsafe_baseline = None, exc
|
|
1292
|
+
in_tree = baseline_path is not None and baseline_path.exists()
|
|
1293
|
+
head_ok = _git(root, ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"], env).returncode == 0
|
|
1294
|
+
head_text = baseline_at(root, "HEAD", USER_BASELINE, env) if head_ok else None
|
|
1295
|
+
sha, notes = resolve_base_ladder(root, base, env, baseline=USER_BASELINE)
|
|
1296
|
+
notes = [pre + note for note in notes]
|
|
1297
|
+
base_text = baseline_at(root, sha, USER_BASELINE, env) if sha else None
|
|
1298
|
+
anywhere = in_tree or head_text is not None or base_text is not None
|
|
1299
|
+
# A repository that never opted in pays nothing, so an unsafe file only matters once a baseline exists
|
|
1300
|
+
# (or a config is in play): then it is a defect of the repository, exit 2.
|
|
1301
|
+
if unsafe_baseline is not None and anywhere:
|
|
1302
|
+
raise unsafe_baseline
|
|
1303
|
+
if problems and anywhere:
|
|
1304
|
+
raise ContentError(problems[0])
|
|
1305
|
+
if not has_config:
|
|
1306
|
+
if anywhere:
|
|
1307
|
+
return UserState("BASELINE_WITHOUT_CONFIG", 1, [
|
|
1308
|
+
pre + "baseline exists but no ruff config detected (no tracked ruff config at {}; nested configs "
|
|
1309
|
+
"unsupported): restore the config, or un-adopt on purpose with git rm -r .lint-ratchet".format(root)],
|
|
1310
|
+
[])
|
|
1311
|
+
return UserState("NO_LINTER", 0, ["lint-ratchet: no linter configured (no tracked ruff config at {}; nested "
|
|
1312
|
+
"configs unsupported); nothing to ratchet".format(root)],
|
|
1313
|
+
[pre + problem for problem in problems])
|
|
1314
|
+
if not in_tree and base_text is not None:
|
|
1315
|
+
return UserState("DELETED", 1, [pre + "baseline deleted since the merge base: " + UNADOPT], [])
|
|
1316
|
+
if not in_tree and head_text is not None:
|
|
1317
|
+
if sha is not None:
|
|
1318
|
+
return UserState("DELETED", 1, [pre + "baseline deleted from the working tree (it is committed at HEAD): "
|
|
1319
|
+
+ UNADOPT], [])
|
|
1320
|
+
text = ("baseline is committed at HEAD but missing from the working tree, and no merge base could be "
|
|
1321
|
+
"resolved: cannot tell a deletion from an un-adoption; " + UNADOPT)
|
|
1322
|
+
if _is_ci(env):
|
|
1323
|
+
return UserState("UNVERIFIED", 2, [pre + text], list(notes))
|
|
1324
|
+
return UserState("UNVERIFIED", 0, ["WARN " + pre + text], list(notes))
|
|
1325
|
+
if not in_tree:
|
|
1326
|
+
if sha is None and _is_ci(env):
|
|
1327
|
+
return UserState("UNVERIFIED", 2, [
|
|
1328
|
+
pre + "no merge base could be resolved under CI, so a repository that never adopted cannot be told "
|
|
1329
|
+
"from one whose baseline this change deleted: fetch more history (fetch-depth: 0)"], list(notes))
|
|
1330
|
+
warnings = list(notes) + [pre + problem for problem in problems]
|
|
1331
|
+
if unsafe_baseline is not None:
|
|
1332
|
+
warnings.append(pre + str(unsafe_baseline))
|
|
1333
|
+
if sha is None:
|
|
1334
|
+
warnings.append(pre + "base unresolvable: baseline-deletion check not performed")
|
|
1335
|
+
return UserState("NOT_ADOPTED", 0, [pre + "not adopted. Ask the user whether to adopt (it installs a pinned "
|
|
1336
|
+
"ruff, writes .lint-ratchet/ruff.baseline.txt for the current findings, "
|
|
1337
|
+
"and new findings then block; do not adopt in an unattended run); if "
|
|
1338
|
+
"yes, run: " + _script_cmd("user-ruff", "--update")], warnings)
|
|
1339
|
+
if sha is None and _is_ci(env):
|
|
1340
|
+
return UserState("UNVERIFIED", 2, [pre + "no merge base could be resolved under CI; refusing to skip the "
|
|
1341
|
+
"shrink-only comparison"], list(notes))
|
|
1342
|
+
warnings = []
|
|
1343
|
+
if _git(root, ["ls-files", "--error-unmatch", "--", USER_BASELINE], env).returncode != 0:
|
|
1344
|
+
if _git(root, ["check-ignore", "-q", "--", USER_BASELINE], env).returncode == 0:
|
|
1345
|
+
warnings.append(pre + "the baseline is git-ignored, so it never reaches the base branch and the "
|
|
1346
|
+
"shrink-only guard stays dark: remove the ignore rule (git add would refuse it)")
|
|
1347
|
+
else:
|
|
1348
|
+
warnings.append(pre + "the baseline is not tracked yet: git add .lint-ratchet and commit it")
|
|
1349
|
+
return UserState("ADOPTED", 0, [], warnings + _scan_notices(root, env, pre))
|
|
1350
|
+
|
|
1351
|
+
|
|
1352
|
+
def _ruff_failure(label: str, result, exe=None) -> ContentError:
|
|
1353
|
+
"""A ruff exit 2 in user mode. Only the first stderr line is echoed (normally the literal ``ruff failed``):
|
|
1354
|
+
the lines after it quote the failing file, and an ``extend`` can point anywhere on the machine."""
|
|
1355
|
+
first = next((line.strip() for line in result.stderr.splitlines() if line.strip()), "")
|
|
1356
|
+
text = "{} exited {}: {}".format(label, result.returncode, _clip(first) or "(no message)")
|
|
1357
|
+
if first.lower().startswith("ruff failed"):
|
|
1358
|
+
text += "; the detail lines are not echoed (an extend can point at any file): run `{} check --no-cache " \
|
|
1359
|
+
"--show-settings` in the repository to see them".format(shlex.quote(str(exe)) if exe else "ruff")
|
|
1360
|
+
if PIN_HINT_RE.search(result.stderr):
|
|
1361
|
+
text += ("; pinned ruff is {}: this is a typo in your config, or a setting newer than the pin".format(
|
|
1362
|
+
requirements_pin(RUFF_REQUIREMENTS)))
|
|
1363
|
+
return ContentError(text)
|
|
1364
|
+
|
|
1365
|
+
|
|
1366
|
+
def _chunks(files: list, budget=None):
|
|
1367
|
+
"""Split a file list so no single ruff launch exceeds the platform's argument-size limit."""
|
|
1368
|
+
budget = budget or ARGV_BUDGET
|
|
1369
|
+
chunk, size = [], 0
|
|
1370
|
+
for name in files:
|
|
1371
|
+
weight = len(name.encode("utf-8", "replace")) + 1
|
|
1372
|
+
if chunk and size + weight > budget:
|
|
1373
|
+
yield chunk
|
|
1374
|
+
chunk, size = [], 0
|
|
1375
|
+
chunk.append(name)
|
|
1376
|
+
size += weight
|
|
1377
|
+
if chunk:
|
|
1378
|
+
yield chunk
|
|
1379
|
+
|
|
1380
|
+
|
|
1381
|
+
def run_user_ruff(root, env=None, ruff_path=None, run=subprocess.run) -> tuple:
|
|
1382
|
+
"""The census of the user's own ruff findings. The totality checks are in ADR-290 Amendment 1."""
|
|
1383
|
+
root = Path(root)
|
|
1384
|
+
_config_files(root) # an unsafe config is refused before any ruff is provisioned
|
|
1385
|
+
files = []
|
|
1386
|
+
for name in tracked_files(root, USER_PY_PATHSPECS, env):
|
|
1387
|
+
if "\ufffd" in name:
|
|
1388
|
+
raise ContentError("a tracked file has a name that is not valid UTF-8 ({!r}); it cannot be scanned or "
|
|
1389
|
+
"baselined, rename it".format(_clip(name)))
|
|
1390
|
+
if (root / name).exists() and not (root / name).is_symlink():
|
|
1391
|
+
files.append(name)
|
|
1392
|
+
if not files:
|
|
1393
|
+
return [], 0
|
|
1394
|
+
base_env = dict(env if env is not None else os.environ)
|
|
1395
|
+
exe = ruff_path if ruff_path is not None else ensure_ruff(
|
|
1396
|
+
RUFF_REQUIREMENTS, env=dict(base_env), recovery=_script_cmd("user-ruff", "--check"))
|
|
1397
|
+
ruff_env = {k: v for k, v in base_env.items() if not k.startswith(("RUFF_", "PYTHON"))}
|
|
1398
|
+
deadline = time.monotonic() + TOTAL_RUN_BUDGET
|
|
1399
|
+
|
|
1400
|
+
def launch_timeout() -> float:
|
|
1401
|
+
remaining = deadline - time.monotonic()
|
|
1402
|
+
if remaining <= 0:
|
|
1403
|
+
raise InfraError("ruff exceeded the total budget of {} s across its chunked launches".format(
|
|
1404
|
+
TOTAL_RUN_BUDGET))
|
|
1405
|
+
return min(RUN_TIMEOUT, remaining)
|
|
1406
|
+
|
|
1407
|
+
listed = set()
|
|
1408
|
+
for chunk in _chunks(files):
|
|
1409
|
+
listing = _exec(run, show_files_argv(exe, chunk, user=True), launch_timeout(), cwd=str(root), env=ruff_env)
|
|
1410
|
+
if listing.returncode != 0:
|
|
1411
|
+
raise _ruff_failure("ruff --show-files", listing, exe)
|
|
1412
|
+
listed.update(_relative(line.strip(), root) for line in listing.stdout.splitlines() if line.strip())
|
|
1413
|
+
if not listed:
|
|
1414
|
+
return [], 0
|
|
1415
|
+
findings = []
|
|
1416
|
+
for chunk in _chunks(files):
|
|
1417
|
+
result = _exec(run, tool_argv("ruff", root, chunk, exe, user=True), launch_timeout(), cwd=str(root),
|
|
1418
|
+
env=ruff_env)
|
|
1419
|
+
if result.returncode not in (0, 1):
|
|
1420
|
+
raise _ruff_failure("ruff", result, exe)
|
|
1421
|
+
failed = [line.strip() for line in result.stderr.splitlines() if "Failed to" in line]
|
|
1422
|
+
if failed:
|
|
1423
|
+
raise ContentError("ruff could not lint a file (a file it cannot read hides its findings): {}".format(
|
|
1424
|
+
_clip(failed[0])))
|
|
1425
|
+
findings.extend(adapt_ruff(result.stdout, root))
|
|
1426
|
+
return findings, len(listed)
|
|
1427
|
+
|
|
1428
|
+
|
|
1429
|
+
def build_user_config(env) -> dict:
|
|
1430
|
+
return {
|
|
1431
|
+
"name": "user-ruff", "baseline": USER_BASELINE, "floor": 0, "run": lambda root: run_user_ruff(root, env),
|
|
1432
|
+
"remedy": REMEDY_USER, "waiver_note": WAIVER_NOTE_USER,
|
|
1433
|
+
"ladder": True, "header": lambda root: toolchain_header(root, env), "repo_content": True,
|
|
1434
|
+
"recovery_cmd": "python3 {} user-ruff".format(shlex.quote(str(Path(__file__).resolve()))),
|
|
1435
|
+
"header_recovery": 'python3 "${CLAUDE_PLUGIN_ROOT}/scripts/lint-ratchet.py" user-ruff',
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
|
|
1439
|
+
# --------------------------------------------------------------------------- CLI
|
|
1440
|
+
|
|
1441
|
+
|
|
1442
|
+
def build_config(name: str, env) -> dict:
|
|
1443
|
+
own = REPO_ROOT / SCRIPT_REL if REPO_ROOT is not None else None
|
|
1444
|
+
if own is None or not own.exists() or not own.samefile(Path(__file__)):
|
|
1445
|
+
raise InfraError("the {} scope runs only from the Soleur repository checkout ({} is not it); user "
|
|
1446
|
+
"repositories use the user-ruff or auto scope".format(name, REPO_ROOT))
|
|
1447
|
+
runner = (lambda root: run_ruff(root, env)) if name == "ruff" else (lambda root: run_eslint(root, env))
|
|
1448
|
+
return dict(SCOPES[name], name=name, run=runner, script=SCRIPT_REL)
|
|
1449
|
+
|
|
1450
|
+
|
|
1451
|
+
def _emit(code: int, lines: list, warnings: list, scope: str) -> int:
|
|
1452
|
+
"""Print the report: warnings to stderr, then the lines to stdout (exit 0) or stderr. Never exits 120."""
|
|
1453
|
+
stream = sys.stdout if code == 0 else sys.stderr
|
|
1454
|
+
try:
|
|
1455
|
+
for line in warnings:
|
|
1456
|
+
print(_clip(line, LINE_LIMIT), file=sys.stderr)
|
|
1457
|
+
for line in lines:
|
|
1458
|
+
text = _clip(line, LINE_LIMIT)
|
|
1459
|
+
# A tracked path can start a --list line; never let it read as a workflow command.
|
|
1460
|
+
print((" " + text) if text.startswith(("::", "##[")) else text, file=stream)
|
|
1461
|
+
sys.stdout.flush()
|
|
1462
|
+
sys.stderr.flush()
|
|
1463
|
+
except OSError as exc:
|
|
1464
|
+
with contextlib.suppress(OSError):
|
|
1465
|
+
print(_clean("lint-ratchet({}): could not write the report: {}".format(scope, exc)), file=sys.stderr)
|
|
1466
|
+
return 2
|
|
1467
|
+
return code
|
|
1468
|
+
|
|
1469
|
+
|
|
1470
|
+
def main_user(args, env) -> int:
|
|
1471
|
+
"""The user scopes: ``user-ruff`` (--check, --update, --list) and ``auto`` (check only)."""
|
|
1472
|
+
scope = args.scope
|
|
1473
|
+
verb = "update" if args.update else "list" if args.list else "check"
|
|
1474
|
+
pre = "lint-ratchet({}): ".format(scope)
|
|
1475
|
+
env = user_env(env)
|
|
1476
|
+
try:
|
|
1477
|
+
root = resolve_root(env)
|
|
1478
|
+
state = classify_user(root, args.base, env)
|
|
1479
|
+
if state.kind == "SOLEUR" or (verb == "check" and state.kind != "ADOPTED"):
|
|
1480
|
+
return _emit(state.code, state.lines, state.warnings, scope)
|
|
1481
|
+
if state.kind in ("NO_LINTER", "BASELINE_WITHOUT_CONFIG"):
|
|
1482
|
+
return _emit(2, [pre + "cannot --{}: no usable tracked ruff config at {}".format(verb, root)], [], scope)
|
|
1483
|
+
config = build_user_config(env)
|
|
1484
|
+
if verb == "update":
|
|
1485
|
+
code, lines = update(config, root, base=args.base, env=env)
|
|
1486
|
+
elif verb == "list":
|
|
1487
|
+
code, lines = list_live(config, root)
|
|
1488
|
+
else:
|
|
1489
|
+
code, lines = check(config, root, base=args.base, env=env)
|
|
1490
|
+
return _emit(code, lines, state.warnings, scope)
|
|
1491
|
+
except Skip as exc:
|
|
1492
|
+
if verb == "check":
|
|
1493
|
+
print(_clean("SKIP lint-ratchet({}): no scan was performed ({}); no ratchet protection this run".format(
|
|
1494
|
+
scope, exc)))
|
|
1495
|
+
return 0
|
|
1496
|
+
print(_clean(pre + "{} -- cannot --{}".format(exc, verb)), file=sys.stderr)
|
|
1497
|
+
return 2
|
|
1498
|
+
except InfraError as exc:
|
|
1499
|
+
prefix = REPO_CONTENT if isinstance(exc, ContentError) else ""
|
|
1500
|
+
print(_clip(pre + prefix + str(exc), LINE_LIMIT), file=sys.stderr)
|
|
1501
|
+
return 2
|
|
1502
|
+
except Exception as exc: # the documented contract: nothing unexpected exits 1
|
|
1503
|
+
print(_clip(pre + "unexpected {}: {}".format(type(exc).__name__, exc), LINE_LIMIT), file=sys.stderr)
|
|
1504
|
+
return 2
|
|
1505
|
+
|
|
1506
|
+
|
|
1507
|
+
def main(argv=None) -> int:
|
|
1508
|
+
parser = argparse.ArgumentParser(description="Shrink-only lint ratchet (see the module docstring).")
|
|
1509
|
+
parser.add_argument("scope", choices=sorted(SCOPES) + list(USER_SCOPES))
|
|
1510
|
+
mode = parser.add_mutually_exclusive_group()
|
|
1511
|
+
mode.add_argument("--check", action="store_true", help="verify the live tree against the baseline (default)")
|
|
1512
|
+
mode.add_argument("--update", action="store_true", help="rewrite the baseline from the live census (never grows it)")
|
|
1513
|
+
mode.add_argument("--list", action="store_true", help="print the live findings")
|
|
1514
|
+
parser.add_argument("--base", help="ref to compare the baseline against (default: merge-base with origin/main)")
|
|
1515
|
+
args = parser.parse_args(argv)
|
|
1516
|
+
env = dict(os.environ)
|
|
1517
|
+
if args.scope == "auto" and (args.update or args.list):
|
|
1518
|
+
parser.error("auto accepts only --check and --base")
|
|
1519
|
+
if args.scope in USER_SCOPES:
|
|
1520
|
+
return main_user(args, env)
|
|
1521
|
+
verb = "update" if args.update else "list" if args.list else "check"
|
|
1522
|
+
try:
|
|
1523
|
+
config = build_config(args.scope, env)
|
|
1524
|
+
if args.update:
|
|
1525
|
+
code, lines = update(config, REPO_ROOT, base=args.base, env=env)
|
|
1526
|
+
elif args.list:
|
|
1527
|
+
code, lines = list_live(config, REPO_ROOT)
|
|
1528
|
+
else:
|
|
1529
|
+
code, lines = check(config, REPO_ROOT, base=args.base, env=env)
|
|
1530
|
+
except Skip as exc:
|
|
1531
|
+
if verb == "check":
|
|
1532
|
+
print(_clean("SKIP lint-ratchet({}): {}".format(args.scope, exc)))
|
|
1533
|
+
return 0
|
|
1534
|
+
print(_clean("lint-ratchet({}): {} -- cannot --{}".format(args.scope, exc, verb)), file=sys.stderr)
|
|
1535
|
+
return 2
|
|
1536
|
+
except InfraError as exc:
|
|
1537
|
+
print(_clip("lint-ratchet({}): {}".format(args.scope, exc), LINE_LIMIT), file=sys.stderr)
|
|
1538
|
+
return 2
|
|
1539
|
+
except Exception as exc: # the documented contract: nothing unexpected exits 1
|
|
1540
|
+
print(_clip("lint-ratchet({}): unexpected {}: {}".format(args.scope, type(exc).__name__, exc), LINE_LIMIT),
|
|
1541
|
+
file=sys.stderr)
|
|
1542
|
+
return 2
|
|
1543
|
+
return _emit(code, lines, [], args.scope)
|
|
1544
|
+
|
|
1545
|
+
|
|
1546
|
+
if __name__ == "__main__":
|
|
1547
|
+
sys.exit(main())
|