semlint 0.1.0__tar.gz

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.
semlint-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,95 @@
1
+ Metadata-Version: 2.3
2
+ Name: semlint
3
+ Version: 0.1.0
4
+ Summary: Find semantic redundancy in agent instruction corpora.
5
+ Author: Karlis Vagalis
6
+ Author-email: Karlis Vagalis <karlis.vagalis@gmail.com>
7
+ License: MIT
8
+ Requires-Dist: markdown-it-py[plugins]>=4.2.0
9
+ Requires-Dist: pydantic>=2.13.5
10
+ Requires-Dist: rich>=15.0.0
11
+ Requires-Dist: semhash>=0.4.1
12
+ Requires-Dist: typer>=0.27.2
13
+ Requires-Python: >=3.13
14
+ Description-Content-Type: text/markdown
15
+
16
+ # semlint
17
+
18
+ `semlint` is a small, local Python CLI for finding redundant information in agent instruction corpora: `SKILL.md`, `AGENTS.md`, prompts, rules, and other Markdown/text files.
19
+
20
+ It answers: **how many tokens are consuming context without adding unique information, and where are they?**
21
+
22
+ ## Quick start
23
+
24
+ ```bash
25
+ uv sync
26
+ uv run semlint ./skills
27
+ uv run semlint ./skills ./rules/AGENTS.md
28
+ uv run semlint './skills/**/*.md' './prompts/*.txt'
29
+ uv run semlint ./skills --threshold 0.90 --min-block-tokens 10
30
+ uv run semlint ./skills --split markdown
31
+ uv run semlint ./skills --output json > report.json
32
+ uv run semlint ./skills --limit 10
33
+ uv run semlint ./skills -s asc similarity
34
+ uv run semlint ./skills --fail-above 0.10
35
+ uv run semlint self list
36
+ ```
37
+
38
+ The command exits with `1` when `--fail-above` is exceeded and `2` for invalid input/errors.
39
+
40
+ ### Stronger semantic matching
41
+
42
+ SemHash with local Model2Vec embeddings is the default semantic engine. It loads its small CPU model on first run (and caches it locally). For an explicit alternate Model2Vec model, use:
43
+
44
+ ```bash
45
+ uv run semlint ./skills --model minishlab/potion-base-8M --threshold 0.90
46
+ ```
47
+
48
+ SemHash and Model2Vec run locally on CPU. No API, LLM, vector database, server, or GPU is needed.
49
+
50
+ ## Agent capability discovery
51
+
52
+ `semlint self list` prints one full command synopsis and help description per line. Optional arguments and options are bracketed inline. The listing is generated by inspecting the registered Typer command definitions, so it stays aligned with the CLI.
53
+
54
+ ## What it scans
55
+
56
+ - Accepts one or more file paths, directories, or glob patterns; recursively scans `.md` and `.txt` files and de-duplicates overlapping matches.
57
+ - By default (`--split auto`), uses Markdown block boundaries for `.md` files and blank-line paragraph splitting for other supported text files. Markdown blocks include paragraphs (including list and blockquote paragraphs), fenced/indented code, HTML blocks, and tables. Headings and front matter are not treated as instruction blocks.
58
+ - Use `--split paragraph` to force blank-line splitting, or `--split markdown` to force Markdown parsing.
59
+ - All modes preserve original source text and line ranges; none infer sections or rule IDs.
60
+ - Ignores blocks below `--min-block-tokens`.
61
+ - Clusters likely duplicates without modifying source files.
62
+
63
+ Similarity is intentionally configurable because generic phrases can be false positives. Review clusters before replacing instructions.
64
+
65
+ ## Output formats
66
+
67
+ Text is the default and uses Rich-formatted summary metrics, cluster separators, cluster summaries, then a blank line followed by blank-line-separated `path:start-end` locations and numbered source lines. By default, clusters sort by estimated redundant tokens (largest first); `-s|--sort <asc|desc> <occurrences|similarity|estimated-savings>` selects a different order. `--limit N` displays only the first N clusters while keeping corpus-wide metrics unchanged. Linters commonly use this compact location notation with a source code frame; Rich provides readable terminal rendering without introducing a separate diagnostic framework.
68
+
69
+ Choose JSON with `--output json`. Top-level fields include `files_scanned`, `blocks_scanned`, `total_tokens`, `exact_duplicate_percentage`, `semantic_redundancy_percentage`, `estimated_unique_tokens`, `estimated_redundant_tokens`, `estimated_context_token_savings`, and `clusters`. Clusters include similarity, all source blocks, and token/savings estimates—no canonical or action suggestions.
70
+
71
+ The savings estimate assumes retaining the shortest occurrence in each cluster; review the source blocks yourself before deciding what to consolidate. `context_redundancy` is token-weighted:
72
+
73
+ ```text
74
+ estimated_redundant_tokens / total_tokens
75
+ ```
76
+
77
+ This is an estimate, not a tokenizer-specific bill. The initial version counts word tokens for transparent, consistent reporting.
78
+
79
+ ## Development
80
+
81
+ `just build` uses the installed `doxxer` binary to inject the next Git-derived version into `pyproject.toml`, runs `uv build`, then restores the `0.0.0` source placeholder. Install `doxxer` before building.
82
+
83
+ ```bash
84
+ just build
85
+ ```
86
+
87
+ Install the project and development tools with `uv sync`, then run static checks:
88
+
89
+ ```bash
90
+ uv sync
91
+ uv run ruff check src
92
+ uv run ty check src
93
+ ```
94
+
95
+ The project uses Python 3.13+, Pydantic `BaseModel`s for domain and report models, and a modular `src/semlint` package (parser/scanner, SemHash clustering, metrics, reporting, and Typer CLI). Structured SemHash inputs and JSON report output are validated with Pydantic. It does not automatically edit files.
@@ -0,0 +1,80 @@
1
+ # semlint
2
+
3
+ `semlint` is a small, local Python CLI for finding redundant information in agent instruction corpora: `SKILL.md`, `AGENTS.md`, prompts, rules, and other Markdown/text files.
4
+
5
+ It answers: **how many tokens are consuming context without adding unique information, and where are they?**
6
+
7
+ ## Quick start
8
+
9
+ ```bash
10
+ uv sync
11
+ uv run semlint ./skills
12
+ uv run semlint ./skills ./rules/AGENTS.md
13
+ uv run semlint './skills/**/*.md' './prompts/*.txt'
14
+ uv run semlint ./skills --threshold 0.90 --min-block-tokens 10
15
+ uv run semlint ./skills --split markdown
16
+ uv run semlint ./skills --output json > report.json
17
+ uv run semlint ./skills --limit 10
18
+ uv run semlint ./skills -s asc similarity
19
+ uv run semlint ./skills --fail-above 0.10
20
+ uv run semlint self list
21
+ ```
22
+
23
+ The command exits with `1` when `--fail-above` is exceeded and `2` for invalid input/errors.
24
+
25
+ ### Stronger semantic matching
26
+
27
+ SemHash with local Model2Vec embeddings is the default semantic engine. It loads its small CPU model on first run (and caches it locally). For an explicit alternate Model2Vec model, use:
28
+
29
+ ```bash
30
+ uv run semlint ./skills --model minishlab/potion-base-8M --threshold 0.90
31
+ ```
32
+
33
+ SemHash and Model2Vec run locally on CPU. No API, LLM, vector database, server, or GPU is needed.
34
+
35
+ ## Agent capability discovery
36
+
37
+ `semlint self list` prints one full command synopsis and help description per line. Optional arguments and options are bracketed inline. The listing is generated by inspecting the registered Typer command definitions, so it stays aligned with the CLI.
38
+
39
+ ## What it scans
40
+
41
+ - Accepts one or more file paths, directories, or glob patterns; recursively scans `.md` and `.txt` files and de-duplicates overlapping matches.
42
+ - By default (`--split auto`), uses Markdown block boundaries for `.md` files and blank-line paragraph splitting for other supported text files. Markdown blocks include paragraphs (including list and blockquote paragraphs), fenced/indented code, HTML blocks, and tables. Headings and front matter are not treated as instruction blocks.
43
+ - Use `--split paragraph` to force blank-line splitting, or `--split markdown` to force Markdown parsing.
44
+ - All modes preserve original source text and line ranges; none infer sections or rule IDs.
45
+ - Ignores blocks below `--min-block-tokens`.
46
+ - Clusters likely duplicates without modifying source files.
47
+
48
+ Similarity is intentionally configurable because generic phrases can be false positives. Review clusters before replacing instructions.
49
+
50
+ ## Output formats
51
+
52
+ Text is the default and uses Rich-formatted summary metrics, cluster separators, cluster summaries, then a blank line followed by blank-line-separated `path:start-end` locations and numbered source lines. By default, clusters sort by estimated redundant tokens (largest first); `-s|--sort <asc|desc> <occurrences|similarity|estimated-savings>` selects a different order. `--limit N` displays only the first N clusters while keeping corpus-wide metrics unchanged. Linters commonly use this compact location notation with a source code frame; Rich provides readable terminal rendering without introducing a separate diagnostic framework.
53
+
54
+ Choose JSON with `--output json`. Top-level fields include `files_scanned`, `blocks_scanned`, `total_tokens`, `exact_duplicate_percentage`, `semantic_redundancy_percentage`, `estimated_unique_tokens`, `estimated_redundant_tokens`, `estimated_context_token_savings`, and `clusters`. Clusters include similarity, all source blocks, and token/savings estimates—no canonical or action suggestions.
55
+
56
+ The savings estimate assumes retaining the shortest occurrence in each cluster; review the source blocks yourself before deciding what to consolidate. `context_redundancy` is token-weighted:
57
+
58
+ ```text
59
+ estimated_redundant_tokens / total_tokens
60
+ ```
61
+
62
+ This is an estimate, not a tokenizer-specific bill. The initial version counts word tokens for transparent, consistent reporting.
63
+
64
+ ## Development
65
+
66
+ `just build` uses the installed `doxxer` binary to inject the next Git-derived version into `pyproject.toml`, runs `uv build`, then restores the `0.0.0` source placeholder. Install `doxxer` before building.
67
+
68
+ ```bash
69
+ just build
70
+ ```
71
+
72
+ Install the project and development tools with `uv sync`, then run static checks:
73
+
74
+ ```bash
75
+ uv sync
76
+ uv run ruff check src
77
+ uv run ty check src
78
+ ```
79
+
80
+ The project uses Python 3.13+, Pydantic `BaseModel`s for domain and report models, and a modular `src/semlint` package (parser/scanner, SemHash clustering, metrics, reporting, and Typer CLI). Structured SemHash inputs and JSON report output are validated with Pydantic. It does not automatically edit files.
@@ -0,0 +1,48 @@
1
+ [project]
2
+ name = "semlint"
3
+ version = "0.1.0"
4
+ description = "Find semantic redundancy in agent instruction corpora."
5
+ readme = "README.md"
6
+ requires-python = ">=3.13"
7
+ dependencies = [
8
+ "markdown-it-py[plugins]>=4.2.0",
9
+ "pydantic>=2.13.5",
10
+ "rich>=15.0.0",
11
+ "semhash>=0.4.1",
12
+ "typer>=0.27.2",
13
+ ]
14
+
15
+ [project.license]
16
+ text = "MIT"
17
+
18
+ [[project.authors]]
19
+ name = "Karlis Vagalis"
20
+ email = "karlis.vagalis@gmail.com"
21
+
22
+ [project.scripts]
23
+ semlint = "semlint.cli:main"
24
+
25
+ [dependency-groups]
26
+ dev = [
27
+ "ruff>=0.16.8",
28
+ "ty>=0.0.83",
29
+ ]
30
+
31
+ [build-system]
32
+ requires = ["uv_build>=0.12.18,<0.13"]
33
+ build-backend = "uv_build"
34
+
35
+ [tool.ruff]
36
+ line-length = 100
37
+
38
+ [tool.ruff.lint]
39
+ select = [
40
+ "E",
41
+ "F",
42
+ "I",
43
+ "UP",
44
+ "B",
45
+ ]
46
+
47
+ [tool.ty.environment]
48
+ python-version = "3.13"
@@ -0,0 +1,37 @@
1
+ [project]
2
+ name = "semlint"
3
+ version = "0.1.0"
4
+ description = "Find semantic redundancy in agent instruction corpora."
5
+ readme = "README.md"
6
+ requires-python = ">=3.13"
7
+ license = { text = "MIT" }
8
+ authors = [{ name = "Karlis Vagalis", email = "karlis.vagalis@gmail.com" }]
9
+ dependencies = [
10
+ "markdown-it-py[plugins]>=4.2.0",
11
+ "pydantic>=2.13.5",
12
+ "rich>=15.0.0",
13
+ "semhash>=0.4.1",
14
+ "typer>=0.27.2",
15
+ ]
16
+
17
+ [project.scripts]
18
+ semlint = "semlint.cli:main"
19
+
20
+ [dependency-groups]
21
+ dev = [
22
+ "ruff>=0.16.8",
23
+ "ty>=0.0.83",
24
+ ]
25
+
26
+ [build-system]
27
+ requires = ["uv_build>=0.12.18,<0.13"]
28
+ build-backend = "uv_build"
29
+
30
+ [tool.ruff]
31
+ line-length = 100
32
+
33
+ [tool.ruff.lint]
34
+ select = ["E", "F", "I", "UP", "B"]
35
+
36
+ [tool.ty.environment]
37
+ python-version = "3.13"
@@ -0,0 +1,8 @@
1
+ """Semantic redundancy analysis for agent instruction files."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("semlint")
7
+ except PackageNotFoundError:
8
+ __version__ = "0.0.0"
@@ -0,0 +1,225 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Annotated, Any, Literal, cast
5
+
6
+ import typer
7
+ from typer._click import Context
8
+ from typer.core import TyperArgument, TyperCommand, TyperGroup, TyperOption
9
+ from typer.main import get_command
10
+
11
+ from .core import (
12
+ AnalysisConfig,
13
+ SortCategory,
14
+ SortOrder,
15
+ SplitMode,
16
+ analyze,
17
+ )
18
+ from .report import render, render_json
19
+
20
+ app = typer.Typer(
21
+ help="Find redundant information in agent instruction files.", add_completion=False
22
+ )
23
+ discovery_app = typer.Typer(help="Agent-facing CLI capability discovery.", add_completion=False)
24
+ self_app = typer.Typer(help="CLI capability discovery for agent clients.", add_completion=False)
25
+
26
+
27
+ def _parameter_type(parameter: TyperArgument | TyperOption) -> str:
28
+ choices = getattr(parameter.type, "choices", None)
29
+ if choices is not None:
30
+ return "|".join(str(choice) for choice in choices)
31
+ type_name = getattr(parameter.type, "name", None)
32
+ return str(type_name or parameter.type)
33
+
34
+
35
+ def _command_entry(
36
+ command: TyperCommand | TyperGroup,
37
+ command_path: str,
38
+ ) -> dict[str, Any]:
39
+ arguments = [param for param in command.params if isinstance(param, TyperArgument)]
40
+ options = [param for param in command.params if isinstance(param, TyperOption)]
41
+ usage_parts = [command_path]
42
+ for parameter in arguments:
43
+ name = (parameter.name or "ARG").upper().replace("_", "-")
44
+ if parameter.name == "paths":
45
+ name = "PATH"
46
+ part = f"<{name}>"
47
+ if parameter.nargs == -1:
48
+ part += "..."
49
+ usage_parts.append(part if parameter.required else f"[{part}]")
50
+ for parameter in options:
51
+ long_flag = next((option for option in parameter.opts if option.startswith("--")), None)
52
+ short_flag = next(
53
+ (
54
+ option
55
+ for option in parameter.opts
56
+ if option.startswith("-") and not option.startswith("--")
57
+ ),
58
+ None,
59
+ )
60
+ flag = (
61
+ f"{short_flag}/{long_flag}"
62
+ if short_flag and long_flag
63
+ else long_flag or parameter.opts[0]
64
+ )
65
+ value_types = getattr(parameter.type, "types", None)
66
+ if value_types:
67
+ value_parts = []
68
+ for index, value_type in enumerate(value_types):
69
+ choices = getattr(value_type, "choices", None)
70
+ label = "|".join(str(choice) for choice in choices) if choices else ""
71
+ if not label:
72
+ label = "order" if index == 0 else "category"
73
+ value_parts.append(f"<{label}>")
74
+ value = " " + " ".join(value_parts)
75
+ elif parameter.is_flag:
76
+ value = ""
77
+ else:
78
+ value = f" <{(parameter.name or 'VALUE').replace('_', '-')}>"
79
+ part = f"{flag}{value}"
80
+ usage_parts.append(part if parameter.required else f"[{part}]")
81
+ usage = " ".join(usage_parts)
82
+ params: list[dict[str, Any]] = []
83
+ for param in command.params:
84
+ record: dict[str, Any] = {
85
+ "name": param.name,
86
+ "kind": "option" if isinstance(param, TyperOption) else "argument",
87
+ "required": param.required,
88
+ "multiple": isinstance(param, TyperArgument) and param.nargs == -1,
89
+ "help": getattr(param, "help", None),
90
+ }
91
+ if isinstance(param, TyperOption):
92
+ record["flags"] = list(param.opts)
93
+ record["type"] = _parameter_type(param)
94
+ choices = getattr(param.type, "choices", None)
95
+ if choices is not None:
96
+ record["choices"] = list(choices)
97
+ if param.default is not None:
98
+ record["default"] = param.default
99
+ elif isinstance(param, TyperArgument):
100
+ record["type"] = _parameter_type(param)
101
+ params.append(record)
102
+ return {
103
+ "command": usage,
104
+ "description": command.help or "",
105
+ "parameters": params,
106
+ }
107
+
108
+
109
+ def discover_commands() -> list[dict[str, Any]]:
110
+ """Describe executable commands from the registered Typer/Click tree."""
111
+ scan_command = get_command(app)
112
+ entries = [_command_entry(cast(TyperCommand | TyperGroup, scan_command), "semlint")]
113
+ root = get_command(discovery_app)
114
+ root_context = Context(root, info_name="semlint")
115
+
116
+ def visit(group: TyperGroup, prefix: str, parent: Context) -> None:
117
+ for name, command in group.commands.items():
118
+ command_path = f"{prefix} {name}"
119
+ context = Context(command, info_name=command_path, parent=parent)
120
+ if isinstance(command, TyperGroup):
121
+ visit(command, command_path, context)
122
+ elif isinstance(command, TyperCommand):
123
+ entries.append(_command_entry(command, command_path))
124
+
125
+ if isinstance(root, TyperGroup):
126
+ visit(root, "semlint", root_context)
127
+ return entries
128
+
129
+
130
+ @self_app.command("list")
131
+ def list_commands() -> None:
132
+ """List each available command and its usage in a single line."""
133
+ for command in discover_commands():
134
+ description = " ".join(command["description"].split())
135
+ suffix = f" - {description}" if description else ""
136
+ typer.echo(f"{command['command']}{suffix}")
137
+
138
+
139
+ discovery_app.add_typer(self_app, name="self")
140
+
141
+
142
+ @app.command()
143
+ def scan(
144
+ paths: Annotated[
145
+ list[Path],
146
+ typer.Argument(help="One or more files, directories, or glob patterns to scan."),
147
+ ],
148
+ threshold: Annotated[
149
+ float,
150
+ typer.Option(
151
+ "--threshold",
152
+ "-t",
153
+ min=0.0,
154
+ max=1.0,
155
+ help="SemHash similarity threshold.",
156
+ ),
157
+ ] = 0.90,
158
+ min_block_tokens: Annotated[
159
+ int, typer.Option("--min-block-tokens", min=1, help="Ignore shorter blocks.")
160
+ ] = 10,
161
+ split_mode: Annotated[
162
+ SplitMode,
163
+ typer.Option("--split", help="Block splitting mode: auto, paragraph, or markdown."),
164
+ ] = SplitMode.AUTO,
165
+ limit: Annotated[
166
+ int | None, typer.Option("--limit", min=1, help="Maximum number of clusters to display.")
167
+ ] = None,
168
+ fail_above: Annotated[
169
+ float | None,
170
+ typer.Option(
171
+ "--fail-above",
172
+ min=0.0,
173
+ max=1.0,
174
+ help="Exit 1 when context redundancy exceeds this ratio.",
175
+ ),
176
+ ] = None,
177
+ output: Annotated[
178
+ Literal["text", "json"],
179
+ typer.Option("--output", help="Output format: text or json."),
180
+ ] = "text",
181
+ model: Annotated[str | None, typer.Option("--model", help="Model2Vec model name/path.")] = None,
182
+ sort: Annotated[
183
+ tuple[SortOrder, SortCategory],
184
+ typer.Option(
185
+ "--sort",
186
+ "-s",
187
+ help="Sort clusters by asc/desc and occurrences, similarity, or estimated-savings.",
188
+ ),
189
+ ] = (SortOrder.DESC, SortCategory.ESTIMATED_SAVINGS),
190
+ ) -> None:
191
+ """Scan paths, directories, or glob patterns for redundant instruction blocks."""
192
+ try:
193
+ report = analyze(
194
+ paths,
195
+ AnalysisConfig(
196
+ threshold=threshold,
197
+ min_block_tokens=min_block_tokens,
198
+ split_mode=split_mode,
199
+ model=model,
200
+ ),
201
+ )
202
+ except (OSError, RuntimeError, ImportError) as exc:
203
+ typer.echo(f"semlint: {exc}", err=True)
204
+ raise typer.Exit(2) from exc
205
+ typer.echo(
206
+ render_json(report, limit=limit, sort=sort)
207
+ if output == "json"
208
+ else render(report, limit=limit, sort=sort)
209
+ )
210
+ if fail_above is not None and report.context_redundancy > fail_above:
211
+ raise typer.Exit(1)
212
+
213
+
214
+ def main() -> None:
215
+ import sys
216
+
217
+ args = sys.argv[1:]
218
+ if args and args[0] == "self":
219
+ discovery_app(args=args)
220
+ else:
221
+ app(args=args)
222
+
223
+
224
+ if __name__ == "__main__":
225
+ main()
@@ -0,0 +1,331 @@
1
+ from __future__ import annotations
2
+
3
+ import glob
4
+ import os
5
+ import re
6
+ from collections import defaultdict
7
+ from collections.abc import Sequence
8
+ from enum import StrEnum
9
+ from pathlib import Path
10
+ from typing import Any, cast
11
+
12
+ from markdown_it import MarkdownIt
13
+ from mdit_py_plugins.front_matter import front_matter_plugin
14
+ from pydantic import BaseModel, ConfigDict, Field
15
+
16
+ WORD_RE = re.compile(r"[\w][\w'-]*", re.UNICODE)
17
+
18
+
19
+ class Block(BaseModel):
20
+ model_config = ConfigDict(extra="forbid", frozen=True)
21
+
22
+ path: str = Field(min_length=1)
23
+ start_line: int = Field(ge=1)
24
+ end_line: int = Field(ge=1)
25
+ text: str = Field(min_length=1)
26
+ tokens: int = Field(gt=0)
27
+
28
+ @property
29
+ def location(self) -> str:
30
+ return f"{self.path}:{self.start_line}-{self.end_line}"
31
+
32
+
33
+ class SortOrder(StrEnum):
34
+ ASC = "asc"
35
+ DESC = "desc"
36
+
37
+
38
+ class SortCategory(StrEnum):
39
+ OCCURRENCES = "occurrences"
40
+ SIMILARITY = "similarity"
41
+ ESTIMATED_SAVINGS = "estimated-savings"
42
+
43
+
44
+ class SplitMode(StrEnum):
45
+ AUTO = "auto"
46
+ PARAGRAPH = "paragraph"
47
+ MARKDOWN = "markdown"
48
+
49
+
50
+ class AnalysisConfig(BaseModel):
51
+ model_config = ConfigDict(extra="forbid", frozen=True)
52
+
53
+ threshold: float = Field(default=0.90, ge=0.0, le=1.0)
54
+ min_block_tokens: int = Field(default=10, ge=1)
55
+ split_mode: SplitMode = SplitMode.AUTO
56
+ extensions: frozenset[str] = frozenset({".md", ".txt"})
57
+ model: str | None = None
58
+
59
+
60
+ class Cluster(BaseModel):
61
+ model_config = ConfigDict(extra="forbid", frozen=True)
62
+
63
+ blocks: list[Block] = Field(min_length=2)
64
+ similarity: float = Field(ge=0.0, le=1.0)
65
+ exact: bool = False
66
+
67
+ @property
68
+ def total_tokens(self) -> int:
69
+ return sum(block.tokens for block in self.blocks)
70
+
71
+ @property
72
+ def redundant_tokens(self) -> int:
73
+ return self.total_tokens - min(block.tokens for block in self.blocks)
74
+
75
+ @property
76
+ def estimated_saving(self) -> float:
77
+ return self.redundant_tokens / self.total_tokens if self.total_tokens else 0.0
78
+
79
+
80
+ class Report(BaseModel):
81
+ model_config = ConfigDict(extra="forbid", frozen=True)
82
+
83
+ files_scanned: int = Field(ge=0)
84
+ blocks_scanned: int = Field(ge=0)
85
+ total_tokens: int = Field(ge=0)
86
+ exact_duplicate_tokens: int = Field(ge=0)
87
+ clusters: list[Cluster] = Field(default_factory=list)
88
+
89
+ @property
90
+ def redundant_tokens(self) -> int:
91
+ return sum(cluster.redundant_tokens for cluster in self.clusters)
92
+
93
+ @property
94
+ def unique_tokens(self) -> int:
95
+ return max(0, self.total_tokens - self.redundant_tokens)
96
+
97
+ @property
98
+ def context_redundancy(self) -> float:
99
+ return self.redundant_tokens / self.total_tokens if self.total_tokens else 0.0
100
+
101
+ @property
102
+ def exact_duplicate_percentage(self) -> float:
103
+ return self.exact_duplicate_tokens / self.total_tokens if self.total_tokens else 0.0
104
+
105
+ @property
106
+ def semantic_redundancy_percentage(self) -> float:
107
+ return self.context_redundancy
108
+
109
+
110
+ def normalize(text: str) -> str:
111
+ return " ".join(WORD_RE.findall(text.casefold()))
112
+
113
+
114
+ def token_count(text: str) -> int:
115
+ return len(WORD_RE.findall(text))
116
+
117
+
118
+ def scan_paths(roots: Sequence[Path], extensions: frozenset[str]) -> tuple[list[Path], Path]:
119
+ if not roots:
120
+ raise ValueError("provide at least one file, directory, or glob pattern")
121
+ discovered: dict[Path, Path] = {}
122
+ display_bases: list[Path] = []
123
+ for root in roots:
124
+ raw = os.fspath(root)
125
+ has_glob = glob.has_magic(raw)
126
+ matches = [Path(value) for value in glob.glob(raw, recursive=True)] if has_glob else [root]
127
+ if not matches or any(not match.exists() for match in matches):
128
+ raise FileNotFoundError(f"input path or glob did not match: {root}")
129
+
130
+ if has_glob:
131
+ prefix = raw[: min(raw.find(char) for char in "*?[" if char in raw)]
132
+ base = (
133
+ Path(prefix).resolve() if prefix.endswith(os.sep) else Path(prefix).parent.resolve()
134
+ )
135
+ elif len(matches) == 1 and matches[0].is_dir():
136
+ base = matches[0].resolve()
137
+ else:
138
+ base = matches[0].resolve().parent
139
+ display_bases.append(base)
140
+
141
+ for match in matches:
142
+ candidates = match.rglob("*") if match.is_dir() else [match]
143
+ for candidate in candidates:
144
+ if candidate.is_file() and candidate.suffix.lower() in extensions:
145
+ resolved = candidate.resolve()
146
+ discovered[resolved] = resolved
147
+
148
+ display_root = (
149
+ display_bases[0] if len(display_bases) == 1 else Path(os.path.commonpath(display_bases))
150
+ )
151
+ return sorted(discovered), display_root
152
+
153
+
154
+ def parse_file(
155
+ path: Path,
156
+ root: Path,
157
+ min_tokens: int,
158
+ split_mode: SplitMode = SplitMode.AUTO,
159
+ ) -> list[Block]:
160
+ """Split a file into blocks using blank lines or Markdown block boundaries."""
161
+ source = path.read_text(encoding="utf-8", errors="replace")
162
+ lines = source.splitlines()
163
+ if split_mode is SplitMode.MARKDOWN or (
164
+ split_mode is SplitMode.AUTO and path.suffix.lower() == ".md"
165
+ ):
166
+ return parse_markdown(path, root, lines, source, min_tokens)
167
+
168
+ blocks: list[Block] = []
169
+ pending: list[str] = []
170
+ start = 0
171
+
172
+ def flush(end: int) -> None:
173
+ nonlocal pending
174
+ text = "\n".join(pending).strip()
175
+ pending = []
176
+ if not text or token_count(text) < min_tokens:
177
+ return
178
+ blocks.append(
179
+ Block(
180
+ path=str(path.relative_to(root)),
181
+ start_line=start + 1,
182
+ end_line=end + 1,
183
+ text=text,
184
+ tokens=token_count(text),
185
+ )
186
+ )
187
+
188
+ for index, line in enumerate(lines):
189
+ if not line.strip():
190
+ flush(index - 1)
191
+ else:
192
+ if not pending:
193
+ start = index
194
+ pending.append(line)
195
+ flush(len(lines) - 1)
196
+ return blocks
197
+
198
+
199
+ def parse_markdown(
200
+ path: Path,
201
+ root: Path,
202
+ lines: list[str],
203
+ source: str,
204
+ min_tokens: int,
205
+ ) -> list[Block]:
206
+ """Extract Markdown leaf blocks while retaining parser-provided source line ranges."""
207
+ parser = MarkdownIt("default").use(front_matter_plugin)
208
+ block_types = {"paragraph_open", "fence", "code_block", "html_block", "table_open"}
209
+ blocks: list[Block] = []
210
+ for token in parser.parse(source):
211
+ if token.type not in block_types or token.map is None:
212
+ continue
213
+ start, end = token.map
214
+ text = "\n".join(lines[start:end]).strip()
215
+ tokens = token_count(text)
216
+ if not text or tokens < min_tokens:
217
+ continue
218
+ blocks.append(
219
+ Block(
220
+ path=str(path.relative_to(root)),
221
+ start_line=start + 1,
222
+ end_line=end,
223
+ text=text,
224
+ tokens=tokens,
225
+ )
226
+ )
227
+ return blocks
228
+
229
+
230
+ class SemHashRecord(BaseModel):
231
+ model_config = ConfigDict(extra="forbid")
232
+
233
+ text: str
234
+ index: int
235
+
236
+
237
+ def sort_clusters(
238
+ clusters: Sequence[Cluster],
239
+ order: SortOrder = SortOrder.DESC,
240
+ category: SortCategory = SortCategory.ESTIMATED_SAVINGS,
241
+ ) -> list[Cluster]:
242
+ tie_sorted = sorted(
243
+ clusters,
244
+ key=lambda cluster: (
245
+ -cluster.redundant_tokens,
246
+ -cluster.similarity,
247
+ -len(cluster.blocks),
248
+ cluster.blocks[0].path,
249
+ cluster.blocks[0].start_line,
250
+ ),
251
+ )
252
+ value = {
253
+ SortCategory.OCCURRENCES: lambda cluster: len(cluster.blocks),
254
+ SortCategory.SIMILARITY: lambda cluster: cluster.similarity,
255
+ SortCategory.ESTIMATED_SAVINGS: lambda cluster: cluster.redundant_tokens,
256
+ }[category]
257
+ return sorted(tie_sorted, key=value, reverse=order is SortOrder.DESC)
258
+
259
+
260
+ def analyze(roots: Path | Sequence[Path], config: AnalysisConfig) -> Report:
261
+ inputs = [roots] if isinstance(roots, Path) else list(roots)
262
+ paths, display_root = scan_paths(inputs, config.extensions)
263
+ blocks = [
264
+ block
265
+ for path in paths
266
+ for block in parse_file(path, display_root, config.min_block_tokens, config.split_mode)
267
+ ]
268
+ clusters: list[Cluster] = []
269
+ exact_tokens = 0
270
+ if blocks:
271
+ from semhash import SemHash
272
+
273
+ model: Any = None
274
+ if config.model:
275
+ from model2vec import StaticModel
276
+
277
+ model = cast(Any, StaticModel.from_pretrained(config.model))
278
+ records = [
279
+ SemHashRecord(text=block.text, index=index).model_dump()
280
+ for index, block in enumerate(blocks)
281
+ ]
282
+ semhash = SemHash.from_records(records=records, columns=["text"], model=model)
283
+ neighbors = semhash.index.query_threshold(semhash.index.vectors, threshold=config.threshold)
284
+ graph: dict[int, list[tuple[int, float]]] = defaultdict(list)
285
+ indexed_items = semhash.index.items
286
+ for row_number, row in enumerate(neighbors):
287
+ if not row:
288
+ continue
289
+ source_record = indexed_items[row_number][0]
290
+ source = SemHashRecord.model_validate(source_record).index
291
+ for record, score in row:
292
+ target = SemHashRecord.model_validate(record).index
293
+ if source == target:
294
+ continue
295
+ graph[source].append((target, float(score)))
296
+
297
+ visited: set[int] = set()
298
+ for index in range(len(blocks)):
299
+ if index in visited or index not in graph:
300
+ continue
301
+ stack, members, scores = [index], set(), []
302
+ while stack:
303
+ current = stack.pop()
304
+ if current in members:
305
+ continue
306
+ members.add(current)
307
+ for neighbor, score in graph[current]:
308
+ scores.append(score)
309
+ if neighbor not in members:
310
+ stack.append(neighbor)
311
+ visited.update(members)
312
+ if len(members) < 2:
313
+ continue
314
+ matched = [blocks[item] for item in sorted(members)]
315
+ exact = len({normalize(item.text) for item in matched}) == 1
316
+ if exact:
317
+ exact_tokens += sum(item.tokens for item in matched[1:])
318
+ clusters.append(
319
+ Cluster(
320
+ blocks=matched,
321
+ similarity=min(scores, default=1.0),
322
+ exact=exact,
323
+ )
324
+ )
325
+ return Report(
326
+ files_scanned=len(paths),
327
+ blocks_scanned=len(blocks),
328
+ total_tokens=sum(block.tokens for block in blocks),
329
+ exact_duplicate_tokens=exact_tokens,
330
+ clusters=clusters,
331
+ )
@@ -0,0 +1,187 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from io import StringIO
5
+ from typing import Any
6
+
7
+ from pydantic import BaseModel, ConfigDict
8
+ from rich.console import Console
9
+ from rich.rule import Rule
10
+ from rich.table import Table
11
+ from rich.text import Text
12
+
13
+ from .core import Cluster, Report, SortCategory, SortOrder, sort_clusters
14
+
15
+
16
+ class BlockOutput(BaseModel):
17
+ model_config = ConfigDict(extra="forbid")
18
+
19
+ file: str
20
+ start_line: int
21
+ end_line: int
22
+ text: str
23
+ tokens: int
24
+
25
+
26
+ class ClusterOutput(BaseModel):
27
+ model_config = ConfigDict(extra="forbid")
28
+
29
+ number: int
30
+ similarity: float
31
+ exact: bool
32
+ total_tokens: int
33
+ estimated_redundant_tokens: int
34
+ estimated_saving: float
35
+ blocks: list[BlockOutput]
36
+
37
+
38
+ class ReportOutput(BaseModel):
39
+ model_config = ConfigDict(extra="forbid")
40
+
41
+ files_scanned: int
42
+ blocks_scanned: int
43
+ total_tokens: int
44
+ exact_duplicate_percentage: float
45
+ semantic_redundancy_percentage: float
46
+ context_redundancy: float
47
+ estimated_unique_tokens: int
48
+ estimated_redundant_tokens: int
49
+ estimated_context_token_savings: int
50
+ clusters: list[ClusterOutput]
51
+
52
+
53
+ def cluster_output(cluster: Cluster, number: int) -> ClusterOutput:
54
+ total_tokens = cluster.total_tokens
55
+ redundant_tokens = cluster.redundant_tokens
56
+ return ClusterOutput(
57
+ number=number,
58
+ similarity=round(cluster.similarity, 4),
59
+ exact=cluster.exact,
60
+ total_tokens=total_tokens,
61
+ estimated_redundant_tokens=redundant_tokens,
62
+ estimated_saving=redundant_tokens / total_tokens if total_tokens else 0,
63
+ blocks=[
64
+ BlockOutput(
65
+ file=block.path,
66
+ start_line=block.start_line,
67
+ end_line=block.end_line,
68
+ text=block.text,
69
+ tokens=block.tokens,
70
+ )
71
+ for block in cluster.blocks
72
+ ],
73
+ )
74
+
75
+
76
+ def output_model(
77
+ report: Report,
78
+ limit: int | None = None,
79
+ sort: tuple[SortOrder, SortCategory] = (
80
+ SortOrder.DESC,
81
+ SortCategory.ESTIMATED_SAVINGS,
82
+ ),
83
+ ) -> ReportOutput:
84
+ ordered_clusters = sort_clusters(report.clusters, order=sort[0], category=sort[1])
85
+ if limit is not None:
86
+ ordered_clusters = ordered_clusters[:limit]
87
+ return ReportOutput(
88
+ files_scanned=report.files_scanned,
89
+ blocks_scanned=report.blocks_scanned,
90
+ total_tokens=report.total_tokens,
91
+ exact_duplicate_percentage=report.exact_duplicate_percentage,
92
+ semantic_redundancy_percentage=report.semantic_redundancy_percentage,
93
+ context_redundancy=report.context_redundancy,
94
+ estimated_unique_tokens=report.unique_tokens,
95
+ estimated_redundant_tokens=report.redundant_tokens,
96
+ estimated_context_token_savings=report.redundant_tokens,
97
+ clusters=[
98
+ cluster_output(cluster, index) for index, cluster in enumerate(ordered_clusters, 1)
99
+ ],
100
+ )
101
+
102
+
103
+ def as_dict(
104
+ report: Report,
105
+ limit: int | None = None,
106
+ sort: tuple[SortOrder, SortCategory] = (
107
+ SortOrder.DESC,
108
+ SortCategory.ESTIMATED_SAVINGS,
109
+ ),
110
+ ) -> dict[str, Any]:
111
+ return output_model(report, limit=limit, sort=sort).model_dump(mode="json")
112
+
113
+
114
+ def render(
115
+ report: Report,
116
+ limit: int | None = None,
117
+ sort: tuple[SortOrder, SortCategory] = (
118
+ SortOrder.DESC,
119
+ SortCategory.ESTIMATED_SAVINGS,
120
+ ),
121
+ ) -> str:
122
+ data = output_model(report, limit=limit, sort=sort)
123
+ stream = StringIO()
124
+ console = Console(
125
+ file=stream,
126
+ force_terminal=False,
127
+ color_system=None,
128
+ width=100,
129
+ highlight=False,
130
+ )
131
+ console.print(Rule("semlint · instruction redundancy report", style="bright_blue"))
132
+
133
+ summary = Table(show_header=False, box=None, padding=(0, 2))
134
+ summary.add_column("Metric", style="cyan")
135
+ summary.add_column("Value", justify="right")
136
+ summary.add_row("Files scanned", str(data.files_scanned))
137
+ summary.add_row("Blocks scanned", str(data.blocks_scanned))
138
+ summary.add_row("Total word tokens", f"{data.total_tokens:,}")
139
+ summary.add_row("Exact duplicate share", f"{data.exact_duplicate_percentage:.1%}")
140
+ summary.add_row("Semantic redundancy", f"{data.semantic_redundancy_percentage:.1%}")
141
+ summary.add_row("Estimated unique tokens", f"{data.estimated_unique_tokens:,}")
142
+ summary.add_row("Estimated redundant tokens", f"{data.estimated_redundant_tokens:,}")
143
+ summary.add_row("Potential context-token savings", f"{data.estimated_context_token_savings:,}")
144
+ console.print(summary)
145
+
146
+ for cluster in data.clusters:
147
+ console.print()
148
+ console.print(
149
+ Rule(
150
+ f"Cluster #{cluster.number} · similarity {cluster.similarity:.2f}"
151
+ f" · {'exact' if cluster.exact else 'semantic'}",
152
+ style="bright_blue",
153
+ )
154
+ )
155
+ cluster_stats = Table(show_header=False, box=None, padding=(0, 2))
156
+ cluster_stats.add_column("Metric", style="dim")
157
+ cluster_stats.add_column("Value", justify="right")
158
+ cluster_stats.add_row("Occurrences", str(len(cluster.blocks)))
159
+ cluster_stats.add_row("Tokens in cluster", str(cluster.total_tokens))
160
+ cluster_stats.add_row("Estimated redundant tokens", str(cluster.estimated_redundant_tokens))
161
+ cluster_stats.add_row("Estimated savings", f"{cluster.estimated_saving:.1%}")
162
+ console.print(cluster_stats)
163
+ console.print()
164
+
165
+ for index, block in enumerate(cluster.blocks):
166
+ if index:
167
+ console.print()
168
+ if block.start_line == block.end_line:
169
+ location = f"{block.file}:{block.start_line}"
170
+ else:
171
+ location = f"{block.file}:{block.start_line}-{block.end_line}"
172
+ console.print(Text(location, style="bold cyan"))
173
+ for offset, line in enumerate(block.text.splitlines()):
174
+ console.print(Text(f"{block.start_line + offset:>5} │ {line}"))
175
+
176
+ return stream.getvalue().rstrip()
177
+
178
+
179
+ def render_json(
180
+ report: Report,
181
+ limit: int | None = None,
182
+ sort: tuple[SortOrder, SortCategory] = (
183
+ SortOrder.DESC,
184
+ SortCategory.ESTIMATED_SAVINGS,
185
+ ),
186
+ ) -> str:
187
+ return json.dumps(as_dict(report, limit=limit, sort=sort), indent=2, ensure_ascii=False)