@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.
Files changed (832) hide show
  1. package/.claude-plugin/agents.manifest.json +476 -0
  2. package/.claude-plugin/plugin.json +42 -0
  3. package/README.md +44 -2
  4. package/agents/community/.gitkeep +0 -0
  5. package/agents/engineering/cto.md +87 -0
  6. package/agents/engineering/design/ddd-architect.md +95 -0
  7. package/agents/engineering/discovery/agent-finder.md +214 -0
  8. package/agents/engineering/discovery/functional-discovery.md +182 -0
  9. package/agents/engineering/infra/infra-security.md +151 -0
  10. package/agents/engineering/infra/platform-strategist.md +109 -0
  11. package/agents/engineering/infra/terraform-architect.md +171 -0
  12. package/agents/engineering/prompt-engineer.md +37 -0
  13. package/agents/engineering/research/best-practices-researcher.md +115 -0
  14. package/agents/engineering/research/framework-docs-researcher.md +93 -0
  15. package/agents/engineering/research/git-history-analyzer.md +53 -0
  16. package/agents/engineering/research/learnings-researcher.md +285 -0
  17. package/agents/engineering/research/repo-research-analyst.md +118 -0
  18. package/agents/engineering/review/agent-native-reviewer.md +279 -0
  19. package/agents/engineering/review/architecture-strategist.md +61 -0
  20. package/agents/engineering/review/code-quality-analyst.md +92 -0
  21. package/agents/engineering/review/code-simplicity-reviewer.md +109 -0
  22. package/agents/engineering/review/data-integrity-guardian.md +82 -0
  23. package/agents/engineering/review/data-migration-expert.md +98 -0
  24. package/agents/engineering/review/deployment-verification-agent.md +183 -0
  25. package/agents/engineering/review/dhh-rails-reviewer.md +46 -0
  26. package/agents/engineering/review/kieran-rails-reviewer.md +87 -0
  27. package/agents/engineering/review/legacy-code-expert.md +55 -0
  28. package/agents/engineering/review/observability-coverage-reviewer.md +147 -0
  29. package/agents/engineering/review/pattern-recognition-specialist.md +59 -0
  30. package/agents/engineering/review/performance-oracle.md +130 -0
  31. package/agents/engineering/review/security-sentinel.md +167 -0
  32. package/agents/engineering/review/semgrep-sast.md +36 -0
  33. package/agents/engineering/review/test-design-reviewer.md +150 -0
  34. package/agents/engineering/review/user-impact-reviewer.md +70 -0
  35. package/agents/engineering/workflow/pr-comment-resolver.md +69 -0
  36. package/agents/finance/budget-analyst.md +29 -0
  37. package/agents/finance/cfo.md +44 -0
  38. package/agents/finance/financial-reporter.md +30 -0
  39. package/agents/finance/revenue-analyst.md +30 -0
  40. package/agents/legal/clo.md +115 -0
  41. package/agents/legal/legal-compliance-auditor.md +110 -0
  42. package/agents/legal/legal-document-generator.md +134 -0
  43. package/agents/marketing/analytics-analyst.md +25 -0
  44. package/agents/marketing/brand-architect.md +181 -0
  45. package/agents/marketing/cmo.md +76 -0
  46. package/agents/marketing/conversion-optimizer.md +19 -0
  47. package/agents/marketing/copywriter.md +20 -0
  48. package/agents/marketing/fact-checker.md +70 -0
  49. package/agents/marketing/growth-strategist.md +145 -0
  50. package/agents/marketing/paid-media-strategist.md +28 -0
  51. package/agents/marketing/pricing-strategist.md +17 -0
  52. package/agents/marketing/programmatic-seo-specialist.md +25 -0
  53. package/agents/marketing/retention-strategist.md +23 -0
  54. package/agents/marketing/seo-aeo-analyst.md +164 -0
  55. package/agents/operations/coo.md +50 -0
  56. package/agents/operations/ops-advisor.md +78 -0
  57. package/agents/operations/ops-provisioner.md +101 -0
  58. package/agents/operations/ops-research.md +52 -0
  59. package/agents/operations/service-automator.md +278 -0
  60. package/agents/product/business-validator.md +206 -0
  61. package/agents/product/competitive-intelligence.md +89 -0
  62. package/agents/product/cpo.md +63 -0
  63. package/agents/product/design/ux-design-lead.md +209 -0
  64. package/agents/product/spec-flow-analyzer.md +122 -0
  65. package/agents/sales/cro.md +44 -0
  66. package/agents/sales/deal-architect.md +29 -0
  67. package/agents/sales/outbound-strategist.md +29 -0
  68. package/agents/sales/pipeline-analyst.md +29 -0
  69. package/agents/support/cco.md +48 -0
  70. package/agents/support/community-manager.md +570 -0
  71. package/agents/support/ticket-triage.md +70 -0
  72. package/bin/soleur-opencode.js +29 -0
  73. package/commands/go.md +660 -0
  74. package/commands/help.md +273 -0
  75. package/commands/sync.md +977 -0
  76. package/commands/version.md +103 -0
  77. package/hooks/antigravity-hooks.json +84 -0
  78. package/hooks/antigravity-session-start.sh +15 -0
  79. package/hooks/bare-root-cwd-guard.sh +131 -0
  80. package/hooks/browser-snapshot-credential-guard.sh +200 -0
  81. package/hooks/codex-session-start.sh +9 -0
  82. package/hooks/compaction-state.sh +525 -0
  83. package/hooks/destructive-command-guard.sh +1387 -0
  84. package/hooks/devin-session-start.sh +90 -0
  85. package/hooks/ext-filter-detect.sh +171 -0
  86. package/hooks/hooks.json +133 -0
  87. package/hooks/lib/hook-tool-kind.sh +24 -0
  88. package/hooks/lib/log-rotation.sh +176 -0
  89. package/hooks/lib/shell-argv.pl +576 -0
  90. package/hooks/memory-backstop.sh +1114 -0
  91. package/hooks/operator-stage-approval.sh +391 -0
  92. package/hooks/stop-hook.sh +367 -0
  93. package/hooks/unkept-promise-hook.sh +383 -0
  94. package/hooks/version-banner.sh +127 -0
  95. package/hooks/welcome-hook.sh +50 -0
  96. package/install.js +29 -0
  97. package/lib/agent-registry.ts +154 -0
  98. package/lib/c4-canonical-cli.mjs +34 -0
  99. package/lib/c4-canonical.mjs +46 -0
  100. package/lib/c4-from-components.ts +554 -0
  101. package/lib/go-routing.ts +81 -0
  102. package/lib/grok-inspect-contract.ts +311 -0
  103. package/lib/harness-model-map.ts +101 -0
  104. package/lib/harness-parity.ts +726 -0
  105. package/lib/harness-tool-map.ts +180 -0
  106. package/lib/harness.ts +716 -0
  107. package/lib/heartbeat-live-reconcile.ts +1153 -0
  108. package/lib/heartbeat-manifest.ts +361 -0
  109. package/lib/kb-coverage.ts +252 -0
  110. package/lib/pr-merge-poll.ts +103 -0
  111. package/lib/workflow-fidelity.ts +388 -0
  112. package/opencode/INSTRUCTIONS.md +171 -0
  113. package/opencode/agents/engineering/cto.md +10 -0
  114. package/opencode/agents/engineering/design/ddd-architect.md +10 -0
  115. package/opencode/agents/engineering/discovery/agent-finder.md +10 -0
  116. package/opencode/agents/engineering/discovery/functional-discovery.md +10 -0
  117. package/opencode/agents/engineering/infra/infra-security.md +10 -0
  118. package/opencode/agents/engineering/infra/platform-strategist.md +10 -0
  119. package/opencode/agents/engineering/infra/terraform-architect.md +10 -0
  120. package/opencode/agents/engineering/prompt-engineer.md +10 -0
  121. package/opencode/agents/engineering/research/best-practices-researcher.md +10 -0
  122. package/opencode/agents/engineering/research/framework-docs-researcher.md +10 -0
  123. package/opencode/agents/engineering/research/git-history-analyzer.md +10 -0
  124. package/opencode/agents/engineering/research/learnings-researcher.md +10 -0
  125. package/opencode/agents/engineering/research/repo-research-analyst.md +10 -0
  126. package/opencode/agents/engineering/review/agent-native-reviewer.md +10 -0
  127. package/opencode/agents/engineering/review/architecture-strategist.md +10 -0
  128. package/opencode/agents/engineering/review/code-quality-analyst.md +10 -0
  129. package/opencode/agents/engineering/review/code-simplicity-reviewer.md +10 -0
  130. package/opencode/agents/engineering/review/data-integrity-guardian.md +10 -0
  131. package/opencode/agents/engineering/review/data-migration-expert.md +10 -0
  132. package/opencode/agents/engineering/review/deployment-verification-agent.md +10 -0
  133. package/opencode/agents/engineering/review/dhh-rails-reviewer.md +10 -0
  134. package/opencode/agents/engineering/review/kieran-rails-reviewer.md +10 -0
  135. package/opencode/agents/engineering/review/legacy-code-expert.md +10 -0
  136. package/opencode/agents/engineering/review/observability-coverage-reviewer.md +10 -0
  137. package/opencode/agents/engineering/review/pattern-recognition-specialist.md +10 -0
  138. package/opencode/agents/engineering/review/performance-oracle.md +10 -0
  139. package/opencode/agents/engineering/review/security-sentinel.md +10 -0
  140. package/opencode/agents/engineering/review/semgrep-sast.md +10 -0
  141. package/opencode/agents/engineering/review/test-design-reviewer.md +10 -0
  142. package/opencode/agents/engineering/review/user-impact-reviewer.md +10 -0
  143. package/opencode/agents/engineering/workflow/pr-comment-resolver.md +10 -0
  144. package/opencode/agents/finance/budget-analyst.md +10 -0
  145. package/opencode/agents/finance/cfo.md +10 -0
  146. package/opencode/agents/finance/financial-reporter.md +10 -0
  147. package/opencode/agents/finance/revenue-analyst.md +10 -0
  148. package/opencode/agents/legal/clo.md +10 -0
  149. package/opencode/agents/legal/legal-compliance-auditor.md +10 -0
  150. package/opencode/agents/legal/legal-document-generator.md +10 -0
  151. package/opencode/agents/marketing/analytics-analyst.md +10 -0
  152. package/opencode/agents/marketing/brand-architect.md +10 -0
  153. package/opencode/agents/marketing/cmo.md +10 -0
  154. package/opencode/agents/marketing/conversion-optimizer.md +10 -0
  155. package/opencode/agents/marketing/copywriter.md +10 -0
  156. package/opencode/agents/marketing/fact-checker.md +10 -0
  157. package/opencode/agents/marketing/growth-strategist.md +10 -0
  158. package/opencode/agents/marketing/paid-media-strategist.md +10 -0
  159. package/opencode/agents/marketing/pricing-strategist.md +10 -0
  160. package/opencode/agents/marketing/programmatic-seo-specialist.md +10 -0
  161. package/opencode/agents/marketing/retention-strategist.md +10 -0
  162. package/opencode/agents/marketing/seo-aeo-analyst.md +10 -0
  163. package/opencode/agents/operations/coo.md +10 -0
  164. package/opencode/agents/operations/ops-advisor.md +10 -0
  165. package/opencode/agents/operations/ops-provisioner.md +10 -0
  166. package/opencode/agents/operations/ops-research.md +10 -0
  167. package/opencode/agents/operations/service-automator.md +10 -0
  168. package/opencode/agents/product/business-validator.md +10 -0
  169. package/opencode/agents/product/competitive-intelligence.md +10 -0
  170. package/opencode/agents/product/cpo.md +10 -0
  171. package/opencode/agents/product/design/ux-design-lead.md +10 -0
  172. package/opencode/agents/product/spec-flow-analyzer.md +10 -0
  173. package/opencode/agents/sales/cro.md +10 -0
  174. package/opencode/agents/sales/deal-architect.md +10 -0
  175. package/opencode/agents/sales/outbound-strategist.md +10 -0
  176. package/opencode/agents/sales/pipeline-analyst.md +10 -0
  177. package/opencode/agents/support/cco.md +10 -0
  178. package/opencode/agents/support/community-manager.md +10 -0
  179. package/opencode/agents/support/ticket-triage.md +10 -0
  180. package/opencode/commands/go.md +10 -0
  181. package/opencode/commands/help.md +10 -0
  182. package/opencode/commands/sync.md +10 -0
  183. package/opencode/commands/version.md +11 -0
  184. package/opencode/opencode.json +8 -0
  185. package/opencode/plugin.ts +818 -0
  186. package/package.json +36 -4
  187. package/scripts/admin-merge-ready.sh +545 -0
  188. package/scripts/admin-merge-ready.test.sh +753 -0
  189. package/scripts/affected-tests.sh +908 -0
  190. package/scripts/affected-tests.test.sh +1797 -0
  191. package/scripts/alpha-metrics.sh +78 -0
  192. package/scripts/alpha-metrics.test.sh +115 -0
  193. package/scripts/ancestor-signal-helpers.baseline.txt +16 -0
  194. package/scripts/audit-flag-flip.sh +107 -0
  195. package/scripts/check-red-on-main.sh +316 -0
  196. package/scripts/ci-head-verdict.sh +411 -0
  197. package/scripts/cloud-detect.sh +193 -0
  198. package/scripts/delegation-verify.sh +657 -0
  199. package/scripts/delegation-verify.test.sh +1424 -0
  200. package/scripts/deploy-arm.sh +475 -0
  201. package/scripts/domain-model-drift.sh +419 -0
  202. package/scripts/emit-decision.sh +144 -0
  203. package/scripts/emit-decision.test.sh +144 -0
  204. package/scripts/ext-filter-divergence-corpus.txt +15 -0
  205. package/scripts/ext-filter-divergence.sh +189 -0
  206. package/scripts/generate-c4-from-components.ts +442 -0
  207. package/scripts/grok-fidelity-gate.sh +68 -0
  208. package/scripts/grok-pre-push-gate.sh +232 -0
  209. package/scripts/harness-discovery-smoke.ts +906 -0
  210. package/scripts/harness-parity-census.ts +92 -0
  211. package/scripts/inngest-luks-alert-armed-probe.ts +29 -0
  212. package/scripts/install-opencode-adapter.ts +509 -0
  213. package/scripts/lib/affected-tests/common.sh +365 -0
  214. package/scripts/lib/affected-tests/go.sh +157 -0
  215. package/scripts/lib/affected-tests/js.sh +188 -0
  216. package/scripts/lib/affected-tests/nx-turbo.sh +98 -0
  217. package/scripts/lib/affected-tests/python.sh +185 -0
  218. package/scripts/lib/context-sanitize.sh +49 -0
  219. package/scripts/lib/domain-model-lib.sh +180 -0
  220. package/scripts/lib/ext-filter-grammar.tsv +34 -0
  221. package/scripts/lib/operator-script.sh +1236 -0
  222. package/scripts/lib/proc.sh +580 -0
  223. package/scripts/lib/session-state.sh +748 -0
  224. package/scripts/lib/tmp-classify.sh +843 -0
  225. package/scripts/lib/wrapper-boundary.sh +47 -0
  226. package/scripts/lint-ratchet.py +1547 -0
  227. package/scripts/monitor-pr-checks.sh +441 -0
  228. package/scripts/pipeline-tally.sh +756 -0
  229. package/scripts/pipeline-tally.test.sh +740 -0
  230. package/scripts/plugin-version.sh +528 -0
  231. package/scripts/precommit-guard.sh +115 -0
  232. package/scripts/precommit-guard.test.sh +254 -0
  233. package/scripts/reconcile-live-heartbeats.ts +705 -0
  234. package/scripts/render-c4-model.sh +217 -0
  235. package/scripts/resolve-git-root.sh +55 -0
  236. package/scripts/resolve-regenerable-conflicts.sh +363 -0
  237. package/scripts/resolve-regenerable-conflicts.test.sh +780 -0
  238. package/scripts/ruff-requirements.txt +22 -0
  239. package/scripts/run-in-pid-namespace.sh +98 -0
  240. package/scripts/run-in-pid-namespace.test.sh +290 -0
  241. package/scripts/scan-ancestor-signal-helpers.py +556 -0
  242. package/scripts/scan-ancestor-signal-helpers.test.sh +670 -0
  243. package/scripts/setup-ralph-loop.sh +238 -0
  244. package/scripts/sync-cursor-name-map.ts +316 -0
  245. package/scripts/sync-grok-agent-compat.ts +188 -0
  246. package/scripts/sync-npm-package.ts +165 -0
  247. package/scripts/sync-opencode-adapter.ts +498 -0
  248. package/scripts/sync-pr-behind.sh +647 -0
  249. package/scripts/sync-pr-behind.test.sh +252 -0
  250. package/scripts/taste-profile-update.sh +180 -0
  251. package/scripts/taste-profile-update.test.sh +167 -0
  252. package/scripts/write-budget-marker.sh +23 -0
  253. package/scripts/write-kb-coverage.ts +113 -0
  254. package/skills/admin-ip-refresh/SKILL.md +81 -0
  255. package/skills/admin-ip-refresh/references/admin-ip-refresh-procedure.md +201 -0
  256. package/skills/agent-browser/SKILL.md +698 -0
  257. package/skills/agent-browser/scripts/playwright-mcp-profile-slot.sh +304 -0
  258. package/skills/agent-browser/scripts/playwright-mcp-redact-proxy.py +964 -0
  259. package/skills/agent-browser/scripts/redact-a11y-snapshot.py +443 -0
  260. package/skills/agent-browser/test/fixtures/capture-playwright-mcp-fixtures.py +217 -0
  261. package/skills/agent-browser/test/fixtures/fake-passthrough-proxy.py +24 -0
  262. package/skills/agent-browser/test/fixtures/fake-playwright-mcp.py +131 -0
  263. package/skills/agent-browser/test/fixtures/make-proxy-mutant.py +48 -0
  264. package/skills/agent-browser/test/fixtures/odd-shapes/dialog-link-injection.json +10 -0
  265. package/skills/agent-browser/test/fixtures/odd-shapes/empty-content.json +5 -0
  266. package/skills/agent-browser/test/fixtures/odd-shapes/error-and-result.json +4 -0
  267. package/skills/agent-browser/test/fixtures/odd-shapes/error-code-bool.json +6 -0
  268. package/skills/agent-browser/test/fixtures/odd-shapes/error-code-string.json +6 -0
  269. package/skills/agent-browser/test/fixtures/odd-shapes/error-message-tree.json +6 -0
  270. package/skills/agent-browser/test/fixtures/odd-shapes/error-no-data.json +6 -0
  271. package/skills/agent-browser/test/fixtures/odd-shapes/error-string.json +4 -0
  272. package/skills/agent-browser/test/fixtures/odd-shapes/error-with-data.json +9 -0
  273. package/skills/agent-browser/test/fixtures/odd-shapes/evaluate-escaped-object.json +10 -0
  274. package/skills/agent-browser/test/fixtures/odd-shapes/invalid-utf8.json +4 -0
  275. package/skills/agent-browser/test/fixtures/odd-shapes/link-result.json +10 -0
  276. package/skills/agent-browser/test/fixtures/odd-shapes/list-two-results.json +6 -0
  277. package/skills/agent-browser/test/fixtures/odd-shapes/meta-in-result.json +13 -0
  278. package/skills/agent-browser/test/fixtures/odd-shapes/no-content.json +5 -0
  279. package/skills/agent-browser/test/fixtures/odd-shapes/quoted-key.json +10 -0
  280. package/skills/agent-browser/test/fixtures/odd-shapes/resource-block.json +18 -0
  281. package/skills/agent-browser/test/fixtures/odd-shapes/result-and-method.json +11 -0
  282. package/skills/agent-browser/test/fixtures/odd-shapes/result-string.json +3 -0
  283. package/skills/agent-browser/test/fixtures/odd-shapes/run-code-escaped-one-row.json +10 -0
  284. package/skills/agent-browser/test/fixtures/odd-shapes/run-code-escaped.json +10 -0
  285. package/skills/agent-browser/test/fixtures/odd-shapes/structured-content.json +13 -0
  286. package/skills/agent-browser/test/fixtures/odd-shapes/tools-list-no-tools.json +5 -0
  287. package/skills/agent-browser/test/fixtures/odd-shapes/tree-no-credential.json +10 -0
  288. package/skills/agent-browser/test/fixtures/odd-shapes/two-text-blocks.json +14 -0
  289. package/skills/agent-browser/test/fixtures/odd-shapes/unparsable-line.json +3 -0
  290. package/skills/agent-browser/test/fixtures/odd-shapes/value-contains-brace.json +10 -0
  291. package/skills/agent-browser/test/fixtures/odd-shapes/value-contains-link-substring.json +10 -0
  292. package/skills/agent-browser/test/fixtures/playwright-mcp-0.0.78/close.json +12 -0
  293. package/skills/agent-browser/test/fixtures/playwright-mcp-0.0.78/evaluate.json +12 -0
  294. package/skills/agent-browser/test/fixtures/playwright-mcp-0.0.78/find.json +12 -0
  295. package/skills/agent-browser/test/fixtures/playwright-mcp-0.0.78/framing.json +6 -0
  296. package/skills/agent-browser/test/fixtures/playwright-mcp-0.0.78/initialize.json +14 -0
  297. package/skills/agent-browser/test/fixtures/playwright-mcp-0.0.78/navigate-link.json +12 -0
  298. package/skills/agent-browser/test/fixtures/playwright-mcp-0.0.78/navigate.json +12 -0
  299. package/skills/agent-browser/test/fixtures/playwright-mcp-0.0.78/roots-list-request.json +5 -0
  300. package/skills/agent-browser/test/fixtures/playwright-mcp-0.0.78/screenshot.json +17 -0
  301. package/skills/agent-browser/test/fixtures/playwright-mcp-0.0.78/snapshot-filename.json +12 -0
  302. package/skills/agent-browser/test/fixtures/playwright-mcp-0.0.78/snapshot-meta-json.json +12 -0
  303. package/skills/agent-browser/test/fixtures/playwright-mcp-0.0.78/snapshot-meta-raw.json +12 -0
  304. package/skills/agent-browser/test/fixtures/playwright-mcp-0.0.78/snapshot.json +12 -0
  305. package/skills/agent-browser/test/fixtures/playwright-mcp-0.0.78/tools-list.json +827 -0
  306. package/skills/agent-browser/test/fixtures/proxy-session-driver.py +138 -0
  307. package/skills/agent-browser/test/fixtures/session-facts.py +102 -0
  308. package/skills/agent-browser/test/playwright-mcp-lifetime.test.sh +1620 -0
  309. package/skills/agent-browser/test/playwright-mcp-redact-proxy.test.sh +1599 -0
  310. package/skills/agent-browser/test/redact-a11y-snapshot.test.sh +527 -0
  311. package/skills/agent-native-architecture/SKILL.md +479 -0
  312. package/skills/agent-native-architecture/references/action-parity-discipline.md +430 -0
  313. package/skills/agent-native-architecture/references/agent-execution-patterns.md +483 -0
  314. package/skills/agent-native-architecture/references/agent-native-testing.md +605 -0
  315. package/skills/agent-native-architecture/references/architecture-patterns.md +493 -0
  316. package/skills/agent-native-architecture/references/dynamic-context-injection.md +358 -0
  317. package/skills/agent-native-architecture/references/files-universal-interface.md +314 -0
  318. package/skills/agent-native-architecture/references/from-primitives-to-domain-tools.md +381 -0
  319. package/skills/agent-native-architecture/references/mcp-tool-design.md +524 -0
  320. package/skills/agent-native-architecture/references/mobile-patterns.md +897 -0
  321. package/skills/agent-native-architecture/references/product-implications.md +478 -0
  322. package/skills/agent-native-architecture/references/refactoring-to-prompt-native.md +342 -0
  323. package/skills/agent-native-architecture/references/self-modification.md +294 -0
  324. package/skills/agent-native-architecture/references/shared-workspace-architecture.md +700 -0
  325. package/skills/agent-native-architecture/references/system-prompt-design.md +263 -0
  326. package/skills/agent-native-audit/SKILL.md +300 -0
  327. package/skills/agent-native-audit/workflows/agent-native-audit.workflow.js +423 -0
  328. package/skills/andrew-kane-gem-writer/SKILL.md +189 -0
  329. package/skills/andrew-kane-gem-writer/references/database-adapters.md +231 -0
  330. package/skills/andrew-kane-gem-writer/references/module-organization.md +121 -0
  331. package/skills/andrew-kane-gem-writer/references/rails-integration.md +183 -0
  332. package/skills/andrew-kane-gem-writer/references/resources.md +126 -0
  333. package/skills/andrew-kane-gem-writer/references/testing-patterns.md +261 -0
  334. package/skills/architecture/SKILL.md +436 -0
  335. package/skills/architecture/references/adr-template.md +154 -0
  336. package/skills/architecture/references/likec4-reference.md +136 -0
  337. package/skills/architecture/references/nfr-reference.md +126 -0
  338. package/skills/archive-kb/SKILL.md +86 -0
  339. package/skills/archive-kb/scripts/archive-kb.sh +300 -0
  340. package/skills/archive-kb/test/archive-kb-partial-run.test.sh +276 -0
  341. package/skills/atdd-developer/SKILL.md +65 -0
  342. package/skills/brainstorm/SKILL.md +701 -0
  343. package/skills/brainstorm/references/brainstorm-brand-workshop.md +153 -0
  344. package/skills/brainstorm/references/brainstorm-domain-config.md +63 -0
  345. package/skills/brainstorm/references/brainstorm-validation-workshop.md +65 -0
  346. package/skills/brainstorm/references/ui-surface-terms.md +42 -0
  347. package/skills/brainstorm-techniques/SKILL.md +231 -0
  348. package/skills/brainstorm-techniques/references/decision-principles.md +150 -0
  349. package/skills/brainstorm-techniques/references/phase-boundaries.md +103 -0
  350. package/skills/brainstorm-techniques/references/standing-delegation.md +407 -0
  351. package/skills/brainstorm-techniques/references/technical-ruling-delegation.md +174 -0
  352. package/skills/campaign-calendar/SKILL.md +145 -0
  353. package/skills/cf-token-scope/SKILL.md +164 -0
  354. package/skills/cf-token-scope/references/widen-playbook.md +114 -0
  355. package/skills/cf-token-scope/scripts/cf-token-scope.sh +253 -0
  356. package/skills/changelog/SKILL.md +142 -0
  357. package/skills/code-to-prd/SKILL.md +66 -0
  358. package/skills/code-to-prd/references/banner-template.md +12 -0
  359. package/skills/code-to-prd/references/prd-template.md +41 -0
  360. package/skills/code-to-prd/scripts/code-to-prd.sh +634 -0
  361. package/skills/code-to-prd/test/code-to-prd.test.sh +447 -0
  362. package/skills/code-to-prd/test/fixture/.env.example +7 -0
  363. package/skills/code-to-prd/test/fixture/app/about/page.tsx +11 -0
  364. package/skills/code-to-prd/test/fixture/app/api/health/route.ts +10 -0
  365. package/skills/code-to-prd/test/fixture/app/page.tsx +14 -0
  366. package/skills/code-to-prd/test/fixture/next.config.mjs +3 -0
  367. package/skills/code-to-prd/test/fixture/package.json +6 -0
  368. package/skills/cohort-status/SKILL.md +62 -0
  369. package/skills/community/SKILL.md +210 -0
  370. package/skills/community/scripts/bsky-community.sh +644 -0
  371. package/skills/community/scripts/bsky-setup.sh +631 -0
  372. package/skills/community/scripts/community-router.sh +93 -0
  373. package/skills/community/scripts/discord-community.sh +487 -0
  374. package/skills/community/scripts/discord-setup.sh +784 -0
  375. package/skills/community/scripts/github-community.sh +706 -0
  376. package/skills/community/scripts/hn-community.sh +233 -0
  377. package/skills/community/scripts/lib/hmac-sha1-b64.sh +126 -0
  378. package/skills/community/scripts/linkedin-community.sh +750 -0
  379. package/skills/community/scripts/linkedin-setup.sh +758 -0
  380. package/skills/community/scripts/x-community.sh +890 -0
  381. package/skills/community/scripts/x-setup.sh +713 -0
  382. package/skills/community/test/community-argv.test.sh +2922 -0
  383. package/skills/community/test/github-community.test.sh +981 -0
  384. package/skills/competitive-analysis/SKILL.md +69 -0
  385. package/skills/competitive-analysis/references/peer-plugin-audit.md +218 -0
  386. package/skills/compound/SKILL.md +673 -0
  387. package/skills/compound/scripts/token-efficiency-report.sh +334 -0
  388. package/skills/compound/test/phase-16.test.sh +379 -0
  389. package/skills/compound-capture/SKILL.md +791 -0
  390. package/skills/compound-capture/assets/critical-pattern-template.md +37 -0
  391. package/skills/compound-capture/assets/resolution-template.md +116 -0
  392. package/skills/compound-capture/references/yaml-schema.md +67 -0
  393. package/skills/compound-capture/schema.yaml +181 -0
  394. package/skills/constraint-scaffold/SKILL.md +175 -0
  395. package/skills/constraint-scaffold/references/boundary-readme.template +35 -0
  396. package/skills/constraint-scaffold/references/constraint-gates-workflow.template +69 -0
  397. package/skills/constraint-scaffold/references/depcruise-config.template +230 -0
  398. package/skills/constraint-scaffold/references/fix-constraints-stage-a.template +240 -0
  399. package/skills/constraint-scaffold/references/fix-constraints-stage-b.template +313 -0
  400. package/skills/constraint-scaffold/references/shared-runner.template +135 -0
  401. package/skills/constraint-scaffold/scripts/constraint-scaffold.sh +589 -0
  402. package/skills/constraint-scaffold/test/bite-proof.test.sh +1338 -0
  403. package/skills/constraint-scaffold/test/boundary.test.sh +734 -0
  404. package/skills/constraint-scaffold/test/emit-fix-constraints.test.sh +193 -0
  405. package/skills/constraint-scaffold/test/generator.test.sh +93 -0
  406. package/skills/constraint-scaffold/test/parity.test.sh +285 -0
  407. package/skills/content-writer/SKILL.md +242 -0
  408. package/skills/content-writer/scripts/blog-jargon-scan.sh +42 -0
  409. package/skills/cron-delete/SKILL.md +56 -0
  410. package/skills/cron-list/SKILL.md +54 -0
  411. package/skills/deepen-plan/SKILL.md +937 -0
  412. package/skills/deepen-plan/workflows/deepen-plan.workflow.js +488 -0
  413. package/skills/deploy/SKILL.md +115 -0
  414. package/skills/deploy/references/hetzner-setup.md +67 -0
  415. package/skills/deploy/scripts/deploy.sh +37 -0
  416. package/skills/deploy-docs/SKILL.md +95 -0
  417. package/skills/dhh-rails-style/SKILL.md +203 -0
  418. package/skills/dhh-rails-style/references/architecture.md +717 -0
  419. package/skills/dhh-rails-style/references/controllers.md +323 -0
  420. package/skills/dhh-rails-style/references/frontend.md +556 -0
  421. package/skills/dhh-rails-style/references/gems.md +316 -0
  422. package/skills/dhh-rails-style/references/models.md +388 -0
  423. package/skills/dhh-rails-style/references/testing.md +354 -0
  424. package/skills/discord-content/SKILL.md +126 -0
  425. package/skills/docs-site/SKILL.md +214 -0
  426. package/skills/drain-labeled-backlog/SKILL.md +203 -0
  427. package/skills/drain-labeled-backlog/scripts/group-by-area.sh +208 -0
  428. package/skills/drain-labeled-backlog/workflows/drain-labeled-backlog.workflow.js +433 -0
  429. package/skills/drain-prs/SKILL.md +143 -0
  430. package/skills/drain-prs/scripts/triage-prs.sh +192 -0
  431. package/skills/drain-prs/test/argv-ceiling.test.sh +183 -0
  432. package/skills/dspy-ruby/SKILL.md +634 -0
  433. package/skills/dspy-ruby/assets/config-template.rb +359 -0
  434. package/skills/dspy-ruby/assets/module-template.rb +326 -0
  435. package/skills/dspy-ruby/assets/signature-template.rb +143 -0
  436. package/skills/dspy-ruby/references/core-concepts.md +268 -0
  437. package/skills/dspy-ruby/references/optimization.md +623 -0
  438. package/skills/dspy-ruby/references/providers.md +335 -0
  439. package/skills/eval-harness/README.md +194 -0
  440. package/skills/eval-harness/SKILL.md +157 -0
  441. package/skills/eval-harness/enums/go-routes.json +13 -0
  442. package/skills/eval-harness/enums/incident-threshold.json +5 -0
  443. package/skills/eval-harness/enums/lane.json +5 -0
  444. package/skills/eval-harness/enums/tool-selection.json +20 -0
  445. package/skills/eval-harness/enums/triage-levels.json +5 -0
  446. package/skills/eval-harness/gated-skills.json +34 -0
  447. package/skills/eval-harness/models.generated.json +5 -0
  448. package/skills/eval-harness/promptfooconfig-go-routing.yaml +35 -0
  449. package/skills/eval-harness/promptfooconfig-incident-threshold.yaml +32 -0
  450. package/skills/eval-harness/promptfooconfig-lane-inference.yaml +33 -0
  451. package/skills/eval-harness/promptfooconfig-ticket-triage.yaml +34 -0
  452. package/skills/eval-harness/promptfooconfig-tool-selection.yaml +33 -0
  453. package/skills/eval-harness/prompts/go-baseline.txt +6 -0
  454. package/skills/eval-harness/prompts/go-skill.txt +21 -0
  455. package/skills/eval-harness/prompts/incident-threshold-baseline.txt +6 -0
  456. package/skills/eval-harness/prompts/incident-threshold-skill.txt +12 -0
  457. package/skills/eval-harness/prompts/lane-inference-baseline.txt +6 -0
  458. package/skills/eval-harness/prompts/lane-inference-skill.txt +15 -0
  459. package/skills/eval-harness/prompts/tool-selection-baseline.txt +19 -0
  460. package/skills/eval-harness/prompts/tool-selection-skill.txt +22 -0
  461. package/skills/eval-harness/prompts/triage-baseline.txt +7 -0
  462. package/skills/eval-harness/prompts/triage-skill.txt +10 -0
  463. package/skills/eval-harness/scripts/eval-gate.cjs +318 -0
  464. package/skills/eval-harness/scripts/extract-block.cjs +87 -0
  465. package/skills/eval-harness/scripts/gate-classification.cjs +28 -0
  466. package/skills/eval-harness/scripts/gen-models.sh +41 -0
  467. package/skills/eval-harness/scripts/gen-skill-prompt.cjs +177 -0
  468. package/skills/eval-harness/scripts/measure-classification.cjs +30 -0
  469. package/skills/eval-harness/scripts/parse-label.cjs +93 -0
  470. package/skills/eval-harness/scripts/verdict.cjs +132 -0
  471. package/skills/eval-harness/tasks/go-routing.jsonl +8 -0
  472. package/skills/eval-harness/tasks/incident-threshold.jsonl +7 -0
  473. package/skills/eval-harness/tasks/lane-inference.jsonl +7 -0
  474. package/skills/eval-harness/tasks/ticket-triage.jsonl +6 -0
  475. package/skills/eval-harness/tasks/tool-selection.jsonl +15 -0
  476. package/skills/eval-harness/test/eval-gate.test.sh +132 -0
  477. package/skills/eval-harness/test/extract-block.test.sh +87 -0
  478. package/skills/eval-harness/test/gate-classification.test.sh +77 -0
  479. package/skills/eval-harness/test/gen-models.test.sh +75 -0
  480. package/skills/eval-harness/test/measure-classification.test.sh +74 -0
  481. package/skills/eval-harness/test/parse-label.test.sh +129 -0
  482. package/skills/eval-harness/test/registry-completeness.test.sh +109 -0
  483. package/skills/eval-harness/test/verdict.test.sh +123 -0
  484. package/skills/every-style-editor/SKILL.md +142 -0
  485. package/skills/every-style-editor/references/EVERY_WRITE_STYLE.md +529 -0
  486. package/skills/feature-tweet/SKILL.md +199 -0
  487. package/skills/feature-video/SKILL.md +412 -0
  488. package/skills/feature-video/scripts/check_deps.sh +281 -0
  489. package/skills/file-todos/SKILL.md +267 -0
  490. package/skills/file-todos/assets/todo-template.md +166 -0
  491. package/skills/fix-issue/SKILL.md +278 -0
  492. package/skills/fix-issue/references/agent-authored-exclusion.md +120 -0
  493. package/skills/fix-issue/references/exclude-label-jq-snippet.md +97 -0
  494. package/skills/flag-bootstrap/SETUP.md +142 -0
  495. package/skills/flag-create/SKILL.md +145 -0
  496. package/skills/flag-create/scripts/create.sh +273 -0
  497. package/skills/flag-delete/SKILL.md +167 -0
  498. package/skills/flag-delete/scripts/delete.sh +288 -0
  499. package/skills/flag-list/SKILL.md +99 -0
  500. package/skills/flag-list/scripts/list.sh +275 -0
  501. package/skills/flag-set-role/SKILL.md +229 -0
  502. package/skills/flag-set-role/scripts/flip.sh +774 -0
  503. package/skills/frontend-anti-slop/SKILL.md +106 -0
  504. package/skills/frontend-anti-slop/references/anti-patterns.md +199 -0
  505. package/skills/frontend-anti-slop/references/slop-rules.md +65 -0
  506. package/skills/frontend-anti-slop/scripts/tier1-scan.ts +525 -0
  507. package/skills/frontend-design/SKILL.md +77 -0
  508. package/skills/gdpr-gate/NOTICE +136 -0
  509. package/skills/gdpr-gate/SKILL.md +341 -0
  510. package/skills/gdpr-gate/references/fields.md +160 -0
  511. package/skills/gdpr-gate/references/layers/api-layer.md +355 -0
  512. package/skills/gdpr-gate/references/layers/auth-sessions.md +290 -0
  513. package/skills/gdpr-gate/references/layers/data-in-transit.md +331 -0
  514. package/skills/gdpr-gate/references/layers/data-lifecycle.md +341 -0
  515. package/skills/gdpr-gate/references/layers/frontend.md +249 -0
  516. package/skills/gdpr-gate/references/layers/testing-seeding.md +256 -0
  517. package/skills/gdpr-gate/references/leakage-vectors.md +248 -0
  518. package/skills/gdpr-gate/references/legacy/legal-consent-v1-prose.md +48 -0
  519. package/skills/gdpr-gate/references/legal-consent.md +140 -0
  520. package/skills/gdpr-gate/references/non-negotiables.md +60 -0
  521. package/skills/gdpr-gate/scripts/gdpr-gate.sh +271 -0
  522. package/skills/gdpr-gate/scripts/notice-frontmatter.sh +337 -0
  523. package/skills/gdpr-gate/scripts/path-denylist.txt +39 -0
  524. package/skills/gdpr-gate/scripts/repo-scan.sh +158 -0
  525. package/skills/gdpr-gate/scripts/vendor-drift-classify.sh +120 -0
  526. package/skills/gdpr-gate/scripts/vendor-pin-integrity.sh +237 -0
  527. package/skills/gemini-imagegen/SKILL.md +283 -0
  528. package/skills/gemini-imagegen/requirements.txt +5 -0
  529. package/skills/gemini-imagegen/scripts/_error_handling.py +146 -0
  530. package/skills/gemini-imagegen/scripts/compose_images.py +150 -0
  531. package/skills/gemini-imagegen/scripts/edit_image.py +137 -0
  532. package/skills/gemini-imagegen/scripts/gemini_images.py +256 -0
  533. package/skills/gemini-imagegen/scripts/generate_image.py +147 -0
  534. package/skills/gemini-imagegen/scripts/multi_turn_chat.py +223 -0
  535. package/skills/gemini-imagegen/scripts/test_error_handling.py +179 -0
  536. package/skills/git-worktree/SKILL.md +534 -0
  537. package/skills/git-worktree/scripts/git-repo-readiness-diag.sh +98 -0
  538. package/skills/git-worktree/scripts/worktree-manager.sh +4773 -0
  539. package/skills/git-worktree/test/create-from-origin-main.test.sh +116 -0
  540. package/skills/git-worktree/test/lease-protects-active.test.sh +1056 -0
  541. package/skills/git-worktree/test/no-repo-fail-loud.test.sh +88 -0
  542. package/skills/git-worktree/test/orphan-reaper-honest-count.test.sh +379 -0
  543. package/skills/git-worktree/test/reap-archive-persistence.test.sh +725 -0
  544. package/skills/git-worktree/test/stale-lock-sweep.test.sh +176 -0
  545. package/skills/go/SKILL.md +54 -0
  546. package/skills/growth/SKILL.md +164 -0
  547. package/skills/harvest-debt/SKILL.md +78 -0
  548. package/skills/harvest-debt/scripts/harvest-debt.sh +114 -0
  549. package/skills/heal-skill/SKILL.md +173 -0
  550. package/skills/help/SKILL.md +46 -0
  551. package/skills/incident/NOTICE +39 -0
  552. package/skills/incident/SKILL.md +395 -0
  553. package/skills/incident/scripts/dry-run.sh +448 -0
  554. package/skills/incident/scripts/redact-engine.py +412 -0
  555. package/skills/incident/scripts/redact-sentinel.sh +44 -0
  556. package/skills/incident/templates/pir.md +139 -0
  557. package/skills/incident/test/fixtures/dry-run-incident.json +26 -0
  558. package/skills/incident/test/fixtures/dry-run-secret-leak.json +26 -0
  559. package/skills/incident/test/fixtures/legacy-bash-scanner.sh +82 -0
  560. package/skills/incident/test/fixtures/positive-corpus.md +87 -0
  561. package/skills/incident/test/redact-sentinel.test.sh +1420 -0
  562. package/skills/invoice/SKILL.md +206 -0
  563. package/skills/kb-glossary/SKILL.md +71 -0
  564. package/skills/kb-glossary/references/glossary-format.md +111 -0
  565. package/skills/kb-glossary/references/rejected-request-register.md +115 -0
  566. package/skills/kb-search/SKILL.md +276 -0
  567. package/skills/kb-search/scripts/kb-search-cache.sh +123 -0
  568. package/skills/legal-audit/SKILL.md +161 -0
  569. package/skills/legal-generate/NOTICE +242 -0
  570. package/skills/legal-generate/SKILL.md +244 -0
  571. package/skills/legal-generate/references/templates/advisor-agreement/template.md +12 -0
  572. package/skills/legal-generate/references/templates/business-associate-agreement/template.md +112 -0
  573. package/skills/legal-generate/references/templates/cookie-notice/template.md +82 -0
  574. package/skills/legal-generate/references/templates/dpa-global/template.md +439 -0
  575. package/skills/legal-generate/references/templates/dpa-us/template.md +222 -0
  576. package/skills/legal-generate/references/templates/employee-offer-letter/template.md +75 -0
  577. package/skills/legal-generate/references/templates/master-services-agreement/template.md +252 -0
  578. package/skills/legal-generate/references/templates/mutual-nda/template.md +66 -0
  579. package/skills/legal-generate/references/templates/one-way-nda/template.md +38 -0
  580. package/skills/legal-generate/references/templates/privacy-policy-gdpr/template.md +427 -0
  581. package/skills/legal-generate/references/templates/privacy-policy-us/template.md +313 -0
  582. package/skills/legal-generate/references/templates/terms-of-use/template.md +75 -0
  583. package/skills/legal-generate/scripts/strip-vendor-credit.sh +96 -0
  584. package/skills/linear-fetch/SKILL.md +247 -0
  585. package/skills/linear-fetch/scripts/assert-no-linear-telemetry.sh +66 -0
  586. package/skills/linear-fetch/scripts/redact-linear-urls.sh +51 -0
  587. package/skills/linear-fetch/scripts/render-caller-template.sh +36 -0
  588. package/skills/linear-fetch/test/assert-no-linear-telemetry.test.sh +113 -0
  589. package/skills/linear-fetch/test/parity.test.sh +112 -0
  590. package/skills/linear-fetch/test/persist-safe-integration.test.sh +191 -0
  591. package/skills/linear-fetch/test/redact-linear-urls.test.sh +226 -0
  592. package/skills/merge-pr/SKILL.md +670 -0
  593. package/skills/model-launch-review/SKILL.md +255 -0
  594. package/skills/model-launch-review/scripts/audit-models.sh +501 -0
  595. package/skills/one-shot/SKILL.md +316 -0
  596. package/skills/one-shot/references/token-discipline.md +74 -0
  597. package/skills/operator-bootstrap/SKILL.md +412 -0
  598. package/skills/operator-bootstrap/template.sh +439 -0
  599. package/skills/operator-digest/SKILL.md +287 -0
  600. package/skills/operator-digest/assets/operator-digest.workflow.yml +175 -0
  601. package/skills/operator-digest/scripts/digest-scrub.sh +119 -0
  602. package/skills/operator-digest/scripts/provision-operator-digest-repo.sh +147 -0
  603. package/skills/operator-rephrase/SKILL.md +98 -0
  604. package/skills/pencil-setup/SKILL.md +201 -0
  605. package/skills/pencil-setup/scripts/check_deps.sh +556 -0
  606. package/skills/pencil-setup/scripts/copy_adapter.sh +80 -0
  607. package/skills/pencil-setup/scripts/package-lock.json +1172 -0
  608. package/skills/pencil-setup/scripts/package.json +14 -0
  609. package/skills/pencil-setup/scripts/pencil-error-enrichment.mjs +21 -0
  610. package/skills/pencil-setup/scripts/pencil-mcp-adapter.mjs +733 -0
  611. package/skills/pencil-setup/scripts/pencil-response-classify.mjs +38 -0
  612. package/skills/pencil-setup/scripts/pencil-save-gate.mjs +26 -0
  613. package/skills/pencil-setup/scripts/sanitize-filename.mjs +25 -0
  614. package/skills/plan/SKILL.md +1140 -0
  615. package/skills/plan/references/differential-migration-testing.md +185 -0
  616. package/skills/plan/references/plan-community-discovery.md +42 -0
  617. package/skills/plan/references/plan-founder-check.md +137 -0
  618. package/skills/plan/references/plan-functional-overlap.md +25 -0
  619. package/skills/plan/references/plan-host-fanout.md +51 -0
  620. package/skills/plan/references/plan-issue-templates.md +779 -0
  621. package/skills/plan/references/plan-network-outage-checklist.md +131 -0
  622. package/skills/plan/references/plan-scope-check.md +126 -0
  623. package/skills/plan/references/plan-sharp-edges.md +331 -0
  624. package/skills/plan/references/plan-signoff-lifecycle.md +12 -0
  625. package/skills/plan/references/webapp-cwv-observability.md +143 -0
  626. package/skills/plan-review/SKILL.md +91 -0
  627. package/skills/plan-review/lib/named-panel.mjs +58 -0
  628. package/skills/plan-review/workflows/plan-review.workflow.js +477 -0
  629. package/skills/postmerge/SKILL.md +642 -0
  630. package/skills/postmerge/references/deploy-status-debugging.md +141 -0
  631. package/skills/postmerge/references/graceful-degradation.md +20 -0
  632. package/skills/postmerge/references/sentry-error-count-delta.md +137 -0
  633. package/skills/preflight/SKILL.md +1553 -0
  634. package/skills/preflight/references/check-13-founder-check.md +286 -0
  635. package/skills/preflight/scripts/founder-check.py +1640 -0
  636. package/skills/preflight/scripts/parse-form-a.awk +107 -0
  637. package/skills/preflight/scripts/probe-verb-gate.sh +108 -0
  638. package/skills/product-roadmap/SKILL.md +357 -0
  639. package/skills/product-roadmap/scripts/roadmap-reconcile.sh +327 -0
  640. package/skills/provision-cloudflare/SKILL.md +67 -0
  641. package/skills/provision-cloudflare/scripts/provision-cloudflare.sh +271 -0
  642. package/skills/provision-doppler/SKILL.md +58 -0
  643. package/skills/provision-doppler/scripts/provision-doppler.sh +358 -0
  644. package/skills/provision-github/SKILL.md +59 -0
  645. package/skills/provision-github/scripts/provision-github.sh +300 -0
  646. package/skills/provision-hetzner/SKILL.md +76 -0
  647. package/skills/provision-hetzner/scripts/provision-hetzner.sh +262 -0
  648. package/skills/provision-hetzner/test/fixture/knowledge-base/legal/tenant-dpa-register.md +26 -0
  649. package/skills/provision-hetzner/test/fixture/provision-hetzner-dry-run.golden +34 -0
  650. package/skills/provision-hetzner/test/provision-hetzner-characterization.test.sh +227 -0
  651. package/skills/qa/SKILL.md +263 -0
  652. package/skills/qa/references/browser-preflight.md +22 -0
  653. package/skills/questionnaire-generate/SKILL.md +328 -0
  654. package/skills/questionnaire-generate/references/questionnaire.template +60 -0
  655. package/skills/rclone/SKILL.md +162 -0
  656. package/skills/rclone/scripts/check_setup.sh +63 -0
  657. package/skills/release-announce/SKILL.md +65 -0
  658. package/skills/release-docs/SKILL.md +92 -0
  659. package/skills/reproduce-bug/SKILL.md +358 -0
  660. package/skills/resolve-debt/SKILL.md +85 -0
  661. package/skills/resolve-debt/scripts/resolve-debt.py +584 -0
  662. package/skills/resolve-parallel/SKILL.md +45 -0
  663. package/skills/resolve-parallel/workflows/resolve-parallel.workflow.js +415 -0
  664. package/skills/resolve-pr-parallel/SKILL.md +68 -0
  665. package/skills/resolve-pr-parallel/workflows/resolve-pr-parallel.workflow.js +419 -0
  666. package/skills/resolve-todo-parallel/SKILL.md +57 -0
  667. package/skills/resolve-todo-parallel/workflows/resolve-todo-parallel.workflow.js +398 -0
  668. package/skills/review/SKILL.md +1620 -0
  669. package/skills/review/references/anti-slop-scanner-hook.md +40 -0
  670. package/skills/review/references/defect-classes-migrations.md +9 -0
  671. package/skills/review/references/generated-input-oracle-surfaces.md +15 -0
  672. package/skills/review/references/review-e2e-testing.md +116 -0
  673. package/skills/review/references/review-evidence-comment.md +36 -0
  674. package/skills/review/references/review-todo-structure.md +165 -0
  675. package/skills/review/references/risk-tier-and-fix-rounds.md +97 -0
  676. package/skills/review/references/semgrep-custom-rules.yaml +174 -0
  677. package/skills/review/references/ultrathink-phases.md +98 -0
  678. package/skills/review/references/wfs.md +6 -0
  679. package/skills/review/scripts/emit-review-trailer.sh +499 -0
  680. package/skills/review/scripts/ensure-semgrep.sh +99 -0
  681. package/skills/review/scripts/fix-round-seats.sh +197 -0
  682. package/skills/review/test/emit-review-trailer.test.sh +387 -0
  683. package/skills/review/test/ensure-semgrep.test.sh +111 -0
  684. package/skills/review/test/fix-round-seats.test.sh +237 -0
  685. package/skills/review/workflows/README.md +147 -0
  686. package/skills/review/workflows/review.workflow.js +667 -0
  687. package/skills/schedule/SKILL.md +796 -0
  688. package/skills/seo-aeo/SKILL.md +139 -0
  689. package/skills/seo-aeo/scripts/validate-csp.sh +233 -0
  690. package/skills/seo-aeo/scripts/validate-seo.sh +262 -0
  691. package/skills/ship/SKILL.md +3023 -0
  692. package/skills/ship/references/adr-ordinal-collision.md +47 -0
  693. package/skills/ship/references/affected-gate-contract.md +115 -0
  694. package/skills/ship/references/ci-run-diagnosis.md +29 -0
  695. package/skills/ship/references/ci-workflow-authoring.md +68 -0
  696. package/skills/ship/references/cleanup-merged-archival.md +39 -0
  697. package/skills/ship/references/counsel-review-clo-attestation.md +38 -0
  698. package/skills/ship/references/cto-ruling-armed-push.md +5 -0
  699. package/skills/ship/references/decision-challenges-render.md +71 -0
  700. package/skills/ship/references/delegated-act-authoring-form.md +7 -0
  701. package/skills/ship/references/detached-battery-verdict.md +13 -0
  702. package/skills/ship/references/epic-close-sweep.md +62 -0
  703. package/skills/ship/references/feature-tweet-draft.md +42 -0
  704. package/skills/ship/references/followthrough-issue-body-template.md +47 -0
  705. package/skills/ship/references/followthrough-stub-template.sh +92 -0
  706. package/skills/ship/references/merge-queue-dequeue.md +39 -0
  707. package/skills/ship/references/operator-holds.md +179 -0
  708. package/skills/ship/references/pipeline-tally-render.md +43 -0
  709. package/skills/ship/references/pr-overlap.md +153 -0
  710. package/skills/ship/references/queue-mode.md +7 -0
  711. package/skills/ship/references/ready-run-wait.md +36 -0
  712. package/skills/ship/references/red-on-main-quarantine.md +110 -0
  713. package/skills/ship/references/register-update-pr-pattern.md +88 -0
  714. package/skills/ship/references/settle-then-admin-merge.md +130 -0
  715. package/skills/ship/references/supabase-column-probe.md +13 -0
  716. package/skills/ship/scripts/auto-close-scan.sh +76 -0
  717. package/skills/ship/scripts/battery-owed.sh +403 -0
  718. package/skills/ship/scripts/hold-gate-eval.sh +410 -0
  719. package/skills/ship/scripts/net-issue-flow.sh +665 -0
  720. package/skills/ship/scripts/pr-overlap-preflight.sh +483 -0
  721. package/skills/ship/scripts/ship-pir-action-items-gate.sh +246 -0
  722. package/skills/ship/scripts/technical-ruling-gate.sh +444 -0
  723. package/skills/skill-creator/SKILL.md +299 -0
  724. package/skills/skill-creator/references/api-security.md +248 -0
  725. package/skills/skill-creator/references/authoring-levers.md +110 -0
  726. package/skills/skill-creator/references/be-clear-and-direct.md +587 -0
  727. package/skills/skill-creator/references/best-practices.md +431 -0
  728. package/skills/skill-creator/references/common-patterns.md +639 -0
  729. package/skills/skill-creator/references/core-principles.md +193 -0
  730. package/skills/skill-creator/references/executable-code.md +188 -0
  731. package/skills/skill-creator/references/iteration-and-testing.md +509 -0
  732. package/skills/skill-creator/references/official-spec.md +193 -0
  733. package/skills/skill-creator/references/recommended-structure.md +180 -0
  734. package/skills/skill-creator/references/skill-structure.md +396 -0
  735. package/skills/skill-creator/references/using-scripts.md +122 -0
  736. package/skills/skill-creator/references/using-templates.md +120 -0
  737. package/skills/skill-creator/references/workflows-and-validation.md +541 -0
  738. package/skills/skill-creator/scripts/init_skill.py +322 -0
  739. package/skills/skill-creator/scripts/package_skill.py +110 -0
  740. package/skills/skill-creator/scripts/quick_validate.py +64 -0
  741. package/skills/skill-creator/templates/router-skill.md +73 -0
  742. package/skills/skill-creator/templates/simple-skill.md +34 -0
  743. package/skills/skill-creator/workflows/add-reference.md +96 -0
  744. package/skills/skill-creator/workflows/add-script.md +104 -0
  745. package/skills/skill-creator/workflows/add-template.md +83 -0
  746. package/skills/skill-creator/workflows/add-workflow.md +128 -0
  747. package/skills/skill-creator/workflows/audit-skill.md +159 -0
  748. package/skills/skill-creator/workflows/create-domain-expertise-skill.md +623 -0
  749. package/skills/skill-creator/workflows/create-new-skill.md +207 -0
  750. package/skills/skill-creator/workflows/get-guidance.md +134 -0
  751. package/skills/skill-creator/workflows/upgrade-to-router.md +170 -0
  752. package/skills/skill-creator/workflows/verify-skill.md +226 -0
  753. package/skills/skill-security-scan/SKILL.md +145 -0
  754. package/skills/skill-security-scan/references/disclaimer.md +21 -0
  755. package/skills/skill-security-scan/references/first-party-allowlist.yaml +38 -0
  756. package/skills/skill-security-scan/references/override-artifact-schema.json +68 -0
  757. package/skills/skill-security-scan/references/override-mechanism.md +171 -0
  758. package/skills/skill-security-scan/references/redirect-domains.yaml +27 -0
  759. package/skills/skill-security-scan/references/regex-patterns.md +136 -0
  760. package/skills/skill-security-scan/references/rules/code-exec.yaml +70 -0
  761. package/skills/skill-security-scan/references/rules/filesystem-boundary.yaml +29 -0
  762. package/skills/skill-security-scan/references/rules/manifest.yaml +44 -0
  763. package/skills/skill-security-scan/references/rules/supply-chain.yaml +36 -0
  764. package/skills/skill-security-scan/references/scan-meta-schema.json +63 -0
  765. package/skills/skill-security-scan/references/test-fixtures/clean-soleur-style.skill.md +23 -0
  766. package/skills/skill-security-scan/references/test-fixtures/clean-third-party.skill.md +26 -0
  767. package/skills/skill-security-scan/references/test-fixtures/malicious-codeexec.skill.md +23 -0
  768. package/skills/skill-security-scan/references/test-fixtures/malicious-curl-pipe-bash.skill.md +69 -0
  769. package/skills/skill-security-scan/references/test-fixtures/malicious-prompt-injection.skill.md +17 -0
  770. package/skills/skill-security-scan/references/test-fixtures/malicious-telemetry-beacon.skill.md +23 -0
  771. package/skills/skill-security-scan/references/typosquat-targets.yaml +65 -0
  772. package/skills/skill-security-scan/scripts/check-codeexec.sh +41 -0
  773. package/skills/skill-security-scan/scripts/check-filesystem-boundary.sh +19 -0
  774. package/skills/skill-security-scan/scripts/check-prompt-injection.sh +95 -0
  775. package/skills/skill-security-scan/scripts/check-supply-chain.sh +219 -0
  776. package/skills/skill-security-scan/scripts/check-telemetry-surface.sh +126 -0
  777. package/skills/skill-security-scan/scripts/lib.sh +197 -0
  778. package/skills/skill-security-scan/scripts/parse-override.sh +231 -0
  779. package/skills/skill-security-scan/scripts/run-scan.sh +342 -0
  780. package/skills/skill-security-scan/scripts/run-self-test.sh +95 -0
  781. package/skills/skill-security-scan/test/argv-ceiling.test.sh +180 -0
  782. package/skills/skill-security-scan/test/meta-dir-reap.test.sh +411 -0
  783. package/skills/skill-security-scan/test/parse-override.test.sh +607 -0
  784. package/skills/skill-security-scan/test/redact-pii-ghs-shape.test.sh +251 -0
  785. package/skills/social-distribute/SKILL.md +485 -0
  786. package/skills/spec-templates/SKILL.md +274 -0
  787. package/skills/sync/SKILL.md +49 -0
  788. package/skills/test-browser/SKILL.md +464 -0
  789. package/skills/test-browser/references/feature-map.md +161 -0
  790. package/skills/test-fix-loop/SKILL.md +159 -0
  791. package/skills/test-fix-loop/references/determinism-checklist.md +154 -0
  792. package/skills/triage/SKILL.md +402 -0
  793. package/skills/trigger-cron/SKILL.md +136 -0
  794. package/skills/trigger-cron/scripts/trigger.sh +198 -0
  795. package/skills/user-set-role/SKILL.md +147 -0
  796. package/skills/user-set-role/scripts/set-role.sh +218 -0
  797. package/skills/user-story-writer/SKILL.md +77 -0
  798. package/skills/ux-audit/SKILL.md +251 -0
  799. package/skills/ux-audit/references/finding.schema.json +62 -0
  800. package/skills/ux-audit/references/route-list.yaml +95 -0
  801. package/skills/ux-audit/scripts/bot-fixture.ts +270 -0
  802. package/skills/ux-audit/scripts/bot-signin.ts +135 -0
  803. package/skills/ux-audit/scripts/dedup-hash.ts +46 -0
  804. package/skills/verification-scaffold/SKILL.md +73 -0
  805. package/skills/verification-scaffold/references/gitleaks.toml.template +13 -0
  806. package/skills/verification-scaffold/references/guardrail-audit.md +156 -0
  807. package/skills/verification-scaffold/references/guards/block-stash-hook.sh.template +72 -0
  808. package/skills/verification-scaffold/references/guards/commit-on-main-hook.sh.template +82 -0
  809. package/skills/verification-scaffold/references/guards/orphan-test-check.sh.template +102 -0
  810. package/skills/verification-scaffold/references/guards/precommit-guard.sh.template +115 -0
  811. package/skills/verification-scaffold/references/guards/secret-scan-hook.sh.template +176 -0
  812. package/skills/verification-scaffold/references/guards/verification-gates.yml.template +49 -0
  813. package/skills/verification-scaffold/references/guards/wrapper-boundary.sh.template +47 -0
  814. package/skills/verification-scaffold/references/guards.tsv +21 -0
  815. package/skills/verification-scaffold/references/verification-readme.template +55 -0
  816. package/skills/verification-scaffold/scripts/verification-scaffold.sh +785 -0
  817. package/skills/verification-scaffold/test/bite-proof.test.sh +159 -0
  818. package/skills/verification-scaffold/test/generator.test.sh +319 -0
  819. package/skills/verification-scaffold/test/manifest.test.sh +144 -0
  820. package/skills/work/SKILL.md +1480 -0
  821. package/skills/work/references/end-of-work-resume.md +7 -0
  822. package/skills/work/references/hook-test-main-cwd.md +24 -0
  823. package/skills/work/references/key-principles.md +83 -0
  824. package/skills/work/references/property-testing.md +122 -0
  825. package/skills/work/references/sql-security-definer-rpc-scaffold.sql +78 -0
  826. package/skills/work/references/work-agent-teams.md +97 -0
  827. package/skills/work/references/work-lifecycle-parallel.md +147 -0
  828. package/skills/work/references/work-lockfile-bumps.md +97 -0
  829. package/skills/work/references/work-scratch-sandboxes.md +212 -0
  830. package/skills/work/references/work-subagent-fanout.md +61 -0
  831. package/skills/work/references/work-test-harness-sharp-edges.md +73 -0
  832. 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())