Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions SKILL-QUICK.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ $CLI list --limit 5 --offset 10 --format compact # → paginated + co
| `--limit N` / `--offset N` | Pagination on list-type commands (`list`, `search`, `trace`, `symbols`, `outline`). Default limit=20 (issue #17). `--top N` is an alias for `--limit N --offset 0` |
| `--deep` | Enable LSP-enhanced deep analysis (requires language server; check with `lsp-status`) |
| `--db-path PATH` | Custom SQLite database path (default: `.codelens/codelens.db`) |
| `--diff-base REF` | Git ref (branch/tag/SHA/`HEAD~1`) to diff against. Only findings from files changed relative to REF are reported. Empty diff → early exit. Useful for CI PR checks. Works on all analysis commands (issue #157) |

### Lite Mode Per Command

Expand Down
8 changes: 8 additions & 0 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -336,5 +336,13 @@ CodeLens v8.0+ adds 7 major capability pillars over v7.x:
5. **Cross-File Dataflow Engine** (`dataflow` v2) — Workspace-wide call graph with import resolution (`from/import`, `require` destructuring) and bidirectional taint propagation.
6. **OWASP Top 10 + Compliance Mapping** — 89 rules total (A01-A10 + PCI-DSS requirements 1-12 + HIPAA 45 CFR § 164.312).
7. **CI/CD Quality Gate** (`check` command) — Exits non-zero on failure, SARIF output for GitHub Advanced Security / VS Code.
8. **Diff-Scoped Analysis** (`--diff-base REF` flag, issue #157) — Global flag that restricts any analysis command to only report findings from files changed relative to a git ref (branch/tag/SHA/`HEAD~1`). Empty diff → early exit with clear message. Invalid ref → clear error + non-zero exit. Useful for CI PR checks where only NEW findings introduced by the PR should fail the gate.

```bash
# CI PR check — only findings from files changed vs main
codelens check . --diff-base origin/main --format sarif > codelens.sarif
codelens taint . --diff-base origin/main
codelens secrets . --diff-base HEAD~1
```

v8.1 follows up with F1 benchmark improvements (avg F1 0.803 → 0.872), circular engine depth fixes (F1 0.667 → 1.000), dead-code engine fixes (F1 0.800 → 0.952), and AST taint depth enhancements (return-value propagation, scope-hierarchical TaintState, branch condition refinement).
122 changes: 122 additions & 0 deletions scripts/codelens.py
Original file line number Diff line number Diff line change
Expand Up @@ -907,13 +907,28 @@ def main():
sub.add_argument("--db-path", default=None, metavar="PATH",
help="Custom path for SQLite database file")

# Issue #157: --diff-base <ref> on every subparser so it works both
# before and after the subcommand (matches --db-path / --format pattern).
if "diff_base" not in existing_dests:
sub.add_argument("--diff-base", default=None, metavar="REF",
help="Git ref to diff against — only findings from "
"changed files are reported (issue #157)")

# Global format option (works before subcommand)
# Default: "ai" if CODELENS_AI_MODE is set (for AI consumers), else "json"
_default_format = "ai" if os.environ.get("CODELENS_AI_MODE", "").lower() in ("1", "true", "yes") else "json"
parser.add_argument("--format", "-f", choices=["json", "markdown", "ai", "sarif", "compact"], default=_default_format,
help=f"Output format (default: {_default_format}. Set CODELENS_AI_MODE=1 for ai default. compact = token-efficient single-char keys)")
parser.add_argument("--db-path", default=None,
help="Custom path for SQLite database (default: .codelens/codelens.db)")
# Issue #157: --diff-base <ref> restricts analysis to files changed
# relative to <ref>. Pre-filter layer: commands still scan the full
# workspace, but findings from unchanged files are filtered out of
# the result. Empty diff → early exit with a clear message.
parser.add_argument("--diff-base", default=None, metavar="REF",
help="Git ref (branch/tag/SHA/HEAD~1) to diff against. "
"Only findings from files changed relative to REF "
"are reported. Useful for CI PR checks. (issue #157)")

# ─── Parse and dispatch ─────────────────────────────

Expand Down Expand Up @@ -943,6 +958,7 @@ def main():
global_deep = False
global_disable_suppression = False
global_ignore_pattern = None
global_diff_base = None # issue #157

i = 1
while i < len(sys.argv):
Expand Down Expand Up @@ -985,6 +1001,12 @@ def main():
global_ignore_pattern = sys.argv[i + 1]
elif arg.startswith('--codelens-ignore-pattern='):
global_ignore_pattern = arg.split('=', 1)[1]
# Issue #157: --diff-base <ref> (space form)
elif arg == '--diff-base' and i + 1 < len(sys.argv):
global_diff_base = sys.argv[i + 1]
# Issue #157: --diff-base=<ref> (equals form)
elif arg.startswith('--diff-base='):
global_diff_base = arg.split('=', 1)[1]
i += 1

args = parser.parse_args()
Expand Down Expand Up @@ -1034,6 +1056,53 @@ def main():
if workspace != (getattr(args, 'workspace', None) or ""):
print(f"[CodeLens] Auto-detected workspace: {workspace}", file=sys.stderr)

# ─── Issue #157: --diff-base <ref> ───────────────────────────
# Build the DiffScope once, before command execution. If the diff is
# empty, early-exit with a clear message. The scope is attached to
# ``args`` so commands that want to do in-engine pre-filtering can
# access it (none do yet — this is a post-filter layer for now).
diff_scope = None
# Resolve --diff-base: global pre-parse value or subparser value.
# argparse stores --diff-base as ``diff_base`` on the subparser too,
# but since it's a global flag, the pre-parse value is authoritative.
diff_base_ref = global_diff_base or getattr(args, 'diff_base', None)
if diff_base_ref:
from diff_scope import DiffScope, DiffScopeError
try:
diff_scope = DiffScope.from_ref(workspace, diff_base_ref)
except DiffScopeError as exc:
error_result = {
"status": "error",
"command": args.command,
"error": str(exc),
"error_type": "diff_scope_error",
"suggestion": (
"Ensure the workspace is a git repository and the ref "
"exists. Use `git rev-parse --verify <ref>` to check."
),
}
print(format_output(error_result, args.format, args.command), file=sys.stderr)
sys.exit(1)
if diff_scope.is_empty:
# Empty diff → early exit per issue #157 DoD
empty_result = {
"status": "ok",
"command": args.command,
"message": f"No changed files relative to {diff_base_ref!r}",
"diff_scope": diff_scope.summary(),
"stats": {},
"findings": [],
}
print(format_output(empty_result, args.format, args.command, workspace))
sys.exit(0)
# Attach to args so commands can opt-in to in-engine pre-filtering
args.diff_scope = diff_scope
print(
f"[CodeLens] --diff-base {diff_base_ref!r}: {diff_scope.changed_count} "
f"file(s) in scope",
file=sys.stderr,
)

# ─── Auto-setup: if command needs registry and none exists, bootstrap it ────
# Commands that need a registry to work meaningfully
_REGISTRY_COMMANDS = {
Expand Down Expand Up @@ -1276,6 +1345,59 @@ def main():
except Exception as e:
logger.warning(f"Suppression processing failed: {e}", exc_info=True)

# ─── Issue #157: --diff-base post-filter ──
# Drop findings from files not in the changed-file allowlist. This
# is a post-filter layer — commands still scan the full workspace,
# but findings from unchanged files are removed before output.
# Commands that produce graph data (trace, impact, circular, scan)
# are NOT filtered because their results are structural (node/edge
# graphs) rather than file-keyed findings — filtering them would
# silently corrupt the graph.
if diff_scope is not None and isinstance(result, dict) and args.command in (
"secrets", "smell", "complexity", "dead-code", "debug-leak",
"circular", "taint", "vuln-scan", "check", "analyze",
"missing-refs", "side-effect", "perf-hint", "regex-audit",
"a11y", "css-deep", "dataflow", "stack-trace", "config-drift",
"ownership", "test-map",
):
_FILTER_KEYS = (
"findings", "leaks", "hints", "issues", "violations",
"matches", "chains", "results",
)
total_before = 0
total_after = 0
for key in _FILTER_KEYS:
val = result.get(key)
if isinstance(val, list):
before = len(val)
result[key] = diff_scope.filter_findings(val)
total_before += before
total_after += len(result[key])
elif isinstance(val, dict):
# Category-keyed (dead-code by_category, smell by_category)
for sub_key, sub_val in val.items():
if isinstance(sub_val, list):
before = len(sub_val)
val[sub_key] = diff_scope.filter_findings(sub_val)
total_before += before
total_after += len(val[sub_key])
# Also filter the flat ``findings`` list that some commands
# (e.g., ``check``) produce at the top level.
if "findings" in result and isinstance(result["findings"], list):
# Already filtered above if ``findings`` is in _FILTER_KEYS,
# but ``check`` stores them under ``findings`` — covered.
pass
# Attach diff_scope summary so consumers can see what was filtered
result["diff_scope"] = diff_scope.summary()
result["diff_scope"]["findings_before_filter"] = total_before
result["diff_scope"]["findings_after_filter"] = total_after
if total_before != total_after:
print(
f"[CodeLens] --diff-base: {total_before - total_after} "
f"finding(s) from unchanged files filtered out",
file=sys.stderr,
)

# ─── Format and print output ──
# Some commands (doctor issue #64 Phase 1, sessions issue #64
# Phase 2) print their own human-readable output directly and
Expand Down
Loading
Loading