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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ CodeLens consolidates what used to be ~78 separate commands into **12 umbrella c
|---|---|---|
| `scan` | scan (default) · rescan | Build/refresh the workspace graph. Everything else depends on this having run once. |
| `search` | semantic (default) · symbol · regex · graph | The grep replacement. `pattern` comes **first**, workspace second — opposite of every other command below. See [gotcha](#a-gotcha-worth-memorizing). |
| `context` | orient (default) · outline · trace · context · diagnostics · overview | Orientation, file structure, call-chain tracing, rich symbol context, LSP diagnostics (`--file`), token-efficient symbol map. |
| `context` | orient (default) · outline · trace · context · diagnostics · overview · tags | Orientation, file structure, call-chain tracing, rich symbol context, LSP diagnostics (`--file`), token-efficient symbol map, `@FLOW`/`@ENTRY` doc-tag audit. |
| `deps` | affected · dependents · circular (default: all three) · import-snapshot · export-snapshot | Dependency graph: what's affected by a change, who imports what, circular imports, team snapshot sharing. |
| `audit` | dead-code · complexity · smell · staleness · perf-hint · side-effect · css · a11y (default: all) | Code quality. `dead-code` cross-checked against `context --check trace` before you trust it. `css` = deep CSS analysis, `a11y` = WCAG 2.1 accessibility. |
| `security` | secrets · vuln-scan · taint · binary-scan · regex-audit (default: all) | Hardcoded secrets, CVE/OSV dependency scanning, AST taint analysis, ReDoS. **Taint is Python/JS/TS/TSX only** — no Rust source/sink rules yet. |
Expand Down
2 changes: 1 addition & 1 deletion SKILL-QUICK.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ codelens search "pattern" . --mode regex --limit 5 --offset 10 --format compact
|---|---|
| `scan` | scan (default) · rescan |
| `search "pattern" [workspace]` | semantic (default) · symbol · regex · graph — **pattern first, workspace second**, opposite of every command below |
| `context [workspace]` | orient (default) · outline · trace (`--name X --direction up\|down\|both`) · context (`--name X`) · diagnostics (`--file X`, LSP) · overview (symbol map) |
| `context [workspace]` | orient (default) · outline · trace (`--name X --direction up\|down\|both`) · context (`--name X`) · diagnostics (`--file X`, LSP) · overview (symbol map) · tags (doc-tag audit) |
| `deps [workspace]` | affected (`--files ...`) · dependents (`--files ...`) · circular · import-snapshot (`--input path.gz`) · export-snapshot (`--output path.gz`) |
| `audit [workspace]` | dead-code · complexity · smell · staleness · perf-hint · side-effect · css · a11y |
| `security [workspace]` | secrets · vuln-scan · taint · binary-scan · regex-audit |
Expand Down
2 changes: 1 addition & 1 deletion SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ codelens guard --pre --file X → DROPPED
|---|---|
| `scan` | scan (default) · rescan |
| `search` | semantic (default) · symbol · regex · graph — **`pattern` comes first, workspace second**, opposite of every other command here |
| `context` | orient (default) · outline · trace · context · diagnostics (LSP lint, needs `--file`) · overview (token-efficient symbol map) |
| `context` | orient (default) · outline · trace · context · diagnostics (LSP lint, needs `--file`) · overview (token-efficient symbol map) · tags (`@FLOW`/`@ENTRY` doc-tag audit) |
| `deps` | affected · dependents · circular (default: all three) · import-snapshot · export-snapshot |
| `audit` | dead-code · complexity · smell · staleness · perf-hint · side-effect · css (deep CSS) · a11y (WCAG 2.1) (default: all) |
| `security` | secrets · vuln-scan · taint · binary-scan · regex-audit (default: all) |
Expand Down
84 changes: 84 additions & 0 deletions docs/design/0305-tag-audit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# Design Doc: Doc-Tag Audit (`context --check tags`)

> **Status:** Accepted
> **Date:** 2026-07-17
> **Author:** Wolfvin (BOS) / Claude Code
> **Related issues:** #305
> **Related PRs:** (this PR)

---

## Problem

CodeLens (and every Wolfvin project) documents intent with a per-file header
(`@WHO`/`@WHAT`/`@PART`/`@ENTRY`) and per-function docstring tags
(`@FLOW`/`@CALLS`/`@MUTATES`). The convention is real and enforced by habit,
but **nothing reads it back**, so the data rots silently:

- 46 of 236 `.py` files in `scripts/` carry the header (19%). The other 190 are
invisible to any "which files are undocumented?" question.
- 21 named flows are hand-written (`AUDIT_DISPATCH`, `SECRETS_SCAN`, `ORIENT`…),
but there is no way to list them or ask "which flow does this file belong to?"
- Two flows — `LLM_INVOKE` (`scripts/llm/provider.py:447`) and `LLM_TOOL_INVOKE`
(`scripts/llm/base_tool.py:241`) — name code whose command (`llm`) was dropped
in #195. Orphaned tags accumulate with nothing to surface them.

A convention with no tooling is documentation that lies over time.

## Goal

One sub-check that answers three questions from the tags **already in the
source**, inventing nothing: what flows exist and where, which files carry a
full / partial / no header, and which files are untagged. Fully deterministic
(regex only, no LLM), so two runs on the same tree are byte-identical.

## Changes

### New Files
- `scripts/tag_audit_engine.py` — `TagAuditEngine(BaseEngine)` + `audit_tags()`.
Walks the workspace via the shared `BaseEngine` (reusing its ignore-dir and
time-budget logic — no new walker), regex-matches the tag convention, and
aggregates into a report.
- `scripts/commands/tags.py` — thin `execute()` wrapper delegating to the engine.

### Registry
- `commands/context.py`: register `tags` in `_CHECKS` + epilog. Command count
stays 12 (sub-check, not a top-level command).

### Identity / parsing decisions
- A tag is counted only when it **opens** a comment/docstring line (optional
`#`/`//`/`*`/`--` marker, then whitespace, then `@TAG:`). This is what
separates a real declaration from a prose mention like ``the `@FLOW: PURE`
example`` — without the anchor, any file *documenting* the convention (this
engine included) registers phantom flows. Caught by dogfooding during
implementation.
- A flow's name is the first whitespace-delimited token of its `@FLOW` value;
the remainder is human prose.
- File header completeness = presence of all four of `@WHO/@WHAT/@PART/@ENTRY`.
Partial (1–3 present) is reported separately as a likely-forgotten tag.
- Language-agnostic: the tags are identical across Python (`#`) and TS/JS
(`//`); only the comment marker differs, so detection keys on the tag, not
the language.

## Non-goals (explicit)

- **No writing.** The engine never adds or updates a tag in source. Auto-tagging
and staleness-by-body-hash are deliberately out of scope: deciding a tag's
*value* is authorship, which for a deterministic no-LLM tool belongs to the
human/agent, not the scanner. Tracked as a possible follow-up.
- **No reachability claim.** Orphan flows (e.g. `LLM_*`) are surfaced by
location, not asserted as dead — reachability needs more than a regex.

## Testing

Unit tests with synthetic fixtures (full / partial / no header, prose-mention
rejection, flow-name tokenization) plus a self-scan sanity check against
CodeLens's own tree (22 flows including this feature's own `TAG_AUDIT`, the two
`LLM_*` orphans surfaced). Deterministic output asserted by sorting.

