333 lines
12 KiB
Python
333 lines
12 KiB
Python
"""
|
|
calibrate_helpers.py — deterministic helpers for the `:calibrate` skill
|
|
(lifecycle-aware-doc-hygiene change, task 5.4).
|
|
|
|
Stdlib-only, no model. Three responsibilities, each a small
|
|
single-responsibility class taking injected inputs and returning
|
|
JSON-serializable output:
|
|
|
|
1. `ClusterSampler` — groups unmatched paths by path-shape (directory
|
|
prefix + a filename "shape class" where digit runs -> `#` and hex-looking
|
|
runs -> `~`) and emits capped representative samples per cluster.
|
|
2. `RuleReportBuilder` — given proposed rules (glob + lifetime) and the
|
|
repo's file list, assembles the 5-element rule-report data: glob
|
|
verbatim, matched paths (capped sample + total), near-miss boundary
|
|
paths, lifetime/tier, and a `why` placeholder the caller fills in.
|
|
3. `RuleQualityChecker` — deterministic lint: `class_not_path` (flags globs
|
|
whose filename segment looks instance-unique: long digit runs, hex
|
|
hashes, bare timestamps, or a wildcard-free glob matching exactly one
|
|
existing path) and `prefer_narrower` (compares two candidate globs by
|
|
match-count on the actual tree; the narrower one wins ties).
|
|
|
|
None of these classes call an LLM, read history, or touch git. They operate
|
|
purely on lists of strings (paths) handed in by the orchestrating skill.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import fnmatch
|
|
import re
|
|
from dataclasses import dataclass, field
|
|
from pathlib import PurePosixPath
|
|
from typing import Optional
|
|
|
|
_DEFAULT_SAMPLE_CAP = 5
|
|
|
|
# --- shape-class helpers ---------------------------------------------------
|
|
|
|
_DIGIT_RUN = re.compile(r"\d+")
|
|
# hex run: 6+ consecutive hex chars (avoids false-triggering on short words)
|
|
_HEX_RUN = re.compile(r"(?<![0-9a-fA-F])[0-9a-fA-F]{6,}(?![0-9a-fA-F])")
|
|
|
|
# "instance-unique" detectors used by both shape-classing and rule-quality
|
|
# lints: a long digit run (8+, e.g. a timestamp or run-id), or a hex run
|
|
# (6+ chars, e.g. a git sha / hash).
|
|
_LONG_DIGIT_RUN = re.compile(r"\d{6,}")
|
|
|
|
|
|
def _filename_shape(name: str) -> str:
|
|
"""Collapse a filename into a shape class: digit runs -> '#', hex runs
|
|
(6+ hex chars) -> '~'. Order matters: hex collapse first (a hex run may
|
|
contain digits that would otherwise be independently collapsed)."""
|
|
shaped = _HEX_RUN.sub("~", name)
|
|
shaped = _DIGIT_RUN.sub("#", shaped)
|
|
return shaped
|
|
|
|
|
|
def _dir_prefix(path: str) -> str:
|
|
parts = PurePosixPath(path).parts[:-1]
|
|
return "/".join(parts)
|
|
|
|
|
|
@dataclass
|
|
class Cluster:
|
|
key: str # "<dir_prefix>::<shape>"
|
|
dir_prefix: str
|
|
shape: str
|
|
paths: list # all paths in this cluster, sorted
|
|
sample: list # capped representative sample
|
|
|
|
@property
|
|
def total(self) -> int:
|
|
return len(self.paths)
|
|
|
|
def to_dict(self) -> dict:
|
|
return {
|
|
"key": self.key,
|
|
"dir_prefix": self.dir_prefix,
|
|
"shape": self.shape,
|
|
"total": self.total,
|
|
"sample": list(self.sample),
|
|
}
|
|
|
|
|
|
class ClusterSampler:
|
|
"""Groups unmatched paths by path-shape (directory prefix + filename
|
|
shape class) and emits per-cluster representative samples, capped."""
|
|
|
|
def __init__(self, sample_cap: int = _DEFAULT_SAMPLE_CAP) -> None:
|
|
self._cap = sample_cap
|
|
|
|
def cluster(self, unmatched_paths: list) -> list:
|
|
"""Returns a list of Cluster, sorted by cluster key for determinism.
|
|
Each cluster groups paths sharing the same directory prefix AND the
|
|
same filename shape class."""
|
|
groups: dict = {}
|
|
for path in unmatched_paths:
|
|
dir_prefix = _dir_prefix(path)
|
|
filename = PurePosixPath(path).name
|
|
shape = _filename_shape(filename)
|
|
key = f"{dir_prefix}::{shape}"
|
|
groups.setdefault(key, []).append(path)
|
|
|
|
clusters = []
|
|
for key in sorted(groups.keys()):
|
|
paths = sorted(groups[key])
|
|
dir_prefix, shape = key.split("::", 1)
|
|
clusters.append(
|
|
Cluster(
|
|
key=key,
|
|
dir_prefix=dir_prefix,
|
|
shape=shape,
|
|
paths=paths,
|
|
sample=paths[: self._cap],
|
|
)
|
|
)
|
|
return clusters
|
|
|
|
def cluster_to_dicts(self, unmatched_paths: list) -> list:
|
|
return [c.to_dict() for c in self.cluster(unmatched_paths)]
|
|
|
|
|
|
# --- rule report assembly ---------------------------------------------------
|
|
|
|
|
|
def _glob_matches(glob_pattern: str, path: str) -> bool:
|
|
return fnmatch.fnmatch(path, glob_pattern)
|
|
|
|
|
|
def _relax_glob(glob_pattern: str) -> Optional[str]:
|
|
"""Produce a strictly broader variant of a glob for near-miss detection.
|
|
|
|
Widens every path segment that already contains a wildcard character to
|
|
a bare '*' (fully generic for that segment), preserving fixed segments
|
|
and the final segment's extension when present. This surfaces exactly
|
|
the #45 boundary-bug class: a glob like `autoresearch/classic-*/` that
|
|
silently misses a sibling `autoresearch/improve-*/` — relaxing the
|
|
`classic-*` segment to `*` reveals the sibling as a near-miss.
|
|
|
|
Returns None if no segment has a wildcard to relax (fully-fixed glob has
|
|
no meaningful near-miss boundary)."""
|
|
parts = glob_pattern.split("/")
|
|
if not parts:
|
|
return None
|
|
|
|
relaxed_parts = list(parts)
|
|
changed = False
|
|
for i, part in enumerate(parts):
|
|
if part in ("*", "**"):
|
|
continue # already fully generic
|
|
if any(ch in part for ch in "*?[") :
|
|
if "." in part:
|
|
stem, _, ext = part.rpartition(".")
|
|
relaxed = "*." + ext
|
|
else:
|
|
relaxed = "*"
|
|
if relaxed != part:
|
|
relaxed_parts[i] = relaxed
|
|
changed = True
|
|
|
|
if not changed:
|
|
return None
|
|
return "/".join(relaxed_parts)
|
|
|
|
|
|
@dataclass
|
|
class RuleReportEntry:
|
|
glob: str
|
|
lifetime: str
|
|
tier: str
|
|
matched_sample: list
|
|
matched_total: int
|
|
near_miss: list
|
|
why: str = ""
|
|
|
|
def to_dict(self) -> dict:
|
|
return {
|
|
"glob": self.glob,
|
|
"lifetime": self.lifetime,
|
|
"tier": self.tier,
|
|
"matches": {
|
|
"sample": list(self.matched_sample),
|
|
"total": self.matched_total,
|
|
},
|
|
"near_miss": list(self.near_miss),
|
|
"why": self.why,
|
|
}
|
|
|
|
|
|
class RuleReportBuilder:
|
|
"""Given proposed rules and the repo's file list, produces the 5-element
|
|
rule-report data per proposed rule: glob verbatim, matched paths (capped
|
|
sample + total), near-miss boundary, lifetime + tier, and a why string
|
|
(caller-supplied; this class does not author prose)."""
|
|
|
|
def __init__(self, sample_cap: int = _DEFAULT_SAMPLE_CAP) -> None:
|
|
self._cap = sample_cap
|
|
|
|
def build(self, proposed_rule: dict, all_paths: list) -> RuleReportEntry:
|
|
glob_pattern = proposed_rule["glob"]
|
|
lifetime = proposed_rule.get("lifetime", "keep")
|
|
tier = proposed_rule.get("tier", "confirm")
|
|
why = proposed_rule.get("why", "")
|
|
|
|
matched = sorted(p for p in all_paths if _glob_matches(glob_pattern, p))
|
|
|
|
near_miss: list = []
|
|
relaxed = _relax_glob(glob_pattern)
|
|
if relaxed is not None:
|
|
matched_set = set(matched)
|
|
near_miss = sorted(
|
|
p
|
|
for p in all_paths
|
|
if p not in matched_set and _glob_matches(relaxed, p)
|
|
)
|
|
|
|
return RuleReportEntry(
|
|
glob=glob_pattern,
|
|
lifetime=lifetime,
|
|
tier=tier,
|
|
matched_sample=matched[: self._cap],
|
|
matched_total=len(matched),
|
|
near_miss=near_miss,
|
|
why=why,
|
|
)
|
|
|
|
def build_all(self, proposed_rules: list, all_paths: list) -> list:
|
|
return [self.build(r, all_paths).to_dict() for r in proposed_rules]
|
|
|
|
|
|
# --- rule-quality checks -----------------------------------------------------
|
|
|
|
|
|
@dataclass
|
|
class QualityFinding:
|
|
check: str # "class_not_path" | "prefer_narrower"
|
|
glob: str
|
|
passed: bool
|
|
reason: str
|
|
|
|
def to_dict(self) -> dict:
|
|
return {
|
|
"check": self.check,
|
|
"glob": self.glob,
|
|
"passed": self.passed,
|
|
"reason": self.reason,
|
|
}
|
|
|
|
|
|
class RuleQualityChecker:
|
|
"""Deterministic lints applied to a proposed glob before persistence:
|
|
|
|
- `class_not_path`: flags a glob whose final path segment looks
|
|
instance-unique (a long digit run / bare timestamp, or a hex-looking
|
|
hash run), OR a glob with zero wildcard characters that currently
|
|
matches exactly one existing path (a rule that can, by construction,
|
|
only ever match that one file — a failed generalization).
|
|
- `prefer_narrower`: given two candidate globs for the same cluster,
|
|
report which one is narrower by match-count against the actual tree
|
|
(ties broken by raw pattern length, longer = more specific = narrower).
|
|
"""
|
|
|
|
def class_not_path(self, glob_pattern: str, all_paths: Optional[list] = None) -> QualityFinding:
|
|
last_segment = glob_pattern.split("/")[-1]
|
|
|
|
if _LONG_DIGIT_RUN.search(last_segment):
|
|
return QualityFinding(
|
|
check="class_not_path",
|
|
glob=glob_pattern,
|
|
passed=False,
|
|
reason=(
|
|
"glob's final segment contains a long digit run "
|
|
"(looks like a run-id or timestamp unique to one instance)"
|
|
),
|
|
)
|
|
|
|
if _HEX_RUN.search(last_segment):
|
|
return QualityFinding(
|
|
check="class_not_path",
|
|
glob=glob_pattern,
|
|
passed=False,
|
|
reason=(
|
|
"glob's final segment contains a hex-looking run "
|
|
"(looks like a hash unique to one instance)"
|
|
),
|
|
)
|
|
|
|
has_wildcard = any(ch in glob_pattern for ch in "*?[")
|
|
if not has_wildcard and all_paths is not None:
|
|
matches = [p for p in all_paths if _glob_matches(glob_pattern, p)]
|
|
if len(matches) <= 1:
|
|
return QualityFinding(
|
|
check="class_not_path",
|
|
glob=glob_pattern,
|
|
passed=False,
|
|
reason=(
|
|
"glob has no wildcard and matches at most one existing "
|
|
"path — by construction it can never match a future "
|
|
"file (failed generalization), flagged loudly"
|
|
),
|
|
)
|
|
|
|
return QualityFinding(
|
|
check="class_not_path",
|
|
glob=glob_pattern,
|
|
passed=True,
|
|
reason="glob names a recurring class, not a single instance",
|
|
)
|
|
|
|
def prefer_narrower(self, glob_a: str, glob_b: str, all_paths: list) -> dict:
|
|
"""Returns {"narrower": <glob>, "match_counts": {a: n, b: n}}."""
|
|
count_a = sum(1 for p in all_paths if _glob_matches(glob_a, p))
|
|
count_b = sum(1 for p in all_paths if _glob_matches(glob_b, p))
|
|
|
|
if count_a != count_b:
|
|
narrower = glob_a if count_a < count_b else glob_b
|
|
else:
|
|
# tie-break on identical match counts: fewer wildcard characters
|
|
# is more specific/narrower; if that also ties, the longer raw
|
|
# pattern is treated as more specific.
|
|
def _wildcard_count(g: str) -> int:
|
|
return sum(g.count(ch) for ch in "*?[")
|
|
|
|
wc_a, wc_b = _wildcard_count(glob_a), _wildcard_count(glob_b)
|
|
if wc_a != wc_b:
|
|
narrower = glob_a if wc_a < wc_b else glob_b
|
|
else:
|
|
narrower = glob_a if len(glob_a) >= len(glob_b) else glob_b
|
|
|
|
return {
|
|
"narrower": narrower,
|
|
"match_counts": {glob_a: count_a, glob_b: count_b},
|
|
}
|