brasscoders 2.0.4__py3-none-any.whl

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 (615) hide show
  1. brass/__init__.py +9 -0
  2. brass/cli/__init__.py +7 -0
  3. brass/cli/brass_cli.py +2721 -0
  4. brass/config/patterns.yaml +243 -0
  5. brass/core/__init__.py +13 -0
  6. brass/core/atomic_writer.py +181 -0
  7. brass/core/brassignore.py +182 -0
  8. brass/core/change_detection.py +206 -0
  9. brass/core/error_handling.py +262 -0
  10. brass/core/error_reporter.py +308 -0
  11. brass/core/file_classifier.py +433 -0
  12. brass/core/file_index.py +111 -0
  13. brass/core/file_integrity.py +199 -0
  14. brass/core/finding_cache.py +254 -0
  15. brass/core/framework_registry.py +523 -0
  16. brass/core/logging_config.py +160 -0
  17. brass/core/path_safety.py +36 -0
  18. brass/core/scanner_status.py +56 -0
  19. brass/core/startup_checks.py +247 -0
  20. brass/core/state_validator.py +188 -0
  21. brass/core/user_error_handler.py +202 -0
  22. brass/core/version_check.py +104 -0
  23. brass/data/ast_grep_rules/javascript/sql_injection.yml +27 -0
  24. brass/data/ast_grep_rules/javascript/sql_injection_ts.yml +20 -0
  25. brass/data/ast_grep_rules/javascript/xss.yml +15 -0
  26. brass/data/ast_grep_rules/javascript/xss_ts.yml +14 -0
  27. brass/data/ast_grep_rules/python/command_injection.yml +25 -0
  28. brass/data/ast_grep_rules/python/sql_injection.yml +26 -0
  29. brass/data/ast_grep_rules/python/sql_var_execute.yml +24 -0
  30. brass/data/ast_grep_rules/python/weak_crypto.yml +31 -0
  31. brass/data/ast_grep_rules/sgconfig.yml +3 -0
  32. brass/data/framework_registry/javascript.yaml +205 -0
  33. brass/data/framework_registry/python.yaml +355 -0
  34. brass/data/framework_registry/typescript.yaml +165 -0
  35. brass/data/pysa_models/model_queries.pysa +51 -0
  36. brass/data/pysa_models/stdlib.pysa +82 -0
  37. brass/data/pysa_models/taint.config +62 -0
  38. brass/data/pysa_models/third_party.pysa +78 -0
  39. brass/data/pysa_stubs/django/__init__.pyi +2 -0
  40. brass/data/pysa_stubs/django/db/__init__.pyi +0 -0
  41. brass/data/pysa_stubs/django/db/backends/__init__.pyi +0 -0
  42. brass/data/pysa_stubs/django/db/backends/utils.pyi +4 -0
  43. brass/data/pysa_stubs/django/db/models/__init__.pyi +0 -0
  44. brass/data/pysa_stubs/django/db/models/query.pyi +4 -0
  45. brass/data/pysa_stubs/django/http/__init__.pyi +8 -0
  46. brass/data/pysa_stubs/django/utils/__init__.pyi +0 -0
  47. brass/data/pysa_stubs/django/utils/safestring.pyi +1 -0
  48. brass/data/pysa_stubs/flask/__init__.pyi +26 -0
  49. brass/data/pysa_stubs/httpx/__init__.pyi +4 -0
  50. brass/data/pysa_stubs/requests/__init__.pyi +0 -0
  51. brass/data/pysa_stubs/requests/api.pyi +7 -0
  52. brass/data/pysa_stubs/sqlalchemy/__init__.pyi +3 -0
  53. brass/data/pysa_stubs/sqlalchemy/engine/__init__.pyi +4 -0
  54. brass/data/pysa_stubs/sqlalchemy/orm/__init__.pyi +0 -0
  55. brass/data/pysa_stubs/sqlalchemy/orm/session.pyi +4 -0
  56. brass/data/pysa_stubs/yaml/__init__.pyi +4 -0
  57. brass/data/semgrep_rules/javascript/command_injection.yml +50 -0
  58. brass/data/semgrep_rules/javascript/sql_injection.yml +61 -0
  59. brass/data/semgrep_rules/javascript/ssrf.yml +82 -0
  60. brass/data/semgrep_rules/javascript/xss.yml +44 -0
  61. brass/data/semgrep_rules/python/command_injection.yml +82 -0
  62. brass/data/semgrep_rules/python/deserialization.yml +87 -0
  63. brass/data/semgrep_rules/python/path_traversal.yml +120 -0
  64. brass/data/semgrep_rules/python/sql_injection.yml +77 -0
  65. brass/data/semgrep_rules/python/ssrf.yml +102 -0
  66. brass/data/semgrep_rules/python/xss.yml +86 -0
  67. brass/enrichment/__init__.py +51 -0
  68. brass/enrichment/_token_budget.py +112 -0
  69. brass/enrichment/_wire_clamp.py +42 -0
  70. brass/enrichment/client.py +683 -0
  71. brass/enrichment/filter.py +371 -0
  72. brass/enrichment/project_signature.py +197 -0
  73. brass/filtering/__init__.py +8 -0
  74. brass/filtering/ai_review_filter.py +302 -0
  75. brass/js_analysis/babel_parser.js +416 -0
  76. brass/js_analysis/node_modules/@babel/code-frame/LICENSE +22 -0
  77. brass/js_analysis/node_modules/@babel/code-frame/README.md +19 -0
  78. brass/js_analysis/node_modules/@babel/code-frame/lib/index.js +216 -0
  79. brass/js_analysis/node_modules/@babel/code-frame/lib/index.js.map +1 -0
  80. brass/js_analysis/node_modules/@babel/code-frame/package.json +31 -0
  81. brass/js_analysis/node_modules/@babel/generator/LICENSE +22 -0
  82. brass/js_analysis/node_modules/@babel/generator/README.md +19 -0
  83. brass/js_analysis/node_modules/@babel/generator/lib/buffer.js +317 -0
  84. brass/js_analysis/node_modules/@babel/generator/lib/buffer.js.map +1 -0
  85. brass/js_analysis/node_modules/@babel/generator/lib/generators/base.js +87 -0
  86. brass/js_analysis/node_modules/@babel/generator/lib/generators/base.js.map +1 -0
  87. brass/js_analysis/node_modules/@babel/generator/lib/generators/classes.js +212 -0
  88. brass/js_analysis/node_modules/@babel/generator/lib/generators/classes.js.map +1 -0
  89. brass/js_analysis/node_modules/@babel/generator/lib/generators/deprecated.js +28 -0
  90. brass/js_analysis/node_modules/@babel/generator/lib/generators/deprecated.js.map +1 -0
  91. brass/js_analysis/node_modules/@babel/generator/lib/generators/expressions.js +300 -0
  92. brass/js_analysis/node_modules/@babel/generator/lib/generators/expressions.js.map +1 -0
  93. brass/js_analysis/node_modules/@babel/generator/lib/generators/flow.js +660 -0
  94. brass/js_analysis/node_modules/@babel/generator/lib/generators/flow.js.map +1 -0
  95. brass/js_analysis/node_modules/@babel/generator/lib/generators/index.js +128 -0
  96. brass/js_analysis/node_modules/@babel/generator/lib/generators/index.js.map +1 -0
  97. brass/js_analysis/node_modules/@babel/generator/lib/generators/jsx.js +126 -0
  98. brass/js_analysis/node_modules/@babel/generator/lib/generators/jsx.js.map +1 -0
  99. brass/js_analysis/node_modules/@babel/generator/lib/generators/methods.js +198 -0
  100. brass/js_analysis/node_modules/@babel/generator/lib/generators/methods.js.map +1 -0
  101. brass/js_analysis/node_modules/@babel/generator/lib/generators/modules.js +287 -0
  102. brass/js_analysis/node_modules/@babel/generator/lib/generators/modules.js.map +1 -0
  103. brass/js_analysis/node_modules/@babel/generator/lib/generators/statements.js +279 -0
  104. brass/js_analysis/node_modules/@babel/generator/lib/generators/statements.js.map +1 -0
  105. brass/js_analysis/node_modules/@babel/generator/lib/generators/template-literals.js +40 -0
  106. brass/js_analysis/node_modules/@babel/generator/lib/generators/template-literals.js.map +1 -0
  107. brass/js_analysis/node_modules/@babel/generator/lib/generators/types.js +238 -0
  108. brass/js_analysis/node_modules/@babel/generator/lib/generators/types.js.map +1 -0
  109. brass/js_analysis/node_modules/@babel/generator/lib/generators/typescript.js +724 -0
  110. brass/js_analysis/node_modules/@babel/generator/lib/generators/typescript.js.map +1 -0
  111. brass/js_analysis/node_modules/@babel/generator/lib/index.js +112 -0
  112. brass/js_analysis/node_modules/@babel/generator/lib/index.js.map +1 -0
  113. brass/js_analysis/node_modules/@babel/generator/lib/node/index.js +122 -0
  114. brass/js_analysis/node_modules/@babel/generator/lib/node/index.js.map +1 -0
  115. brass/js_analysis/node_modules/@babel/generator/lib/node/parentheses.js +262 -0
  116. brass/js_analysis/node_modules/@babel/generator/lib/node/parentheses.js.map +1 -0
  117. brass/js_analysis/node_modules/@babel/generator/lib/node/whitespace.js +145 -0
  118. brass/js_analysis/node_modules/@babel/generator/lib/node/whitespace.js.map +1 -0
  119. brass/js_analysis/node_modules/@babel/generator/lib/printer.js +781 -0
  120. brass/js_analysis/node_modules/@babel/generator/lib/printer.js.map +1 -0
  121. brass/js_analysis/node_modules/@babel/generator/lib/source-map.js +85 -0
  122. brass/js_analysis/node_modules/@babel/generator/lib/source-map.js.map +1 -0
  123. brass/js_analysis/node_modules/@babel/generator/lib/token-map.js +191 -0
  124. brass/js_analysis/node_modules/@babel/generator/lib/token-map.js.map +1 -0
  125. brass/js_analysis/node_modules/@babel/generator/package.json +40 -0
  126. brass/js_analysis/node_modules/@babel/helper-globals/LICENSE +22 -0
  127. brass/js_analysis/node_modules/@babel/helper-globals/README.md +19 -0
  128. brass/js_analysis/node_modules/@babel/helper-globals/data/browser-upper.json +911 -0
  129. brass/js_analysis/node_modules/@babel/helper-globals/data/builtin-lower.json +15 -0
  130. brass/js_analysis/node_modules/@babel/helper-globals/data/builtin-upper.json +51 -0
  131. brass/js_analysis/node_modules/@babel/helper-globals/package.json +32 -0
  132. brass/js_analysis/node_modules/@babel/helper-string-parser/LICENSE +22 -0
  133. brass/js_analysis/node_modules/@babel/helper-string-parser/README.md +19 -0
  134. brass/js_analysis/node_modules/@babel/helper-string-parser/lib/index.js +295 -0
  135. brass/js_analysis/node_modules/@babel/helper-string-parser/lib/index.js.map +1 -0
  136. brass/js_analysis/node_modules/@babel/helper-string-parser/package.json +31 -0
  137. brass/js_analysis/node_modules/@babel/helper-validator-identifier/LICENSE +22 -0
  138. brass/js_analysis/node_modules/@babel/helper-validator-identifier/README.md +19 -0
  139. brass/js_analysis/node_modules/@babel/helper-validator-identifier/lib/identifier.js +70 -0
  140. brass/js_analysis/node_modules/@babel/helper-validator-identifier/lib/identifier.js.map +1 -0
  141. brass/js_analysis/node_modules/@babel/helper-validator-identifier/lib/index.js +57 -0
  142. brass/js_analysis/node_modules/@babel/helper-validator-identifier/lib/index.js.map +1 -0
  143. brass/js_analysis/node_modules/@babel/helper-validator-identifier/lib/keyword.js +35 -0
  144. brass/js_analysis/node_modules/@babel/helper-validator-identifier/lib/keyword.js.map +1 -0
  145. brass/js_analysis/node_modules/@babel/helper-validator-identifier/package.json +31 -0
  146. brass/js_analysis/node_modules/@babel/parser/CHANGELOG.md +1073 -0
  147. brass/js_analysis/node_modules/@babel/parser/LICENSE +19 -0
  148. brass/js_analysis/node_modules/@babel/parser/README.md +19 -0
  149. brass/js_analysis/node_modules/@babel/parser/bin/babel-parser.js +15 -0
  150. brass/js_analysis/node_modules/@babel/parser/lib/index.js +14586 -0
  151. brass/js_analysis/node_modules/@babel/parser/lib/index.js.map +1 -0
  152. brass/js_analysis/node_modules/@babel/parser/package.json +50 -0
  153. brass/js_analysis/node_modules/@babel/parser/typings/babel-parser.d.ts +239 -0
  154. brass/js_analysis/node_modules/@babel/template/LICENSE +22 -0
  155. brass/js_analysis/node_modules/@babel/template/README.md +19 -0
  156. brass/js_analysis/node_modules/@babel/template/lib/builder.js +69 -0
  157. brass/js_analysis/node_modules/@babel/template/lib/builder.js.map +1 -0
  158. brass/js_analysis/node_modules/@babel/template/lib/formatters.js +61 -0
  159. brass/js_analysis/node_modules/@babel/template/lib/formatters.js.map +1 -0
  160. brass/js_analysis/node_modules/@babel/template/lib/index.js +23 -0
  161. brass/js_analysis/node_modules/@babel/template/lib/index.js.map +1 -0
  162. brass/js_analysis/node_modules/@babel/template/lib/literal.js +69 -0
  163. brass/js_analysis/node_modules/@babel/template/lib/literal.js.map +1 -0
  164. brass/js_analysis/node_modules/@babel/template/lib/options.js +73 -0
  165. brass/js_analysis/node_modules/@babel/template/lib/options.js.map +1 -0
  166. brass/js_analysis/node_modules/@babel/template/lib/parse.js +163 -0
  167. brass/js_analysis/node_modules/@babel/template/lib/parse.js.map +1 -0
  168. brass/js_analysis/node_modules/@babel/template/lib/populate.js +138 -0
  169. brass/js_analysis/node_modules/@babel/template/lib/populate.js.map +1 -0
  170. brass/js_analysis/node_modules/@babel/template/lib/string.js +20 -0
  171. brass/js_analysis/node_modules/@babel/template/lib/string.js.map +1 -0
  172. brass/js_analysis/node_modules/@babel/template/package.json +27 -0
  173. brass/js_analysis/node_modules/@babel/traverse/LICENSE +22 -0
  174. brass/js_analysis/node_modules/@babel/traverse/README.md +19 -0
  175. brass/js_analysis/node_modules/@babel/traverse/lib/cache.js +38 -0
  176. brass/js_analysis/node_modules/@babel/traverse/lib/cache.js.map +1 -0
  177. brass/js_analysis/node_modules/@babel/traverse/lib/context.js +119 -0
  178. brass/js_analysis/node_modules/@babel/traverse/lib/context.js.map +1 -0
  179. brass/js_analysis/node_modules/@babel/traverse/lib/hub.js +19 -0
  180. brass/js_analysis/node_modules/@babel/traverse/lib/hub.js.map +1 -0
  181. brass/js_analysis/node_modules/@babel/traverse/lib/index.js +87 -0
  182. brass/js_analysis/node_modules/@babel/traverse/lib/index.js.map +1 -0
  183. brass/js_analysis/node_modules/@babel/traverse/lib/path/ancestry.js +139 -0
  184. brass/js_analysis/node_modules/@babel/traverse/lib/path/ancestry.js.map +1 -0
  185. brass/js_analysis/node_modules/@babel/traverse/lib/path/comments.js +52 -0
  186. brass/js_analysis/node_modules/@babel/traverse/lib/path/comments.js.map +1 -0
  187. brass/js_analysis/node_modules/@babel/traverse/lib/path/context.js +242 -0
  188. brass/js_analysis/node_modules/@babel/traverse/lib/path/context.js.map +1 -0
  189. brass/js_analysis/node_modules/@babel/traverse/lib/path/conversion.js +612 -0
  190. brass/js_analysis/node_modules/@babel/traverse/lib/path/conversion.js.map +1 -0
  191. brass/js_analysis/node_modules/@babel/traverse/lib/path/evaluation.js +368 -0
  192. brass/js_analysis/node_modules/@babel/traverse/lib/path/evaluation.js.map +1 -0
  193. brass/js_analysis/node_modules/@babel/traverse/lib/path/family.js +346 -0
  194. brass/js_analysis/node_modules/@babel/traverse/lib/path/family.js.map +1 -0
  195. brass/js_analysis/node_modules/@babel/traverse/lib/path/index.js +293 -0
  196. brass/js_analysis/node_modules/@babel/traverse/lib/path/index.js.map +1 -0
  197. brass/js_analysis/node_modules/@babel/traverse/lib/path/inference/index.js +149 -0
  198. brass/js_analysis/node_modules/@babel/traverse/lib/path/inference/index.js.map +1 -0
  199. brass/js_analysis/node_modules/@babel/traverse/lib/path/inference/inferer-reference.js +151 -0
  200. brass/js_analysis/node_modules/@babel/traverse/lib/path/inference/inferer-reference.js.map +1 -0
  201. brass/js_analysis/node_modules/@babel/traverse/lib/path/inference/inferers.js +207 -0
  202. brass/js_analysis/node_modules/@babel/traverse/lib/path/inference/inferers.js.map +1 -0
  203. brass/js_analysis/node_modules/@babel/traverse/lib/path/inference/util.js +30 -0
  204. brass/js_analysis/node_modules/@babel/traverse/lib/path/inference/util.js.map +1 -0
  205. brass/js_analysis/node_modules/@babel/traverse/lib/path/introspection.js +398 -0
  206. brass/js_analysis/node_modules/@babel/traverse/lib/path/introspection.js.map +1 -0
  207. brass/js_analysis/node_modules/@babel/traverse/lib/path/lib/hoister.js +171 -0
  208. brass/js_analysis/node_modules/@babel/traverse/lib/path/lib/hoister.js.map +1 -0
  209. brass/js_analysis/node_modules/@babel/traverse/lib/path/lib/removal-hooks.js +37 -0
  210. brass/js_analysis/node_modules/@babel/traverse/lib/path/lib/removal-hooks.js.map +1 -0
  211. brass/js_analysis/node_modules/@babel/traverse/lib/path/lib/virtual-types-validator.js +163 -0
  212. brass/js_analysis/node_modules/@babel/traverse/lib/path/lib/virtual-types-validator.js.map +1 -0
  213. brass/js_analysis/node_modules/@babel/traverse/lib/path/lib/virtual-types.js +26 -0
  214. brass/js_analysis/node_modules/@babel/traverse/lib/path/lib/virtual-types.js.map +1 -0
  215. brass/js_analysis/node_modules/@babel/traverse/lib/path/modification.js +230 -0
  216. brass/js_analysis/node_modules/@babel/traverse/lib/path/modification.js.map +1 -0
  217. brass/js_analysis/node_modules/@babel/traverse/lib/path/removal.js +70 -0
  218. brass/js_analysis/node_modules/@babel/traverse/lib/path/removal.js.map +1 -0
  219. brass/js_analysis/node_modules/@babel/traverse/lib/path/replacement.js +263 -0
  220. brass/js_analysis/node_modules/@babel/traverse/lib/path/replacement.js.map +1 -0
  221. brass/js_analysis/node_modules/@babel/traverse/lib/scope/binding.js +84 -0
  222. brass/js_analysis/node_modules/@babel/traverse/lib/scope/binding.js.map +1 -0
  223. brass/js_analysis/node_modules/@babel/traverse/lib/scope/index.js +1039 -0
  224. brass/js_analysis/node_modules/@babel/traverse/lib/scope/index.js.map +1 -0
  225. brass/js_analysis/node_modules/@babel/traverse/lib/scope/lib/renamer.js +131 -0
  226. brass/js_analysis/node_modules/@babel/traverse/lib/scope/lib/renamer.js.map +1 -0
  227. brass/js_analysis/node_modules/@babel/traverse/lib/traverse-node.js +138 -0
  228. brass/js_analysis/node_modules/@babel/traverse/lib/traverse-node.js.map +1 -0
  229. brass/js_analysis/node_modules/@babel/traverse/lib/types.js +3 -0
  230. brass/js_analysis/node_modules/@babel/traverse/lib/types.js.map +1 -0
  231. brass/js_analysis/node_modules/@babel/traverse/lib/visitors.js +258 -0
  232. brass/js_analysis/node_modules/@babel/traverse/lib/visitors.js.map +1 -0
  233. brass/js_analysis/node_modules/@babel/traverse/package.json +35 -0
  234. brass/js_analysis/node_modules/@babel/types/LICENSE +22 -0
  235. brass/js_analysis/node_modules/@babel/types/README.md +19 -0
  236. brass/js_analysis/node_modules/@babel/types/lib/asserts/assertNode.js +16 -0
  237. brass/js_analysis/node_modules/@babel/types/lib/asserts/assertNode.js.map +1 -0
  238. brass/js_analysis/node_modules/@babel/types/lib/asserts/generated/index.js +1251 -0
  239. brass/js_analysis/node_modules/@babel/types/lib/asserts/generated/index.js.map +1 -0
  240. brass/js_analysis/node_modules/@babel/types/lib/ast-types/generated/index.js +3 -0
  241. brass/js_analysis/node_modules/@babel/types/lib/ast-types/generated/index.js.map +1 -0
  242. brass/js_analysis/node_modules/@babel/types/lib/builders/flow/createFlowUnionType.js +18 -0
  243. brass/js_analysis/node_modules/@babel/types/lib/builders/flow/createFlowUnionType.js.map +1 -0
  244. brass/js_analysis/node_modules/@babel/types/lib/builders/flow/createTypeAnnotationBasedOnTypeof.js +31 -0
  245. brass/js_analysis/node_modules/@babel/types/lib/builders/flow/createTypeAnnotationBasedOnTypeof.js.map +1 -0
  246. brass/js_analysis/node_modules/@babel/types/lib/builders/generated/index.js +29 -0
  247. brass/js_analysis/node_modules/@babel/types/lib/builders/generated/index.js.map +1 -0
  248. brass/js_analysis/node_modules/@babel/types/lib/builders/generated/lowercase.js +2896 -0
  249. brass/js_analysis/node_modules/@babel/types/lib/builders/generated/lowercase.js.map +1 -0
  250. brass/js_analysis/node_modules/@babel/types/lib/builders/generated/uppercase.js +274 -0
  251. brass/js_analysis/node_modules/@babel/types/lib/builders/generated/uppercase.js.map +1 -0
  252. brass/js_analysis/node_modules/@babel/types/lib/builders/productions.js +12 -0
  253. brass/js_analysis/node_modules/@babel/types/lib/builders/productions.js.map +1 -0
  254. brass/js_analysis/node_modules/@babel/types/lib/builders/react/buildChildren.js +24 -0
  255. brass/js_analysis/node_modules/@babel/types/lib/builders/react/buildChildren.js.map +1 -0
  256. brass/js_analysis/node_modules/@babel/types/lib/builders/typescript/createTSUnionType.js +22 -0
  257. brass/js_analysis/node_modules/@babel/types/lib/builders/typescript/createTSUnionType.js.map +1 -0
  258. brass/js_analysis/node_modules/@babel/types/lib/builders/validateNode.js +21 -0
  259. brass/js_analysis/node_modules/@babel/types/lib/builders/validateNode.js.map +1 -0
  260. brass/js_analysis/node_modules/@babel/types/lib/clone/clone.js +12 -0
  261. brass/js_analysis/node_modules/@babel/types/lib/clone/clone.js.map +1 -0
  262. brass/js_analysis/node_modules/@babel/types/lib/clone/cloneDeep.js +12 -0
  263. brass/js_analysis/node_modules/@babel/types/lib/clone/cloneDeep.js.map +1 -0
  264. brass/js_analysis/node_modules/@babel/types/lib/clone/cloneDeepWithoutLoc.js +12 -0
  265. brass/js_analysis/node_modules/@babel/types/lib/clone/cloneDeepWithoutLoc.js.map +1 -0
  266. brass/js_analysis/node_modules/@babel/types/lib/clone/cloneNode.js +107 -0
  267. brass/js_analysis/node_modules/@babel/types/lib/clone/cloneNode.js.map +1 -0
  268. brass/js_analysis/node_modules/@babel/types/lib/clone/cloneWithoutLoc.js +12 -0
  269. brass/js_analysis/node_modules/@babel/types/lib/clone/cloneWithoutLoc.js.map +1 -0
  270. brass/js_analysis/node_modules/@babel/types/lib/comments/addComment.js +15 -0
  271. brass/js_analysis/node_modules/@babel/types/lib/comments/addComment.js.map +1 -0
  272. brass/js_analysis/node_modules/@babel/types/lib/comments/addComments.js +22 -0
  273. brass/js_analysis/node_modules/@babel/types/lib/comments/addComments.js.map +1 -0
  274. brass/js_analysis/node_modules/@babel/types/lib/comments/inheritInnerComments.js +12 -0
  275. brass/js_analysis/node_modules/@babel/types/lib/comments/inheritInnerComments.js.map +1 -0
  276. brass/js_analysis/node_modules/@babel/types/lib/comments/inheritLeadingComments.js +12 -0
  277. brass/js_analysis/node_modules/@babel/types/lib/comments/inheritLeadingComments.js.map +1 -0
  278. brass/js_analysis/node_modules/@babel/types/lib/comments/inheritTrailingComments.js +12 -0
  279. brass/js_analysis/node_modules/@babel/types/lib/comments/inheritTrailingComments.js.map +1 -0
  280. brass/js_analysis/node_modules/@babel/types/lib/comments/inheritsComments.js +17 -0
  281. brass/js_analysis/node_modules/@babel/types/lib/comments/inheritsComments.js.map +1 -0
  282. brass/js_analysis/node_modules/@babel/types/lib/comments/removeComments.js +15 -0
  283. brass/js_analysis/node_modules/@babel/types/lib/comments/removeComments.js.map +1 -0
  284. brass/js_analysis/node_modules/@babel/types/lib/constants/generated/index.js +60 -0
  285. brass/js_analysis/node_modules/@babel/types/lib/constants/generated/index.js.map +1 -0
  286. brass/js_analysis/node_modules/@babel/types/lib/constants/index.js +33 -0
  287. brass/js_analysis/node_modules/@babel/types/lib/constants/index.js.map +1 -0
  288. brass/js_analysis/node_modules/@babel/types/lib/converters/ensureBlock.js +14 -0
  289. brass/js_analysis/node_modules/@babel/types/lib/converters/ensureBlock.js.map +1 -0
  290. brass/js_analysis/node_modules/@babel/types/lib/converters/gatherSequenceExpressions.js +66 -0
  291. brass/js_analysis/node_modules/@babel/types/lib/converters/gatherSequenceExpressions.js.map +1 -0
  292. brass/js_analysis/node_modules/@babel/types/lib/converters/toBindingIdentifierName.js +14 -0
  293. brass/js_analysis/node_modules/@babel/types/lib/converters/toBindingIdentifierName.js.map +1 -0
  294. brass/js_analysis/node_modules/@babel/types/lib/converters/toBlock.js +29 -0
  295. brass/js_analysis/node_modules/@babel/types/lib/converters/toBlock.js.map +1 -0
  296. brass/js_analysis/node_modules/@babel/types/lib/converters/toComputedKey.js +14 -0
  297. brass/js_analysis/node_modules/@babel/types/lib/converters/toComputedKey.js.map +1 -0
  298. brass/js_analysis/node_modules/@babel/types/lib/converters/toExpression.js +28 -0
  299. brass/js_analysis/node_modules/@babel/types/lib/converters/toExpression.js.map +1 -0
  300. brass/js_analysis/node_modules/@babel/types/lib/converters/toIdentifier.js +25 -0
  301. brass/js_analysis/node_modules/@babel/types/lib/converters/toIdentifier.js.map +1 -0
  302. brass/js_analysis/node_modules/@babel/types/lib/converters/toKeyAlias.js +38 -0
  303. brass/js_analysis/node_modules/@babel/types/lib/converters/toKeyAlias.js.map +1 -0
  304. brass/js_analysis/node_modules/@babel/types/lib/converters/toSequenceExpression.js +20 -0
  305. brass/js_analysis/node_modules/@babel/types/lib/converters/toSequenceExpression.js.map +1 -0
  306. brass/js_analysis/node_modules/@babel/types/lib/converters/toStatement.js +39 -0
  307. brass/js_analysis/node_modules/@babel/types/lib/converters/toStatement.js.map +1 -0
  308. brass/js_analysis/node_modules/@babel/types/lib/converters/valueToNode.js +89 -0
  309. brass/js_analysis/node_modules/@babel/types/lib/converters/valueToNode.js.map +1 -0
  310. brass/js_analysis/node_modules/@babel/types/lib/definitions/core.js +1659 -0
  311. brass/js_analysis/node_modules/@babel/types/lib/definitions/core.js.map +1 -0
  312. brass/js_analysis/node_modules/@babel/types/lib/definitions/deprecated-aliases.js +11 -0
  313. brass/js_analysis/node_modules/@babel/types/lib/definitions/deprecated-aliases.js.map +1 -0
  314. brass/js_analysis/node_modules/@babel/types/lib/definitions/experimental.js +126 -0
  315. brass/js_analysis/node_modules/@babel/types/lib/definitions/experimental.js.map +1 -0
  316. brass/js_analysis/node_modules/@babel/types/lib/definitions/flow.js +495 -0
  317. brass/js_analysis/node_modules/@babel/types/lib/definitions/flow.js.map +1 -0
  318. brass/js_analysis/node_modules/@babel/types/lib/definitions/index.js +100 -0
  319. brass/js_analysis/node_modules/@babel/types/lib/definitions/index.js.map +1 -0
  320. brass/js_analysis/node_modules/@babel/types/lib/definitions/jsx.js +157 -0
  321. brass/js_analysis/node_modules/@babel/types/lib/definitions/jsx.js.map +1 -0
  322. brass/js_analysis/node_modules/@babel/types/lib/definitions/misc.js +33 -0
  323. brass/js_analysis/node_modules/@babel/types/lib/definitions/misc.js.map +1 -0
  324. brass/js_analysis/node_modules/@babel/types/lib/definitions/placeholders.js +27 -0
  325. brass/js_analysis/node_modules/@babel/types/lib/definitions/placeholders.js.map +1 -0
  326. brass/js_analysis/node_modules/@babel/types/lib/definitions/typescript.js +528 -0
  327. brass/js_analysis/node_modules/@babel/types/lib/definitions/typescript.js.map +1 -0
  328. brass/js_analysis/node_modules/@babel/types/lib/definitions/utils.js +292 -0
  329. brass/js_analysis/node_modules/@babel/types/lib/definitions/utils.js.map +1 -0
  330. brass/js_analysis/node_modules/@babel/types/lib/index-legacy.d.ts +2797 -0
  331. brass/js_analysis/node_modules/@babel/types/lib/index.d.ts +3308 -0
  332. brass/js_analysis/node_modules/@babel/types/lib/index.js +584 -0
  333. brass/js_analysis/node_modules/@babel/types/lib/index.js.flow +2650 -0
  334. brass/js_analysis/node_modules/@babel/types/lib/index.js.map +1 -0
  335. brass/js_analysis/node_modules/@babel/types/lib/modifications/appendToMemberExpression.js +15 -0
  336. brass/js_analysis/node_modules/@babel/types/lib/modifications/appendToMemberExpression.js.map +1 -0
  337. brass/js_analysis/node_modules/@babel/types/lib/modifications/flow/removeTypeDuplicates.js +65 -0
  338. brass/js_analysis/node_modules/@babel/types/lib/modifications/flow/removeTypeDuplicates.js.map +1 -0
  339. brass/js_analysis/node_modules/@babel/types/lib/modifications/inherits.js +28 -0
  340. brass/js_analysis/node_modules/@babel/types/lib/modifications/inherits.js.map +1 -0
  341. brass/js_analysis/node_modules/@babel/types/lib/modifications/prependToMemberExpression.js +17 -0
  342. brass/js_analysis/node_modules/@babel/types/lib/modifications/prependToMemberExpression.js.map +1 -0
  343. brass/js_analysis/node_modules/@babel/types/lib/modifications/removeProperties.js +24 -0
  344. brass/js_analysis/node_modules/@babel/types/lib/modifications/removeProperties.js.map +1 -0
  345. brass/js_analysis/node_modules/@babel/types/lib/modifications/removePropertiesDeep.js +14 -0
  346. brass/js_analysis/node_modules/@babel/types/lib/modifications/removePropertiesDeep.js.map +1 -0
  347. brass/js_analysis/node_modules/@babel/types/lib/modifications/typescript/removeTypeDuplicates.js +66 -0
  348. brass/js_analysis/node_modules/@babel/types/lib/modifications/typescript/removeTypeDuplicates.js.map +1 -0
  349. brass/js_analysis/node_modules/@babel/types/lib/retrievers/getAssignmentIdentifiers.js +48 -0
  350. brass/js_analysis/node_modules/@babel/types/lib/retrievers/getAssignmentIdentifiers.js.map +1 -0
  351. brass/js_analysis/node_modules/@babel/types/lib/retrievers/getBindingIdentifiers.js +102 -0
  352. brass/js_analysis/node_modules/@babel/types/lib/retrievers/getBindingIdentifiers.js.map +1 -0
  353. brass/js_analysis/node_modules/@babel/types/lib/retrievers/getFunctionName.js +63 -0
  354. brass/js_analysis/node_modules/@babel/types/lib/retrievers/getFunctionName.js.map +1 -0
  355. brass/js_analysis/node_modules/@babel/types/lib/retrievers/getOuterBindingIdentifiers.js +13 -0
  356. brass/js_analysis/node_modules/@babel/types/lib/retrievers/getOuterBindingIdentifiers.js.map +1 -0
  357. brass/js_analysis/node_modules/@babel/types/lib/traverse/traverse.js +50 -0
  358. brass/js_analysis/node_modules/@babel/types/lib/traverse/traverse.js.map +1 -0
  359. brass/js_analysis/node_modules/@babel/types/lib/traverse/traverseFast.js +40 -0
  360. brass/js_analysis/node_modules/@babel/types/lib/traverse/traverseFast.js.map +1 -0
  361. brass/js_analysis/node_modules/@babel/types/lib/utils/deprecationWarning.js +44 -0
  362. brass/js_analysis/node_modules/@babel/types/lib/utils/deprecationWarning.js.map +1 -0
  363. brass/js_analysis/node_modules/@babel/types/lib/utils/inherit.js +13 -0
  364. brass/js_analysis/node_modules/@babel/types/lib/utils/inherit.js.map +1 -0
  365. brass/js_analysis/node_modules/@babel/types/lib/utils/react/cleanJSXElementLiteralChild.js +40 -0
  366. brass/js_analysis/node_modules/@babel/types/lib/utils/react/cleanJSXElementLiteralChild.js.map +1 -0
  367. brass/js_analysis/node_modules/@babel/types/lib/utils/shallowEqual.js +17 -0
  368. brass/js_analysis/node_modules/@babel/types/lib/utils/shallowEqual.js.map +1 -0
  369. brass/js_analysis/node_modules/@babel/types/lib/validators/buildMatchMemberExpression.js +13 -0
  370. brass/js_analysis/node_modules/@babel/types/lib/validators/buildMatchMemberExpression.js.map +1 -0
  371. brass/js_analysis/node_modules/@babel/types/lib/validators/generated/index.js +2797 -0
  372. brass/js_analysis/node_modules/@babel/types/lib/validators/generated/index.js.map +1 -0
  373. brass/js_analysis/node_modules/@babel/types/lib/validators/is.js +27 -0
  374. brass/js_analysis/node_modules/@babel/types/lib/validators/is.js.map +1 -0
  375. brass/js_analysis/node_modules/@babel/types/lib/validators/isBinding.js +27 -0
  376. brass/js_analysis/node_modules/@babel/types/lib/validators/isBinding.js.map +1 -0
  377. brass/js_analysis/node_modules/@babel/types/lib/validators/isBlockScoped.js +13 -0
  378. brass/js_analysis/node_modules/@babel/types/lib/validators/isBlockScoped.js.map +1 -0
  379. brass/js_analysis/node_modules/@babel/types/lib/validators/isImmutable.js +21 -0
  380. brass/js_analysis/node_modules/@babel/types/lib/validators/isImmutable.js.map +1 -0
  381. brass/js_analysis/node_modules/@babel/types/lib/validators/isLet.js +17 -0
  382. brass/js_analysis/node_modules/@babel/types/lib/validators/isLet.js.map +1 -0
  383. brass/js_analysis/node_modules/@babel/types/lib/validators/isNode.js +12 -0
  384. brass/js_analysis/node_modules/@babel/types/lib/validators/isNode.js.map +1 -0
  385. brass/js_analysis/node_modules/@babel/types/lib/validators/isNodesEquivalent.js +57 -0
  386. brass/js_analysis/node_modules/@babel/types/lib/validators/isNodesEquivalent.js.map +1 -0
  387. brass/js_analysis/node_modules/@babel/types/lib/validators/isPlaceholderType.js +15 -0
  388. brass/js_analysis/node_modules/@babel/types/lib/validators/isPlaceholderType.js.map +1 -0
  389. brass/js_analysis/node_modules/@babel/types/lib/validators/isReferenced.js +96 -0
  390. brass/js_analysis/node_modules/@babel/types/lib/validators/isReferenced.js.map +1 -0
  391. brass/js_analysis/node_modules/@babel/types/lib/validators/isScope.js +18 -0
  392. brass/js_analysis/node_modules/@babel/types/lib/validators/isScope.js.map +1 -0
  393. brass/js_analysis/node_modules/@babel/types/lib/validators/isSpecifierDefault.js +14 -0
  394. brass/js_analysis/node_modules/@babel/types/lib/validators/isSpecifierDefault.js.map +1 -0
  395. brass/js_analysis/node_modules/@babel/types/lib/validators/isType.js +17 -0
  396. brass/js_analysis/node_modules/@babel/types/lib/validators/isType.js.map +1 -0
  397. brass/js_analysis/node_modules/@babel/types/lib/validators/isValidES3Identifier.js +13 -0
  398. brass/js_analysis/node_modules/@babel/types/lib/validators/isValidES3Identifier.js.map +1 -0
  399. brass/js_analysis/node_modules/@babel/types/lib/validators/isValidIdentifier.js +18 -0
  400. brass/js_analysis/node_modules/@babel/types/lib/validators/isValidIdentifier.js.map +1 -0
  401. brass/js_analysis/node_modules/@babel/types/lib/validators/isVar.js +19 -0
  402. brass/js_analysis/node_modules/@babel/types/lib/validators/isVar.js.map +1 -0
  403. brass/js_analysis/node_modules/@babel/types/lib/validators/matchesPattern.js +44 -0
  404. brass/js_analysis/node_modules/@babel/types/lib/validators/matchesPattern.js.map +1 -0
  405. brass/js_analysis/node_modules/@babel/types/lib/validators/react/isCompatTag.js +11 -0
  406. brass/js_analysis/node_modules/@babel/types/lib/validators/react/isCompatTag.js.map +1 -0
  407. brass/js_analysis/node_modules/@babel/types/lib/validators/react/isReactComponent.js +11 -0
  408. brass/js_analysis/node_modules/@babel/types/lib/validators/react/isReactComponent.js.map +1 -0
  409. brass/js_analysis/node_modules/@babel/types/lib/validators/validate.js +42 -0
  410. brass/js_analysis/node_modules/@babel/types/lib/validators/validate.js.map +1 -0
  411. brass/js_analysis/node_modules/@babel/types/package.json +39 -0
  412. brass/js_analysis/node_modules/@jridgewell/gen-mapping/LICENSE +19 -0
  413. brass/js_analysis/node_modules/@jridgewell/gen-mapping/README.md +227 -0
  414. brass/js_analysis/node_modules/@jridgewell/gen-mapping/dist/gen-mapping.mjs +292 -0
  415. brass/js_analysis/node_modules/@jridgewell/gen-mapping/dist/gen-mapping.mjs.map +6 -0
  416. brass/js_analysis/node_modules/@jridgewell/gen-mapping/dist/gen-mapping.umd.js +346 -0
  417. brass/js_analysis/node_modules/@jridgewell/gen-mapping/dist/gen-mapping.umd.js.map +6 -0
  418. brass/js_analysis/node_modules/@jridgewell/gen-mapping/package.json +71 -0
  419. brass/js_analysis/node_modules/@jridgewell/gen-mapping/src/gen-mapping.ts +614 -0
  420. brass/js_analysis/node_modules/@jridgewell/gen-mapping/src/set-array.ts +82 -0
  421. brass/js_analysis/node_modules/@jridgewell/gen-mapping/src/sourcemap-segment.ts +16 -0
  422. brass/js_analysis/node_modules/@jridgewell/gen-mapping/src/types.ts +61 -0
  423. brass/js_analysis/node_modules/@jridgewell/gen-mapping/types/gen-mapping.d.cts +89 -0
  424. brass/js_analysis/node_modules/@jridgewell/gen-mapping/types/gen-mapping.d.cts.map +1 -0
  425. brass/js_analysis/node_modules/@jridgewell/gen-mapping/types/gen-mapping.d.mts +89 -0
  426. brass/js_analysis/node_modules/@jridgewell/gen-mapping/types/gen-mapping.d.mts.map +1 -0
  427. brass/js_analysis/node_modules/@jridgewell/gen-mapping/types/set-array.d.cts +33 -0
  428. brass/js_analysis/node_modules/@jridgewell/gen-mapping/types/set-array.d.cts.map +1 -0
  429. brass/js_analysis/node_modules/@jridgewell/gen-mapping/types/set-array.d.mts +33 -0
  430. brass/js_analysis/node_modules/@jridgewell/gen-mapping/types/set-array.d.mts.map +1 -0
  431. brass/js_analysis/node_modules/@jridgewell/gen-mapping/types/sourcemap-segment.d.cts +13 -0
  432. brass/js_analysis/node_modules/@jridgewell/gen-mapping/types/sourcemap-segment.d.cts.map +1 -0
  433. brass/js_analysis/node_modules/@jridgewell/gen-mapping/types/sourcemap-segment.d.mts +13 -0
  434. brass/js_analysis/node_modules/@jridgewell/gen-mapping/types/sourcemap-segment.d.mts.map +1 -0
  435. brass/js_analysis/node_modules/@jridgewell/gen-mapping/types/types.d.cts +44 -0
  436. brass/js_analysis/node_modules/@jridgewell/gen-mapping/types/types.d.cts.map +1 -0
  437. brass/js_analysis/node_modules/@jridgewell/gen-mapping/types/types.d.mts +44 -0
  438. brass/js_analysis/node_modules/@jridgewell/gen-mapping/types/types.d.mts.map +1 -0
  439. brass/js_analysis/node_modules/@jridgewell/resolve-uri/LICENSE +19 -0
  440. brass/js_analysis/node_modules/@jridgewell/resolve-uri/README.md +40 -0
  441. brass/js_analysis/node_modules/@jridgewell/resolve-uri/dist/resolve-uri.mjs +232 -0
  442. brass/js_analysis/node_modules/@jridgewell/resolve-uri/dist/resolve-uri.mjs.map +1 -0
  443. brass/js_analysis/node_modules/@jridgewell/resolve-uri/dist/resolve-uri.umd.js +240 -0
  444. brass/js_analysis/node_modules/@jridgewell/resolve-uri/dist/resolve-uri.umd.js.map +1 -0
  445. brass/js_analysis/node_modules/@jridgewell/resolve-uri/dist/types/resolve-uri.d.ts +4 -0
  446. brass/js_analysis/node_modules/@jridgewell/resolve-uri/package.json +69 -0
  447. brass/js_analysis/node_modules/@jridgewell/sourcemap-codec/LICENSE +19 -0
  448. brass/js_analysis/node_modules/@jridgewell/sourcemap-codec/README.md +264 -0
  449. brass/js_analysis/node_modules/@jridgewell/sourcemap-codec/dist/sourcemap-codec.mjs +423 -0
  450. brass/js_analysis/node_modules/@jridgewell/sourcemap-codec/dist/sourcemap-codec.mjs.map +6 -0
  451. brass/js_analysis/node_modules/@jridgewell/sourcemap-codec/dist/sourcemap-codec.umd.js +452 -0
  452. brass/js_analysis/node_modules/@jridgewell/sourcemap-codec/dist/sourcemap-codec.umd.js.map +6 -0
  453. brass/js_analysis/node_modules/@jridgewell/sourcemap-codec/package.json +67 -0
  454. brass/js_analysis/node_modules/@jridgewell/sourcemap-codec/src/scopes.ts +345 -0
  455. brass/js_analysis/node_modules/@jridgewell/sourcemap-codec/src/sourcemap-codec.ts +111 -0
  456. brass/js_analysis/node_modules/@jridgewell/sourcemap-codec/src/strings.ts +65 -0
  457. brass/js_analysis/node_modules/@jridgewell/sourcemap-codec/src/vlq.ts +55 -0
  458. brass/js_analysis/node_modules/@jridgewell/sourcemap-codec/types/scopes.d.cts +50 -0
  459. brass/js_analysis/node_modules/@jridgewell/sourcemap-codec/types/scopes.d.cts.map +1 -0
  460. brass/js_analysis/node_modules/@jridgewell/sourcemap-codec/types/scopes.d.mts +50 -0
  461. brass/js_analysis/node_modules/@jridgewell/sourcemap-codec/types/scopes.d.mts.map +1 -0
  462. brass/js_analysis/node_modules/@jridgewell/sourcemap-codec/types/sourcemap-codec.d.cts +9 -0
  463. brass/js_analysis/node_modules/@jridgewell/sourcemap-codec/types/sourcemap-codec.d.cts.map +1 -0
  464. brass/js_analysis/node_modules/@jridgewell/sourcemap-codec/types/sourcemap-codec.d.mts +9 -0
  465. brass/js_analysis/node_modules/@jridgewell/sourcemap-codec/types/sourcemap-codec.d.mts.map +1 -0
  466. brass/js_analysis/node_modules/@jridgewell/sourcemap-codec/types/strings.d.cts +16 -0
  467. brass/js_analysis/node_modules/@jridgewell/sourcemap-codec/types/strings.d.cts.map +1 -0
  468. brass/js_analysis/node_modules/@jridgewell/sourcemap-codec/types/strings.d.mts +16 -0
  469. brass/js_analysis/node_modules/@jridgewell/sourcemap-codec/types/strings.d.mts.map +1 -0
  470. brass/js_analysis/node_modules/@jridgewell/sourcemap-codec/types/vlq.d.cts +7 -0
  471. brass/js_analysis/node_modules/@jridgewell/sourcemap-codec/types/vlq.d.cts.map +1 -0
  472. brass/js_analysis/node_modules/@jridgewell/sourcemap-codec/types/vlq.d.mts +7 -0
  473. brass/js_analysis/node_modules/@jridgewell/sourcemap-codec/types/vlq.d.mts.map +1 -0
  474. brass/js_analysis/node_modules/@jridgewell/trace-mapping/LICENSE +19 -0
  475. brass/js_analysis/node_modules/@jridgewell/trace-mapping/README.md +348 -0
  476. brass/js_analysis/node_modules/@jridgewell/trace-mapping/dist/trace-mapping.mjs +504 -0
  477. brass/js_analysis/node_modules/@jridgewell/trace-mapping/dist/trace-mapping.mjs.map +6 -0
  478. brass/js_analysis/node_modules/@jridgewell/trace-mapping/dist/trace-mapping.umd.js +558 -0
  479. brass/js_analysis/node_modules/@jridgewell/trace-mapping/dist/trace-mapping.umd.js.map +6 -0
  480. brass/js_analysis/node_modules/@jridgewell/trace-mapping/package.json +71 -0
  481. brass/js_analysis/node_modules/@jridgewell/trace-mapping/src/binary-search.ts +115 -0
  482. brass/js_analysis/node_modules/@jridgewell/trace-mapping/src/by-source.ts +65 -0
  483. brass/js_analysis/node_modules/@jridgewell/trace-mapping/src/flatten-map.ts +192 -0
  484. brass/js_analysis/node_modules/@jridgewell/trace-mapping/src/resolve.ts +16 -0
  485. brass/js_analysis/node_modules/@jridgewell/trace-mapping/src/sort.ts +45 -0
  486. brass/js_analysis/node_modules/@jridgewell/trace-mapping/src/sourcemap-segment.ts +23 -0
  487. brass/js_analysis/node_modules/@jridgewell/trace-mapping/src/strip-filename.ts +8 -0
  488. brass/js_analysis/node_modules/@jridgewell/trace-mapping/src/trace-mapping.ts +504 -0
  489. brass/js_analysis/node_modules/@jridgewell/trace-mapping/src/types.ts +114 -0
  490. brass/js_analysis/node_modules/@jridgewell/trace-mapping/types/binary-search.d.cts +33 -0
  491. brass/js_analysis/node_modules/@jridgewell/trace-mapping/types/binary-search.d.cts.map +1 -0
  492. brass/js_analysis/node_modules/@jridgewell/trace-mapping/types/binary-search.d.mts +33 -0
  493. brass/js_analysis/node_modules/@jridgewell/trace-mapping/types/binary-search.d.mts.map +1 -0
  494. brass/js_analysis/node_modules/@jridgewell/trace-mapping/types/by-source.d.cts +8 -0
  495. brass/js_analysis/node_modules/@jridgewell/trace-mapping/types/by-source.d.cts.map +1 -0
  496. brass/js_analysis/node_modules/@jridgewell/trace-mapping/types/by-source.d.mts +8 -0
  497. brass/js_analysis/node_modules/@jridgewell/trace-mapping/types/by-source.d.mts.map +1 -0
  498. brass/js_analysis/node_modules/@jridgewell/trace-mapping/types/flatten-map.d.cts +9 -0
  499. brass/js_analysis/node_modules/@jridgewell/trace-mapping/types/flatten-map.d.cts.map +1 -0
  500. brass/js_analysis/node_modules/@jridgewell/trace-mapping/types/flatten-map.d.mts +9 -0
  501. brass/js_analysis/node_modules/@jridgewell/trace-mapping/types/flatten-map.d.mts.map +1 -0
  502. brass/js_analysis/node_modules/@jridgewell/trace-mapping/types/resolve.d.cts +4 -0
  503. brass/js_analysis/node_modules/@jridgewell/trace-mapping/types/resolve.d.cts.map +1 -0
  504. brass/js_analysis/node_modules/@jridgewell/trace-mapping/types/resolve.d.mts +4 -0
  505. brass/js_analysis/node_modules/@jridgewell/trace-mapping/types/resolve.d.mts.map +1 -0
  506. brass/js_analysis/node_modules/@jridgewell/trace-mapping/types/sort.d.cts +3 -0
  507. brass/js_analysis/node_modules/@jridgewell/trace-mapping/types/sort.d.cts.map +1 -0
  508. brass/js_analysis/node_modules/@jridgewell/trace-mapping/types/sort.d.mts +3 -0
  509. brass/js_analysis/node_modules/@jridgewell/trace-mapping/types/sort.d.mts.map +1 -0
  510. brass/js_analysis/node_modules/@jridgewell/trace-mapping/types/sourcemap-segment.d.cts +17 -0
  511. brass/js_analysis/node_modules/@jridgewell/trace-mapping/types/sourcemap-segment.d.cts.map +1 -0
  512. brass/js_analysis/node_modules/@jridgewell/trace-mapping/types/sourcemap-segment.d.mts +17 -0
  513. brass/js_analysis/node_modules/@jridgewell/trace-mapping/types/sourcemap-segment.d.mts.map +1 -0
  514. brass/js_analysis/node_modules/@jridgewell/trace-mapping/types/strip-filename.d.cts +5 -0
  515. brass/js_analysis/node_modules/@jridgewell/trace-mapping/types/strip-filename.d.cts.map +1 -0
  516. brass/js_analysis/node_modules/@jridgewell/trace-mapping/types/strip-filename.d.mts +5 -0
  517. brass/js_analysis/node_modules/@jridgewell/trace-mapping/types/strip-filename.d.mts.map +1 -0
  518. brass/js_analysis/node_modules/@jridgewell/trace-mapping/types/trace-mapping.d.cts +80 -0
  519. brass/js_analysis/node_modules/@jridgewell/trace-mapping/types/trace-mapping.d.cts.map +1 -0
  520. brass/js_analysis/node_modules/@jridgewell/trace-mapping/types/trace-mapping.d.mts +80 -0
  521. brass/js_analysis/node_modules/@jridgewell/trace-mapping/types/trace-mapping.d.mts.map +1 -0
  522. brass/js_analysis/node_modules/@jridgewell/trace-mapping/types/types.d.cts +107 -0
  523. brass/js_analysis/node_modules/@jridgewell/trace-mapping/types/types.d.cts.map +1 -0
  524. brass/js_analysis/node_modules/@jridgewell/trace-mapping/types/types.d.mts +107 -0
  525. brass/js_analysis/node_modules/@jridgewell/trace-mapping/types/types.d.mts.map +1 -0
  526. brass/js_analysis/node_modules/debug/LICENSE +20 -0
  527. brass/js_analysis/node_modules/debug/README.md +481 -0
  528. brass/js_analysis/node_modules/debug/package.json +64 -0
  529. brass/js_analysis/node_modules/debug/src/browser.js +272 -0
  530. brass/js_analysis/node_modules/debug/src/common.js +292 -0
  531. brass/js_analysis/node_modules/debug/src/index.js +10 -0
  532. brass/js_analysis/node_modules/debug/src/node.js +263 -0
  533. brass/js_analysis/node_modules/js-tokens/CHANGELOG.md +151 -0
  534. brass/js_analysis/node_modules/js-tokens/LICENSE +21 -0
  535. brass/js_analysis/node_modules/js-tokens/README.md +240 -0
  536. brass/js_analysis/node_modules/js-tokens/index.js +23 -0
  537. brass/js_analysis/node_modules/js-tokens/package.json +30 -0
  538. brass/js_analysis/node_modules/jsesc/LICENSE-MIT.txt +20 -0
  539. brass/js_analysis/node_modules/jsesc/README.md +422 -0
  540. brass/js_analysis/node_modules/jsesc/bin/jsesc +148 -0
  541. brass/js_analysis/node_modules/jsesc/jsesc.js +337 -0
  542. brass/js_analysis/node_modules/jsesc/man/jsesc.1 +94 -0
  543. brass/js_analysis/node_modules/jsesc/package.json +56 -0
  544. brass/js_analysis/node_modules/ms/index.js +162 -0
  545. brass/js_analysis/node_modules/ms/license.md +21 -0
  546. brass/js_analysis/node_modules/ms/package.json +38 -0
  547. brass/js_analysis/node_modules/ms/readme.md +59 -0
  548. brass/js_analysis/node_modules/picocolors/LICENSE +15 -0
  549. brass/js_analysis/node_modules/picocolors/README.md +21 -0
  550. brass/js_analysis/node_modules/picocolors/package.json +25 -0
  551. brass/js_analysis/node_modules/picocolors/picocolors.browser.js +4 -0
  552. brass/js_analysis/node_modules/picocolors/picocolors.d.ts +5 -0
  553. brass/js_analysis/node_modules/picocolors/picocolors.js +75 -0
  554. brass/js_analysis/node_modules/picocolors/types.d.ts +51 -0
  555. brass/js_analysis/package-lock.json +218 -0
  556. brass/js_analysis/package.json +13 -0
  557. brass/licensing/__init__.py +55 -0
  558. brass/licensing/lemonsqueezy.py +205 -0
  559. brass/licensing/store.py +134 -0
  560. brass/models/__init__.py +7 -0
  561. brass/models/finding.py +142 -0
  562. brass/monitoring/__init__.py +7 -0
  563. brass/monitoring/file_watcher.py +408 -0
  564. brass/output/__init__.py +7 -0
  565. brass/output/cross_scanner_overlap.py +132 -0
  566. brass/output/output_generator.py +679 -0
  567. brass/output/redaction_checker.py +254 -0
  568. brass/output/yaml_builders/__init__.py +28 -0
  569. brass/output/yaml_builders/ai_instructions_builder.py +1867 -0
  570. brass/output/yaml_builders/base_builder.py +735 -0
  571. brass/output/yaml_builders/constants.py +44 -0
  572. brass/output/yaml_builders/detailed_analysis_builder.py +111 -0
  573. brass/output/yaml_builders/file_intelligence_builder.py +115 -0
  574. brass/output/yaml_builders/metadata_builder.py +40 -0
  575. brass/output/yaml_builders/privacy_report_builder.py +226 -0
  576. brass/output/yaml_builders/security_report_builder.py +165 -0
  577. brass/output/yaml_builders/statistics_builder.py +264 -0
  578. brass/output/yaml_builders/yaml_utils.py +156 -0
  579. brass/output/yaml_output_generator.py +918 -0
  580. brass/output/yaml_output_generator_v2.py +424 -0
  581. brass/ranking/__init__.py +7 -0
  582. brass/ranking/intelligence_ranker.py +736 -0
  583. brass/scanners/__init__.py +15 -0
  584. brass/scanners/_known_test_values.py +183 -0
  585. brass/scanners/ai_context_coherence_scanner.py +811 -0
  586. brass/scanners/api_security_refactored/__init__.py +35 -0
  587. brass/scanners/api_security_refactored/auth_patterns.py +234 -0
  588. brass/scanners/api_security_refactored/input_validation.py +236 -0
  589. brass/scanners/api_security_refactored/package_hallucination.py +207 -0
  590. brass/scanners/api_security_refactored/scanner.py +213 -0
  591. brass/scanners/api_security_refactored/utils.py +192 -0
  592. brass/scanners/api_security_scanner.py +900 -0
  593. brass/scanners/ast_grep_scanner.py +277 -0
  594. brass/scanners/brass2_privacy_scanner.py +1122 -0
  595. brass/scanners/brass_performance_scanner.py +1615 -0
  596. brass/scanners/content_moderation_scanner.py +793 -0
  597. brass/scanners/file_prefilter_scanner.py +413 -0
  598. brass/scanners/javascript_typescript_scanner.py +771 -0
  599. brass/scanners/noise_reduction_scanner.py +448 -0
  600. brass/scanners/phantom_ai_code_scanner.py +935 -0
  601. brass/scanners/professional_code_scanner.py +1470 -0
  602. brass/scanners/pysa_taint_scanner.py +1501 -0
  603. brass/scanners/secrets_scanner.py +392 -0
  604. brass/scanners/semgrep_taint_scanner.py +478 -0
  605. brass/telemetry/__init__.py +50 -0
  606. brass/telemetry/backend.py +61 -0
  607. brass/telemetry/client.py +82 -0
  608. brass/telemetry/consent.py +82 -0
  609. brasscoders-2.0.4.dist-info/METADATA +251 -0
  610. brasscoders-2.0.4.dist-info/RECORD +615 -0
  611. brasscoders-2.0.4.dist-info/WHEEL +5 -0
  612. brasscoders-2.0.4.dist-info/entry_points.txt +2 -0
  613. brasscoders-2.0.4.dist-info/licenses/LICENSE +202 -0
  614. brasscoders-2.0.4.dist-info/licenses/NOTICE +5 -0
  615. brasscoders-2.0.4.dist-info/top_level.txt +1 -0