## Known limitation

`--format markdown` and `--format ai` render the `context` umbrella envelope
`{s,st,r}` empty ("Symbol not found") — a pre-existing formatter gap affecting
all context sub-checks, tracked in #306. `json` and `compact` (the agent-facing
formats) are correct.
6 changes: 6 additions & 0 deletions scripts/commands/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@
"module": "commands.symbols_overview",
"help": "Token-efficient hierarchical symbols map from registry (issue #254)",
},
"tags": {
"module": "commands.tags",
"help": "Audit @FLOW/@ENTRY/@PART doc-tags: flow inventory, header coverage, untagged files (issue #305)",
},
}

ALL_CHECKS = list(_CHECKS.keys())
Expand All @@ -74,6 +78,7 @@ def add_args(parser):
" orient 10-second codebase orientation brief\n"
" diagnostics LSP lint/errors/warnings for a file (needs --file, issue #253)\n"
" overview Token-efficient hierarchical symbols map (issue #254)\n"
" tags Audit @FLOW/@ENTRY/@PART doc-tags (issue #305)\n"
"\n"
"Examples:\n"
" codelens context . # orient (default)\n"
Expand All @@ -83,6 +88,7 @@ def add_args(parser):
" codelens context . --check diagnostics --file src/app.ts\n"
" codelens context . --check overview # workspace symbol map\n"
" codelens context . --check overview --file src/auth.ts\n"
" codelens context . --check tags # doc-tag audit\n"
)
parser.add_argument("workspace", nargs="?", default=None,
help="Path to workspace root (auto-detected if omitted)")
Expand Down
32 changes: 32 additions & 0 deletions scripts/commands/tags.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# @WHO: scripts/commands/tags.py
# @WHAT: Thin CLI wrapper for the doc-tag audit — delegates to tag_audit_engine
# @PART: command (sub-check of `context`)
# @ENTRY: execute()
"""`context --check tags` — audit the @FLOW/@ENTRY/@PART doc-tag convention.

Inventories named flows, measures header coverage, and lists untagged /
partially-tagged files, all from the tags already present in the source. Pure
read-only scan (issue #305). The real work lives in ``tag_audit_engine``.
"""

