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.
- brass/__init__.py +9 -0
- brass/cli/__init__.py +7 -0
- brass/cli/brass_cli.py +2721 -0
- brass/config/patterns.yaml +243 -0
- brass/core/__init__.py +13 -0
- brass/core/atomic_writer.py +181 -0
- brass/core/brassignore.py +182 -0
- brass/core/change_detection.py +206 -0
- brass/core/error_handling.py +262 -0
- brass/core/error_reporter.py +308 -0
- brass/core/file_classifier.py +433 -0
- brass/core/file_index.py +111 -0
- brass/core/file_integrity.py +199 -0
- brass/core/finding_cache.py +254 -0
- brass/core/framework_registry.py +523 -0
- brass/core/logging_config.py +160 -0
- brass/core/path_safety.py +36 -0
- brass/core/scanner_status.py +56 -0
- brass/core/startup_checks.py +247 -0
- brass/core/state_validator.py +188 -0
- brass/core/user_error_handler.py +202 -0
- brass/core/version_check.py +104 -0
- brass/data/ast_grep_rules/javascript/sql_injection.yml +27 -0
- brass/data/ast_grep_rules/javascript/sql_injection_ts.yml +20 -0
- brass/data/ast_grep_rules/javascript/xss.yml +15 -0
- brass/data/ast_grep_rules/javascript/xss_ts.yml +14 -0
- brass/data/ast_grep_rules/python/command_injection.yml +25 -0
- brass/data/ast_grep_rules/python/sql_injection.yml +26 -0
- brass/data/ast_grep_rules/python/sql_var_execute.yml +24 -0
- brass/data/ast_grep_rules/python/weak_crypto.yml +31 -0
- brass/data/ast_grep_rules/sgconfig.yml +3 -0
- brass/data/framework_registry/javascript.yaml +205 -0
- brass/data/framework_registry/python.yaml +355 -0
- brass/data/framework_registry/typescript.yaml +165 -0
- brass/data/pysa_models/model_queries.pysa +51 -0
- brass/data/pysa_models/stdlib.pysa +82 -0
- brass/data/pysa_models/taint.config +62 -0
- brass/data/pysa_models/third_party.pysa +78 -0
- brass/data/pysa_stubs/django/__init__.pyi +2 -0
- brass/data/pysa_stubs/django/db/__init__.pyi +0 -0
- brass/data/pysa_stubs/django/db/backends/__init__.pyi +0 -0
- brass/data/pysa_stubs/django/db/backends/utils.pyi +4 -0
- brass/data/pysa_stubs/django/db/models/__init__.pyi +0 -0
- brass/data/pysa_stubs/django/db/models/query.pyi +4 -0
- brass/data/pysa_stubs/django/http/__init__.pyi +8 -0
- brass/data/pysa_stubs/django/utils/__init__.pyi +0 -0
- brass/data/pysa_stubs/django/utils/safestring.pyi +1 -0
- brass/data/pysa_stubs/flask/__init__.pyi +26 -0
- brass/data/pysa_stubs/httpx/__init__.pyi +4 -0
- brass/data/pysa_stubs/requests/__init__.pyi +0 -0
- brass/data/pysa_stubs/requests/api.pyi +7 -0
- brass/data/pysa_stubs/sqlalchemy/__init__.pyi +3 -0
- brass/data/pysa_stubs/sqlalchemy/engine/__init__.pyi +4 -0
- brass/data/pysa_stubs/sqlalchemy/orm/__init__.pyi +0 -0
- brass/data/pysa_stubs/sqlalchemy/orm/session.pyi +4 -0
- brass/data/pysa_stubs/yaml/__init__.pyi +4 -0
- brass/data/semgrep_rules/javascript/command_injection.yml +50 -0
- brass/data/semgrep_rules/javascript/sql_injection.yml +61 -0
- brass/data/semgrep_rules/javascript/ssrf.yml +82 -0
- brass/data/semgrep_rules/javascript/xss.yml +44 -0
- brass/data/semgrep_rules/python/command_injection.yml +82 -0
- brass/data/semgrep_rules/python/deserialization.yml +87 -0
- brass/data/semgrep_rules/python/path_traversal.yml +120 -0
- brass/data/semgrep_rules/python/sql_injection.yml +77 -0
- brass/data/semgrep_rules/python/ssrf.yml +102 -0
- brass/data/semgrep_rules/python/xss.yml +86 -0
- brass/enrichment/__init__.py +51 -0
- brass/enrichment/_token_budget.py +112 -0
- brass/enrichment/_wire_clamp.py +42 -0
- brass/enrichment/client.py +683 -0
- brass/enrichment/filter.py +371 -0
- brass/enrichment/project_signature.py +197 -0
- brass/filtering/__init__.py +8 -0
- brass/filtering/ai_review_filter.py +302 -0
- brass/js_analysis/babel_parser.js +416 -0
- brass/js_analysis/node_modules/@babel/code-frame/LICENSE +22 -0
- brass/js_analysis/node_modules/@babel/code-frame/README.md +19 -0
- brass/js_analysis/node_modules/@babel/code-frame/lib/index.js +216 -0
- brass/js_analysis/node_modules/@babel/code-frame/lib/index.js.map +1 -0
- brass/js_analysis/node_modules/@babel/code-frame/package.json +31 -0
- brass/js_analysis/node_modules/@babel/generator/LICENSE +22 -0
- brass/js_analysis/node_modules/@babel/generator/README.md +19 -0
- brass/js_analysis/node_modules/@babel/generator/lib/buffer.js +317 -0
- brass/js_analysis/node_modules/@babel/generator/lib/buffer.js.map +1 -0
- brass/js_analysis/node_modules/@babel/generator/lib/generators/base.js +87 -0
- brass/js_analysis/node_modules/@babel/generator/lib/generators/base.js.map +1 -0
- brass/js_analysis/node_modules/@babel/generator/lib/generators/classes.js +212 -0
- brass/js_analysis/node_modules/@babel/generator/lib/generators/classes.js.map +1 -0
- brass/js_analysis/node_modules/@babel/generator/lib/generators/deprecated.js +28 -0
- brass/js_analysis/node_modules/@babel/generator/lib/generators/deprecated.js.map +1 -0
- brass/js_analysis/node_modules/@babel/generator/lib/generators/expressions.js +300 -0
- brass/js_analysis/node_modules/@babel/generator/lib/generators/expressions.js.map +1 -0
- brass/js_analysis/node_modules/@babel/generator/lib/generators/flow.js +660 -0
- brass/js_analysis/node_modules/@babel/generator/lib/generators/flow.js.map +1 -0
- brass/js_analysis/node_modules/@babel/generator/lib/generators/index.js +128 -0
- brass/js_analysis/node_modules/@babel/generator/lib/generators/index.js.map +1 -0
- brass/js_analysis/node_modules/@babel/generator/lib/generators/jsx.js +126 -0
- brass/js_analysis/node_modules/@babel/generator/lib/generators/jsx.js.map +1 -0
- brass/js_analysis/node_modules/@babel/generator/lib/generators/methods.js +198 -0
- brass/js_analysis/node_modules/@babel/generator/lib/generators/methods.js.map +1 -0
- brass/js_analysis/node_modules/@babel/generator/lib/generators/modules.js +287 -0
- brass/js_analysis/node_modules/@babel/generator/lib/generators/modules.js.map +1 -0
- brass/js_analysis/node_modules/@babel/generator/lib/generators/statements.js +279 -0
- brass/js_analysis/node_modules/@babel/generator/lib/generators/statements.js.map +1 -0
- brass/js_analysis/node_modules/@babel/generator/lib/generators/template-literals.js +40 -0
- brass/js_analysis/node_modules/@babel/generator/lib/generators/template-literals.js.map +1 -0
- brass/js_analysis/node_modules/@babel/generator/lib/generators/types.js +238 -0
- brass/js_analysis/node_modules/@babel/generator/lib/generators/types.js.map +1 -0
- brass/js_analysis/node_modules/@babel/generator/lib/generators/typescript.js +724 -0
- brass/js_analysis/node_modules/@babel/generator/lib/generators/typescript.js.map +1 -0
- brass/js_analysis/node_modules/@babel/generator/lib/index.js +112 -0
- brass/js_analysis/node_modules/@babel/generator/lib/index.js.map +1 -0
- brass/js_analysis/node_modules/@babel/generator/lib/node/index.js +122 -0
- brass/js_analysis/node_modules/@babel/generator/lib/node/index.js.map +1 -0
- brass/js_analysis/node_modules/@babel/generator/lib/node/parentheses.js +262 -0
- brass/js_analysis/node_modules/@babel/generator/lib/node/parentheses.js.map +1 -0
- brass/js_analysis/node_modules/@babel/generator/lib/node/whitespace.js +145 -0
- brass/js_analysis/node_modules/@babel/generator/lib/node/whitespace.js.map +1 -0
- brass/js_analysis/node_modules/@babel/generator/lib/printer.js +781 -0
- brass/js_analysis/node_modules/@babel/generator/lib/printer.js.map +1 -0
- brass/js_analysis/node_modules/@babel/generator/lib/source-map.js +85 -0
- brass/js_analysis/node_modules/@babel/generator/lib/source-map.js.map +1 -0
- brass/js_analysis/node_modules/@babel/generator/lib/token-map.js +191 -0
- brass/js_analysis/node_modules/@babel/generator/lib/token-map.js.map +1 -0
- brass/js_analysis/node_modules/@babel/generator/package.json +40 -0
- brass/js_analysis/node_modules/@babel/helper-globals/LICENSE +22 -0
- brass/js_analysis/node_modules/@babel/helper-globals/README.md +19 -0
- brass/js_analysis/node_modules/@babel/helper-globals/data/browser-upper.json +911 -0
- brass/js_analysis/node_modules/@babel/helper-globals/data/builtin-lower.json +15 -0
- brass/js_analysis/node_modules/@babel/helper-globals/data/builtin-upper.json +51 -0
- brass/js_analysis/node_modules/@babel/helper-globals/package.json +32 -0
- brass/js_analysis/node_modules/@babel/helper-string-parser/LICENSE +22 -0
- brass/js_analysis/node_modules/@babel/helper-string-parser/README.md +19 -0
- brass/js_analysis/node_modules/@babel/helper-string-parser/lib/index.js +295 -0
- brass/js_analysis/node_modules/@babel/helper-string-parser/lib/index.js.map +1 -0
- brass/js_analysis/node_modules/@babel/helper-string-parser/package.json +31 -0
- brass/js_analysis/node_modules/@babel/helper-validator-identifier/LICENSE +22 -0
- brass/js_analysis/node_modules/@babel/helper-validator-identifier/README.md +19 -0
- brass/js_analysis/node_modules/@babel/helper-validator-identifier/lib/identifier.js +70 -0
- brass/js_analysis/node_modules/@babel/helper-validator-identifier/lib/identifier.js.map +1 -0
- brass/js_analysis/node_modules/@babel/helper-validator-identifier/lib/index.js +57 -0
- brass/js_analysis/node_modules/@babel/helper-validator-identifier/lib/index.js.map +1 -0
- brass/js_analysis/node_modules/@babel/helper-validator-identifier/lib/keyword.js +35 -0
- brass/js_analysis/node_modules/@babel/helper-validator-identifier/lib/keyword.js.map +1 -0
- brass/js_analysis/node_modules/@babel/helper-validator-identifier/package.json +31 -0
- brass/js_analysis/node_modules/@babel/parser/CHANGELOG.md +1073 -0
- brass/js_analysis/node_modules/@babel/parser/LICENSE +19 -0
- brass/js_analysis/node_modules/@babel/parser/README.md +19 -0
- brass/js_analysis/node_modules/@babel/parser/bin/babel-parser.js +15 -0
- brass/js_analysis/node_modules/@babel/parser/lib/index.js +14586 -0
- brass/js_analysis/node_modules/@babel/parser/lib/index.js.map +1 -0
- brass/js_analysis/node_modules/@babel/parser/package.json +50 -0
- brass/js_analysis/node_modules/@babel/parser/typings/babel-parser.d.ts +239 -0
- brass/js_analysis/node_modules/@babel/template/LICENSE +22 -0
- brass/js_analysis/node_modules/@babel/template/README.md +19 -0
- brass/js_analysis/node_modules/@babel/template/lib/builder.js +69 -0
- brass/js_analysis/node_modules/@babel/template/lib/builder.js.map +1 -0
- brass/js_analysis/node_modules/@babel/template/lib/formatters.js +61 -0
- brass/js_analysis/node_modules/@babel/template/lib/formatters.js.map +1 -0
- brass/js_analysis/node_modules/@babel/template/lib/index.js +23 -0
- brass/js_analysis/node_modules/@babel/template/lib/index.js.map +1 -0
- brass/js_analysis/node_modules/@babel/template/lib/literal.js +69 -0
- brass/js_analysis/node_modules/@babel/template/lib/literal.js.map +1 -0
- brass/js_analysis/node_modules/@babel/template/lib/options.js +73 -0
- brass/js_analysis/node_modules/@babel/template/lib/options.js.map +1 -0
- brass/js_analysis/node_modules/@babel/template/lib/parse.js +163 -0
- brass/js_analysis/node_modules/@babel/template/lib/parse.js.map +1 -0
- brass/js_analysis/node_modules/@babel/template/lib/populate.js +138 -0
- brass/js_analysis/node_modules/@babel/template/lib/populate.js.map +1 -0
- brass/js_analysis/node_modules/@babel/template/lib/string.js +20 -0
- brass/js_analysis/node_modules/@babel/template/lib/string.js.map +1 -0
- brass/js_analysis/node_modules/@babel/template/package.json +27 -0
- brass/js_analysis/node_modules/@babel/traverse/LICENSE +22 -0
- brass/js_analysis/node_modules/@babel/traverse/README.md +19 -0
- brass/js_analysis/node_modules/@babel/traverse/lib/cache.js +38 -0
- brass/js_analysis/node_modules/@babel/traverse/lib/cache.js.map +1 -0
- brass/js_analysis/node_modules/@babel/traverse/lib/context.js +119 -0
- brass/js_analysis/node_modules/@babel/traverse/lib/context.js.map +1 -0
- brass/js_analysis/node_modules/@babel/traverse/lib/hub.js +19 -0
- brass/js_analysis/node_modules/@babel/traverse/lib/hub.js.map +1 -0
- brass/js_analysis/node_modules/@babel/traverse/lib/index.js +87 -0
- brass/js_analysis/node_modules/@babel/traverse/lib/index.js.map +1 -0
- brass/js_analysis/node_modules/@babel/traverse/lib/path/ancestry.js +139 -0
- brass/js_analysis/node_modules/@babel/traverse/lib/path/ancestry.js.map +1 -0
- brass/js_analysis/node_modules/@babel/traverse/lib/path/comments.js +52 -0
- brass/js_analysis/node_modules/@babel/traverse/lib/path/comments.js.map +1 -0
- brass/js_analysis/node_modules/@babel/traverse/lib/path/context.js +242 -0
- brass/js_analysis/node_modules/@babel/traverse/lib/path/context.js.map +1 -0
- brass/js_analysis/node_modules/@babel/traverse/lib/path/conversion.js +612 -0
- brass/js_analysis/node_modules/@babel/traverse/lib/path/conversion.js.map +1 -0
- brass/js_analysis/node_modules/@babel/traverse/lib/path/evaluation.js +368 -0
- brass/js_analysis/node_modules/@babel/traverse/lib/path/evaluation.js.map +1 -0
- brass/js_analysis/node_modules/@babel/traverse/lib/path/family.js +346 -0
- brass/js_analysis/node_modules/@babel/traverse/lib/path/family.js.map +1 -0
- brass/js_analysis/node_modules/@babel/traverse/lib/path/index.js +293 -0
- brass/js_analysis/node_modules/@babel/traverse/lib/path/index.js.map +1 -0
- brass/js_analysis/node_modules/@babel/traverse/lib/path/inference/index.js +149 -0
- brass/js_analysis/node_modules/@babel/traverse/lib/path/inference/index.js.map +1 -0
- brass/js_analysis/node_modules/@babel/traverse/lib/path/inference/inferer-reference.js +151 -0
- brass/js_analysis/node_modules/@babel/traverse/lib/path/inference/inferer-reference.js.map +1 -0
- brass/js_analysis/node_modules/@babel/traverse/lib/path/inference/inferers.js +207 -0
- brass/js_analysis/node_modules/@babel/traverse/lib/path/inference/inferers.js.map +1 -0
- brass/js_analysis/node_modules/@babel/traverse/lib/path/inference/util.js +30 -0
- brass/js_analysis/node_modules/@babel/traverse/lib/path/inference/util.js.map +1 -0
- brass/js_analysis/node_modules/@babel/traverse/lib/path/introspection.js +398 -0
- brass/js_analysis/node_modules/@babel/traverse/lib/path/introspection.js.map +1 -0
- brass/js_analysis/node_modules/@babel/traverse/lib/path/lib/hoister.js +171 -0
- brass/js_analysis/node_modules/@babel/traverse/lib/path/lib/hoister.js.map +1 -0
- brass/js_analysis/node_modules/@babel/traverse/lib/path/lib/removal-hooks.js +37 -0
- brass/js_analysis/node_modules/@babel/traverse/lib/path/lib/removal-hooks.js.map +1 -0
- brass/js_analysis/node_modules/@babel/traverse/lib/path/lib/virtual-types-validator.js +163 -0
- brass/js_analysis/node_modules/@babel/traverse/lib/path/lib/virtual-types-validator.js.map +1 -0
- brass/js_analysis/node_modules/@babel/traverse/lib/path/lib/virtual-types.js +26 -0
- brass/js_analysis/node_modules/@babel/traverse/lib/path/lib/virtual-types.js.map +1 -0
- brass/js_analysis/node_modules/@babel/traverse/lib/path/modification.js +230 -0
- brass/js_analysis/node_modules/@babel/traverse/lib/path/modification.js.map +1 -0
- brass/js_analysis/node_modules/@babel/traverse/lib/path/removal.js +70 -0
- brass/js_analysis/node_modules/@babel/traverse/lib/path/removal.js.map +1 -0
- brass/js_analysis/node_modules/@babel/traverse/lib/path/replacement.js +263 -0
- brass/js_analysis/node_modules/@babel/traverse/lib/path/replacement.js.map +1 -0
- brass/js_analysis/node_modules/@babel/traverse/lib/scope/binding.js +84 -0
- brass/js_analysis/node_modules/@babel/traverse/lib/scope/binding.js.map +1 -0
- brass/js_analysis/node_modules/@babel/traverse/lib/scope/index.js +1039 -0
- brass/js_analysis/node_modules/@babel/traverse/lib/scope/index.js.map +1 -0
- brass/js_analysis/node_modules/@babel/traverse/lib/scope/lib/renamer.js +131 -0
- brass/js_analysis/node_modules/@babel/traverse/lib/scope/lib/renamer.js.map +1 -0
- brass/js_analysis/node_modules/@babel/traverse/lib/traverse-node.js +138 -0
- brass/js_analysis/node_modules/@babel/traverse/lib/traverse-node.js.map +1 -0
- brass/js_analysis/node_modules/@babel/traverse/lib/types.js +3 -0
- brass/js_analysis/node_modules/@babel/traverse/lib/types.js.map +1 -0
- brass/js_analysis/node_modules/@babel/traverse/lib/visitors.js +258 -0
- brass/js_analysis/node_modules/@babel/traverse/lib/visitors.js.map +1 -0
- brass/js_analysis/node_modules/@babel/traverse/package.json +35 -0
- brass/js_analysis/node_modules/@babel/types/LICENSE +22 -0
- brass/js_analysis/node_modules/@babel/types/README.md +19 -0
- brass/js_analysis/node_modules/@babel/types/lib/asserts/assertNode.js +16 -0
- brass/js_analysis/node_modules/@babel/types/lib/asserts/assertNode.js.map +1 -0
- brass/js_analysis/node_modules/@babel/types/lib/asserts/generated/index.js +1251 -0
- brass/js_analysis/node_modules/@babel/types/lib/asserts/generated/index.js.map +1 -0
- brass/js_analysis/node_modules/@babel/types/lib/ast-types/generated/index.js +3 -0
- brass/js_analysis/node_modules/@babel/types/lib/ast-types/generated/index.js.map +1 -0
- brass/js_analysis/node_modules/@babel/types/lib/builders/flow/createFlowUnionType.js +18 -0
- brass/js_analysis/node_modules/@babel/types/lib/builders/flow/createFlowUnionType.js.map +1 -0
- brass/js_analysis/node_modules/@babel/types/lib/builders/flow/createTypeAnnotationBasedOnTypeof.js +31 -0
- brass/js_analysis/node_modules/@babel/types/lib/builders/flow/createTypeAnnotationBasedOnTypeof.js.map +1 -0
- brass/js_analysis/node_modules/@babel/types/lib/builders/generated/index.js +29 -0
- brass/js_analysis/node_modules/@babel/types/lib/builders/generated/index.js.map +1 -0
- brass/js_analysis/node_modules/@babel/types/lib/builders/generated/lowercase.js +2896 -0
- brass/js_analysis/node_modules/@babel/types/lib/builders/generated/lowercase.js.map +1 -0
- brass/js_analysis/node_modules/@babel/types/lib/builders/generated/uppercase.js +274 -0
- brass/js_analysis/node_modules/@babel/types/lib/builders/generated/uppercase.js.map +1 -0
- brass/js_analysis/node_modules/@babel/types/lib/builders/productions.js +12 -0
- brass/js_analysis/node_modules/@babel/types/lib/builders/productions.js.map +1 -0
- brass/js_analysis/node_modules/@babel/types/lib/builders/react/buildChildren.js +24 -0
- brass/js_analysis/node_modules/@babel/types/lib/builders/react/buildChildren.js.map +1 -0
- brass/js_analysis/node_modules/@babel/types/lib/builders/typescript/createTSUnionType.js +22 -0
- brass/js_analysis/node_modules/@babel/types/lib/builders/typescript/createTSUnionType.js.map +1 -0
- brass/js_analysis/node_modules/@babel/types/lib/builders/validateNode.js +21 -0
- brass/js_analysis/node_modules/@babel/types/lib/builders/validateNode.js.map +1 -0
- brass/js_analysis/node_modules/@babel/types/lib/clone/clone.js +12 -0
- brass/js_analysis/node_modules/@babel/types/lib/clone/clone.js.map +1 -0
- brass/js_analysis/node_modules/@babel/types/lib/clone/cloneDeep.js +12 -0
- brass/js_analysis/node_modules/@babel/types/lib/clone/cloneDeep.js.map +1 -0
- brass/js_analysis/node_modules/@babel/types/lib/clone/cloneDeepWithoutLoc.js +12 -0
- brass/js_analysis/node_modules/@babel/types/lib/clone/cloneDeepWithoutLoc.js.map +1 -0
- brass/js_analysis/node_modules/@babel/types/lib/clone/cloneNode.js +107 -0
- brass/js_analysis/node_modules/@babel/types/lib/clone/cloneNode.js.map +1 -0
- brass/js_analysis/node_modules/@babel/types/lib/clone/cloneWithoutLoc.js +12 -0
- brass/js_analysis/node_modules/@babel/types/lib/clone/cloneWithoutLoc.js.map +1 -0
- brass/js_analysis/node_modules/@babel/types/lib/comments/addComment.js +15 -0
- brass/js_analysis/node_modules/@babel/types/lib/comments/addComment.js.map +1 -0
- brass/js_analysis/node_modules/@babel/types/lib/comments/addComments.js +22 -0
- brass/js_analysis/node_modules/@babel/types/lib/comments/addComments.js.map +1 -0
- brass/js_analysis/node_modules/@babel/types/lib/comments/inheritInnerComments.js +12 -0
- brass/js_analysis/node_modules/@babel/types/lib/comments/inheritInnerComments.js.map +1 -0
- brass/js_analysis/node_modules/@babel/types/lib/comments/inheritLeadingComments.js +12 -0
- brass/js_analysis/node_modules/@babel/types/lib/comments/inheritLeadingComments.js.map +1 -0
- brass/js_analysis/node_modules/@babel/types/lib/comments/inheritTrailingComments.js +12 -0
- brass/js_analysis/node_modules/@babel/types/lib/comments/inheritTrailingComments.js.map +1 -0
- brass/js_analysis/node_modules/@babel/types/lib/comments/inheritsComments.js +17 -0
- brass/js_analysis/node_modules/@babel/types/lib/comments/inheritsComments.js.map +1 -0
- brass/js_analysis/node_modules/@babel/types/lib/comments/removeComments.js +15 -0
- brass/js_analysis/node_modules/@babel/types/lib/comments/removeComments.js.map +1 -0
- brass/js_analysis/node_modules/@babel/types/lib/constants/generated/index.js +60 -0
- brass/js_analysis/node_modules/@babel/types/lib/constants/generated/index.js.map +1 -0
- brass/js_analysis/node_modules/@babel/types/lib/constants/index.js +33 -0
- brass/js_analysis/node_modules/@babel/types/lib/constants/index.js.map +1 -0
- brass/js_analysis/node_modules/@babel/types/lib/converters/ensureBlock.js +14 -0
- brass/js_analysis/node_modules/@babel/types/lib/converters/ensureBlock.js.map +1 -0
- brass/js_analysis/node_modules/@babel/types/lib/converters/gatherSequenceExpressions.js +66 -0
- brass/js_analysis/node_modules/@babel/types/lib/converters/gatherSequenceExpressions.js.map +1 -0
- brass/js_analysis/node_modules/@babel/types/lib/converters/toBindingIdentifierName.js +14 -0
- brass/js_analysis/node_modules/@babel/types/lib/converters/toBindingIdentifierName.js.map +1 -0
- brass/js_analysis/node_modules/@babel/types/lib/converters/toBlock.js +29 -0
- brass/js_analysis/node_modules/@babel/types/lib/converters/toBlock.js.map +1 -0
- brass/js_analysis/node_modules/@babel/types/lib/converters/toComputedKey.js +14 -0
- brass/js_analysis/node_modules/@babel/types/lib/converters/toComputedKey.js.map +1 -0
- brass/js_analysis/node_modules/@babel/types/lib/converters/toExpression.js +28 -0
- brass/js_analysis/node_modules/@babel/types/lib/converters/toExpression.js.map +1 -0
- brass/js_analysis/node_modules/@babel/types/lib/converters/toIdentifier.js +25 -0
- brass/js_analysis/node_modules/@babel/types/lib/converters/toIdentifier.js.map +1 -0
- brass/js_analysis/node_modules/@babel/types/lib/converters/toKeyAlias.js +38 -0
- brass/js_analysis/node_modules/@babel/types/lib/converters/toKeyAlias.js.map +1 -0
- brass/js_analysis/node_modules/@babel/types/lib/converters/toSequenceExpression.js +20 -0
- brass/js_analysis/node_modules/@babel/types/lib/converters/toSequenceExpression.js.map +1 -0
- brass/js_analysis/node_modules/@babel/types/lib/converters/toStatement.js +39 -0
- brass/js_analysis/node_modules/@babel/types/lib/converters/toStatement.js.map +1 -0
- brass/js_analysis/node_modules/@babel/types/lib/converters/valueToNode.js +89 -0
- brass/js_analysis/node_modules/@babel/types/lib/converters/valueToNode.js.map +1 -0
- brass/js_analysis/node_modules/@babel/types/lib/definitions/core.js +1659 -0
- brass/js_analysis/node_modules/@babel/types/lib/definitions/core.js.map +1 -0
- brass/js_analysis/node_modules/@babel/types/lib/definitions/deprecated-aliases.js +11 -0
- brass/js_analysis/node_modules/@babel/types/lib/definitions/deprecated-aliases.js.map +1 -0
- brass/js_analysis/node_modules/@babel/types/lib/definitions/experimental.js +126 -0
- brass/js_analysis/node_modules/@babel/types/lib/definitions/experimental.js.map +1 -0
- brass/js_analysis/node_modules/@babel/types/lib/definitions/flow.js +495 -0
- brass/js_analysis/node_modules/@babel/types/lib/definitions/flow.js.map +1 -0
- brass/js_analysis/node_modules/@babel/types/lib/definitions/index.js +100 -0
- brass/js_analysis/node_modules/@babel/types/lib/definitions/index.js.map +1 -0
- brass/js_analysis/node_modules/@babel/types/lib/definitions/jsx.js +157 -0
- brass/js_analysis/node_modules/@babel/types/lib/definitions/jsx.js.map +1 -0
- brass/js_analysis/node_modules/@babel/types/lib/definitions/misc.js +33 -0
- brass/js_analysis/node_modules/@babel/types/lib/definitions/misc.js.map +1 -0
- brass/js_analysis/node_modules/@babel/types/lib/definitions/placeholders.js +27 -0
- brass/js_analysis/node_modules/@babel/types/lib/definitions/placeholders.js.map +1 -0
- brass/js_analysis/node_modules/@babel/types/lib/definitions/typescript.js +528 -0
- brass/js_analysis/node_modules/@babel/types/lib/definitions/typescript.js.map +1 -0
- brass/js_analysis/node_modules/@babel/types/lib/definitions/utils.js +292 -0
- brass/js_analysis/node_modules/@babel/types/lib/definitions/utils.js.map +1 -0
- brass/js_analysis/node_modules/@babel/types/lib/index-legacy.d.ts +2797 -0
- brass/js_analysis/node_modules/@babel/types/lib/index.d.ts +3308 -0
- brass/js_analysis/node_modules/@babel/types/lib/index.js +584 -0
- brass/js_analysis/node_modules/@babel/types/lib/index.js.flow +2650 -0
- brass/js_analysis/node_modules/@babel/types/lib/index.js.map +1 -0
- brass/js_analysis/node_modules/@babel/types/lib/modifications/appendToMemberExpression.js +15 -0
- brass/js_analysis/node_modules/@babel/types/lib/modifications/appendToMemberExpression.js.map +1 -0
- brass/js_analysis/node_modules/@babel/types/lib/modifications/flow/removeTypeDuplicates.js +65 -0
- brass/js_analysis/node_modules/@babel/types/lib/modifications/flow/removeTypeDuplicates.js.map +1 -0
- brass/js_analysis/node_modules/@babel/types/lib/modifications/inherits.js +28 -0
- brass/js_analysis/node_modules/@babel/types/lib/modifications/inherits.js.map +1 -0
- brass/js_analysis/node_modules/@babel/types/lib/modifications/prependToMemberExpression.js +17 -0
- brass/js_analysis/node_modules/@babel/types/lib/modifications/prependToMemberExpression.js.map +1 -0
- brass/js_analysis/node_modules/@babel/types/lib/modifications/removeProperties.js +24 -0
- brass/js_analysis/node_modules/@babel/types/lib/modifications/removeProperties.js.map +1 -0
- brass/js_analysis/node_modules/@babel/types/lib/modifications/removePropertiesDeep.js +14 -0
- brass/js_analysis/node_modules/@babel/types/lib/modifications/removePropertiesDeep.js.map +1 -0
- brass/js_analysis/node_modules/@babel/types/lib/modifications/typescript/removeTypeDuplicates.js +66 -0
- brass/js_analysis/node_modules/@babel/types/lib/modifications/typescript/removeTypeDuplicates.js.map +1 -0
- brass/js_analysis/node_modules/@babel/types/lib/retrievers/getAssignmentIdentifiers.js +48 -0
- brass/js_analysis/node_modules/@babel/types/lib/retrievers/getAssignmentIdentifiers.js.map +1 -0
- brass/js_analysis/node_modules/@babel/types/lib/retrievers/getBindingIdentifiers.js +102 -0
- brass/js_analysis/node_modules/@babel/types/lib/retrievers/getBindingIdentifiers.js.map +1 -0
- brass/js_analysis/node_modules/@babel/types/lib/retrievers/getFunctionName.js +63 -0
- brass/js_analysis/node_modules/@babel/types/lib/retrievers/getFunctionName.js.map +1 -0
- brass/js_analysis/node_modules/@babel/types/lib/retrievers/getOuterBindingIdentifiers.js +13 -0
- brass/js_analysis/node_modules/@babel/types/lib/retrievers/getOuterBindingIdentifiers.js.map +1 -0
- brass/js_analysis/node_modules/@babel/types/lib/traverse/traverse.js +50 -0
- brass/js_analysis/node_modules/@babel/types/lib/traverse/traverse.js.map +1 -0
- brass/js_analysis/node_modules/@babel/types/lib/traverse/traverseFast.js +40 -0
- brass/js_analysis/node_modules/@babel/types/lib/traverse/traverseFast.js.map +1 -0
- brass/js_analysis/node_modules/@babel/types/lib/utils/deprecationWarning.js +44 -0
- brass/js_analysis/node_modules/@babel/types/lib/utils/deprecationWarning.js.map +1 -0
- brass/js_analysis/node_modules/@babel/types/lib/utils/inherit.js +13 -0
- brass/js_analysis/node_modules/@babel/types/lib/utils/inherit.js.map +1 -0
- brass/js_analysis/node_modules/@babel/types/lib/utils/react/cleanJSXElementLiteralChild.js +40 -0
- brass/js_analysis/node_modules/@babel/types/lib/utils/react/cleanJSXElementLiteralChild.js.map +1 -0
- brass/js_analysis/node_modules/@babel/types/lib/utils/shallowEqual.js +17 -0
- brass/js_analysis/node_modules/@babel/types/lib/utils/shallowEqual.js.map +1 -0
- brass/js_analysis/node_modules/@babel/types/lib/validators/buildMatchMemberExpression.js +13 -0
- brass/js_analysis/node_modules/@babel/types/lib/validators/buildMatchMemberExpression.js.map +1 -0
- brass/js_analysis/node_modules/@babel/types/lib/validators/generated/index.js +2797 -0
- brass/js_analysis/node_modules/@babel/types/lib/validators/generated/index.js.map +1 -0
- brass/js_analysis/node_modules/@babel/types/lib/validators/is.js +27 -0
- brass/js_analysis/node_modules/@babel/types/lib/validators/is.js.map +1 -0
- brass/js_analysis/node_modules/@babel/types/lib/validators/isBinding.js +27 -0
- brass/js_analysis/node_modules/@babel/types/lib/validators/isBinding.js.map +1 -0
- brass/js_analysis/node_modules/@babel/types/lib/validators/isBlockScoped.js +13 -0
- brass/js_analysis/node_modules/@babel/types/lib/validators/isBlockScoped.js.map +1 -0
- brass/js_analysis/node_modules/@babel/types/lib/validators/isImmutable.js +21 -0
- brass/js_analysis/node_modules/@babel/types/lib/validators/isImmutable.js.map +1 -0
- brass/js_analysis/node_modules/@babel/types/lib/validators/isLet.js +17 -0
- brass/js_analysis/node_modules/@babel/types/lib/validators/isLet.js.map +1 -0
- brass/js_analysis/node_modules/@babel/types/lib/validators/isNode.js +12 -0
- brass/js_analysis/node_modules/@babel/types/lib/validators/isNode.js.map +1 -0
- brass/js_analysis/node_modules/@babel/types/lib/validators/isNodesEquivalent.js +57 -0
- brass/js_analysis/node_modules/@babel/types/lib/validators/isNodesEquivalent.js.map +1 -0
- brass/js_analysis/node_modules/@babel/types/lib/validators/isPlaceholderType.js +15 -0
- brass/js_analysis/node_modules/@babel/types/lib/validators/isPlaceholderType.js.map +1 -0
- brass/js_analysis/node_modules/@babel/types/lib/validators/isReferenced.js +96 -0
- brass/js_analysis/node_modules/@babel/types/lib/validators/isReferenced.js.map +1 -0
- brass/js_analysis/node_modules/@babel/types/lib/validators/isScope.js +18 -0
- brass/js_analysis/node_modules/@babel/types/lib/validators/isScope.js.map +1 -0
- brass/js_analysis/node_modules/@babel/types/lib/validators/isSpecifierDefault.js +14 -0
- brass/js_analysis/node_modules/@babel/types/lib/validators/isSpecifierDefault.js.map +1 -0
- brass/js_analysis/node_modules/@babel/types/lib/validators/isType.js +17 -0
- brass/js_analysis/node_modules/@babel/types/lib/validators/isType.js.map +1 -0
- brass/js_analysis/node_modules/@babel/types/lib/validators/isValidES3Identifier.js +13 -0
- brass/js_analysis/node_modules/@babel/types/lib/validators/isValidES3Identifier.js.map +1 -0
- brass/js_analysis/node_modules/@babel/types/lib/validators/isValidIdentifier.js +18 -0
- brass/js_analysis/node_modules/@babel/types/lib/validators/isValidIdentifier.js.map +1 -0
- brass/js_analysis/node_modules/@babel/types/lib/validators/isVar.js +19 -0
- brass/js_analysis/node_modules/@babel/types/lib/validators/isVar.js.map +1 -0
- brass/js_analysis/node_modules/@babel/types/lib/validators/matchesPattern.js +44 -0
- brass/js_analysis/node_modules/@babel/types/lib/validators/matchesPattern.js.map +1 -0
- brass/js_analysis/node_modules/@babel/types/lib/validators/react/isCompatTag.js +11 -0
- brass/js_analysis/node_modules/@babel/types/lib/validators/react/isCompatTag.js.map +1 -0
- brass/js_analysis/node_modules/@babel/types/lib/validators/react/isReactComponent.js +11 -0
- brass/js_analysis/node_modules/@babel/types/lib/validators/react/isReactComponent.js.map +1 -0
- brass/js_analysis/node_modules/@babel/types/lib/validators/validate.js +42 -0
- brass/js_analysis/node_modules/@babel/types/lib/validators/validate.js.map +1 -0
- brass/js_analysis/node_modules/@babel/types/package.json +39 -0
- brass/js_analysis/node_modules/@jridgewell/gen-mapping/LICENSE +19 -0
- brass/js_analysis/node_modules/@jridgewell/gen-mapping/README.md +227 -0
- brass/js_analysis/node_modules/@jridgewell/gen-mapping/dist/gen-mapping.mjs +292 -0
- brass/js_analysis/node_modules/@jridgewell/gen-mapping/dist/gen-mapping.mjs.map +6 -0
- brass/js_analysis/node_modules/@jridgewell/gen-mapping/dist/gen-mapping.umd.js +346 -0
- brass/js_analysis/node_modules/@jridgewell/gen-mapping/dist/gen-mapping.umd.js.map +6 -0
- brass/js_analysis/node_modules/@jridgewell/gen-mapping/package.json +71 -0
- brass/js_analysis/node_modules/@jridgewell/gen-mapping/src/gen-mapping.ts +614 -0
- brass/js_analysis/node_modules/@jridgewell/gen-mapping/src/set-array.ts +82 -0
- brass/js_analysis/node_modules/@jridgewell/gen-mapping/src/sourcemap-segment.ts +16 -0
- brass/js_analysis/node_modules/@jridgewell/gen-mapping/src/types.ts +61 -0
- brass/js_analysis/node_modules/@jridgewell/gen-mapping/types/gen-mapping.d.cts +89 -0
- brass/js_analysis/node_modules/@jridgewell/gen-mapping/types/gen-mapping.d.cts.map +1 -0
- brass/js_analysis/node_modules/@jridgewell/gen-mapping/types/gen-mapping.d.mts +89 -0
- brass/js_analysis/node_modules/@jridgewell/gen-mapping/types/gen-mapping.d.mts.map +1 -0
- brass/js_analysis/node_modules/@jridgewell/gen-mapping/types/set-array.d.cts +33 -0
- brass/js_analysis/node_modules/@jridgewell/gen-mapping/types/set-array.d.cts.map +1 -0
- brass/js_analysis/node_modules/@jridgewell/gen-mapping/types/set-array.d.mts +33 -0
- brass/js_analysis/node_modules/@jridgewell/gen-mapping/types/set-array.d.mts.map +1 -0
- brass/js_analysis/node_modules/@jridgewell/gen-mapping/types/sourcemap-segment.d.cts +13 -0
- brass/js_analysis/node_modules/@jridgewell/gen-mapping/types/sourcemap-segment.d.cts.map +1 -0
- brass/js_analysis/node_modules/@jridgewell/gen-mapping/types/sourcemap-segment.d.mts +13 -0
- brass/js_analysis/node_modules/@jridgewell/gen-mapping/types/sourcemap-segment.d.mts.map +1 -0
- brass/js_analysis/node_modules/@jridgewell/gen-mapping/types/types.d.cts +44 -0
- brass/js_analysis/node_modules/@jridgewell/gen-mapping/types/types.d.cts.map +1 -0
- brass/js_analysis/node_modules/@jridgewell/gen-mapping/types/types.d.mts +44 -0
- brass/js_analysis/node_modules/@jridgewell/gen-mapping/types/types.d.mts.map +1 -0
- brass/js_analysis/node_modules/@jridgewell/resolve-uri/LICENSE +19 -0
- brass/js_analysis/node_modules/@jridgewell/resolve-uri/README.md +40 -0
- brass/js_analysis/node_modules/@jridgewell/resolve-uri/dist/resolve-uri.mjs +232 -0
- brass/js_analysis/node_modules/@jridgewell/resolve-uri/dist/resolve-uri.mjs.map +1 -0
- brass/js_analysis/node_modules/@jridgewell/resolve-uri/dist/resolve-uri.umd.js +240 -0
- brass/js_analysis/node_modules/@jridgewell/resolve-uri/dist/resolve-uri.umd.js.map +1 -0
- brass/js_analysis/node_modules/@jridgewell/resolve-uri/dist/types/resolve-uri.d.ts +4 -0
- brass/js_analysis/node_modules/@jridgewell/resolve-uri/package.json +69 -0
- brass/js_analysis/node_modules/@jridgewell/sourcemap-codec/LICENSE +19 -0
- brass/js_analysis/node_modules/@jridgewell/sourcemap-codec/README.md +264 -0
- brass/js_analysis/node_modules/@jridgewell/sourcemap-codec/dist/sourcemap-codec.mjs +423 -0
- brass/js_analysis/node_modules/@jridgewell/sourcemap-codec/dist/sourcemap-codec.mjs.map +6 -0
- brass/js_analysis/node_modules/@jridgewell/sourcemap-codec/dist/sourcemap-codec.umd.js +452 -0
- brass/js_analysis/node_modules/@jridgewell/sourcemap-codec/dist/sourcemap-codec.umd.js.map +6 -0
- brass/js_analysis/node_modules/@jridgewell/sourcemap-codec/package.json +67 -0
- brass/js_analysis/node_modules/@jridgewell/sourcemap-codec/src/scopes.ts +345 -0
- brass/js_analysis/node_modules/@jridgewell/sourcemap-codec/src/sourcemap-codec.ts +111 -0
- brass/js_analysis/node_modules/@jridgewell/sourcemap-codec/src/strings.ts +65 -0
- brass/js_analysis/node_modules/@jridgewell/sourcemap-codec/src/vlq.ts +55 -0
- brass/js_analysis/node_modules/@jridgewell/sourcemap-codec/types/scopes.d.cts +50 -0
- brass/js_analysis/node_modules/@jridgewell/sourcemap-codec/types/scopes.d.cts.map +1 -0
- brass/js_analysis/node_modules/@jridgewell/sourcemap-codec/types/scopes.d.mts +50 -0
- brass/js_analysis/node_modules/@jridgewell/sourcemap-codec/types/scopes.d.mts.map +1 -0
- brass/js_analysis/node_modules/@jridgewell/sourcemap-codec/types/sourcemap-codec.d.cts +9 -0
- brass/js_analysis/node_modules/@jridgewell/sourcemap-codec/types/sourcemap-codec.d.cts.map +1 -0
- brass/js_analysis/node_modules/@jridgewell/sourcemap-codec/types/sourcemap-codec.d.mts +9 -0
- brass/js_analysis/node_modules/@jridgewell/sourcemap-codec/types/sourcemap-codec.d.mts.map +1 -0
- brass/js_analysis/node_modules/@jridgewell/sourcemap-codec/types/strings.d.cts +16 -0
- brass/js_analysis/node_modules/@jridgewell/sourcemap-codec/types/strings.d.cts.map +1 -0
- brass/js_analysis/node_modules/@jridgewell/sourcemap-codec/types/strings.d.mts +16 -0
- brass/js_analysis/node_modules/@jridgewell/sourcemap-codec/types/strings.d.mts.map +1 -0
- brass/js_analysis/node_modules/@jridgewell/sourcemap-codec/types/vlq.d.cts +7 -0
- brass/js_analysis/node_modules/@jridgewell/sourcemap-codec/types/vlq.d.cts.map +1 -0
- brass/js_analysis/node_modules/@jridgewell/sourcemap-codec/types/vlq.d.mts +7 -0
- brass/js_analysis/node_modules/@jridgewell/sourcemap-codec/types/vlq.d.mts.map +1 -0
- brass/js_analysis/node_modules/@jridgewell/trace-mapping/LICENSE +19 -0
- brass/js_analysis/node_modules/@jridgewell/trace-mapping/README.md +348 -0
- brass/js_analysis/node_modules/@jridgewell/trace-mapping/dist/trace-mapping.mjs +504 -0
- brass/js_analysis/node_modules/@jridgewell/trace-mapping/dist/trace-mapping.mjs.map +6 -0
- brass/js_analysis/node_modules/@jridgewell/trace-mapping/dist/trace-mapping.umd.js +558 -0
- brass/js_analysis/node_modules/@jridgewell/trace-mapping/dist/trace-mapping.umd.js.map +6 -0
- brass/js_analysis/node_modules/@jridgewell/trace-mapping/package.json +71 -0
- brass/js_analysis/node_modules/@jridgewell/trace-mapping/src/binary-search.ts +115 -0
- brass/js_analysis/node_modules/@jridgewell/trace-mapping/src/by-source.ts +65 -0
- brass/js_analysis/node_modules/@jridgewell/trace-mapping/src/flatten-map.ts +192 -0
- brass/js_analysis/node_modules/@jridgewell/trace-mapping/src/resolve.ts +16 -0
- brass/js_analysis/node_modules/@jridgewell/trace-mapping/src/sort.ts +45 -0
- brass/js_analysis/node_modules/@jridgewell/trace-mapping/src/sourcemap-segment.ts +23 -0
- brass/js_analysis/node_modules/@jridgewell/trace-mapping/src/strip-filename.ts +8 -0
- brass/js_analysis/node_modules/@jridgewell/trace-mapping/src/trace-mapping.ts +504 -0
- brass/js_analysis/node_modules/@jridgewell/trace-mapping/src/types.ts +114 -0
- brass/js_analysis/node_modules/@jridgewell/trace-mapping/types/binary-search.d.cts +33 -0
- brass/js_analysis/node_modules/@jridgewell/trace-mapping/types/binary-search.d.cts.map +1 -0
- brass/js_analysis/node_modules/@jridgewell/trace-mapping/types/binary-search.d.mts +33 -0
- brass/js_analysis/node_modules/@jridgewell/trace-mapping/types/binary-search.d.mts.map +1 -0
- brass/js_analysis/node_modules/@jridgewell/trace-mapping/types/by-source.d.cts +8 -0
- brass/js_analysis/node_modules/@jridgewell/trace-mapping/types/by-source.d.cts.map +1 -0
- brass/js_analysis/node_modules/@jridgewell/trace-mapping/types/by-source.d.mts +8 -0
- brass/js_analysis/node_modules/@jridgewell/trace-mapping/types/by-source.d.mts.map +1 -0
- brass/js_analysis/node_modules/@jridgewell/trace-mapping/types/flatten-map.d.cts +9 -0
- brass/js_analysis/node_modules/@jridgewell/trace-mapping/types/flatten-map.d.cts.map +1 -0
- brass/js_analysis/node_modules/@jridgewell/trace-mapping/types/flatten-map.d.mts +9 -0
- brass/js_analysis/node_modules/@jridgewell/trace-mapping/types/flatten-map.d.mts.map +1 -0
- brass/js_analysis/node_modules/@jridgewell/trace-mapping/types/resolve.d.cts +4 -0
- brass/js_analysis/node_modules/@jridgewell/trace-mapping/types/resolve.d.cts.map +1 -0
- brass/js_analysis/node_modules/@jridgewell/trace-mapping/types/resolve.d.mts +4 -0
- brass/js_analysis/node_modules/@jridgewell/trace-mapping/types/resolve.d.mts.map +1 -0
- brass/js_analysis/node_modules/@jridgewell/trace-mapping/types/sort.d.cts +3 -0
- brass/js_analysis/node_modules/@jridgewell/trace-mapping/types/sort.d.cts.map +1 -0
- brass/js_analysis/node_modules/@jridgewell/trace-mapping/types/sort.d.mts +3 -0
- brass/js_analysis/node_modules/@jridgewell/trace-mapping/types/sort.d.mts.map +1 -0
- brass/js_analysis/node_modules/@jridgewell/trace-mapping/types/sourcemap-segment.d.cts +17 -0
- brass/js_analysis/node_modules/@jridgewell/trace-mapping/types/sourcemap-segment.d.cts.map +1 -0
- brass/js_analysis/node_modules/@jridgewell/trace-mapping/types/sourcemap-segment.d.mts +17 -0
- brass/js_analysis/node_modules/@jridgewell/trace-mapping/types/sourcemap-segment.d.mts.map +1 -0
- brass/js_analysis/node_modules/@jridgewell/trace-mapping/types/strip-filename.d.cts +5 -0
- brass/js_analysis/node_modules/@jridgewell/trace-mapping/types/strip-filename.d.cts.map +1 -0
- brass/js_analysis/node_modules/@jridgewell/trace-mapping/types/strip-filename.d.mts +5 -0
- brass/js_analysis/node_modules/@jridgewell/trace-mapping/types/strip-filename.d.mts.map +1 -0
- brass/js_analysis/node_modules/@jridgewell/trace-mapping/types/trace-mapping.d.cts +80 -0
- brass/js_analysis/node_modules/@jridgewell/trace-mapping/types/trace-mapping.d.cts.map +1 -0
- brass/js_analysis/node_modules/@jridgewell/trace-mapping/types/trace-mapping.d.mts +80 -0
- brass/js_analysis/node_modules/@jridgewell/trace-mapping/types/trace-mapping.d.mts.map +1 -0
- brass/js_analysis/node_modules/@jridgewell/trace-mapping/types/types.d.cts +107 -0
- brass/js_analysis/node_modules/@jridgewell/trace-mapping/types/types.d.cts.map +1 -0
- brass/js_analysis/node_modules/@jridgewell/trace-mapping/types/types.d.mts +107 -0
- brass/js_analysis/node_modules/@jridgewell/trace-mapping/types/types.d.mts.map +1 -0
- brass/js_analysis/node_modules/debug/LICENSE +20 -0
- brass/js_analysis/node_modules/debug/README.md +481 -0
- brass/js_analysis/node_modules/debug/package.json +64 -0
- brass/js_analysis/node_modules/debug/src/browser.js +272 -0
- brass/js_analysis/node_modules/debug/src/common.js +292 -0
- brass/js_analysis/node_modules/debug/src/index.js +10 -0
- brass/js_analysis/node_modules/debug/src/node.js +263 -0
- brass/js_analysis/node_modules/js-tokens/CHANGELOG.md +151 -0
- brass/js_analysis/node_modules/js-tokens/LICENSE +21 -0
- brass/js_analysis/node_modules/js-tokens/README.md +240 -0
- brass/js_analysis/node_modules/js-tokens/index.js +23 -0
- brass/js_analysis/node_modules/js-tokens/package.json +30 -0
- brass/js_analysis/node_modules/jsesc/LICENSE-MIT.txt +20 -0
- brass/js_analysis/node_modules/jsesc/README.md +422 -0
- brass/js_analysis/node_modules/jsesc/bin/jsesc +148 -0
- brass/js_analysis/node_modules/jsesc/jsesc.js +337 -0
- brass/js_analysis/node_modules/jsesc/man/jsesc.1 +94 -0
- brass/js_analysis/node_modules/jsesc/package.json +56 -0
- brass/js_analysis/node_modules/ms/index.js +162 -0
- brass/js_analysis/node_modules/ms/license.md +21 -0
- brass/js_analysis/node_modules/ms/package.json +38 -0
- brass/js_analysis/node_modules/ms/readme.md +59 -0
- brass/js_analysis/node_modules/picocolors/LICENSE +15 -0
- brass/js_analysis/node_modules/picocolors/README.md +21 -0
- brass/js_analysis/node_modules/picocolors/package.json +25 -0
- brass/js_analysis/node_modules/picocolors/picocolors.browser.js +4 -0
- brass/js_analysis/node_modules/picocolors/picocolors.d.ts +5 -0
- brass/js_analysis/node_modules/picocolors/picocolors.js +75 -0
- brass/js_analysis/node_modules/picocolors/types.d.ts +51 -0
- brass/js_analysis/package-lock.json +218 -0
- brass/js_analysis/package.json +13 -0
- brass/licensing/__init__.py +55 -0
- brass/licensing/lemonsqueezy.py +205 -0
- brass/licensing/store.py +134 -0
- brass/models/__init__.py +7 -0
- brass/models/finding.py +142 -0
- brass/monitoring/__init__.py +7 -0
- brass/monitoring/file_watcher.py +408 -0
- brass/output/__init__.py +7 -0
- brass/output/cross_scanner_overlap.py +132 -0
- brass/output/output_generator.py +679 -0
- brass/output/redaction_checker.py +254 -0
- brass/output/yaml_builders/__init__.py +28 -0
- brass/output/yaml_builders/ai_instructions_builder.py +1867 -0
- brass/output/yaml_builders/base_builder.py +735 -0
- brass/output/yaml_builders/constants.py +44 -0
- brass/output/yaml_builders/detailed_analysis_builder.py +111 -0
- brass/output/yaml_builders/file_intelligence_builder.py +115 -0
- brass/output/yaml_builders/metadata_builder.py +40 -0
- brass/output/yaml_builders/privacy_report_builder.py +226 -0
- brass/output/yaml_builders/security_report_builder.py +165 -0
- brass/output/yaml_builders/statistics_builder.py +264 -0
- brass/output/yaml_builders/yaml_utils.py +156 -0
- brass/output/yaml_output_generator.py +918 -0
- brass/output/yaml_output_generator_v2.py +424 -0
- brass/ranking/__init__.py +7 -0
- brass/ranking/intelligence_ranker.py +736 -0
- brass/scanners/__init__.py +15 -0
- brass/scanners/_known_test_values.py +183 -0
- brass/scanners/ai_context_coherence_scanner.py +811 -0
- brass/scanners/api_security_refactored/__init__.py +35 -0
- brass/scanners/api_security_refactored/auth_patterns.py +234 -0
- brass/scanners/api_security_refactored/input_validation.py +236 -0
- brass/scanners/api_security_refactored/package_hallucination.py +207 -0
- brass/scanners/api_security_refactored/scanner.py +213 -0
- brass/scanners/api_security_refactored/utils.py +192 -0
- brass/scanners/api_security_scanner.py +900 -0
- brass/scanners/ast_grep_scanner.py +277 -0
- brass/scanners/brass2_privacy_scanner.py +1122 -0
- brass/scanners/brass_performance_scanner.py +1615 -0
- brass/scanners/content_moderation_scanner.py +793 -0
- brass/scanners/file_prefilter_scanner.py +413 -0
- brass/scanners/javascript_typescript_scanner.py +771 -0
- brass/scanners/noise_reduction_scanner.py +448 -0
- brass/scanners/phantom_ai_code_scanner.py +935 -0
- brass/scanners/professional_code_scanner.py +1470 -0
- brass/scanners/pysa_taint_scanner.py +1501 -0
- brass/scanners/secrets_scanner.py +392 -0
- brass/scanners/semgrep_taint_scanner.py +478 -0
- brass/telemetry/__init__.py +50 -0
- brass/telemetry/backend.py +61 -0
- brass/telemetry/client.py +82 -0
- brass/telemetry/consent.py +82 -0
- brasscoders-2.0.4.dist-info/METADATA +251 -0
- brasscoders-2.0.4.dist-info/RECORD +615 -0
- brasscoders-2.0.4.dist-info/WHEEL +5 -0
- brasscoders-2.0.4.dist-info/entry_points.txt +2 -0
- brasscoders-2.0.4.dist-info/licenses/LICENSE +202 -0
- brasscoders-2.0.4.dist-info/licenses/NOTICE +5 -0
- 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())
|