semlint 0.1.0__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.
- semlint/__init__.py +8 -0
- semlint/cli.py +225 -0
- semlint/core.py +331 -0
- semlint/report.py +187 -0
- semlint-0.1.0.dist-info/METADATA +95 -0
- semlint-0.1.0.dist-info/RECORD +8 -0
- semlint-0.1.0.dist-info/WHEEL +4 -0
- semlint-0.1.0.dist-info/entry_points.txt +3 -0
semlint/__init__.py
ADDED
semlint/cli.py
ADDED
|
@@ -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()
|
semlint/core.py
ADDED
|
@@ -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
|
+
)
|
semlint/report.py
ADDED
|
@@ -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)
|
|
@@ -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,8 @@
|
|
|
1
|
+
semlint/__init__.py,sha256=zkplWDyzdFJ_3T4Tr2Nqu_pdFZ0aD9JJXvvLMSktJpk,224
|
|
2
|
+
semlint/cli.py,sha256=OUZzEBlsOv93kdZrJYymYocuavzWSdIJSLDx3uwiwLE,7851
|
|
3
|
+
semlint/core.py,sha256=GYGNJTKMP-tj9cj9oI7Q4AuXN54uHNFiwp_0BoAMkbY,10692
|
|
4
|
+
semlint/report.py,sha256=K7l7qIKpxkpzZkmWGJfoWvfhLxECDCwKHcFiZiaZeV0,6322
|
|
5
|
+
semlint-0.1.0.dist-info/WHEEL,sha256=7hzKWg-J8I3Buqyw5tBii5z_MmAVsDYXXijd_QAtNZ8,81
|
|
6
|
+
semlint-0.1.0.dist-info/entry_points.txt,sha256=ASUp_h04BVwpQFAdnmZxj-3-pStQKCN0iu7WMmYQEDY,46
|
|
7
|
+
semlint-0.1.0.dist-info/METADATA,sha256=4LchKSZHyl9xPuDSdw8-h0gpTNQEOU3VdTf885038PM,5047
|
|
8
|
+
semlint-0.1.0.dist-info/RECORD,,
|