from typing import Any, Dict

from tag_audit_engine import audit_tags


def add_args(parser):
"""Register CLI arguments (workspace is carried by the umbrella)."""
parser.add_argument(
"workspace", nargs="?", default=None,
help="Path to workspace root (auto-detected if omitted)",
)


def execute(args, workspace) -> Dict[str, Any]:

Check warning on line 25 in scripts/commands/tags.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove the unused function parameter "args".

See more on https://sonarcloud.io/project/issues?id=Wolfvin_CodeLens&issues=AZ9woiT_oUxP0Lg30wbv&open=AZ9woiT_oUxP0Lg30wbv&pullRequest=307
"""Run the tag audit for ``workspace``.

@FLOW: TAG_AUDIT
@CALLS: tag_audit_engine.audit_tags() -> dict
@MUTATES: nothing (read-only)
"""
return audit_tags(workspace)
152 changes: 152 additions & 0 deletions scripts/tag_audit_engine.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
# @WHO: scripts/tag_audit_engine.py
# @WHAT: Audit the @WHO/@WHAT/@PART/@ENTRY + @FLOW/@CALLS/@MUTATES doc-tag
# convention: inventory named flows, measure header coverage, list
# untagged and partially-tagged files (issue #305)
# @PART: engine
# @ENTRY: TagAuditEngine, audit_tags()
"""Tag-convention audit for CodeLens.

The codebase documents intent with a header block per file
(``@WHO``/``@WHAT``/``@PART``/``@ENTRY``) and per-function tags in docstrings
(``@FLOW``/``@CALLS``/``@MUTATES``). The convention spans languages — the tags
appear in ``#`` comments (Python) and ``//`` comments (TS/JS) alike — but until
now nothing read them back, so the data rotted silently (e.g. flows naming
code that a command consolidation later dropped).

This engine answers three questions from the tags *already in the code*,
inventing nothing:

1. **Inventory** — every distinct ``@FLOW`` name and where it is declared.
2. **Coverage** — which files carry a full header, a partial one, or none.
3. **Partial headers** — files missing some (not all) of the four header tags,
which usually means a tag was forgotten.

Pure and deterministic: regex only, no LLM, no network, every collection
sorted so two runs on the same tree are byte-identical.

@FLOW: TAG_AUDIT
@CALLS: TagAuditEngine.run() -> dict (walks workspace via BaseEngine)
@MUTATES: none (read-only scan; never writes tags back to source)
"""

from __future__ import annotations

import re
from typing import Any, Dict, List, Set

from base_engine import BaseEngine

# The four file-header tags, in canonical order.
HEADER_TAGS = ("WHO", "WHAT", "PART", "ENTRY")
# Per-function tags.
FUNCTION_TAGS = ("FLOW", "CALLS", "MUTATES")

# Match `@TAG: value` only when it OPENS a comment/docstring line — optional
# comment marker (`#`, `//`, `*`, `--`) then whitespace then the tag. Anchoring
# to line start is what separates a real declaration from a prose mention like
# `... the `@FLOW: PURE` example ...`, which would otherwise pollute the
# inventory (any file *documenting* the convention, this engine included, would
# register phantom flows). The value runs to end of line.
_TAG_RE = re.compile(
r"^[ \t]*(?:#|//|\*|--|/\*)?[ \t]*"
r"@(" + "|".join(HEADER_TAGS + FUNCTION_TAGS) + r"):[ \t]*(.*)"
)