brass/cli/brass_cli.py ADDED
@@ -0,0 +1,2721 @@
1
+ """
2
+ BrassCLI - Command-line interface for the new Copper Sun Brass system.
3
+
4
+ This component provides a user-friendly CLI for running scans, monitoring
5
+ files, and generating intelligence reports.
6
+ """
7
+
8
+ import concurrent.futures
9
+ import json
10
+ import sys
11
+ import os
12
+ import argparse
13
+ import subprocess
14
+ import time
15
+ from contextlib import contextmanager
16
+ from pathlib import Path
17
+ from typing import Callable, Optional, List, Tuple, Dict, Set
18
+ from datetime import datetime
19
+
20
+ from brass.scanners.professional_code_scanner import ProfessionalCodeScanner
21
+ from brass.scanners.brass2_privacy_scanner import Brass2PrivacyScanner
22
+ from brass.scanners.content_moderation_scanner import ContentModerationScanner
23
+ from brass.scanners.javascript_typescript_scanner import JavaScriptTypeScriptScanner
24
+ from brass.scanners.phantom_ai_code_scanner import PhantomAICodeScanner
25
+ from brass.scanners.brass_performance_scanner import BrassPerformanceScanner
26
+ from brass.scanners.api_security_scanner import APISecurityScanner
27
+ from brass.scanners.ai_context_coherence_scanner import AIContextCoherenceScanner
28
+ from brass.scanners.secrets_scanner import SecretsScanner
29
+ from brass.scanners.semgrep_taint_scanner import SemgrepTaintScanner
30
+ from brass.scanners.ast_grep_scanner import AstGrepScanner
31
+ from brass.scanners.pysa_taint_scanner import PysaTaintScanner
32
+ from brass.ranking.intelligence_ranker import IntelligenceRanker
33
+ from brass.scanners.file_prefilter_scanner import FilePrefilterScanner
34
+ from brass.core.file_index import FileIndex
35
+ from brass.core import finding_cache as _finding_cache
36
+ from brass.core import change_detection as _change_detection
37
+ from brass.scanners.noise_reduction_scanner import NoiseReductionScanner
38
+ from brass.output.yaml_output_generator_v2 import YAMLOutputGeneratorV2
39
+ from brass.monitoring.file_watcher import FileWatcher, IncrementalAnalyzer
40
+ from brass.models.finding import Finding
41
+ from brass.core.logging_config import BrassLogger, get_logger
42
+ from brass.core.error_reporter import get_error_reporter, save_session_error_report
43
+ from brass.core.startup_checks import run_startup_checks, StartupError
44
+ from brass.core.user_error_handler import setup_global_error_handler, UserFriendlyError, handle_common_errors
45
+ from brass.core.state_validator import StateValidator
46
+ from brass.core.scanner_status import ScannerStatus
47
+
48
+ logger = get_logger(__name__)
49
+
50
+
51
+ class BrassCLI:
52
+ """
53
+ Command-line interface for the new Copper Sun Brass system.
54
+
55
+ Provides commands for:
56
+ - One-time analysis (scan)
57
+ - Continuous monitoring (watch)
58
+ - Report generation
59
+ - System status and configuration
60
+ """
61
+
62
+ def __init__(self):
63
+ """Initialize CLI with argument parser."""
64
+ self.parser = self._create_parser()
65
+ self.current_directory = Path.cwd()
66
+
67
+ # Components (initialized on demand)
68
+ self.code_scanner = None
69
+ self.brass2_privacy_scanner = None
70
+ self.content_moderation_scanner = None
71
+ self.javascript_typescript_scanner = None
72
+ self.phantom_ai_code_scanner = None
73
+ self.brass_performance_scanner = None
74
+ self.api_security_scanner = None
75
+ self.ai_context_coherence_scanner = None
76
+ self.secrets_scanner = None
77
+ self.semgrep_taint_scanner = None
78
+ self.ast_grep_scanner = None
79
+ self.pysa_taint_scanner = None
80
+ self.ranker = None
81
+ self.output_generator = None
82
+ self.file_watcher = None
83
+
84
+ # Per-scanner status from the most recent scan (loose end #8).
85
+ # Populated in `_run_scanner_task`, dumped to scanner_timings.json
86
+ # and threaded into the YAML output pipeline.
87
+ self._scanner_status: Dict[str, "ScannerStatus"] = {}
88
+
89
+ # Environment features (validated before scan)
90
+ self.features = {
91
+ 'git_available': False,
92
+ 'symlinks_present': False,
93
+ 'large_project': False,
94
+ 'binary_files_detected': False
95
+ }
96
+
97
+ def _ensure_component_initialized(self, component_name: str):
98
+ """
99
+ Ensure component is properly initialized before use.
100
+
101
+ Args:
102
+ component_name: Name of the component attribute
103
+
104
+ Returns:
105
+ The initialized component
106
+
107
+ Raises:
108
+ RuntimeError: If component is not initialized
109
+ """
110
+ component = getattr(self, component_name, None)
111
+ if component is None:
112
+ raise RuntimeError(f"Component {component_name} is not initialized. Initialize components before scanning.")
113
+ return component
114
+
115
+ def run(self, args: Optional[List[str]] = None) -> int:
116
+ """
117
+ Run CLI with provided arguments.
118
+
119
+ Args:
120
+ args: Command line arguments (uses sys.argv if None)
121
+
122
+ Returns:
123
+ Exit code (0 = success, non-zero = error)
124
+ """
125
+ try:
126
+ parsed_args = self.parser.parse_args(args)
127
+
128
+ # Configure logging for scan command - others handle their own logging
129
+ if getattr(parsed_args, 'command', None) != 'scan':
130
+ # Basic logging for non-scan commands
131
+ output_dir = getattr(parsed_args, 'output_dir', '.brass')
132
+ log_file = getattr(parsed_args, 'log_file', None)
133
+ no_log_file = getattr(parsed_args, 'no_log_file', False)
134
+ self._configure_logging(parsed_args.verbose, log_file, no_log_file, output_dir)
135
+
136
+ # Execute command
137
+ if hasattr(parsed_args, 'func'):
138
+ return parsed_args.func(parsed_args)
139
+ else:
140
+ self.parser.print_help()
141
+ return 1
142
+
143
+ except KeyboardInterrupt:
144
+ print("\n❌ Operation cancelled by user")
145
+ return 130
146
+ except Exception as e:
147
+ print(f"❌ Error: {e}")
148
+ logger.exception("CLI error")
149
+ return 1
150
+
151
+ def _create_parser(self) -> argparse.ArgumentParser:
152
+ """Create command-line argument parser."""
153
+ parser = argparse.ArgumentParser(
154
+ prog='brasscoders',
155
+ description='🎺 BrassCoders for AI Coders v2.0 - Revolutionary AI Development Intelligence',
156
+ formatter_class=argparse.RawDescriptionHelpFormatter,
157
+ epilog="""
158
+ 💡 Quick Start:
159
+ brasscoders scan # 🎯 Complete analysis (recommended)
160
+ brasscoders scan --fast # ⚡ Quick code review (skip privacy/content)
161
+ brasscoders scan --dev # 👨‍💻 Developer focus (source code only)
162
+
163
+ 🔍 Selective Analysis:
164
+ brasscoders scan --code # 🐛 Find bugs, security issues, code quality
165
+ brasscoders scan --privacy # 🔒 Detect PII, sensitive data exposure
166
+ brasscoders scan --content # 🚫 Check for inappropriate content
167
+
168
+ 📁 Project Analysis:
169
+ brasscoders scan /path/to/project # Analyze specific project
170
+ brasscoders scan --output-dir=.reports # Custom output location
171
+
172
+ ⚡ Workflow Commands:
173
+ brasscoders watch # 👁️ Monitor files for changes
174
+ brasscoders status # 📊 View last analysis results
175
+ brasscoders version # ℹ️ Show version and components
176
+ """
177
+ )
178
+
179
+ # Global options
180
+ parser.add_argument(
181
+ '-v', '--verbose',
182
+ action='store_true',
183
+ help='Enable verbose logging'
184
+ )
185
+
186
+ parser.add_argument(
187
+ '--log-file',
188
+ type=str,
189
+ help='📝 Log file path (default: .brass/brass.log)'
190
+ )
191
+
192
+ parser.add_argument(
193
+ '--no-log-file',
194
+ action='store_true',
195
+ help='🚫 Disable automatic log file creation'
196
+ )
197
+
198
+ parser.add_argument(
199
+ '--project-path',
200
+ type=str,
201
+ default='.',
202
+ help='Project path to analyze (default: current directory)'
203
+ )
204
+
205
+ parser.add_argument(
206
+ '--offline',
207
+ action='store_true',
208
+ help='🚫 Refuse all outbound network calls. Forces --check-package-hallucination off.'
209
+ )
210
+
211
+ # Subcommands
212
+ subparsers = parser.add_subparsers(dest='command', help='Available commands')
213
+
214
+ # Scan command with user-friendly help
215
+ scan_parser = subparsers.add_parser(
216
+ 'scan',
217
+ help='🔍 Analyze your code for issues and generate AI intelligence',
218
+ formatter_class=argparse.RawDescriptionHelpFormatter,
219
+ description='🎺 BrassCoders for AI Coders - Find bugs, security issues, and quality problems',
220
+ epilog="""
221
+ 💡 Common Usage Patterns:
222
+ brasscoders scan # 🎯 Complete analysis (recommended)
223
+ brasscoders scan --fast # ⚡ Quick code review (skip privacy/content)
224
+ brasscoders scan --dev # 👨‍💻 Developer focus (source code only)
225
+
226
+ 🔍 Targeted Analysis:
227
+ brasscoders scan --code # 🐛 Code quality, bugs, security only
228
+ brasscoders scan --privacy # 🔒 PII detection and data protection
229
+ brasscoders scan --content # 🚫 Content moderation and policy checks
230
+
231
+ 📁 Project Analysis:
232
+ brasscoders scan /path/to/project # Analyze specific project
233
+ brasscoders scan --output-dir=reports # Custom output location
234
+
235
+ ✨ What You'll Get:
236
+ • ai_instructions.yaml - Start here! Main guidance optimized for AI assistants
237
+ • detailed_analysis.yaml - Complete technical breakdown of all issues found
238
+ • security_report.yaml - Security vulnerabilities that need immediate attention
239
+ • privacy_analysis.yaml - Personal data (PII) exposure and compliance issues
240
+ • file_intelligence.yaml - File-by-file breakdown showing problems in each file
241
+ • statistics.yaml - Summary metrics and trends across your entire project
242
+ """
243
+ )
244
+
245
+ # Positional argument
246
+ scan_parser.add_argument(
247
+ 'path',
248
+ nargs='?',
249
+ default='.',
250
+ help='📁 Project path to analyze (default: current directory)'
251
+ )
252
+
253
+ # User-friendly aliases for common workflows
254
+ scan_parser.add_argument(
255
+ '--fast',
256
+ action='store_true',
257
+ help='⚡ Quick scan: code analysis only (skips privacy/content for speed)'
258
+ )
259
+ scan_parser.add_argument(
260
+ '--dev',
261
+ action='store_true',
262
+ help='👨‍💻 Developer mode: focus on source code (excludes tests/build files)'
263
+ )
264
+
265
+ # Selective analysis options
266
+ scan_parser.add_argument(
267
+ '--code',
268
+ action='store_true',
269
+ help='🐛 Code analysis only: bugs, security, code quality'
270
+ )
271
+ scan_parser.add_argument(
272
+ '--privacy',
273
+ action='store_true',
274
+ help='🔒 Privacy analysis only: PII detection, data protection'
275
+ )
276
+ scan_parser.add_argument(
277
+ '--content',
278
+ action='store_true',
279
+ help='🚫 Content moderation only: policy violations, inappropriate content'
280
+ )
281
+
282
+ # Network policy options (default: no outbound calls).
283
+ scan_parser.add_argument(
284
+ '--check-package-hallucination',
285
+ action='store_true',
286
+ help=(
287
+ '🌐 Validate imports against PyPI/npm/pkg.go.dev (sends each unknown '
288
+ 'package name over the network). Off by default for privacy; '
289
+ '--offline overrides this back to off.'
290
+ )
291
+ )
292
+
293
+ # Phase 2 Performance Enhancement Options
294
+ scan_parser.add_argument(
295
+ '--performance-validation',
296
+ action='store_true',
297
+ help='🔍 Add runtime validation to performance findings (requires py-spy)'
298
+ )
299
+ scan_parser.add_argument(
300
+ '--performance-benchmarking',
301
+ action='store_true',
302
+ help='⏱️ Add quantified performance impact estimates (requires pyperf)'
303
+ )
304
+ scan_parser.add_argument(
305
+ '--performance-full',
306
+ action='store_true',
307
+ help='🏆 Complete performance analysis with validation and benchmarking'
308
+ )
309
+
310
+ # Legacy options (hidden from help but still functional)
311
+ scan_parser.add_argument(
312
+ '--code-only',
313
+ action='store_true',
314
+ help=argparse.SUPPRESS # Hidden legacy option
315
+ )
316
+ scan_parser.add_argument(
317
+ '--source-only',
318
+ action='store_true',
319
+ help=argparse.SUPPRESS # Hidden legacy option
320
+ )
321
+ scan_parser.add_argument(
322
+ '--privacy-only',
323
+ action='store_true',
324
+ help=argparse.SUPPRESS # Hidden legacy option
325
+ )
326
+ scan_parser.add_argument(
327
+ '--content-only',
328
+ action='store_true',
329
+ help=argparse.SUPPRESS # Hidden legacy option
330
+ )
331
+ scan_parser.add_argument(
332
+ '--no-privacy',
333
+ action='store_true',
334
+ help=argparse.SUPPRESS # Hidden legacy option
335
+ )
336
+ scan_parser.add_argument(
337
+ '--no-content',
338
+ action='store_true',
339
+ help=argparse.SUPPRESS # Hidden legacy option
340
+ )
341
+
342
+ # Output configuration
343
+ scan_parser.add_argument(
344
+ '--output-dir',
345
+ type=str,
346
+ default='.brass',
347
+ help='📤 Output directory for intelligence files (default: .brass)'
348
+ )
349
+ # Scanner-level parallelism (Perf #1). Default on; flag to disable.
350
+ scan_parser.add_argument(
351
+ '--no-parallel',
352
+ action='store_true',
353
+ help='Disable parallel scanner execution. Useful for debugging '
354
+ 'or for reproducing previous-baseline timings.',
355
+ )
356
+ scan_parser.add_argument(
357
+ '--max-workers',
358
+ type=int,
359
+ default=None,
360
+ help='Max parallel scanner workers (default: min(cpu_count-1, 6)). '
361
+ 'Lower this on resource-constrained machines.',
362
+ )
363
+ # Perf #4: incremental scanning. When set, Semgrep (and other
364
+ # incremental-aware scanners) restrict their scan to files changed
365
+ # since the given git commit. Big speedup for CI / per-PR flows.
366
+ scan_parser.add_argument(
367
+ '--since-commit',
368
+ type=str,
369
+ default=None,
370
+ metavar='COMMIT',
371
+ help='Incremental scan: only analyze files changed since this '
372
+ 'git commit (against HEAD). Falls back to full scan if not '
373
+ 'in a git repo or the diff fails.',
374
+ )
375
+ # Auto-incremental: use the prior scan's cached HEAD sha (or
376
+ # mtime) as the reference point. Same semantics as --since-commit
377
+ # but the developer doesn't have to know what commit they
378
+ # diverged from. Falls back to full scan if no prior cache.
379
+ scan_parser.add_argument(
380
+ '--incremental',
381
+ action='store_true',
382
+ help='Incremental scan against the prior cached scan state. '
383
+ 'On first scan or when cache is missing/stale, falls '
384
+ 'back to a full scan. File-local scanners (BrassPerf, '
385
+ 'bandit/pylint, secrets, privacy, content moderation, '
386
+ 'JS/TS) re-scan only changed files and merge with '
387
+ 'cached findings for unchanged files. Cross-file '
388
+ 'scanners (Pysa, ast-grep, AIContextCoherence) still '
389
+ 'full-scan for correctness.',
390
+ )
391
+ # Pysa interprocedural taint analysis. Soft-fails if pyre absent.
392
+ scan_parser.add_argument(
393
+ '--no-pysa',
394
+ action='store_true',
395
+ help='Skip the Pysa interprocedural taint scanner',
396
+ )
397
+ # ast-grep pattern analysis. Soft-fails if ast-grep is absent.
398
+ scan_parser.add_argument(
399
+ '--no-ast-grep',
400
+ action='store_true',
401
+ help='Skip the ast-grep pattern-match scanner',
402
+ )
403
+ # Semgrep-OSS taint analysis. Soft-fails if semgrep is absent.
404
+ scan_parser.add_argument(
405
+ '--no-semgrep',
406
+ action='store_true',
407
+ help='Skip the Semgrep-OSS taint scanner',
408
+ )
409
+ # AI enrichment (paid feature, gated by active license)
410
+ scan_parser.add_argument(
411
+ '--no-enrich',
412
+ action='store_true',
413
+ help='Skip the AI enrichment layer even with an active license '
414
+ '(forces heuristic-only noise filter; use for CI runs or '
415
+ 'when the gateway is unavailable)',
416
+ )
417
+ scan_parser.set_defaults(func=self._cmd_scan)
418
+
419
+ # Watch command with enhanced help
420
+ watch_parser = subparsers.add_parser(
421
+ 'watch',
422
+ help='👁️ Monitor files for changes and auto-analyze',
423
+ description='🎺 Continuous Monitoring - Watch your code and analyze changes in real-time'
424
+ )
425
+ watch_parser.add_argument(
426
+ '--poll-interval',
427
+ type=float,
428
+ default=2.0,
429
+ help='⏱️ How often to check for file changes (seconds, default: 2.0)'
430
+ )
431
+ watch_parser.add_argument(
432
+ '--debounce-delay',
433
+ type=float,
434
+ default=5.0,
435
+ help='🕐 Wait time before analyzing after changes stop (seconds, default: 5.0)'
436
+ )
437
+ watch_parser.set_defaults(func=self._cmd_watch)
438
+
439
+ # Status command with enhanced help
440
+ status_parser = subparsers.add_parser(
441
+ 'status',
442
+ help='📊 View last analysis results and statistics',
443
+ description='📊 Analysis Status - Review findings from your latest scan'
444
+ )
445
+ status_parser.set_defaults(func=self._cmd_status)
446
+
447
+ # Report command with enhanced help
448
+ report_parser = subparsers.add_parser(
449
+ 'report',
450
+ help='📄 Generate custom reports in different formats',
451
+ description='📄 Custom Reports - Generate targeted reports for specific needs'
452
+ )
453
+ report_parser.add_argument(
454
+ '--type',
455
+ choices=['security', 'privacy', 'quality', 'all'],
456
+ default='all',
457
+ help='🎯 Focus area: security, privacy, quality, or all (default: all)'
458
+ )
459
+ report_parser.add_argument(
460
+ '--format',
461
+ choices=['markdown', 'json', 'both'],
462
+ default='markdown',
463
+ help='📋 Output format: markdown, json, or both (default: markdown)'
464
+ )
465
+ report_parser.set_defaults(func=self._cmd_report)
466
+
467
+ # Filter command — apply BrassCoders noise reduction to a third-party AI
468
+ # reviewer's JSON output (Claude Code, Cursor, etc.).
469
+ filter_parser = subparsers.add_parser(
470
+ 'filter',
471
+ help='🪄 Filter an AI reviewer JSON payload through BrassCoders noise reduction',
472
+ description=(
473
+ 'Apply BrassCoders noise reduction to a list of AI-generated review '
474
+ 'findings. Reads JSON from --input or stdin, writes filtered '
475
+ 'JSON to --output or stdout. The input schema is documented in '
476
+ 'src/brass/filtering/ai_review_filter.py.'
477
+ )
478
+ )
479
+ filter_parser.add_argument('--input', '-i', type=str, default='-',
480
+ help='Path to input JSON (default: stdin).')
481
+ filter_parser.add_argument('--output', '-o', type=str, default='-',
482
+ help='Path to filtered JSON output (default: stdout).')
483
+ filter_parser.set_defaults(func=self._cmd_filter)
484
+
485
+ # Licensing commands. License management uses LemonSqueezy's License
486
+ # API (https://docs.lemonsqueezy.com/api/license-api). Three of the
487
+ # CLI's subcommands talk to LS:
488
+ # - brasscoders activate POST /v1/licenses/activate
489
+ # - brasscoders license POST /v1/licenses/validate (cached weekly)
490
+ # - brasscoders deactivate POST /v1/licenses/deactivate
491
+ # These are the ONLY commands that touch the network for license
492
+ # management. brasscoders scan / watch / filter / status / version stay
493
+ # offline-first and continue to honor --offline.
494
+ activate_parser = subparsers.add_parser(
495
+ 'activate',
496
+ help='🔑 Activate a BrassCoders license key',
497
+ description=(
498
+ 'Activate a BrassCoders license key (emailed at purchase or trial '
499
+ 'signup) on this machine. Calls LemonSqueezy to register '
500
+ 'the activation; persists the license_key + instance_id at '
501
+ '~/.brass/license (0600 perms). One activation per machine; '
502
+ 'use brasscoders deactivate to release the slot.'
503
+ )
504
+ )
505
+ activate_parser.add_argument(
506
+ 'token',
507
+ metavar='LICENSE_KEY',
508
+ help='The license key (UUID-like string from your LS purchase email)',
509
+ )
510
+ activate_parser.set_defaults(func=self._cmd_activate)
511
+
512
+ license_parser = subparsers.add_parser(
513
+ 'license',
514
+ help='🔍 Show current license status',
515
+ description=(
516
+ 'Display the active license. Re-validates against LemonSqueezy '
517
+ 'at most once per week to catch server-side revocations '
518
+ '(cancellations, refunds). Falls back to cached status if LS '
519
+ 'is unreachable.'
520
+ )
521
+ )
522
+ license_parser.set_defaults(func=self._cmd_license)
523
+
524
+ deactivate_parser = subparsers.add_parser(
525
+ 'deactivate',
526
+ help='🗑 Release the activation and remove the local record',
527
+ description=(
528
+ 'Release this machine\'s activation slot with LemonSqueezy '
529
+ 'and delete ~/.brass/license. BrassCoders continues to work in '
530
+ 'OSS-tier mode after this command.'
531
+ )
532
+ )
533
+ deactivate_parser.set_defaults(func=self._cmd_deactivate)
534
+
535
+ portal_parser = subparsers.add_parser(
536
+ 'portal',
537
+ help='🌐 Open the LemonSqueezy customer portal for this license',
538
+ description=(
539
+ 'Open the LemonSqueezy customer portal in your browser to '
540
+ 'manage your subscription, update card, view invoices, '
541
+ 'cancel, etc. Requires an active license activated on this '
542
+ 'machine. The portal URL is fetched fresh each time (LS '
543
+ 'session URLs are signed + short-lived).'
544
+ )
545
+ )
546
+ portal_parser.set_defaults(func=self._cmd_portal)
547
+
548
+ # Telemetry consent management. Off by default. We track only
549
+ # anonymized usage counts (scan event + finding-type counts +
550
+ # version + platform). Source code, paths, PII never leave the
551
+ # machine.
552
+ telemetry_parser = subparsers.add_parser(
553
+ 'telemetry',
554
+ help='📊 Manage opt-in anonymized telemetry',
555
+ description=(
556
+ 'Telemetry is OFF by default. When on, BrassCoders sends '
557
+ 'anonymized usage counts (scan events, finding-type '
558
+ 'distribution, CLI version, OS) to the configured backend. '
559
+ 'Source code, paths, emails, and stack traces never leave '
560
+ 'your machine. Inspect what would be sent at '
561
+ '~/.brass/telemetry-debug.log.'
562
+ )
563
+ )
564
+ telemetry_parser.add_argument(
565
+ 'action',
566
+ choices=['on', 'off', 'status'],
567
+ help="'on' opts in, 'off' opts out, 'status' shows current state"
568
+ )
569
+ telemetry_parser.set_defaults(func=self._cmd_telemetry)
570
+
571
+ # Cache management. Surfaces a CLI escape hatch for the on-disk
572
+ # caches BrassCoders writes under ~/.cache/brass/ — primarily the Pysa
573
+ # state cache (10-300 MB per scanned project), optionally the
574
+ # auto-fetched typeshed bundle. See docs/CACHE.md.
575
+ cache_parser = subparsers.add_parser(
576
+ 'cache',
577
+ help='🧹 Manage BrassCoders on-disk caches',
578
+ description=(
579
+ 'Manage the Pysa state cache (~/.cache/brass/pysa-state/) '
580
+ 'and the optional typeshed cache (~/.cache/brass/typeshed/). '
581
+ 'See docs/CACHE.md for the full lifecycle.'
582
+ ),
583
+ )
584
+ cache_parser.add_argument(
585
+ 'action',
586
+ choices=['clear'],
587
+ help="'clear' removes cached state and frees the disk space",
588
+ )
589
+ cache_parser.add_argument(
590
+ '--include-typeshed',
591
+ action='store_true',
592
+ help='Also remove the auto-fetched typeshed cache '
593
+ '(~/.cache/brass/typeshed/). BRASS_TYPESHED-redirected '
594
+ 'paths are user-owned and left untouched.',
595
+ )
596
+ cache_parser.add_argument(
597
+ '--dry-run',
598
+ action='store_true',
599
+ help='Report what would be removed without removing it',
600
+ )
601
+ cache_parser.set_defaults(func=self._cmd_cache)
602
+
603
+ # Version command with enhanced help
604
+ version_parser = subparsers.add_parser(
605
+ 'version',
606
+ help='ℹ️ Show version and component information',
607
+ description='ℹ️ System Information - Version details and component status'
608
+ )
609
+ version_parser.set_defaults(func=self._cmd_version)
610
+
611
+ return parser
612
+
613
+ def _configure_logging(self, verbose: bool, log_file: Optional[str] = None,
614
+ no_log_file: bool = False, output_dir: str = '.brass') -> None:
615
+ """Configure logging with automatic log file creation."""
616
+ from pathlib import Path
617
+
618
+ # Determine log file path
619
+ actual_log_file = None
620
+ if not no_log_file:
621
+ if log_file:
622
+ actual_log_file = Path(log_file)
623
+ else:
624
+ # Default: create brass.log in output directory
625
+ actual_log_file = Path(output_dir) / 'brass.log'
626
+
627
+ BrassLogger.setup_logging(verbose=verbose, log_file=actual_log_file)
628
+
629
+ # Get logger after setup to ensure proper configuration
630
+ session_logger = get_logger('brass.cli')
631
+ if actual_log_file:
632
+ session_logger.info(f"BrassCoders logging started - session {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
633
+ session_logger.debug(f"Logging configured: verbose={verbose}, log_file={actual_log_file}")
634
+ else:
635
+ session_logger.debug(f"Logging configured: verbose={verbose}, no log file")
636
+
637
+ def _validate_environment(self, project_path: Path) -> None:
638
+ """
639
+ Validate project environment before scanning with production hardening.
640
+
641
+ Args:
642
+ project_path: Path to project directory
643
+ """
644
+ logger.info("Validating project environment...")
645
+
646
+ # Check git repository health. _check_git_health returns a tri-state
647
+ # via (available, reason) so we can warn only on the real "corrupted"
648
+ # case — a non-git directory is a normal scan target and shouldn't
649
+ # produce a misleading "features disabled" warning. (As of 2026-05-18
650
+ # no downstream code actually consumes git_available; the flag is
651
+ # reserved for future file-author / temporal-weighting features.)
652
+ self.features['git_available'], git_status = self._check_git_health(project_path)
653
+
654
+ # Check for symlinks
655
+ self.features['symlinks_present'] = self._check_symlinks(project_path)
656
+
657
+ # Check project size
658
+ self.features['large_project'] = self._check_project_size(project_path)
659
+
660
+ # Report environment status
661
+ if git_status == 'corrupted':
662
+ logger.warning("⚠️ Git repository present but unhealthy (timeout or git error) — file-history features will be unavailable when wired up")
663
+ elif git_status == 'absent':
664
+ logger.debug("No .git directory at scan root; file-history features inert (this is normal for subdirectory or tarball scans)")
665
+ if self.features['symlinks_present']:
666
+ logger.info("ℹ️ Symbolic links detected - loop protection enabled")
667
+ if self.features['large_project']:
668
+ logger.warning("⚠️ Large project detected - resource limits will be applied")
669
+
670
+ def _check_git_health(self, project_path: Path) -> Tuple[bool, str]:
671
+ """
672
+ Check if git repository is healthy and accessible.
673
+
674
+ We sandbox the git subprocess against CVE-2022-24765 (malicious .git/config in
675
+ a fuzzy-owned repo) and related issues by clearing inherited git config and
676
+ suppressing prompts. The check itself is informational — if git refuses, we
677
+ gracefully degrade.
678
+
679
+ Returns:
680
+ (available, reason) where:
681
+ available = True when git is usable here
682
+ reason in {'ok', 'absent', 'corrupted', 'git_missing'}
683
+ Callers use `reason` to differentiate "not a git repo" (normal, debug)
684
+ from "broken git state" (worth warning about).
685
+ """
686
+ try:
687
+ git_dir = project_path / '.git'
688
+ if not git_dir.exists():
689
+ return False, 'absent'
690
+
691
+ sandboxed_env = os.environ.copy()
692
+ sandboxed_env['GIT_CONFIG_GLOBAL'] = '/dev/null'
693
+ sandboxed_env['GIT_CONFIG_SYSTEM'] = '/dev/null'
694
+ sandboxed_env['GIT_CONFIG_NOSYSTEM'] = '1'
695
+ sandboxed_env['GIT_TERMINAL_PROMPT'] = '0'
696
+ sandboxed_env['GIT_ASKPASS'] = '/bin/true'
697
+ sandboxed_env.pop('GIT_DIR', None)
698
+ sandboxed_env.pop('GIT_WORK_TREE', None)
699
+
700
+ result = subprocess.run(
701
+ ['git', 'status', '--porcelain'],
702
+ cwd=project_path,
703
+ capture_output=True,
704
+ timeout=5,
705
+ text=True,
706
+ env=sandboxed_env,
707
+ )
708
+
709
+ if result.returncode == 0:
710
+ logger.debug("Git repository validated successfully")
711
+ return True, 'ok'
712
+ logger.debug(f"Git repository validation failed: {result.stderr}")
713
+ return False, 'corrupted'
714
+
715
+ except subprocess.TimeoutExpired:
716
+ logger.debug("Git operation timed out — repository may be corrupted")
717
+ return False, 'corrupted'
718
+ except (subprocess.SubprocessError, FileNotFoundError):
719
+ logger.debug("Git command not available")
720
+ return False, 'git_missing'
721
+ except Exception as e:
722
+ logger.debug(f"Git validation error: {e}")
723
+ return False, 'corrupted'
724
+
725
+ def _check_symlinks(self, project_path: Path) -> bool:
726
+ """Check if project contains symbolic links."""
727
+ try:
728
+ # Quick check for any symlinks in top-level directories
729
+ for item in project_path.iterdir():
730
+ if item.is_symlink():
731
+ return True
732
+
733
+ # Check first few subdirectories
734
+ for root, dirs, files in os.walk(project_path):
735
+ depth = len(Path(root).relative_to(project_path).parts)
736
+ if depth > 2: # Only check first 2 levels for performance
737
+ break
738
+
739
+ for name in dirs + files:
740
+ path = Path(root) / name
741
+ if path.is_symlink():
742
+ return True
743
+
744
+ return False
745
+ except Exception as e:
746
+ logger.debug(f"Symlink check error: {e}")
747
+ return False
748
+
749
+ def _check_project_size(self, project_path: Path) -> bool:
750
+ """Check if project is large (>10K files or >1GB)."""
751
+ try:
752
+ file_count = 0
753
+ total_size = 0
754
+
755
+ for root, dirs, files in os.walk(project_path):
756
+ file_count += len(files)
757
+
758
+ # Early exit if already large
759
+ if file_count > 10000:
760
+ return True
761
+
762
+ # Sample size calculation (check every 100th file)
763
+ if file_count % 100 == 0:
764
+ for f in files[:1]: # Sample one file
765
+ try:
766
+ total_size += (Path(root) / f).stat().st_size
767
+ except:
768
+ pass
769
+
770
+ # Extrapolate total size
771
+ estimated_size = total_size * 100 if file_count > 0 else 0
772
+
773
+ return file_count > 10000 or estimated_size > 1_000_000_000
774
+
775
+ except Exception as e:
776
+ logger.debug(f"Project size check error: {e}")
777
+ return False
778
+
779
+ def _initialize_components(self, project_path: str, output_dir: str = '.brass',
780
+ *, check_package_hallucination: bool = False) -> None:
781
+ """Initialize all system components.
782
+
783
+ Args:
784
+ project_path: Project root.
785
+ output_dir: Output directory.
786
+ check_package_hallucination: Pass-through for the API security scanner;
787
+ must be opted into explicitly because it triggers outbound HTTPS
788
+ calls that leak imported package names.
789
+ """
790
+ if not self.code_scanner:
791
+ self.code_scanner = ProfessionalCodeScanner(project_path)
792
+
793
+ if not self.brass2_privacy_scanner:
794
+ self.brass2_privacy_scanner = Brass2PrivacyScanner(project_path)
795
+
796
+ if not self.content_moderation_scanner:
797
+ self.content_moderation_scanner = ContentModerationScanner(project_path)
798
+
799
+ if not self.javascript_typescript_scanner:
800
+ try:
801
+ self.javascript_typescript_scanner = JavaScriptTypeScriptScanner(project_path)
802
+ except Exception as e:
803
+ logger.warning(f"JavaScript/TypeScript scanner unavailable: {e}")
804
+ self.javascript_typescript_scanner = None
805
+
806
+ if not self.phantom_ai_code_scanner:
807
+ self.phantom_ai_code_scanner = PhantomAICodeScanner(project_path)
808
+
809
+ if not self.brass_performance_scanner:
810
+ self.brass_performance_scanner = BrassPerformanceScanner(project_path)
811
+
812
+ if not self.api_security_scanner:
813
+ self.api_security_scanner = APISecurityScanner(
814
+ project_path,
815
+ check_package_hallucination=check_package_hallucination,
816
+ )
817
+
818
+ if not self.ai_context_coherence_scanner:
819
+ self.ai_context_coherence_scanner = AIContextCoherenceScanner(project_path)
820
+
821
+ if not self.secrets_scanner:
822
+ try:
823
+ self.secrets_scanner = SecretsScanner(project_path)
824
+ except Exception as e:
825
+ logger.warning(f"Secrets scanner unavailable: {e}")
826
+ self.secrets_scanner = None
827
+
828
+ if not self.semgrep_taint_scanner:
829
+ try:
830
+ self.semgrep_taint_scanner = SemgrepTaintScanner(project_path)
831
+ except Exception as e:
832
+ logger.warning(f"Semgrep taint scanner unavailable: {e}")
833
+ self.semgrep_taint_scanner = None
834
+
835
+ if not self.ast_grep_scanner:
836
+ try:
837
+ self.ast_grep_scanner = AstGrepScanner(project_path)
838
+ except Exception as e:
839
+ logger.warning(f"ast-grep scanner unavailable: {e}")
840
+ self.ast_grep_scanner = None
841
+
842
+ if not self.pysa_taint_scanner:
843
+ try:
844
+ self.pysa_taint_scanner = PysaTaintScanner(project_path)
845
+ except Exception as e:
846
+ logger.warning(f"Pysa taint scanner unavailable: {e}")
847
+ self.pysa_taint_scanner = None
848
+
849
+ if not self.ranker:
850
+ # Pass project_path so the ranker can apply framework-aware
851
+ # severity adjustments (Capability 1 of the algorithmic plan).
852
+ self.ranker = IntelligenceRanker(project_path=str(self.project_path))
853
+
854
+ if not self.output_generator:
855
+ self.output_generator = YAMLOutputGeneratorV2(project_path, output_dir, self.ranker)
856
+
857
+ def _filter_findings_for_developer_mode(self, findings: List[Finding]) -> List[Finding]:
858
+ """
859
+ Filter findings to show only source code issues for developer focus.
860
+
861
+ Uses Smart File Classification data to exclude test files, fixtures,
862
+ build artifacts, and other non-production code findings.
863
+
864
+ Args:
865
+ findings: List of all findings from scanners
866
+
867
+ Returns:
868
+ List of findings filtered to source code only
869
+ """
870
+ source_code_findings = []
871
+
872
+ for finding in findings:
873
+ # Safe metadata access with validation
874
+ file_context = {}
875
+ if (hasattr(finding, 'metadata') and
876
+ isinstance(finding.metadata, dict)):
877
+ file_context = finding.metadata.get('file_context', {})
878
+
879
+ is_source_code = file_context.get('is_source_code', False) if isinstance(file_context, dict) else False
880
+
881
+ # Include only findings from actual source code files
882
+ if is_source_code:
883
+ source_code_findings.append(finding)
884
+
885
+ return source_code_findings
886
+
887
+ def _validate_project_path(self, args) -> Optional[Path]:
888
+ """
889
+ Validate and resolve the project path from arguments.
890
+
891
+ Args:
892
+ args: Command line arguments
893
+
894
+ Returns:
895
+ Resolved project path or None if invalid
896
+ """
897
+ # For scan command, use the positional 'path' argument
898
+ # For other commands, use 'project_path'
899
+ scan_path = getattr(args, 'path', None)
900
+ if scan_path is None:
901
+ scan_path = getattr(args, 'project_path', '.')
902
+
903
+ logger.debug(f"Path resolution: args.path={getattr(args, 'path', None)}, args.project_path={getattr(args, 'project_path', None)}, selected={scan_path}")
904
+
905
+ project_path = Path(scan_path).resolve()
906
+ logger.debug(f"Path resolved: {scan_path} -> {project_path}")
907
+
908
+ if not project_path.exists():
909
+ print(f"❌ Project path does not exist: {project_path}")
910
+ logger.warning(f"Project path validation failed: {project_path} does not exist")
911
+ return None
912
+
913
+ logger.info(f"Project path validated: {project_path}")
914
+ return project_path
915
+
916
+ def _print_scan_header(self, project_path: Path, output_dir: str, args) -> None:
917
+ """Print scan header information."""
918
+ print(f"🎺 Copper Sun Brass v2.0 - Scanning {project_path.name}")
919
+ print(f"📁 Project: {project_path}")
920
+ print(f"📤 Output: {project_path / output_dir}")
921
+
922
+ # Show scan mode information
923
+ if getattr(args, 'fast', False):
924
+ print("⚡ Mode: Fast scan (code analysis only)")
925
+ elif getattr(args, 'dev', False):
926
+ print("👨‍💻 Mode: Developer focus (source code only)")
927
+ elif getattr(args, 'code', False):
928
+ print("🐛 Mode: Code analysis")
929
+ elif getattr(args, 'privacy', False):
930
+ print("🔒 Mode: Privacy analysis")
931
+ elif getattr(args, 'content', False):
932
+ print("🚫 Mode: Content moderation")
933
+ else:
934
+ print("🎯 Mode: Complete analysis")
935
+ print()
936
+
937
+ @staticmethod
938
+ def _record_peak_rss_mb() -> Optional[float]:
939
+ """Best-effort peak resident-set-size in MB across the parent
940
+ Python process AND any waited-on subprocesses (pysa, semgrep,
941
+ ast-grep, bandit). Returns None on unsupported platforms
942
+ (Windows resource module is absent).
943
+
944
+ Why include children: brass spawns multiple scanner subprocesses
945
+ whose RSS is invisible to ``RUSAGE_SELF``. On a 2,821-file
946
+ Django scan (2026-05-20), ``RUSAGE_SELF`` reported 181 MB while
947
+ ``time -l`` (which observes the largest child) reported 1,749
948
+ MB — a 10x undercount that misleads customers about the real
949
+ memory cost of a scan. Including ``RUSAGE_CHILDREN`` closes
950
+ that gap.
951
+
952
+ Caveat: ``RUSAGE_CHILDREN.ru_maxrss`` is the MAX peak of any
953
+ single waited child, not the sum across concurrent children.
954
+ For brass that's the right number — the dominant scanner
955
+ (typically pysa on Python codebases) sets the floor on RAM
956
+ needed to run a scan. We report ``max(self, children)``
957
+ because either could dominate depending on the workload.
958
+
959
+ macOS ru_maxrss is in BYTES; Linux/BSD in KIBIBYTES. The unit
960
+ difference is documented in ``getrusage(2)`` and trips most
961
+ callers. Dispatch on ``sys.platform`` rather than the value
962
+ magnitude.
963
+ """
964
+ try:
965
+ import resource
966
+ except ImportError:
967
+ return None
968
+ import sys
969
+ rss_self = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
970
+ rss_children = resource.getrusage(resource.RUSAGE_CHILDREN).ru_maxrss
971
+ rss = max(rss_self, rss_children)
972
+ if sys.platform == "darwin":
973
+ return rss / (1024 * 1024)
974
+ return rss / 1024
975
+
976
+ def _persist_scanner_timings(self, project_path, output_dir) -> None:
977
+ """Write per-scanner wall-time observability to disk.
978
+
979
+ Reads from `self._scanner_timings` (set inside _run_analysis_workflow).
980
+ Single write-point so a future scanner added at the end of the
981
+ pipeline doesn't accidentally get omitted from the timings file.
982
+ """
983
+ timings = getattr(self, "_scanner_timings", None)
984
+ if not timings:
985
+ return
986
+ # Perf #10: include peak-RSS (memory) observability alongside
987
+ # per-scanner timing. Stored under "_meta" so the benchmark
988
+ # harness can pick it up without colliding with scanner names.
989
+ peak_mb = self._record_peak_rss_mb()
990
+ payload = dict(timings)
991
+ if peak_mb is not None:
992
+ payload["_meta_peak_rss_mb"] = round(peak_mb, 1)
993
+ # Loose end #8: per-scanner status (ok/skipped/errored + reason).
994
+ # Same `_meta_*` prefix convention as _meta_peak_rss_mb so the
995
+ # benchmark harness's existing filter ignores it cleanly.
996
+ if self._scanner_status:
997
+ payload["_meta_scanner_status"] = {
998
+ name: s.to_dict() for name, s in self._scanner_status.items()
999
+ }
1000
+ out_dir = Path(project_path) / (output_dir or ".brass")
1001
+ out_dir.mkdir(parents=True, exist_ok=True)
1002
+ (out_dir / "scanner_timings.json").write_text(
1003
+ json.dumps(payload, indent=2, sort_keys=True)
1004
+ )
1005
+
1006
+ def _scanner_for(self, name: str):
1007
+ """Map an orchestration scanner name to the live scanner instance.
1008
+
1009
+ Used by _run_scanner_task to read scanner.last_run_status after
1010
+ scan() completes. The keys here must stay in sync with the names
1011
+ passed to _add() in _run_analysis_workflow and with the attribute
1012
+ names initialized in _initialize_components — if the orchestration
1013
+ name drifts from the attribute name, last_run_status surfacing
1014
+ silently degrades to "ok" (orchestrator default).
1015
+ """
1016
+ mapping = {
1017
+ "code": self.code_scanner,
1018
+ "privacy": self.brass2_privacy_scanner,
1019
+ "content_moderation": self.content_moderation_scanner,
1020
+ "javascript_typescript": self.javascript_typescript_scanner,
1021
+ "phantom_ai": self.phantom_ai_code_scanner,
1022
+ "brass_performance": self.brass_performance_scanner,
1023
+ "api_security": self.api_security_scanner,
1024
+ "pysa_taint": self.pysa_taint_scanner,
1025
+ "ast_grep": self.ast_grep_scanner,
1026
+ "semgrep_taint": self.semgrep_taint_scanner,
1027
+ "secrets": self.secrets_scanner,
1028
+ "ai_context_coherence": self.ai_context_coherence_scanner,
1029
+ }
1030
+ return mapping.get(name)
1031
+
1032
+ @staticmethod
1033
+ def _print_scanner_result(
1034
+ banner: str,
1035
+ findings: list,
1036
+ exc: Optional[Exception],
1037
+ status: "ScannerStatus",
1038
+ ) -> None:
1039
+ """Format the per-scanner console result line.
1040
+
1041
+ On exception: prefix `⚠️`, label "errored" with the exception.
1042
+ On scanner-reported skipped/errored: prefix `⚠️`, suffix the reason.
1043
+ On ok: prefix `✓`, show finding count.
1044
+ """
1045
+ if exc is not None:
1046
+ logger.warning("%s analysis failed: %s", banner, exc)
1047
+ print(f" ⚠️ {banner}: errored ({exc})")
1048
+ return
1049
+ if status.status == "skipped":
1050
+ print(f" ⚠️ {banner}: 0 findings (skipped: {status.reason})")
1051
+ elif status.status == "errored":
1052
+ print(f" ⚠️ {banner}: 0 findings (errored: {status.reason})")
1053
+ else:
1054
+ print(f" ✓ {banner}: {len(findings)} findings")
1055
+
1056
+ def _run_analysis_workflow(self, args) -> List[Finding]:
1057
+ """
1058
+ Run the complete analysis workflow using Brass2-compliant hybrid filtering.
1059
+
1060
+ Args:
1061
+ args: Command line arguments
1062
+
1063
+ Returns:
1064
+ List of all findings from enabled scanners with noise reduction applied
1065
+ """
1066
+ # Top-level workflow wall-clock — used to surface analysis_duration
1067
+ # in statistics.yaml's performance_metrics. Stored on self so
1068
+ # downstream methods like _generate_output (called from a sibling
1069
+ # method, not nested) can read it without threading the value
1070
+ # through several call sites.
1071
+ self._scan_workflow_t0 = time.monotonic()
1072
+
1073
+ # Per-scanner wall-time instrumentation. Each scanner block writes
1074
+ # one entry. Dumped to .brass/scanner_timings.json at the end so
1075
+ # the benchmark harness can attribute speedups to specific scanners.
1076
+ scanner_timings: Dict[str, float] = {}
1077
+
1078
+ @contextmanager
1079
+ def time_scanner(name: str):
1080
+ t0 = time.monotonic()
1081
+ try:
1082
+ yield
1083
+ finally:
1084
+ scanner_timings[name] = round(time.monotonic() - t0, 3)
1085
+
1086
+ # Phase 1: File prefiltering (Brass2-compliant deterministic)
1087
+ print("📁 Running file prefiltering...")
1088
+ with time_scanner("file_prefilter"):
1089
+ prefilter = FilePrefilterScanner(str(self.project_path))
1090
+ files_to_analyze = prefilter.scan()
1091
+ print(f" Files selected for analysis: {len(files_to_analyze)}")
1092
+
1093
+ # Shared file enumeration cache. Migrated scanners (Pysa, Semgrep,
1094
+ # ast-grep so far) read from this instead of re-walking the tree.
1095
+ # Built lazily on first access, but we eagerly build here so its
1096
+ # one-time walk time is recorded as a discrete timing entry rather
1097
+ # than charged to whichever scanner happens to ask first.
1098
+ #
1099
+ # Injection contract: scanners are instantiated in
1100
+ # `_initialize_components` (before this workflow runs) with
1101
+ # file_index=None. We inject the populated cache here as an
1102
+ # attribute assignment. Any code path that calls a migrated
1103
+ # scanner's `scan()` BEFORE this assignment will silently fall
1104
+ # back to the per-scanner rglob walk — correct, just slower. If
1105
+ # parallelism (Opt #1) ever pre-fetches scanner output before
1106
+ # `_run_analysis_workflow` runs, this contract needs revisiting.
1107
+ with time_scanner("file_index"):
1108
+ file_index = FileIndex(self.project_path)
1109
+ file_index.build()
1110
+ # Inject the shared cache into every scanner that has been
1111
+ # migrated to honor it. Scanners not in this list still work
1112
+ # via their per-scanner rglob fallback path.
1113
+ for scanner in (
1114
+ self.pysa_taint_scanner,
1115
+ self.semgrep_taint_scanner,
1116
+ self.ast_grep_scanner,
1117
+ self.phantom_ai_code_scanner,
1118
+ self.brass_performance_scanner,
1119
+ self.api_security_scanner,
1120
+ self.ai_context_coherence_scanner,
1121
+ ):
1122
+ if scanner is not None:
1123
+ scanner.file_index = file_index
1124
+
1125
+ # Perf #4: incremental mode. Propagate the user-supplied baseline
1126
+ # commit to scanners that support it (currently semgrep).
1127
+ since_commit = getattr(args, 'since_commit', None)
1128
+ if since_commit and self.semgrep_taint_scanner is not None:
1129
+ self.semgrep_taint_scanner.since_commit = since_commit
1130
+
1131
+ # Incremental scan setup (--incremental flag, 2026-05-19). The
1132
+ # idea: file-local scanners re-scan only changed files, cached
1133
+ # findings for unchanged files come from .brass/finding_cache.json
1134
+ # produced by the prior scan. Cross-file scanners always full-scan
1135
+ # for correctness. Falls back to a full scan on:
1136
+ # - first run (no cache file)
1137
+ # - schema mismatch / unreadable cache
1138
+ # - both git-diff AND mtime detection failing
1139
+ # so the user never silently gets partial output.
1140
+ output_dir_for_cache = getattr(args, 'output_dir', '.brass')
1141
+ incremental_mode = bool(getattr(args, 'incremental', False))
1142
+ cached_findings_by_scanner: Dict[str, list] = {}
1143
+ changed_files_set: Set[str] = set()
1144
+ incremental_active = False
1145
+ # File-local scanners read from this list. Default: same as the
1146
+ # full prefilter result (no narrowing). Incremental mode below
1147
+ # may overwrite it with the changed-files intersection.
1148
+ file_local_scan_files: List[str] = files_to_analyze
1149
+ if incremental_mode:
1150
+ cache_file_path = _finding_cache.cache_path(self.project_path, output_dir_for_cache)
1151
+ cache_payload = _finding_cache.read_cache(cache_file_path)
1152
+ if cache_payload is None:
1153
+ print(" --incremental: no prior cache; running full scan to seed it.")
1154
+ else:
1155
+ last_sha = cache_payload.get("last_scan_head_sha")
1156
+ last_at = cache_payload.get("last_scan_at")
1157
+ changed: Optional[Set[str]] = None
1158
+ # Prefer git-based detection when we have a prior HEAD ref.
1159
+ if last_sha:
1160
+ changed = _change_detection.files_changed_since_commit(
1161
+ self.project_path, last_sha,
1162
+ )
1163
+ if changed is None and last_at:
1164
+ changed = _change_detection.files_changed_since_mtime(
1165
+ self.project_path, last_at,
1166
+ )
1167
+ if changed is None:
1168
+ print(
1169
+ " --incremental: change detection failed (not a git "
1170
+ "repo and mtime fallback errored); running full scan."
1171
+ )
1172
+ else:
1173
+ changed_files_set = _change_detection.normalize_changed_files(changed)
1174
+ cached_findings_by_scanner = cache_payload["findings_by_scanner"]
1175
+ incremental_active = True
1176
+ # Two file lists from here on:
1177
+ # * files_to_analyze — the FULL prefilter result. Used
1178
+ # by cross-file scanners that need the whole graph
1179
+ # (AIContextCoherence accepts file_paths but its
1180
+ # analysis is project-wide).
1181
+ # * file_local_scan_files — the changed-file slice.
1182
+ # Used by per-file scanners (BrassPerf, secrets,
1183
+ # code, privacy, content_moderation, JS/TS) so
1184
+ # they skip the work that the cache will replay.
1185
+ file_local_scan_files = [
1186
+ fp for fp in files_to_analyze if fp in changed_files_set
1187
+ ]
1188
+ print(
1189
+ f" --incremental: {len(changed_files_set)} changed file(s) "
1190
+ f"detected; {len(file_local_scan_files)} after prefilter "
1191
+ f"intersection. Reusing cached findings for unchanged files."
1192
+ )
1193
+
1194
+ # Per-language gates (Perf #3). A scanner that can only analyze
1195
+ # files of language X has no work to do on a project with zero
1196
+ # files of language X. Skipping at the workflow level avoids the
1197
+ # scanner's cold-start cost (subprocess spawn, model load, etc.).
1198
+ # Each scanner that has a real cold-start (Bandit, Pyre/Pysa,
1199
+ # ast-grep, Babel/Node) saves a few seconds.
1200
+ _JS_TS_EXTS = (".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs")
1201
+ has_python = bool(file_index.files_with_ext(".py"))
1202
+ has_jsts = bool(file_index.files_with_ext(*_JS_TS_EXTS))
1203
+
1204
+ # Phase 2: Analysis on prefiltered files
1205
+ all_findings = []
1206
+
1207
+ # Translate user-friendly aliases to legacy flags for internal logic
1208
+ self._translate_user_friendly_flags(args)
1209
+
1210
+ # Build the scanner task list. Each entry: (scanner_name, callable,
1211
+ # is_enabled_predicate). The predicates encode the existing gate
1212
+ # conditions; callables are zero-arg closures that perform the scan
1213
+ # and return a list of findings.
1214
+ scanner_tasks: List[Tuple[str, str, Callable[[], list]]] = []
1215
+
1216
+ def _add(name: str, banner: str, fn: Callable[[], list], enabled: bool) -> None:
1217
+ if enabled:
1218
+ scanner_tasks.append((name, banner, fn))
1219
+
1220
+ # File-local scanners read from file_local_scan_files. In
1221
+ # --incremental mode that's the narrowed changed-file slice; in
1222
+ # normal scans it's the full prefilter result. Routing through
1223
+ # _run_scanner_with_files (instead of calling .scan() directly)
1224
+ # gives every scanner the cache-narrowed view without per-
1225
+ # scanner code changes. 2026-05-19 incremental MVP.
1226
+ _add("code", "🔍 code analysis",
1227
+ lambda: self._run_scanner_with_files(self.code_scanner, file_local_scan_files),
1228
+ not (args.privacy_only or args.content_only) and has_python)
1229
+
1230
+ _add("privacy", "🔒 privacy analysis",
1231
+ lambda: self._run_scanner_with_files(self.brass2_privacy_scanner, file_local_scan_files),
1232
+ not (args.code_only or args.content_only or getattr(args, 'no_privacy', False)))
1233
+
1234
+ _add("content_moderation", "🚫 content moderation",
1235
+ lambda: self._run_scanner_with_files(
1236
+ self._ensure_component_initialized('content_moderation_scanner'),
1237
+ file_local_scan_files,
1238
+ ),
1239
+ not (args.code_only or args.privacy_only or getattr(args, 'no_content', False)))
1240
+
1241
+ # JavaScript/TypeScript scanner: skip on pure-Python projects.
1242
+ if self.javascript_typescript_scanner:
1243
+ _add("javascript_typescript", "🟨 JavaScript/TypeScript analysis",
1244
+ lambda: self._run_scanner_with_files(
1245
+ self.javascript_typescript_scanner, file_local_scan_files,
1246
+ ),
1247
+ not (args.privacy_only or args.content_only) and has_jsts)
1248
+
1249
+ # Phantom AI scanner is Python-only (AST patterns).
1250
+ if self.phantom_ai_code_scanner:
1251
+ _add("phantom_ai", "👻 Phantom AI Code analysis",
1252
+ lambda: self.phantom_ai_code_scanner.scan(),
1253
+ not (args.privacy_only or args.content_only) and has_python)
1254
+
1255
+ # BrassPerf scanner is Python-only. File-local — use the
1256
+ # narrowed scan list in --incremental mode.
1257
+ if self.brass_performance_scanner:
1258
+ _add("brass_performance", "🏆 BrassPerf Performance",
1259
+ lambda: self._run_scanner_with_files(
1260
+ self.brass_performance_scanner, file_local_scan_files,
1261
+ ),
1262
+ not (args.privacy_only or args.content_only) and has_python)
1263
+
1264
+ # API security: Python + JS/TS. Skip only when both languages absent.
1265
+ if self.api_security_scanner:
1266
+ _add("api_security", "🔐 API Security",
1267
+ lambda: self.api_security_scanner.scan(),
1268
+ not (args.privacy_only or args.content_only) and (has_python or has_jsts))
1269
+
1270
+ # Pysa is Python-only by construction; already has an internal
1271
+ # `_has_python_sources()` check but gating here saves a process spawn.
1272
+ if self.pysa_taint_scanner:
1273
+ _add("pysa_taint", "🧠 Pysa interprocedural taint",
1274
+ lambda: self.pysa_taint_scanner.scan(),
1275
+ not (args.privacy_only or args.content_only)
1276
+ and not getattr(args, 'no_pysa', False)
1277
+ and has_python)
1278
+
1279
+ # ast-grep + semgrep span Python + JS/TS.
1280
+ if self.ast_grep_scanner:
1281
+ _add("ast_grep", "🔎 ast-grep pattern",
1282
+ lambda: self.ast_grep_scanner.scan(),
1283
+ not (args.privacy_only or args.content_only)
1284
+ and not getattr(args, 'no_ast_grep', False)
1285
+ and (has_python or has_jsts))
1286
+
1287
+ if self.semgrep_taint_scanner:
1288
+ _add("semgrep_taint", "🧪 Semgrep taint",
1289
+ lambda: self.semgrep_taint_scanner.scan(),
1290
+ not (args.privacy_only or args.content_only)
1291
+ and not getattr(args, 'no_semgrep', False)
1292
+ and (has_python or has_jsts))
1293
+
1294
+ # Secrets scanner is file-local — narrow to changed files when
1295
+ # --incremental is active.
1296
+ if self.secrets_scanner:
1297
+ _add("secrets", "🔑 Secrets detection",
1298
+ lambda: self._run_scanner_with_files(
1299
+ self.secrets_scanner, file_local_scan_files,
1300
+ ),
1301
+ not (args.privacy_only or args.content_only))
1302
+
1303
+ # AI Context Coherence is Python-only (analyzes class/import graphs).
1304
+ if self.ai_context_coherence_scanner:
1305
+ _add("ai_context_coherence", "🧠 AI Context Coherence",
1306
+ lambda: self.ai_context_coherence_scanner.scan(),
1307
+ not (args.privacy_only or args.content_only) and has_python)
1308
+
1309
+ # Drift defense: warn if any scheduled scanner name has no entry in
1310
+ # _scanner_for(). Such drift would silently degrade last_run_status
1311
+ # surfacing to "ok" — scanner could fail silently and no one would
1312
+ # notice. Soft warning so the scan still runs; the customer-facing
1313
+ # output just won't flag this particular scanner's skip reasons.
1314
+ for _name, _banner, _fn in scanner_tasks:
1315
+ if self._scanner_for(_name) is None:
1316
+ logger.warning(
1317
+ "Scanner '%s' has no _scanner_for() mapping. Its "
1318
+ "last_run_status will silently degrade to 'ok'. "
1319
+ "Update _scanner_for() in brass_cli.py.",
1320
+ _name,
1321
+ )
1322
+
1323
+ # Execute scanners. Parallel by default; --no-parallel falls back to
1324
+ # sequential (useful for debugging / reproducing timing baselines).
1325
+ # ThreadPoolExecutor chosen over ProcessPoolExecutor because most
1326
+ # bottleneck scanners (semgrep, pysa, ast-grep, bandit-via-code)
1327
+ # shell out to subprocess; threads release the GIL during the
1328
+ # subprocess.run wait, so workers overlap naturally. Pure-Python
1329
+ # scanners (privacy, content_moderation, etc.) share the GIL but
1330
+ # their wall time is small.
1331
+ use_parallel = not getattr(args, 'no_parallel', False)
1332
+ max_workers = getattr(args, 'max_workers', None)
1333
+ if max_workers is None:
1334
+ # Default: leave one core free for the OS / IDE / browser.
1335
+ # Cap at 6 to limit FD/process pressure on macOS.
1336
+ max_workers = max(1, min((os.cpu_count() or 2) - 1, 6))
1337
+
1338
+ def _run_scanner_task(name: str, banner: str, fn: Callable[[], list]) -> Tuple[str, str, list, Optional[Exception], ScannerStatus]:
1339
+ """Worker: time the scan, return result or captured exception.
1340
+
1341
+ Returns (name, banner, findings, exception, status).
1342
+ - findings is empty when exception is set
1343
+ - status is always set (ok / skipped / errored) so the
1344
+ orchestrator can surface degraded scanners downstream
1345
+ (loose end #8). Scanner-side `last_run_status` is read
1346
+ after `fn()` completes; if absent and no exception fired,
1347
+ status defaults to `ok`.
1348
+
1349
+ Invariant: at most ONE in-flight `scan()` call per scanner
1350
+ instance per CLI run. Each scanner is constructed once in
1351
+ `_initialize_components`; `_add()` schedules each name once
1352
+ in this workflow. If a future contributor schedules the
1353
+ same scanner instance under two tasks (don't), the read of
1354
+ `scanner.last_run_status` becomes a data race across the
1355
+ ThreadPoolExecutor workers.
1356
+ """
1357
+ # Construct ScannerStatus AFTER the `with` block exits — the
1358
+ # time_scanner context manager records duration on __exit__,
1359
+ # so reading scanner_timings[name] inside the with-block always
1360
+ # returned 0.0 (the dict's default). Bug introduced in the #8
1361
+ # work (commit 4630f93); customer-visible in
1362
+ # scanner_timings.json._meta_scanner_status.<name>.duration_sec.
1363
+ findings: list = []
1364
+ exc_caught: Optional[Exception] = None
1365
+ with time_scanner(name):
1366
+ try:
1367
+ findings = fn() or []
1368
+ except Exception as exc: # pragma: no cover - error path
1369
+ exc_caught = exc
1370
+
1371
+ duration = scanner_timings.get(name, 0.0)
1372
+ if exc_caught is not None:
1373
+ status = ScannerStatus(
1374
+ name=name,
1375
+ status="errored",
1376
+ reason=f"{type(exc_caught).__name__}: {exc_caught}",
1377
+ finding_count=0,
1378
+ duration_sec=duration,
1379
+ )
1380
+ return name, banner, [], exc_caught, status
1381
+
1382
+ scanner_obj = self._scanner_for(name)
1383
+ scanner_reported = getattr(scanner_obj, 'last_run_status', None) if scanner_obj else None
1384
+ if scanner_reported is not None:
1385
+ status_str, reason = scanner_reported
1386
+ else:
1387
+ status_str, reason = "ok", None
1388
+ status = ScannerStatus(
1389
+ name=name,
1390
+ status=status_str,
1391
+ reason=reason,
1392
+ finding_count=len(findings),
1393
+ duration_sec=duration,
1394
+ )
1395
+ return name, banner, findings, None, status
1396
+
1397
+ # Track per-scanner output (in addition to the flat all_findings
1398
+ # accumulator) so the incremental-scan cache can store findings
1399
+ # keyed by scanner. Survives all dispatch modes (parallel,
1400
+ # sequential).
1401
+ findings_by_scanner: Dict[str, List["Finding"]] = {}
1402
+
1403
+ if use_parallel and len(scanner_tasks) > 1:
1404
+ print(f"⚡ Running {len(scanner_tasks)} scanners in parallel (max_workers={max_workers})...")
1405
+ with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as pool:
1406
+ futures = [pool.submit(_run_scanner_task, name, banner, fn)
1407
+ for name, banner, fn in scanner_tasks]
1408
+ for future in concurrent.futures.as_completed(futures):
1409
+ name, banner, findings, exc, status = future.result()
1410
+ self._scanner_status[name] = status
1411
+ self._print_scanner_result(banner, findings, exc, status)
1412
+ if exc is None:
1413
+ all_findings.extend(findings)
1414
+ findings_by_scanner.setdefault(name, []).extend(findings)
1415
+ else:
1416
+ # Sequential fallback. Preserves the previous behavior exactly,
1417
+ # one scanner at a time with its banner line printed before
1418
+ # the scan and the finding count after.
1419
+ for name, banner, fn in scanner_tasks:
1420
+ print(f" {banner}…")
1421
+ name, banner, findings, exc, status = _run_scanner_task(name, banner, fn)
1422
+ self._scanner_status[name] = status
1423
+ self._print_scanner_result(banner, findings, exc, status)
1424
+ if exc is None:
1425
+ all_findings.extend(findings)
1426
+ findings_by_scanner.setdefault(name, []).extend(findings)
1427
+
1428
+ # Incremental-scan merge: inject cached findings for files that
1429
+ # weren't part of this scan's changed-files set. Cross-file
1430
+ # scanners' caches are excluded inside the cache module — only
1431
+ # file-local scanners replay. This restores the FULL current
1432
+ # state of findings (fresh changed-file + cached unchanged-file)
1433
+ # so the customer sees real coverage, not a delta-only view.
1434
+ if incremental_active and cached_findings_by_scanner:
1435
+ cached_for_unchanged = _finding_cache.filter_cache_for_unchanged_files(
1436
+ cached_findings_by_scanner, changed_files_set,
1437
+ )
1438
+ merged_count = 0
1439
+ for scanner_name, cached_list in cached_for_unchanged.items():
1440
+ findings_by_scanner.setdefault(scanner_name, []).extend(cached_list)
1441
+ all_findings.extend(cached_list)
1442
+ merged_count += len(cached_list)
1443
+ if merged_count:
1444
+ print(
1445
+ f"🔁 Incremental merge: restored {merged_count} cached "
1446
+ f"findings on unchanged files."
1447
+ )
1448
+
1449
+ # Write the cache with the FULL post-merge findings_by_scanner so
1450
+ # the next --incremental scan has accurate ground truth to diff
1451
+ # against. Best-effort: a write failure logs at warning and the
1452
+ # scan still completes (incremental just falls back to full next
1453
+ # time). Done before noise reduction / enrichment so the cache
1454
+ # stores raw scanner output, not post-processed survivors —
1455
+ # that lets us re-apply noise reduction + enrichment consistently
1456
+ # across the merged set on every scan.
1457
+ try:
1458
+ cache_file_for_write = _finding_cache.cache_path(self.project_path, output_dir_for_cache)
1459
+ current_head = _change_detection.get_current_head_sha(self.project_path)
1460
+ _finding_cache.write_cache(
1461
+ cache_file_for_write,
1462
+ findings_by_scanner,
1463
+ head_sha=current_head,
1464
+ )
1465
+ except Exception as exc:
1466
+ logger.warning("Could not write finding cache: %s", exc)
1467
+
1468
+ # Persist per-scanner timings on the CLI instance so the caller (or
1469
+ # any post-scanner phase added later) can dump it. We keep the write
1470
+ # to disk in a single place (_persist_scanner_timings) so adding a
1471
+ # new scanner doesn't require remembering to update an "end of
1472
+ # workflow" sentinel — the dump runs after the whole pipeline.
1473
+ self._scanner_timings = scanner_timings
1474
+
1475
+ # Phase 2.5: .brassignore — user-defined suppressions.
1476
+ # Applied before the noise reducer so suppressed findings never
1477
+ # consume downstream enrichment tokens, and before any reporting
1478
+ # so counts reflect the user's actual interests.
1479
+ from brass.core.brassignore import BrassIgnore, filter_findings as _bi_filter
1480
+ brassignore = BrassIgnore.load(self.project_path)
1481
+ if brassignore:
1482
+ before = len(all_findings)
1483
+ all_findings = _bi_filter(all_findings, brassignore)
1484
+ dropped = before - len(all_findings)
1485
+ if dropped:
1486
+ print(f"🙈 .brassignore: dropped {dropped} findings ({before} → {len(all_findings)})")
1487
+
1488
+ # Phase 3: Noise reduction (Brass2-compliant scanner)
1489
+ print("🧹 Running intelligent optimization...")
1490
+ noise_reducer = NoiseReductionScanner(str(self.project_path))
1491
+ clean_findings = noise_reducer.scan(all_findings)
1492
+
1493
+ # Report noise reduction statistics
1494
+ stats = noise_reducer.get_stats()
1495
+ if stats:
1496
+ print(f" Intelligent optimization: {stats.original_count} → {stats.filtered_count} findings "
1497
+ f"({stats.reduction_percentage:.1f}% reduction)")
1498
+
1499
+ # Cross-scanner overlap pre-stash (Phase F architectural fix,
1500
+ # 2026-05-16). The gateway's semantic clusterer will drop
1501
+ # cross-scanner same-line peers as duplicates, hiding the
1502
+ # cross-scanner agreement signal that's the point of
1503
+ # `also_detected_by`. Compute overlap on the pre-enrichment
1504
+ # findings and stash peer lists on each finding's metadata
1505
+ # so the surviving finding carries them through enrichment's
1506
+ # `dataclasses.replace`-based rewriting. ORDER MATTERS: must
1507
+ # run BEFORE _maybe_apply_enrichment.
1508
+ from brass.output.cross_scanner_overlap import stash_overlap_on_metadata
1509
+ clean_findings = stash_overlap_on_metadata(clean_findings)
1510
+
1511
+ # Phase 3.5: AI enrichment (paid feature; gated by active license + --no-enrich opt-out)
1512
+ if not getattr(args, 'no_enrich', False):
1513
+ clean_findings = self._maybe_apply_enrichment(clean_findings)
1514
+
1515
+ return clean_findings
1516
+
1517
+ def _maybe_apply_enrichment(self, findings):
1518
+ """Run findings through the gateway when the license is active.
1519
+
1520
+ Soft-fail to heuristic-only on network / gateway / rate-limit
1521
+ errors. Hard-fail on quota exhaustion (per V1 plan §3 locked
1522
+ decision: option 2 — sharpest revenue signal).
1523
+ """
1524
+ from brass.licensing import LicenseStore
1525
+ from brass.enrichment import (
1526
+ EnrichmentClient,
1527
+ EnrichmentClientError,
1528
+ EnrichmentRateLimitedError,
1529
+ EnrichmentUnavailableError,
1530
+ LicenseRejectedError,
1531
+ QuotaExhaustedError,
1532
+ apply_enrichment,
1533
+ )
1534
+
1535
+ record = LicenseStore.default().read()
1536
+ if record is None or not record.is_active():
1537
+ return findings # OSS tier or inactive — heuristic only.
1538
+
1539
+ client = EnrichmentClient(
1540
+ license_key=record.license_key,
1541
+ instance_id=record.instance_id,
1542
+ )
1543
+
1544
+ print("✨ Running AI enrichment...")
1545
+ try:
1546
+ enriched, report = apply_enrichment(findings, str(self.project_path), client)
1547
+ except QuotaExhaustedError as exc:
1548
+ # Hard fail — locked UX decision.
1549
+ print()
1550
+ print("❌ Enrichment quota exhausted for this billing period.")
1551
+ print(f" Tokens needed: {exc.tokens_needed:,}")
1552
+ print(f" Tokens remaining: {exc.tokens_remaining:,}")
1553
+ if exc.quota_period_end:
1554
+ print(f" Period ends: {exc.quota_period_end}")
1555
+ if exc.topup_url:
1556
+ print(f" Top up: {exc.topup_url}")
1557
+ print(" Or scan with --no-enrich to fall back to the heuristic filter.")
1558
+ raise SystemExit(2)
1559
+ except LicenseRejectedError as exc:
1560
+ print()
1561
+ print(f"❌ License rejected by the enrichment gateway: {exc}")
1562
+ print(" This usually means the license was disabled, refunded,")
1563
+ print(" or the activation slot was released elsewhere.")
1564
+ print(" Run 'brasscoders license' to re-validate, or 'brasscoders scan")
1565
+ print(" --no-enrich' to fall back to the heuristic filter.")
1566
+ raise SystemExit(2)
1567
+ except (EnrichmentRateLimitedError, EnrichmentUnavailableError) as exc:
1568
+ print(f" ⚠️ Enrichment unavailable ({exc}); using heuristic results.")
1569
+ return findings
1570
+ except EnrichmentClientError as exc:
1571
+ # Defensive catch-all: anything else, soft-fail.
1572
+ print(f" ⚠️ Enrichment skipped ({exc}); using heuristic results.")
1573
+ return findings
1574
+
1575
+ used_pct = 0
1576
+ if report.quota_remaining + report.tokens_used > 0:
1577
+ total_period_budget = report.quota_remaining + report.tokens_used
1578
+ used_pct = int(round(100 * report.tokens_used / total_period_budget))
1579
+ print(
1580
+ f" Enriched: {report.input_count} → {report.output_count} findings "
1581
+ f"({report.duplicates_dropped} duplicates dropped)"
1582
+ )
1583
+ # Telemetry-only: keep token usage in brass.log for support /
1584
+ # billing investigations, but hide it from customer-facing
1585
+ # stdout. Use a dedicated `brass.telemetry` logger with
1586
+ # `propagate = False` so a customer's `logging.basicConfig(stdout)`
1587
+ # / `dictConfig` / pipeline log-capture wrapper can't accidentally
1588
+ # surface the tokens via root-logger inheritance — the original
1589
+ # 2026-05-16 "hide tokens" change relied on the brass.cli logger
1590
+ # not propagating, which holds today but isn't enforced.
1591
+ _telemetry_logger = get_logger("brass.telemetry")
1592
+ _telemetry_logger.propagate = False
1593
+ _telemetry_logger.info(
1594
+ "Enrichment tokens: %s used; %s remaining",
1595
+ f"{report.tokens_used:,}",
1596
+ f"{report.quota_remaining:,}",
1597
+ )
1598
+ # Always-on counter + 80%/95% warnings (locked UX decision in plan §4).
1599
+ # The enrich response gives us total remaining but not the monthly
1600
+ # allowance, so we fetch quota state to compute the percentage.
1601
+ try:
1602
+ quota_state = client.quota()
1603
+ except EnrichmentClientError:
1604
+ quota_state = None
1605
+ if quota_state is not None and quota_state.monthly_limit > 0:
1606
+ burned = quota_state.monthly_limit - quota_state.monthly_remaining
1607
+ pct = int(round(100 * burned / quota_state.monthly_limit))
1608
+ if pct >= 95:
1609
+ print(
1610
+ f" 🚨 You have used {pct}% of this period's enrichment "
1611
+ f"allowance — top up at https://coppersun.dev/topup"
1612
+ )
1613
+ elif pct >= 80:
1614
+ print(f" ⚠️ You have used {pct}% of this period's enrichment allowance.")
1615
+
1616
+ return enriched
1617
+
1618
+ def _run_scanner_with_files(self, scanner, file_paths: List[str]) -> List[Finding]:
1619
+ """
1620
+ Run a scanner with prefiltered files.
1621
+
1622
+ Args:
1623
+ scanner: Scanner instance to run
1624
+ file_paths: List of file paths to analyze. Convention:
1625
+ - non-empty list: scan exactly these files
1626
+ - empty list: scan ZERO files (return [])
1627
+ - None: scanner discovers files itself
1628
+ The empty-list short-circuit matters for --incremental: when
1629
+ change detection finds no modified files, file-local scanners
1630
+ should do zero work. Most scanners' ``if file_paths:`` checks
1631
+ treat ``[]`` as falsy and fall back to full discovery, which
1632
+ defeats the entire point of incremental mode (observed
1633
+ 2026-05-19: secrets scanner spent 37.6s on 0 files because
1634
+ it treated empty list as "no filter, scan all").
1635
+
1636
+ Returns:
1637
+ List of findings from the scanner
1638
+ """
1639
+ # Short-circuit on empty list: no files to scan = no findings.
1640
+ # Skips scanner cold-start (subprocess spawn, model load, etc.).
1641
+ if file_paths is not None and len(file_paths) == 0:
1642
+ return []
1643
+ try:
1644
+ # Most scanners support file_paths parameter in their scan method
1645
+ if hasattr(scanner, 'scan') and callable(scanner.scan):
1646
+ # Try to pass file_paths if scanner supports it
1647
+ import inspect
1648
+ scan_signature = inspect.signature(scanner.scan)
1649
+ if 'file_paths' in scan_signature.parameters:
1650
+ return scanner.scan(file_paths=file_paths)
1651
+ else:
1652
+ # Fallback: run scanner normally (it will discover files itself)
1653
+ return scanner.scan()
1654
+ else:
1655
+ logger.warning(f"Scanner {scanner.__class__.__name__} has no scan method")
1656
+ return []
1657
+ except Exception as e:
1658
+ logger.error(f"Scanner {scanner.__class__.__name__} failed: {e}")
1659
+ return []
1660
+
1661
+ def _apply_filtering(self, args, all_findings: List[Finding]) -> List[Finding]:
1662
+ """
1663
+ Apply filtering options to findings based on command arguments.
1664
+
1665
+ Args:
1666
+ args: Command line arguments
1667
+ all_findings: All findings before filtering
1668
+
1669
+ Returns:
1670
+ Filtered findings list
1671
+ """
1672
+ findings_to_process = all_findings
1673
+
1674
+ # Apply developer mode filtering (both --dev and legacy --source-only)
1675
+ if getattr(args, 'dev', False) or getattr(args, 'source_only', False):
1676
+ mode_name = "developer mode" if getattr(args, 'dev', False) else "source-only mode"
1677
+ print(f"🎯 Applying {mode_name} filtering (source code only)...")
1678
+ findings_to_process = self._filter_findings_for_developer_mode(all_findings)
1679
+ filtered_count = len(all_findings) - len(findings_to_process)
1680
+ print(f" Filtered out {filtered_count} test/build findings, showing {len(findings_to_process)} source code issues")
1681
+
1682
+ return findings_to_process
1683
+
1684
+ def _generate_output(self, findings: List[Finding]) -> Tuple[List[Finding], List[str]]:
1685
+ """
1686
+ Generate ranked findings and output files.
1687
+
1688
+ Args:
1689
+ findings: Findings to process
1690
+
1691
+ Returns:
1692
+ Tuple of (ranked_findings, output_files)
1693
+ """
1694
+ print("📊 Ranking findings by importance...")
1695
+ ranked_findings = self.ranker.rank_findings(findings)
1696
+
1697
+ print("📄 Generating intelligence reports...")
1698
+ # Loose end #8: pass scanner_status so the YAML output can flag
1699
+ # degraded scanners. generate_intelligence accepts the kwarg
1700
+ # optionally (callers that don't track status pass nothing).
1701
+ # Read the workflow wall-clock that _run_analysis_workflow set on
1702
+ # self. Defensive default (None) so isolated callers that bypass
1703
+ # the orchestrator don't crash.
1704
+ t0 = getattr(self, "_scan_workflow_t0", None)
1705
+ scan_duration = round(time.monotonic() - t0, 2) if t0 is not None else None
1706
+ # Sample peak RSS once here so statistics.yaml and the on-disk
1707
+ # scanner_timings.json both reflect the same reading. Includes
1708
+ # subprocess memory (pysa, semgrep, ast-grep, bandit) so the
1709
+ # customer-facing number matches `time -l`'s observation.
1710
+ peak_memory_mb = self._record_peak_rss_mb()
1711
+ output_files = self.output_generator.generate_intelligence(
1712
+ ranked_findings,
1713
+ scanner_status=self._scanner_status or None,
1714
+ scan_duration_seconds=scan_duration,
1715
+ peak_memory_mb=peak_memory_mb,
1716
+ )
1717
+
1718
+ return ranked_findings, output_files
1719
+
1720
+ def _display_results_summary(self, args, all_findings: List[Finding],
1721
+ findings_to_process: List[Finding], output_files: List[str],
1722
+ ranked_findings: List[Finding], project_path: Path,
1723
+ output_dir: str) -> None:
1724
+ """
1725
+ Display comprehensive results summary to user.
1726
+
1727
+ Args:
1728
+ args: Command line arguments
1729
+ all_findings: All findings before filtering
1730
+ findings_to_process: Processed findings
1731
+ output_files: Generated output files
1732
+ ranked_findings: Ranked findings for display
1733
+ project_path: Project path
1734
+ output_dir: Output directory
1735
+ """
1736
+ print()
1737
+ print("✅ Analysis complete!")
1738
+
1739
+ # Show filtering information with user-friendly names
1740
+ if getattr(args, 'dev', False) or getattr(args, 'source_only', False):
1741
+ mode_name = "developer mode" if getattr(args, 'dev', False) else "source-only mode"
1742
+ print(f"📊 Found {len(findings_to_process)} source code issues in {mode_name} (filtered from {len(all_findings)} total)")
1743
+ else:
1744
+ print(f"📊 Found {len(all_findings)} total issues")
1745
+ print(f"📄 Generated {len(output_files)} intelligence files")
1746
+
1747
+ # Show top findings
1748
+ critical_findings = [f for f in ranked_findings if f.is_critical()]
1749
+ if critical_findings:
1750
+ print(f"🚨 {len(critical_findings)} critical/high severity issues require attention")
1751
+ print("\n🎯 Top Issues:")
1752
+ for i, finding in enumerate(critical_findings[:5], 1):
1753
+ print(f" {i}. {finding.title} ({finding.severity.value}) - {finding.get_location_string()}")
1754
+
1755
+ print(f"\n📋 View detailed analysis: {project_path / output_dir / 'ai_instructions.yaml'}")
1756
+
1757
+ # Show file usage guidance
1758
+ print(f"\n📚 How to Use Your Results:")
1759
+ print(f" 🤖 For AI coding: Share ai_instructions.yaml with Claude Code")
1760
+ print(f" 🔒 For security review: Check security_report.yaml first")
1761
+ print(f" 📂 For file-specific issues: Browse file_intelligence.yaml")
1762
+ print(f" 📊 For project overview: Review statistics.yaml")
1763
+
1764
+ # Add helpful next steps based on analysis mode
1765
+ self._show_helpful_next_steps(args, len(all_findings), len(critical_findings))
1766
+
1767
+ def _handle_error_reporting(self, project_path: Path, output_dir: str) -> None:
1768
+ """
1769
+ Handle error reporting at the end of analysis.
1770
+
1771
+ Args:
1772
+ project_path: Project path
1773
+ output_dir: Output directory
1774
+ """
1775
+ error_reporter = get_error_reporter(str(project_path / output_dir))
1776
+ error_summary = error_reporter.get_error_summary()
1777
+
1778
+ if error_summary['total_errors'] > 0:
1779
+ logger.info(f"Analysis completed with {error_summary['total_errors']} errors")
1780
+ error_report_path = error_reporter.save_error_report()
1781
+ if error_report_path:
1782
+ logger.debug(f"Error report saved to {error_report_path}")
1783
+
1784
+ @handle_common_errors
1785
+ def _cmd_scan(self, args) -> int:
1786
+ """
1787
+ Execute scan command.
1788
+
1789
+ Orchestrates the complete analysis workflow with reduced complexity
1790
+ through method extraction for better maintainability.
1791
+ """
1792
+ # Validate and setup project path
1793
+ project_path = self._validate_project_path(args)
1794
+ if not project_path:
1795
+ return 1
1796
+
1797
+ # Store project path for use in workflow
1798
+ self.project_path = project_path
1799
+
1800
+ output_dir = args.output_dir or '.brass'
1801
+ # Resolve output directory to absolute path for consistency
1802
+ resolved_output_dir = str(project_path / output_dir)
1803
+
1804
+ # Reconfigure logging with resolved output directory
1805
+ log_file = getattr(args, 'log_file', None)
1806
+ no_log_file = getattr(args, 'no_log_file', False)
1807
+ self._configure_logging(args.verbose, log_file, no_log_file, resolved_output_dir)
1808
+
1809
+ self._print_scan_header(project_path, output_dir, args)
1810
+
1811
+ # Validate environment before initialization
1812
+ self._validate_environment(project_path)
1813
+
1814
+ # NEW: Validate output directory state
1815
+ state_validator = StateValidator(project_path / output_dir)
1816
+ validation_result = state_validator.validate_and_clean()
1817
+
1818
+ # Show cleanup message if files were cleaned
1819
+ if validation_result.files_cleaned > 0:
1820
+ print(f"🧹 {validation_result.message}")
1821
+ logger.info(f"State validation: {validation_result.message} "
1822
+ f"(validated {validation_result.files_validated} files in "
1823
+ f"{validation_result.validation_time_ms:.1f}ms)")
1824
+
1825
+ # Resolve network policy. --offline is a hard override; everything that talks
1826
+ # to the network must respect it. Default is "no outbound calls".
1827
+ offline_mode = bool(getattr(args, 'offline', False))
1828
+ # Propagate to scanners that check via env var (Pysa typeshed
1829
+ # auto-fetch reads BRASS_OFFLINE to gate its git clone). Other
1830
+ # network-touching paths in this process branch on offline_mode
1831
+ # directly.
1832
+ if offline_mode:
1833
+ os.environ["BRASS_OFFLINE"] = "1"
1834
+ check_package_hallucination = (
1835
+ bool(getattr(args, 'check_package_hallucination', False))
1836
+ and not offline_mode
1837
+ )
1838
+ if offline_mode and getattr(args, 'check_package_hallucination', False):
1839
+ print("ℹ️ --offline overrides --check-package-hallucination; staying offline.")
1840
+
1841
+ # Initialize system components
1842
+ self._initialize_components(
1843
+ str(project_path),
1844
+ output_dir,
1845
+ check_package_hallucination=check_package_hallucination,
1846
+ )
1847
+
1848
+ # Run analysis and collect findings
1849
+ all_findings = self._run_analysis_workflow(args)
1850
+ # Persist per-scanner timings for the benchmark harness, regardless
1851
+ # of whether findings were produced. Wrapped because timing
1852
+ # observability must never break the scan.
1853
+ try:
1854
+ self._persist_scanner_timings(project_path, output_dir)
1855
+ except Exception as exc:
1856
+ logger.warning("Failed to persist scanner_timings.json: %s", exc)
1857
+ # Empty-findings scans still need YAML output: AI consumers
1858
+ # read ``.brass/ai_instructions.yaml`` and expect a predictable
1859
+ # file layout — even a clean codebase should emit a confirming
1860
+ # "we ran, here's what we checked, no findings" report. Without
1861
+ # this, a customer integrating brass into a workflow sees
1862
+ # ``.brass/`` materialize with just ``brass.log`` +
1863
+ # ``scanner_timings.json`` and has to special-case "did the
1864
+ # scan even run?". Discovered 2026-05-21 evaluating
1865
+ # tweet-automation-system. Friendly-message stays as a stdout
1866
+ # hint; the canonical signal is the populated YAML set.
1867
+ if not all_findings:
1868
+ print("✅ No issues detected - excellent work!")
1869
+ # Apply filtering and process findings (no-op on empty input).
1870
+ findings_to_process = self._apply_filtering(args, all_findings)
1871
+ if all_findings and not findings_to_process:
1872
+ # all_findings was non-empty but filtering removed everything
1873
+ # (e.g., --dev mode dropped all test-file findings). Still
1874
+ # generate output so the AI consumer sees the filtered view.
1875
+ print("✅ No source code issues detected - clean production code!")
1876
+
1877
+ # Generate output and reports
1878
+ ranked_findings, output_files = self._generate_output(findings_to_process)
1879
+
1880
+ # Display results and summary
1881
+ self._display_results_summary(args, all_findings, findings_to_process,
1882
+ output_files, ranked_findings, project_path, output_dir)
1883
+
1884
+ # Handle error reporting
1885
+ self._handle_error_reporting(project_path, output_dir)
1886
+
1887
+ # Operational note: cache size awareness. Lands after the
1888
+ # results panel so it reads as a "by the way" footer, not
1889
+ # part of the scan results.
1890
+ self._print_cache_footer()
1891
+
1892
+ # Emit anonymized telemetry. No-ops when consent is off (the
1893
+ # default). Records only counts — never source code, file paths,
1894
+ # or PII. Counts are derived from the ``ranked_findings`` list,
1895
+ # not from any file content.
1896
+ from brass.telemetry import record as _telemetry_record
1897
+ try:
1898
+ from collections import Counter
1899
+ type_counts = Counter(
1900
+ getattr(f.type, 'value', str(f.type))
1901
+ for f in ranked_findings
1902
+ )
1903
+ severity_counts = Counter(
1904
+ getattr(f.severity, 'value', str(f.severity))
1905
+ for f in ranked_findings
1906
+ )
1907
+ _telemetry_record(
1908
+ event='scan',
1909
+ total_findings=len(ranked_findings),
1910
+ finding_types=dict(type_counts),
1911
+ severity_counts=dict(severity_counts),
1912
+ fast=bool(getattr(args, 'fast', False)),
1913
+ dev_mode=bool(getattr(args, 'dev', False)),
1914
+ offline=bool(getattr(args, 'offline', False)),
1915
+ )
1916
+ except Exception:
1917
+ # Telemetry must never bubble into the CLI's normal flow.
1918
+ pass
1919
+
1920
+ return 0
1921
+
1922
+ def _translate_user_friendly_flags(self, args) -> None:
1923
+ """
1924
+ Translate user-friendly command aliases to internal legacy flags.
1925
+
1926
+ Args:
1927
+ args: Command line arguments to modify
1928
+ """
1929
+ # Map user-friendly aliases to legacy flags
1930
+ if getattr(args, 'fast', False):
1931
+ args.code_only = True
1932
+ args.no_privacy = True
1933
+ args.no_content = True
1934
+
1935
+ if getattr(args, 'dev', False):
1936
+ args.source_only = True
1937
+
1938
+ if getattr(args, 'code', False):
1939
+ args.code_only = True
1940
+
1941
+ if getattr(args, 'privacy', False):
1942
+ args.privacy_only = True
1943
+
1944
+ if getattr(args, 'content', False):
1945
+ args.content_only = True
1946
+
1947
+ def _show_helpful_next_steps(self, args, total_findings: int, critical_count: int) -> None:
1948
+ """
1949
+ Show context-appropriate next steps to the user.
1950
+
1951
+ Args:
1952
+ args: Command line arguments
1953
+ total_findings: Total number of findings
1954
+ critical_count: Number of critical findings
1955
+ """
1956
+ if total_findings == 0:
1957
+ print("\n🎉 No issues found - your code looks great!")
1958
+ print("💡 Try running with different scan options to check other aspects:")
1959
+ print(" • brasscoders scan --privacy # Check for sensitive data")
1960
+ print(" • brasscoders scan --content # Check content policies")
1961
+ return
1962
+
1963
+ print("\n💡 Next Steps:")
1964
+
1965
+ if critical_count > 0:
1966
+ print(f" 🚨 Priority: Address {critical_count} critical/high severity issues first")
1967
+
1968
+ if getattr(args, 'fast', False):
1969
+ print(" 🔍 Run full analysis: brasscoders scan (includes privacy & content checks)")
1970
+ elif getattr(args, 'code', False):
1971
+ print(" 🔒 Check privacy: brasscoders scan --privacy")
1972
+ print(" 🚫 Check content: brasscoders scan --content")
1973
+ elif getattr(args, 'dev', False):
1974
+ print(" 📊 See all findings: brasscoders scan (includes test/build files)")
1975
+
1976
+ print(" 👁️ Monitor changes: brasscoders watch")
1977
+ print(" 📊 View status: brasscoders status")
1978
+
1979
+ def _cmd_watch(self, args) -> int:
1980
+ """Execute watch command."""
1981
+ project_path = Path(args.project_path).resolve()
1982
+
1983
+ if not project_path.exists():
1984
+ print(f"❌ Project path does not exist: {project_path}")
1985
+ return 1
1986
+
1987
+ print(f"👁️ Starting continuous monitoring of {project_path.name}")
1988
+ print(f"📁 Project: {project_path}")
1989
+ print(f"⏱️ Poll interval: {args.poll_interval}s")
1990
+ print(f"🕐 Debounce delay: {args.debounce_delay}s")
1991
+ print("\nPress Ctrl+C to stop monitoring...\n")
1992
+
1993
+ # Initialize components
1994
+ self._initialize_components(str(project_path))
1995
+
1996
+ # Create incremental analyzer
1997
+ incremental_analyzer = IncrementalAnalyzer(
1998
+ self.code_scanner,
1999
+ self.brass2_privacy_scanner,
2000
+ self.ranker,
2001
+ self.output_generator
2002
+ )
2003
+
2004
+ def on_changes_detected(changed_files: List[str]):
2005
+ """Callback for when file changes are detected."""
2006
+ print(f"📝 Changes detected in {len(changed_files)} files")
2007
+ result = incremental_analyzer.analyze_changes(changed_files)
2008
+
2009
+ if result['status'] == 'success':
2010
+ print(f"✅ Analysis updated: {result['findings_detected']} findings, {result['output_files_updated']} files updated")
2011
+ elif result['status'] == 'no_changes':
2012
+ print("ℹ️ No relevant changes to analyze")
2013
+ else:
2014
+ print(f"❌ Analysis failed: {result.get('error_message', 'Unknown error')}")
2015
+
2016
+ # Start monitoring
2017
+ try:
2018
+ with FileWatcher(
2019
+ str(project_path),
2020
+ on_changes_detected=on_changes_detected,
2021
+ poll_interval=args.poll_interval,
2022
+ debounce_delay=args.debounce_delay
2023
+ ) as watcher:
2024
+
2025
+ print("👁️ Monitoring started - watching for changes...")
2026
+
2027
+ # Block on the watcher's shutdown event rather than busy-
2028
+ # spinning every second. The FileWatcher runs its own
2029
+ # daemon thread; the main thread just waits to be interrupted.
2030
+ try:
2031
+ watcher.shutdown_event.wait()
2032
+ except KeyboardInterrupt:
2033
+ pass
2034
+
2035
+ except Exception as e:
2036
+ print(f"❌ Monitoring error: {e}")
2037
+ return 1
2038
+
2039
+ print("\n👋 Monitoring stopped")
2040
+ return 0
2041
+
2042
+ def _cmd_status(self, args) -> int:
2043
+ """Execute status command."""
2044
+ project_path = Path(args.project_path).resolve()
2045
+ output_dir = project_path / '.brass'
2046
+
2047
+ print(f"📊 Copper Sun Brass Status - {project_path.name}")
2048
+ print(f"📁 Project: {project_path}")
2049
+ print()
2050
+
2051
+ # Check if analysis has been run
2052
+ if not output_dir.exists():
2053
+ print("❌ No analysis found - run 'brasscoders scan' first")
2054
+ return 1
2055
+
2056
+ # Check intelligence files
2057
+ intelligence_files = {
2058
+ 'ai_instructions.yaml': 'Main guidance for AI assistants (start here!)',
2059
+ 'detailed_analysis.yaml': 'Complete technical breakdown of all issues',
2060
+ 'security_report.yaml': 'Security vulnerabilities requiring attention',
2061
+ 'privacy_analysis.yaml': 'Personal data exposure and compliance issues',
2062
+ 'file_intelligence.yaml': 'File-by-file breakdown of problems found',
2063
+ 'statistics.yaml': 'Summary metrics and project trends'
2064
+ }
2065
+
2066
+ print("📄 Intelligence Files:")
2067
+ for filename, description in intelligence_files.items():
2068
+ file_path = output_dir / filename
2069
+ if file_path.exists():
2070
+ size = file_path.stat().st_size
2071
+ mtime = datetime.fromtimestamp(file_path.stat().st_mtime)
2072
+ print(f" ✅ {filename} - {description} ({size} bytes, {mtime.strftime('%Y-%m-%d %H:%M')})")
2073
+ else:
2074
+ print(f" ❌ {filename} - Missing")
2075
+
2076
+ # Load and show summary from JSON if available
2077
+ json_file = output_dir / 'analysis_data.json'
2078
+ if json_file.exists():
2079
+ try:
2080
+ import json
2081
+ with open(json_file, 'r') as f:
2082
+ data = json.load(f)
2083
+
2084
+ summary = data.get('summary', {})
2085
+ print(f"\n📊 Analysis Summary:")
2086
+ print(f" 🔍 Total Findings: {summary.get('total_findings', 0)}")
2087
+ print(f" 📁 Files Analyzed: {summary.get('files_analyzed', 0)}")
2088
+ print(f" 🎯 Average Confidence: {summary.get('avg_confidence', 0):.1%}")
2089
+ print(f" 📈 Average Impact: {summary.get('avg_impact', 0):.1%}")
2090
+
2091
+ by_type = summary.get('by_type', {})
2092
+ if by_type:
2093
+ print(f"\n🏷️ Findings by Type:")
2094
+ for finding_type, count in by_type.items():
2095
+ print(f" - {finding_type.replace('_', ' ').title()}: {count}")
2096
+
2097
+ by_severity = summary.get('by_severity', {})
2098
+ if by_severity:
2099
+ print(f"\n⚡ Findings by Severity:")
2100
+ for severity, count in by_severity.items():
2101
+ print(f" - {severity.title()}: {count}")
2102
+
2103
+ except Exception as e:
2104
+ print(f"❌ Error reading analysis data: {e}")
2105
+
2106
+ return 0
2107
+
2108
+ def _cmd_report(self, args) -> int:
2109
+ """Execute report command."""
2110
+ print(f"📄 Generating {args.type} report(s) in {args.format} format...")
2111
+
2112
+ # This would implement specific report generation
2113
+ # For now, point to existing scan functionality
2114
+ print("ℹ️ Use 'brasscoders scan' to generate all reports")
2115
+ print(" Specific report types will be available in future versions")
2116
+
2117
+ return 0
2118
+
2119
+ def _cmd_filter(self, args) -> int:
2120
+ """Apply BrassCoders noise reduction to an AI reviewer JSON payload."""
2121
+ from brass.filtering.ai_review_filter import main as filter_main
2122
+ argv = [
2123
+ '--input', args.input,
2124
+ '--output', args.output,
2125
+ ]
2126
+ return filter_main(argv)
2127
+
2128
+ def _cmd_activate(self, args) -> int:
2129
+ """Activate a BrassCoders license against LemonSqueezy and persist locally."""
2130
+ from datetime import datetime, timezone
2131
+ from brass.licensing import (
2132
+ LicenseAPIError,
2133
+ LicenseInvalidError,
2134
+ LicenseRecord,
2135
+ LicenseStore,
2136
+ activate as ls_activate,
2137
+ )
2138
+ store = LicenseStore.default()
2139
+ try:
2140
+ result = ls_activate(args.token)
2141
+ except LicenseInvalidError as exc:
2142
+ print(f"❌ License rejected by LemonSqueezy: {exc}")
2143
+ return 1
2144
+ except LicenseAPIError as exc:
2145
+ print(f"❌ Could not reach LemonSqueezy: {exc}")
2146
+ print(" The license API is the only network call this command makes; "
2147
+ "if you're offline, retry when you have connectivity.")
2148
+ return 1
2149
+
2150
+ now = datetime.now(timezone.utc).isoformat()
2151
+ record = LicenseRecord(
2152
+ license_key=result.license_key,
2153
+ instance_id=result.instance_id,
2154
+ status=result.status,
2155
+ activated_at=now,
2156
+ last_validated_at=now,
2157
+ expires_at=result.expires_at,
2158
+ customer_email=result.customer_email,
2159
+ product_name=result.product_name,
2160
+ )
2161
+ store.write(record)
2162
+
2163
+ suffix = "perpetual" if record.expires_at is None else f"expires {record.expires_at}"
2164
+ product = result.product_name or "BrassCoders license"
2165
+ print(f"✅ Activated {product} ({suffix})")
2166
+ if result.activation_limit is not None:
2167
+ print(f" Activations: {result.activation_usage}/{result.activation_limit}")
2168
+ print(f" Stored at: {store.path}")
2169
+ return 0
2170
+
2171
+ def _cmd_license(self, args) -> int:
2172
+ """Show the active license. Re-validates against LS if cached state is stale."""
2173
+ from datetime import datetime, timezone
2174
+ from brass.licensing import (
2175
+ LicenseAPIError,
2176
+ LicenseInvalidError,
2177
+ LicenseStore,
2178
+ validate as ls_validate,
2179
+ )
2180
+ store = LicenseStore.default()
2181
+ record = store.read()
2182
+ if record is None:
2183
+ print("ℹ️ No license activated. BrassCoders is running in OSS-tier mode.")
2184
+ print(" To activate: brasscoders activate <license-key>")
2185
+ return 0
2186
+
2187
+ # Re-validate against LS at most once a week. Keeps the network
2188
+ # surface small while still picking up server-side revocations.
2189
+ if record.days_since_validation() >= 7:
2190
+ print("🔄 Validating license with LemonSqueezy…")
2191
+ try:
2192
+ result = ls_validate(record.license_key, instance_id=record.instance_id)
2193
+ store.update_validation(
2194
+ status=result.status,
2195
+ validated_at=datetime.now(timezone.utc).isoformat(),
2196
+ )
2197
+ record = store.read() or record
2198
+ except LicenseInvalidError as exc:
2199
+ print(f"⚠️ License is no longer valid: {exc}")
2200
+ print(f" File: {store.path} (run 'brasscoders deactivate' to clear)")
2201
+ return 1
2202
+ except LicenseAPIError as exc:
2203
+ print(f"⚠️ Could not reach LemonSqueezy ({exc}); using cached status.")
2204
+
2205
+ marker = "✅" if record.is_active() else "⚠️ "
2206
+ print(f"{marker} {record.product_name or 'BrassCoders license'} — status: {record.status}")
2207
+ if record.customer_email:
2208
+ print(f" Email: {record.customer_email}")
2209
+ print(f" Activated: {record.activated_at}")
2210
+ print(f" Last validated: {record.last_validated_at}")
2211
+ if record.expires_at:
2212
+ print(f" Expires: {record.expires_at}")
2213
+ else:
2214
+ print(f" Expires: never (perpetual)")
2215
+
2216
+ # Enrichment quota — paid feature; only meaningful for active licenses.
2217
+ if record.is_active():
2218
+ self._print_enrichment_quota(record)
2219
+ return 0
2220
+
2221
+ def _print_enrichment_quota(self, record) -> None:
2222
+ """Fetch + display the current enrichment-token quota for this license.
2223
+
2224
+ Best-effort. A network failure here is informational, not blocking
2225
+ — `brasscoders license` is a status command; users still see the
2226
+ license details above even if the gateway is down.
2227
+ """
2228
+ from brass.enrichment import EnrichmentClient, EnrichmentClientError
2229
+
2230
+ client = EnrichmentClient(
2231
+ license_key=record.license_key,
2232
+ instance_id=record.instance_id,
2233
+ )
2234
+ try:
2235
+ quota = client.quota()
2236
+ except EnrichmentClientError as exc:
2237
+ print(f" AI enrichment: could not fetch quota ({exc})")
2238
+ return
2239
+
2240
+ used = quota.monthly_limit - quota.monthly_remaining
2241
+ pct = int(round(100 * used / quota.monthly_limit)) if quota.monthly_limit else 0
2242
+ print(
2243
+ f" AI enrichment: {quota.monthly_remaining:,} of "
2244
+ f"{quota.monthly_limit:,} monthly tokens remaining ({pct}% used)"
2245
+ )
2246
+ if quota.topup_remaining > 0:
2247
+ print(f" + {quota.topup_remaining:,} top-up tokens")
2248
+ print(f" Period ends: {quota.period_end}")
2249
+
2250
+ # Low-quota warning at 90%+ used — gives customers a heads-up
2251
+ # to top up before they hit quota_exhausted mid-scan. Threshold
2252
+ # is generous (10M tokens left when monthly_limit is 50M) so
2253
+ # the message doesn't surface for normal usage.
2254
+ if quota.total_remaining < 10_000_000:
2255
+ print(
2256
+ f" ⚠️ Low quota — only {quota.total_remaining:,} tokens left. "
2257
+ f"Top up: https://coppersun.dev/topup"
2258
+ )
2259
+
2260
+ # Always-useful pointers customers should know about. Kept short.
2261
+ print(f" Manage: brasscoders portal (opens billing portal)")
2262
+ print(f" Top up: https://coppersun.dev/topup")
2263
+
2264
+ def _cmd_deactivate(self, args) -> int:
2265
+ """Release the activation slot for this machine and remove the local record."""
2266
+ from brass.licensing import (
2267
+ LicenseAPIError,
2268
+ LicenseInvalidError,
2269
+ LicenseStore,
2270
+ deactivate as ls_deactivate,
2271
+ )
2272
+ store = LicenseStore.default()
2273
+ record = store.read()
2274
+ if record is None:
2275
+ print("ℹ️ No license to deactivate.")
2276
+ return 0
2277
+
2278
+ try:
2279
+ ls_deactivate(record.license_key, instance_id=record.instance_id)
2280
+ print("🗑 Released activation slot with LemonSqueezy.")
2281
+ except LicenseInvalidError as exc:
2282
+ print(f"ℹ️ LemonSqueezy already considers this slot inactive: {exc}")
2283
+ except LicenseAPIError as exc:
2284
+ print(f"⚠️ Could not reach LemonSqueezy ({exc}); removing local record anyway.")
2285
+
2286
+ store.delete()
2287
+ print(f"🗑 Local record removed ({store.path}). BrassCoders is now OSS-tier.")
2288
+ return 0
2289
+
2290
+ def _cmd_portal(self, args) -> int:
2291
+ """Fetch the LS customer portal URL for the active license and open it in a browser."""
2292
+ import webbrowser
2293
+ from brass.licensing import LicenseStore
2294
+ from brass.enrichment import (
2295
+ EnrichmentClient,
2296
+ EnrichmentClientError,
2297
+ EnrichmentUnavailableError,
2298
+ LicenseRejectedError,
2299
+ )
2300
+
2301
+ store = LicenseStore.default()
2302
+ record = store.read()
2303
+ if record is None:
2304
+ print("ℹ️ No license activated. To activate: brasscoders activate <license-key>")
2305
+ return 1
2306
+ if not record.is_active():
2307
+ print(f"⚠️ License is {record.status}. Cannot open portal for an inactive license.")
2308
+ print(" Run 'brasscoders license' for details.")
2309
+ return 1
2310
+
2311
+ client = EnrichmentClient(
2312
+ license_key=record.license_key,
2313
+ instance_id=record.instance_id,
2314
+ )
2315
+ try:
2316
+ portal_url = client.portal()
2317
+ except LicenseRejectedError as exc:
2318
+ print(f"⚠️ License rejected by gateway: {exc}")
2319
+ print(" Try 'brasscoders license' to see status.")
2320
+ return 1
2321
+ except EnrichmentUnavailableError as exc:
2322
+ print(f"⚠️ Could not fetch portal URL: {exc}")
2323
+ print(" You can manage your subscription directly at https://coppersunbrass.lemonsqueezy.com")
2324
+ return 1
2325
+ except EnrichmentClientError as exc:
2326
+ print(f"⚠️ Unexpected error: {exc}")
2327
+ return 1
2328
+
2329
+ print(f"🌐 Opening customer portal in your browser…")
2330
+ print(f" {portal_url}")
2331
+ try:
2332
+ webbrowser.open(portal_url)
2333
+ except Exception as exc:
2334
+ # Some headless environments (CI, SSH-without-X) can't open
2335
+ # a browser. Print the URL so the user can copy it manually.
2336
+ print(f" (Could not auto-open browser: {exc}. Copy the URL above.)")
2337
+ return 0
2338
+
2339
+ def _cmd_telemetry(self, args) -> int:
2340
+ """Toggle or inspect anonymized telemetry consent."""
2341
+ from brass.telemetry import ConsentStore
2342
+ store = ConsentStore()
2343
+ if args.action == 'on':
2344
+ install_id = store.set(enabled=True)
2345
+ print("✅ Telemetry: ON")
2346
+ print(f" Install ID: {install_id}")
2347
+ print(f" Consent at: {store.path}")
2348
+ print(" Inspect what gets recorded: ~/.brass/telemetry-debug.log")
2349
+ print(" Disable any time: brasscoders telemetry off")
2350
+ return 0
2351
+ if args.action == 'off':
2352
+ store.set(enabled=False)
2353
+ print("🚫 Telemetry: OFF")
2354
+ print(f" Consent at: {store.path}")
2355
+ return 0
2356
+ # status
2357
+ enabled = store.is_enabled()
2358
+ marker = "ON ✅" if enabled else "OFF 🚫"
2359
+ print(f"📊 Telemetry: {marker}")
2360
+ if enabled and store.install_id():
2361
+ print(f" Install ID: {store.install_id()}")
2362
+ print(f" Consent at: {store.path}")
2363
+ print(" Toggle: brasscoders telemetry on | off")
2364
+ return 0
2365
+
2366
+ def _cmd_cache(self, args) -> int:
2367
+ """Dispatch `brasscoders cache <action>`. Currently only 'clear'."""
2368
+ if args.action == 'clear':
2369
+ return self._cmd_cache_clear(args)
2370
+ print(f"❌ Unknown cache action: {args.action}")
2371
+ return 1
2372
+
2373
+ def _cmd_cache_clear(self, args) -> int:
2374
+ """Remove the Pysa state cache (and optionally the typeshed cache).
2375
+
2376
+ Respects BRASS_PYSA_CACHE_ROOT — clears whatever location the var
2377
+ points at, not the hardcoded default. The typeshed half always
2378
+ targets ~/.cache/brass/typeshed/ (the autofetch path);
2379
+ BRASS_TYPESHED-redirected paths are user-owned and left untouched.
2380
+
2381
+ Defense: the typeshed path is also validated against the same
2382
+ blocklist + 3-parts check the Pysa root uses, in case $HOME is
2383
+ misconfigured (HOME=/, HOME=/tmp, etc).
2384
+ """
2385
+ import shutil as _shutil # local import; cli imports are already heavy
2386
+ from brass.scanners.pysa_taint_scanner import PysaTaintScanner
2387
+
2388
+ pysa_root = PysaTaintScanner._resolved_cache_root()
2389
+ typeshed_root = self._resolved_typeshed_cache_root()
2390
+
2391
+ pysa_bytes = self._dir_size(pysa_root) if pysa_root.exists() else 0
2392
+ typeshed_bytes = (
2393
+ self._dir_size(typeshed_root)
2394
+ if (args.include_typeshed and typeshed_root is not None and typeshed_root.exists())
2395
+ else 0
2396
+ )
2397
+
2398
+ nothing_to_do = pysa_bytes == 0 and not (
2399
+ args.include_typeshed and typeshed_bytes > 0
2400
+ )
2401
+ if nothing_to_do:
2402
+ if args.include_typeshed:
2403
+ ts_msg = (
2404
+ str(typeshed_root)
2405
+ if typeshed_root is not None
2406
+ else "(typeshed location rejected by safety check)"
2407
+ )
2408
+ print(
2409
+ f"✅ No cache to clear ({pysa_root} and {ts_msg} are empty or absent)."
2410
+ )
2411
+ else:
2412
+ print(f"✅ No cache to clear ({pysa_root} is empty or absent).")
2413
+ return 0
2414
+
2415
+ total_freed = 0
2416
+ had_failure = False
2417
+
2418
+ if pysa_bytes > 0:
2419
+ n_projects = sum(1 for p in pysa_root.iterdir() if p.is_dir())
2420
+ print(f"🧹 Pysa cache: {pysa_root}")
2421
+ print(
2422
+ f" {n_projects} project cache"
2423
+ f"{'s' if n_projects != 1 else ''}: "
2424
+ f"{self._format_mb(pysa_bytes)} total"
2425
+ )
2426
+ if args.dry_run:
2427
+ print(" (dry-run; not removed)")
2428
+ else:
2429
+ # Per-entry accounting so a partial failure (mid-rmtree
2430
+ # permission error on the Nth hash dir) still reports
2431
+ # the bytes we actually freed from the first N-1 dirs.
2432
+ freed_here = 0
2433
+ first_failure: Optional[Exception] = None
2434
+ for entry in pysa_root.iterdir():
2435
+ try:
2436
+ entry_size = self._dir_size(entry) if entry.is_dir() else (
2437
+ entry.stat().st_blocks * 512
2438
+ if hasattr(entry.stat(), 'st_blocks')
2439
+ else entry.stat().st_size
2440
+ )
2441
+ except OSError:
2442
+ entry_size = 0
2443
+ try:
2444
+ if entry.is_dir():
2445
+ _shutil.rmtree(entry, ignore_errors=False)
2446
+ else:
2447
+ entry.unlink()
2448
+ freed_here += entry_size
2449
+ except OSError as exc:
2450
+ if first_failure is None:
2451
+ first_failure = exc
2452
+ had_failure = True
2453
+ # Continue with remaining entries; reporting all
2454
+ # failures is noisier than necessary, but the
2455
+ # partial-success bytes are tracked.
2456
+ total_freed += freed_here
2457
+ if first_failure is None:
2458
+ print(" ✓ removed")
2459
+ else:
2460
+ print(
2461
+ f" ⚠️ partial: freed {self._format_mb(freed_here)} "
2462
+ f"before {type(first_failure).__name__}: {first_failure}"
2463
+ )
2464
+
2465
+ if args.include_typeshed and typeshed_bytes > 0:
2466
+ print(f"🧹 Typeshed cache: {typeshed_root}")
2467
+ print(f" {self._format_mb(typeshed_bytes)}")
2468
+ if args.dry_run:
2469
+ print(" (dry-run; not removed)")
2470
+ else:
2471
+ try:
2472
+ _shutil.rmtree(typeshed_root, ignore_errors=False)
2473
+ print(" ✓ removed")
2474
+ # Pysa needs typeshed. The next online scan
2475
+ # auto-refetches (~33MB git clone). In offline mode
2476
+ # the scanner skips with a warning. Flag both
2477
+ # outcomes here so the customer is never surprised.
2478
+ print(
2479
+ " ℹ️ Next scan will auto-refetch typeshed "
2480
+ "(~33 MB git clone, unless --offline is set)."
2481
+ )
2482
+ total_freed += typeshed_bytes
2483
+ except OSError as exc:
2484
+ print(f" ❌ failed: {exc}")
2485
+ had_failure = True
2486
+
2487
+ print()
2488
+ if args.dry_run:
2489
+ total_pending = pysa_bytes + typeshed_bytes
2490
+ print(
2491
+ f"ℹ️ Run without --dry-run to free "
2492
+ f"{self._format_mb(total_pending)}."
2493
+ )
2494
+ else:
2495
+ suffix = ' total' if args.include_typeshed else ''
2496
+ if had_failure:
2497
+ print(
2498
+ f"⚠️ Freed {self._format_mb(total_freed)}{suffix} (partial — "
2499
+ f"some entries could not be removed; see warnings above)."
2500
+ )
2501
+ return 1
2502
+ print(f"✅ Freed {self._format_mb(total_freed)}{suffix}.")
2503
+ return 0
2504
+
2505
+ @staticmethod
2506
+ def _resolved_typeshed_cache_root() -> Optional[Path]:
2507
+ """Compute the typeshed cache path, validated against the same
2508
+ blocklist + 3-parts rule that protects the Pysa cache root.
2509
+
2510
+ Default location is `~/.cache/brass/typeshed/`. If $HOME resolves
2511
+ to a system path (`/`, `/etc`, `/tmp`, …) or fewer than 3 path
2512
+ components, return None — the caller treats that as "no typeshed
2513
+ cache to clear" rather than risking rmtree against a system dir.
2514
+
2515
+ This is purely defense-in-depth; under any sane configuration
2516
+ Path.home() returns a 3+ component user-owned dir and the check
2517
+ passes silently.
2518
+ """
2519
+ from brass.scanners.pysa_taint_scanner import PysaTaintScanner
2520
+ try:
2521
+ candidate = (Path.home() / '.cache' / 'brass' / 'typeshed').resolve()
2522
+ except (OSError, ValueError):
2523
+ return None
2524
+ if str(candidate) in PysaTaintScanner._CACHE_ROOT_BLOCKLIST:
2525
+ return None
2526
+ # ~/.cache/brass/typeshed under a 1-part HOME would still produce
2527
+ # a 4-part path, but we keep the >= 3 check symmetric with the
2528
+ # Pysa side. Also guard against $HOME being itself in the blocklist.
2529
+ try:
2530
+ home_resolved = Path.home().resolve()
2531
+ except (OSError, ValueError):
2532
+ return None
2533
+ if (
2534
+ str(home_resolved) in PysaTaintScanner._CACHE_ROOT_BLOCKLIST
2535
+ or len(home_resolved.parts) < 2
2536
+ ):
2537
+ return None
2538
+ if len(candidate.parts) < 3:
2539
+ return None
2540
+ return candidate
2541
+
2542
+ @staticmethod
2543
+ def _dir_size(path: Path) -> int:
2544
+ """Recursive directory size in bytes, reported the way `du -sh`
2545
+ reports it: allocated disk blocks, not file-content bytes. The
2546
+ two differ significantly for trees full of small files (typeshed
2547
+ has 5k+ .pyi files of ~200 bytes each; content-bytes reports
2548
+ ~17 MB but actual disk usage is ~33 MB due to 4 KB block
2549
+ allocation). Users compare our output to `du -sh`; matching that
2550
+ avoids "where did the rest go?" confusion after a clear.
2551
+
2552
+ Uses os.walk(followlinks=False) for deterministic behavior across
2553
+ Python versions — Path.rglob's symlink-descent semantics changed
2554
+ between 3.11 and 3.13 and we don't want size attribution to vary
2555
+ with the interpreter version.
2556
+
2557
+ Falls back to `st_size` when `st_blocks` is unavailable (Windows).
2558
+ Unreadable files are skipped consistently with `du`'s permission-
2559
+ error behavior.
2560
+ """
2561
+ total = 0
2562
+ try:
2563
+ for dirpath, _dirnames, filenames in os.walk(str(path), followlinks=False):
2564
+ for fname in filenames:
2565
+ fpath = Path(dirpath) / fname
2566
+ try:
2567
+ if fpath.is_symlink():
2568
+ # Count the link inode itself, not the target
2569
+ # — same convention as `du` without `-L`.
2570
+ st = fpath.lstat()
2571
+ else:
2572
+ st = fpath.stat()
2573
+ blocks = getattr(st, 'st_blocks', None)
2574
+ if blocks is not None:
2575
+ total += blocks * 512
2576
+ else:
2577
+ total += st.st_size
2578
+ except OSError:
2579
+ continue
2580
+ except OSError:
2581
+ pass
2582
+ return total
2583
+
2584
+ @staticmethod
2585
+ def _format_mb(n_bytes: int) -> str:
2586
+ return f"{n_bytes / (1024 * 1024):.1f} MB"
2587
+
2588
+ def _print_cache_footer(self) -> None:
2589
+ """Print a one-line awareness footer about the Pysa cache size.
2590
+
2591
+ Three-level size-based output:
2592
+ - < 100 MB: silent (uninteresting; a single populated project
2593
+ cache is typical, not "growing unbounded")
2594
+ - 100 MB – 1 GB: info-style, suggests `brasscoders cache clear`
2595
+ - >= 1 GB: warning-style, recommends `cache clear --include-typeshed`
2596
+
2597
+ Suppress entirely via `BRASS_QUIET_CACHE=1` — for power users and
2598
+ CI environments that don't want the noise.
2599
+
2600
+ Best-effort: any error reading the cache root is swallowed so the
2601
+ footer never breaks scan output. The footer is operational info,
2602
+ not result data.
2603
+ """
2604
+ if os.environ.get("BRASS_QUIET_CACHE") == "1":
2605
+ return
2606
+ try:
2607
+ from brass.scanners.pysa_taint_scanner import PysaTaintScanner
2608
+ cache_root = PysaTaintScanner._resolved_cache_root()
2609
+ if not cache_root.exists():
2610
+ return
2611
+ bytes_used = self._dir_size(cache_root)
2612
+ # Silence floor: ignore caches below ~100 MB. A single
2613
+ # typical project is 10-300 MB; users only care once they've
2614
+ # accumulated past a single-project footprint.
2615
+ if bytes_used < 100 * 1024 * 1024:
2616
+ return
2617
+ # "Project caches" is the canonical user-facing term across
2618
+ # brass (matching `brasscoders cache clear`'s output and the
2619
+ # ai_instructions.yaml advisory). Each entry is a SHA-hashed
2620
+ # subdirectory containing one project's Pyre call graph +
2621
+ # config metadata. Phase C's `_prune_stale_entries`
2622
+ # (2026-05-16) removes entries whose source dir no longer
2623
+ # exists, so the count now closely tracks projects the
2624
+ # customer actively scans.
2625
+ entry_count = sum(
2626
+ 1 for p in cache_root.iterdir()
2627
+ if p.is_dir() and not p.name.startswith('.')
2628
+ )
2629
+ if bytes_used >= 1024 ** 3: # >= 1 GB → warning
2630
+ size_str = f"{bytes_used / (1024 ** 3):.1f} GB"
2631
+ print(
2632
+ f"⚠️ BrassCoders cache is {size_str} across {entry_count} "
2633
+ f"project caches. Consider 'brasscoders cache clear --include-typeshed' "
2634
+ f"to reclaim disk space."
2635
+ )
2636
+ else:
2637
+ size_str = self._format_mb(bytes_used)
2638
+ print(
2639
+ f"🧹 BrassCoders cache: {size_str} across {entry_count} "
2640
+ f"project caches (run 'brasscoders cache clear' to free)"
2641
+ )
2642
+ except Exception as exc: # noqa: BLE001 - footer must not break scan
2643
+ logger.debug("cache footer suppressed: %s", exc)
2644
+
2645
+ def _cmd_version(self, args) -> int:
2646
+ """Execute version command (with optional update check).
2647
+
2648
+ The PyPI freshness check is opt-out via ``--offline`` (which the
2649
+ user already passes when they want zero outbound network calls)
2650
+ and via the ``BRASS_DISABLE_VERSION_CHECK`` env var. Failures are
2651
+ silently swallowed so a captive portal or down PyPI never breaks
2652
+ the version command itself. We never auto-update.
2653
+ """
2654
+ from brass.core.version_check import check_for_updates
2655
+ try:
2656
+ from brass import __version__ as current_version
2657
+ except (ImportError, AttributeError):
2658
+ current_version = "2.0.0"
2659
+
2660
+ print("🎺 BrassCoders for AI Coders v2.0 - Revolutionary Intelligence System")
2661
+ print(" AI Development Intelligence for Coding Assistants")
2662
+ print(" Built with clean architecture and fantastic user experience")
2663
+ print()
2664
+ print("🔧 Core Components:")
2665
+ print(" • ProfessionalCodeScanner - Multi-tool code analysis (Bandit + Pylint + Security)")
2666
+ print(" • Brass2PrivacyScanner - Advanced PII detection")
2667
+ print(" • ContentModerationScanner - Policy compliance")
2668
+ print(" • JavaScriptTypeScriptScanner - Unified JS/TS analysis with Babel")
2669
+ print(" • PhantomAICodeScanner - Detects incomplete AI-generated code")
2670
+ print(" • BrassPerformanceScanner - Performance Intelligence for AI code (Radon + Vulture + AI patterns)")
2671
+ print(" • IntelligenceRanker - Weighted priority system")
2672
+ print(" • OutputGenerator - AI-optimized intelligence files")
2673
+ print(" • FileWatcher - Real-time change monitoring")
2674
+ print(" • CLI - User-friendly command interface")
2675
+ print()
2676
+ print("💡 Get Started:")
2677
+ print(" brasscoders scan # Complete analysis")
2678
+ print(" brasscoders scan --fast # Quick code review")
2679
+ print(" brasscoders scan --dev # Developer focus")
2680
+ print(" brasscoders scan --performance-full # Complete performance analysis")
2681
+ print(" brasscoders watch # Monitor changes")
2682
+ print()
2683
+
2684
+ # Soft update warning. Skipped when --offline or
2685
+ # BRASS_DISABLE_VERSION_CHECK=1; failures are swallowed.
2686
+ offline = bool(getattr(args, 'offline', False))
2687
+ check = check_for_updates(current_version, offline=offline)
2688
+ if check and check.is_stale():
2689
+ print(
2690
+ f"⚠️ A newer BrassCoders is available: {check.latest} "
2691
+ f"(running {check.current}). Update with: "
2692
+ f"pipx upgrade brasscoders"
2693
+ )
2694
+ elif check and check.behind_by == 1:
2695
+ print(f"ℹ️ New release available: {check.latest} (running {check.current}).")
2696
+
2697
+ return 0
2698
+
2699
+
2700
+ def main():
2701
+ """Main entry point for CLI."""
2702
+ # Set up global error handling for better user experience
2703
+ setup_global_error_handler()
2704
+
2705
+ try:
2706
+ # Run startup checks before anything else
2707
+ run_startup_checks(verbose=False)
2708
+ except StartupError as e:
2709
+ print(f"\n❌ Startup checks failed:\n{e}")
2710
+ print("\n💡 Please fix the issues above before running BrassAI.")
2711
+ return 1
2712
+ except Exception as e:
2713
+ print(f"\n❌ Unexpected error during startup: {e}")
2714
+ return 1
2715
+
2716
+ cli = BrassCLI()
2717
+ return cli.run()
2718
+
2719
+
2720
+ if __name__ == '__main__':
2721
+ sys.exit(main())