# Source extensions that carry the convention. Language-agnostic: the tags are
# the same across all of them; only the comment marker differs.
_SOURCE_EXTENSIONS: Set[str] = {
".py", ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs",
".go", ".rs", ".java", ".php", ".rb", ".c", ".cc", ".cpp", ".h", ".hpp",
".cs", ".kt", ".swift", ".css", ".scss", ".html", ".vue", ".svelte",
}

# Cap on enumerated file lists; counts stay exact.
_LIST_CAP = 200


def _flow_name(raw: str) -> str:
"""The flow name is the first whitespace-delimited token of an @FLOW value.

`@FLOW: PURE (declarations only)` -> `PURE`; `@FLOW: AUDIT_DISPATCH` ->
`AUDIT_DISPATCH`. The remainder is human prose, not part of the identity.
"""
return raw.strip().split(None, 1)[0] if raw.strip() else ""


class TagAuditEngine(BaseEngine):
"""Scan source files for the doc-tag convention and report on it."""

FILE_EXTENSIONS = _SOURCE_EXTENSIONS

def _pre_scan(self, workspace: str, **kwargs) -> None:
# flow name -> sorted set of "rel_path:line" declaration sites
self._flows: Dict[str, List[str]] = {}
self._full_header: List[str] = []
self._partial_header: List[Dict[str, Any]] = []
self._untagged: List[str] = []

def _analyze_file(self, rel_path, content, ext, abs_path) -> List[Dict[str, Any]]:
header_present: Set[str] = set()
# Normalise separators so keys match across OSes (Windows scan, Linux CI).
rel = rel_path.replace("\\", "/")

for lineno, line in enumerate(content.splitlines(), start=1):
m = _TAG_RE.search(line)
if not m:
continue
tag, value = m.group(1), m.group(2)
if tag in HEADER_TAGS:
header_present.add(tag)
elif tag == "FLOW":
name = _flow_name(value)
if name:
self._flows.setdefault(name, []).append(f"{rel}:{lineno}")

if not header_present:
self._untagged.append(rel)
elif header_present.issuperset(HEADER_TAGS):
self._full_header.append(rel)
else:
self._partial_header.append({
"file": rel,
"present": sorted(header_present),
"missing": [t for t in HEADER_TAGS if t not in header_present],
})

# This engine aggregates in instance state, not per-file findings.
return []

def _build_result(self, workspace: str) -> Dict[str, Any]:
flows = [
{"name": name, "count": len(locs), "locations": sorted(locs)}
for name, locs in sorted(self._flows.items())
]
partial = sorted(self._partial_header, key=lambda d: d["file"])
untagged = sorted(self._untagged)
scanned = self._files_scanned

tagged = len(self._full_header) + len(partial)
return {
"status": "ok",
"workspace": workspace,
"check": "tags",
"summary": {
"files_scanned": scanned,
"with_full_header": len(self._full_header),
"with_partial_header": len(partial),
"without_header": len(untagged),
"header_coverage_pct": round(100 * tagged / scanned, 1) if scanned else 0.0,
"distinct_flows": len(flows),
"total_flow_declarations": sum(f["count"] for f in flows),
},
"flows": flows,
"partial_headers": partial[:_LIST_CAP],
"partial_headers_truncated": len(partial) > _LIST_CAP,
"untagged_files": untagged[:_LIST_CAP],
"untagged_files_truncated": len(untagged) > _LIST_CAP,
}


def audit_tags(workspace: str) -> Dict[str, Any]:
"""Run the tag audit against ``workspace`` and return the report dict."""
return TagAuditEngine().run(workspace)
2 changes: 1 addition & 1 deletion tests/test_command_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ def test_every_command_module_registers():
"export_snapshot", "git_status", "graph_schema", "import_snapshot",
"init", "lsp_status", "orient", "outline", "ownership", "perf_hint",
"query_graph", "regex_audit", "secrets", "side_effect", "smell",
"staleness", "symbols_overview", "taint", "trace", "vuln_scan",
"staleness", "symbols_overview", "tags", "taint", "trace", "vuln_scan",
}
_UTILITY_MODULES |= _DEPRECATED_ALIAS_MODULES
missing = []
Expand Down
Loading
Loading