jearless 0.10.4__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.
- jearless/__init__.py +12 -0
- jearless/add.py +122 -0
- jearless/changelog.py +81 -0
- jearless/cli.py +107 -0
- jearless/config.py +69 -0
- jearless/doctor.py +83 -0
- jearless/duplicate.py +86 -0
- jearless/errors.py +5 -0
- jearless/gitwrap.py +69 -0
- jearless/registry.py +105 -0
- jearless/release.py +83 -0
- jearless/retro.py +69 -0
- jearless/slug.py +28 -0
- jearless/sprint.py +33 -0
- jearless/state.py +86 -0
- jearless/take.py +63 -0
- jearless/timeline.py +121 -0
- jearless-0.10.4.dist-info/METADATA +141 -0
- jearless-0.10.4.dist-info/RECORD +22 -0
- jearless-0.10.4.dist-info/WHEEL +4 -0
- jearless-0.10.4.dist-info/entry_points.txt +3 -0
- jearless-0.10.4.dist-info/licenses/LICENSE +21 -0
jearless/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""jearless — Fearless. Jearless. Task tracking and a changelog on bare git."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError
|
|
4
|
+
from importlib.metadata import version as _package_version
|
|
5
|
+
|
|
6
|
+
# The version is DERIVED from package metadata (which pyproject feeds, which the
|
|
7
|
+
# hook mirrors) — a hardcoded third copy here would drift, and did (0.1.0 in the
|
|
8
|
+
# wheel while the flow lived at 0.10.4). One authored source, everything else derives.
|
|
9
|
+
try:
|
|
10
|
+
__version__ = _package_version("jearless")
|
|
11
|
+
except PackageNotFoundError: # running from a bare source tree, not installed
|
|
12
|
+
__version__ = "0.0.0"
|
jearless/add.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""add: append a task to the eternal registry — the one sanctioned YAML write.
|
|
2
|
+
|
|
3
|
+
The duplicate guard (task tooling/10) watches the door: on high keyword
|
|
4
|
+
overlap with existing summaries it lists the look-alikes and demands
|
|
5
|
+
confirmation. The most common duplicate in nature is a re-suggested DONE
|
|
6
|
+
task — hence the derived state next to each match.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from ruamel.yaml import YAML
|
|
14
|
+
|
|
15
|
+
from jearless import config, duplicate, gitwrap, registry, state
|
|
16
|
+
from jearless.errors import JearlessError
|
|
17
|
+
from jearless.registry import REGISTRY_NAME
|
|
18
|
+
|
|
19
|
+
# Project names live in branch names: lowercase latin, digits, underscores.
|
|
20
|
+
PROJECT_RE = re.compile(r"^[a-z0-9_]+$")
|
|
21
|
+
|
|
22
|
+
# The header a newborn registry is born with — the same covenant comment as the seeded one.
|
|
23
|
+
_TEMPLATE = """\
|
|
24
|
+
# The eternal jearless task registry (README: frozen facts only, no live state).
|
|
25
|
+
# Status is never stored: available = here without a branch; in_progress = a branch
|
|
26
|
+
# on origin; done = a merge commit in main.
|
|
27
|
+
tasks: {}
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _max_from_archives(cwd: Path, project: str) -> int:
|
|
32
|
+
best = 0
|
|
33
|
+
for path in sorted(cwd.glob("archive_*.yml")):
|
|
34
|
+
best = max(best, registry.load_path(path).max_id(project))
|
|
35
|
+
return best
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _max_from_branches(cwd: Path, cfg: config.Config, project: str, remote: str) -> int | None:
|
|
39
|
+
"""None means origin is unreachable: the id is then based on the registry only."""
|
|
40
|
+
try:
|
|
41
|
+
heads = gitwrap.remote_heads(cwd, remote)
|
|
42
|
+
except gitwrap.GitError:
|
|
43
|
+
return None
|
|
44
|
+
best = 0
|
|
45
|
+
for name in heads:
|
|
46
|
+
m = cfg.match_branch(name)
|
|
47
|
+
if m and m.group("project") == project:
|
|
48
|
+
best = max(best, int(m.group("num")))
|
|
49
|
+
return best
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _states(cwd: Path, cfg: config.Config, remote: str) -> dict[tuple[str, int], str]:
|
|
53
|
+
"""Derived states for the warning; an unreachable origin simply drops the annotation."""
|
|
54
|
+
try:
|
|
55
|
+
gitwrap.run(["fetch", "--quiet", remote], cwd)
|
|
56
|
+
view = state.compute(cwd, cfg, registry.load(cwd), remote)
|
|
57
|
+
except (gitwrap.GitError, JearlessError):
|
|
58
|
+
return {}
|
|
59
|
+
return {(tv.task.project, tv.task.id): tv.state for tv in view.tasks}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _guard(cwd: Path, cfg: config.Config, summary: str, remote: str, yes: bool) -> None:
|
|
63
|
+
if yes:
|
|
64
|
+
return
|
|
65
|
+
matches = duplicate.find_similar(registry.load_all(cwd), summary, cfg.duplicate_threshold)
|
|
66
|
+
if not matches:
|
|
67
|
+
return
|
|
68
|
+
states = _states(cwd, cfg, remote)
|
|
69
|
+
print("warning: similar tasks exist:")
|
|
70
|
+
for m in matches:
|
|
71
|
+
derived = states.get((m.task.project, m.task.id))
|
|
72
|
+
tag = f" ({derived})" if derived else ""
|
|
73
|
+
print(f" {m.task.project}/{m.task.id}{tag}: {m.task.summary} — {round(m.score * 100)}%")
|
|
74
|
+
if not sys.stdin.isatty():
|
|
75
|
+
raise JearlessError("declined: no terminal to confirm — re-run with --yes to force")
|
|
76
|
+
if input("Are you sure to add? [y/N] ").strip().lower() not in ("y", "yes"):
|
|
77
|
+
raise JearlessError("declined — the registry is untouched")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def add(
|
|
81
|
+
cwd: Path,
|
|
82
|
+
project: str,
|
|
83
|
+
summary: str,
|
|
84
|
+
points: int | None = None,
|
|
85
|
+
remote: str = "origin",
|
|
86
|
+
yes: bool = False,
|
|
87
|
+
) -> int:
|
|
88
|
+
if not PROJECT_RE.match(project):
|
|
89
|
+
raise JearlessError("project names are [a-z0-9_]: they live in branch names")
|
|
90
|
+
summary = summary.strip()
|
|
91
|
+
if not summary:
|
|
92
|
+
raise JearlessError("summary must not be empty")
|
|
93
|
+
|
|
94
|
+
cfg = config.load(cwd)
|
|
95
|
+
_guard(cwd, cfg, summary, remote, yes)
|
|
96
|
+
candidates = [registry.load(cwd).max_id(project), _max_from_archives(cwd, project)]
|
|
97
|
+
from_branches = _max_from_branches(cwd, cfg, project, remote)
|
|
98
|
+
if from_branches is not None:
|
|
99
|
+
candidates.append(from_branches)
|
|
100
|
+
else:
|
|
101
|
+
print(
|
|
102
|
+
f"jear: cannot reach '{remote}' — next id checked against the registry only",
|
|
103
|
+
file=sys.stderr,
|
|
104
|
+
)
|
|
105
|
+
next_id = max(candidates) + 1
|
|
106
|
+
|
|
107
|
+
path = cwd / REGISTRY_NAME
|
|
108
|
+
yaml = YAML(typ="rt") # round-trip: comments survive the append
|
|
109
|
+
yaml.indent(mapping=2, sequence=4, offset=2)
|
|
110
|
+
yaml.width = 100000 # never wrap a long summary
|
|
111
|
+
data = yaml.load(path) if path.exists() else None
|
|
112
|
+
if data is None:
|
|
113
|
+
data = yaml.load(_TEMPLATE)
|
|
114
|
+
if not isinstance(data, dict):
|
|
115
|
+
raise JearlessError(f"{REGISTRY_NAME}: expected a mapping at the top level")
|
|
116
|
+
entry: dict = {"id": next_id, "summary": summary}
|
|
117
|
+
if points is not None:
|
|
118
|
+
entry["story_points"] = points
|
|
119
|
+
data.setdefault("tasks", {}).setdefault(project, []).append(entry)
|
|
120
|
+
yaml.dump(data, path)
|
|
121
|
+
print(f"task {project}/{next_id} added — commit tasks.yml to main")
|
|
122
|
+
return next_id
|
jearless/changelog.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""changelog: derived from merge commits between refs — printed, never stored (README).
|
|
2
|
+
|
|
3
|
+
Dry-run is the only mode: writing files is the release step's business (task tooling/9).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from jearless import config, gitwrap, registry, state
|
|
9
|
+
from jearless.errors import JearlessError
|
|
10
|
+
|
|
11
|
+
_UNRELEASED = "Unreleased"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _summary_for(reg: registry.Registry, project: str, num: int, slug: str | None) -> str:
|
|
15
|
+
"""The registry (union with archives) first; the branch slug is the fallback (README)."""
|
|
16
|
+
for task in reg.tasks.get(project, []):
|
|
17
|
+
if task.id == num:
|
|
18
|
+
return task.summary
|
|
19
|
+
return (slug or "task").replace("-", " ")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _entries_from_history(
|
|
23
|
+
cwd: Path, cfg: config.Config, reg: registry.Registry, rev_range: str
|
|
24
|
+
) -> tuple[dict[str, list[tuple[tuple[str, int], str]]], int]:
|
|
25
|
+
sections: dict[str, list[tuple[tuple[str, int], str]]] = {}
|
|
26
|
+
skipped = 0
|
|
27
|
+
subjects = gitwrap.lines(["log", rev_range, "--merges", "--format=%s"], cwd)
|
|
28
|
+
for subject in subjects:
|
|
29
|
+
branch = state.branch_from_merge_subject(subject)
|
|
30
|
+
m = cfg.match_branch(branch) if branch else None
|
|
31
|
+
if not m:
|
|
32
|
+
skipped += 1 # off-taxonomy merge: the visible hole, counted aloud
|
|
33
|
+
continue
|
|
34
|
+
project, num = m.group("project"), int(m.group("num"))
|
|
35
|
+
summary = _summary_for(reg, project, num, m.group("slug"))
|
|
36
|
+
line = cfg.line_template.format(
|
|
37
|
+
type=m.group("type"),
|
|
38
|
+
project=project,
|
|
39
|
+
num=num,
|
|
40
|
+
slug=m.group("slug") or "",
|
|
41
|
+
summary=summary,
|
|
42
|
+
)
|
|
43
|
+
sections.setdefault(m.group("type"), []).append(((project, num), line))
|
|
44
|
+
return sections, skipped
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def render(
|
|
48
|
+
cwd: Path, from_tag: str | None = None, to_tag: str | None = None, header: str | None = None
|
|
49
|
+
) -> str:
|
|
50
|
+
"""The changelog as text; `header` overrides the section title (used by release)."""
|
|
51
|
+
cfg = config.load(cwd)
|
|
52
|
+
reg = registry.load_all(cwd)
|
|
53
|
+
to_ref = to_tag or "HEAD"
|
|
54
|
+
rev_range = f"{from_tag}..{to_ref}" if from_tag else to_ref
|
|
55
|
+
try:
|
|
56
|
+
sections, skipped = _entries_from_history(cwd, cfg, reg, rev_range)
|
|
57
|
+
date = gitwrap.out(["log", "-1", "--format=%cd", "--date=short", to_ref], cwd)
|
|
58
|
+
except gitwrap.GitError as exc:
|
|
59
|
+
raise JearlessError(f"cannot read history {rev_range}: {exc}") from exc
|
|
60
|
+
|
|
61
|
+
lines = ["# Changelog"]
|
|
62
|
+
title = header or to_tag or _UNRELEASED
|
|
63
|
+
lines.append(f"## {title} ({date})" if date else f"## {title}")
|
|
64
|
+
for prefix, section_title in cfg.sections.items():
|
|
65
|
+
entries = sorted(sections.pop(prefix, []))
|
|
66
|
+
if not entries:
|
|
67
|
+
continue
|
|
68
|
+
lines.append(f"### {section_title}")
|
|
69
|
+
lines.extend(f"- {line}" for _, line in entries)
|
|
70
|
+
others = sorted((key, line) for entries in sections.values() for key, line in entries)
|
|
71
|
+
if others:
|
|
72
|
+
lines.append("### Other")
|
|
73
|
+
lines.extend(f"- {line}" for _, line in others)
|
|
74
|
+
if skipped:
|
|
75
|
+
lines.append(f"# {skipped} off-taxonomy merge(s) skipped — the visible hole (README)")
|
|
76
|
+
return "\n".join(lines) + "\n"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def run(cwd: Path, from_tag: str | None = None, to_tag: str | None = None) -> int:
|
|
80
|
+
print(render(cwd, from_tag=from_tag, to_tag=to_tag), end="")
|
|
81
|
+
return 0
|
jearless/cli.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""The CLI frame: subcommands per PLAN.md, phases 1-4.
|
|
2
|
+
|
|
3
|
+
In Phase 0 the subcommands are stubs, but empty states are handled by the
|
|
4
|
+
live modules, without crashing (Phase 0 DoD).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from jearless import __version__
|
|
12
|
+
from jearless import add as add_mod
|
|
13
|
+
from jearless import changelog as changelog_mod
|
|
14
|
+
from jearless import doctor as doctor_mod
|
|
15
|
+
from jearless import release as release_mod
|
|
16
|
+
from jearless import retro as retro_mod
|
|
17
|
+
from jearless import sprint as sprint_mod
|
|
18
|
+
from jearless import take as take_mod
|
|
19
|
+
from jearless import timeline as timeline_mod
|
|
20
|
+
from jearless.errors import JearlessError
|
|
21
|
+
|
|
22
|
+
PHASES = {
|
|
23
|
+
"add": 1,
|
|
24
|
+
"take": 1,
|
|
25
|
+
"sprint": 1,
|
|
26
|
+
"changelog": 2,
|
|
27
|
+
"doctor": 2,
|
|
28
|
+
"timeline": 3,
|
|
29
|
+
"retro": 3,
|
|
30
|
+
"archive": 4,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
35
|
+
parser = argparse.ArgumentParser(
|
|
36
|
+
prog="jear",
|
|
37
|
+
description="Fearless. Jira-less. Task tracking and changelog on bare git.",
|
|
38
|
+
)
|
|
39
|
+
parser.add_argument("--version", action="version", version=f"jearless {__version__}")
|
|
40
|
+
sub = parser.add_subparsers(dest="command", required=True, metavar="COMMAND")
|
|
41
|
+
|
|
42
|
+
p = sub.add_parser("add", help="add a task to the registry (Phase 1)")
|
|
43
|
+
p.add_argument("project")
|
|
44
|
+
p.add_argument("summary")
|
|
45
|
+
p.add_argument("--points", type=int, default=None)
|
|
46
|
+
p.add_argument("--yes", action="store_true", help="skip the duplicate guard")
|
|
47
|
+
|
|
48
|
+
p = sub.add_parser("take", help="take a task: branch + push (Phase 1)")
|
|
49
|
+
p.add_argument("project")
|
|
50
|
+
p.add_argument("id", type=int)
|
|
51
|
+
|
|
52
|
+
sub.add_parser("sprint", help="sprint: registry × branches × merge log join (Phase 1)")
|
|
53
|
+
|
|
54
|
+
p = sub.add_parser(
|
|
55
|
+
"changelog", help="changelog from merge commits (Phase 2); dry-run is the only mode"
|
|
56
|
+
)
|
|
57
|
+
p.add_argument("--from", dest="from_tag", default=None, metavar="TAG")
|
|
58
|
+
p.add_argument("--to", dest="to_tag", default=None, metavar="TAG")
|
|
59
|
+
p.add_argument("--dry-run", action="store_true", help="accepted for symmetry; always dry")
|
|
60
|
+
|
|
61
|
+
sub.add_parser("doctor", help="diagnostics: unattributed commits etc. (Phase 2)")
|
|
62
|
+
|
|
63
|
+
p = sub.add_parser("timeline", help="actual timeline, mermaid (Phase 3)")
|
|
64
|
+
group = p.add_mutually_exclusive_group()
|
|
65
|
+
group.add_argument("--live", action="store_true")
|
|
66
|
+
group.add_argument("--snapshot", metavar="TAG")
|
|
67
|
+
|
|
68
|
+
p = sub.add_parser("retro", help="plan versus actual (Phase 3)")
|
|
69
|
+
p.add_argument("plan")
|
|
70
|
+
|
|
71
|
+
p = sub.add_parser(
|
|
72
|
+
"release", help="tag + changelog + timeline snapshot in one move (task tooling/9)"
|
|
73
|
+
)
|
|
74
|
+
p.add_argument("--tag", default=None, metavar="TAG")
|
|
75
|
+
|
|
76
|
+
sub.add_parser("archive", help="move done tasks out (Phase 4, on trigger)")
|
|
77
|
+
|
|
78
|
+
return parser
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def main(argv: list[str] | None = None) -> int:
|
|
82
|
+
args = build_parser().parse_args(argv)
|
|
83
|
+
cwd = Path.cwd()
|
|
84
|
+
try:
|
|
85
|
+
if args.command == "add":
|
|
86
|
+
add_mod.add(cwd, args.project, args.summary, args.points, yes=args.yes)
|
|
87
|
+
return 0
|
|
88
|
+
if args.command == "take":
|
|
89
|
+
print(take_mod.take(cwd, args.project, args.id))
|
|
90
|
+
return 0
|
|
91
|
+
if args.command == "sprint":
|
|
92
|
+
return sprint_mod.run(cwd)
|
|
93
|
+
if args.command == "changelog":
|
|
94
|
+
return changelog_mod.run(cwd, from_tag=args.from_tag, to_tag=args.to_tag)
|
|
95
|
+
if args.command == "doctor":
|
|
96
|
+
return doctor_mod.run(cwd)
|
|
97
|
+
if args.command == "timeline":
|
|
98
|
+
return timeline_mod.run(cwd, snapshot=args.snapshot)
|
|
99
|
+
if args.command == "retro":
|
|
100
|
+
return retro_mod.run(cwd, Path(args.plan))
|
|
101
|
+
if args.command == "release":
|
|
102
|
+
return release_mod.run(cwd, tag=args.tag)
|
|
103
|
+
print(f"not implemented (Phase {PHASES[args.command]}) — see PLAN.md")
|
|
104
|
+
return 0
|
|
105
|
+
except JearlessError as exc:
|
|
106
|
+
print(f"jear: {exc}", file=sys.stderr)
|
|
107
|
+
return 1
|
jearless/config.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""The .jearless.toml config: the branch regex, the changelog line template, sections."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import tomllib
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from jearless.errors import JearlessError
|
|
9
|
+
|
|
10
|
+
CONFIG_NAME = ".jearless.toml"
|
|
11
|
+
|
|
12
|
+
# Slashes are the structural separators: type/project/number-slug (README).
|
|
13
|
+
DEFAULT_BRANCH_PATTERN = (
|
|
14
|
+
r"^(?P<type>[a-z]+)/(?P<project>[a-z0-9_]+)/(?P<num>\d+)(?:-(?P<slug>[a-z0-9-]+))?$"
|
|
15
|
+
)
|
|
16
|
+
REQUIRED_GROUPS = ("type", "project", "num")
|
|
17
|
+
DEFAULT_SECTIONS = {"feature": "Added", "fix": "Fixed"}
|
|
18
|
+
DEFAULT_LINE_TEMPLATE = "{project}-{num}: {summary}"
|
|
19
|
+
DEFAULT_DUPLICATE_THRESHOLD = 0.6
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ConfigError(JearlessError):
|
|
23
|
+
"""The config exists but is invalid."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class Config:
|
|
28
|
+
branch_pattern: re.Pattern
|
|
29
|
+
sections: dict[str, str]
|
|
30
|
+
line_template: str
|
|
31
|
+
duplicate_threshold: float = DEFAULT_DUPLICATE_THRESHOLD
|
|
32
|
+
|
|
33
|
+
def match_branch(self, name: str) -> re.Match | None:
|
|
34
|
+
return self.branch_pattern.match(name)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _compile_branch_pattern(pattern: str) -> re.Pattern:
|
|
38
|
+
try:
|
|
39
|
+
compiled = re.compile(pattern)
|
|
40
|
+
except re.error as exc:
|
|
41
|
+
raise ConfigError(f"branch regex does not compile: {exc}") from exc
|
|
42
|
+
missing = [g for g in REQUIRED_GROUPS if g not in compiled.groupindex]
|
|
43
|
+
if missing:
|
|
44
|
+
raise ConfigError(f"branch regex misses named groups: {', '.join(missing)}")
|
|
45
|
+
return compiled
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def load(cwd: Path) -> Config:
|
|
49
|
+
"""A missing .jearless.toml is not an error: defaults apply."""
|
|
50
|
+
path = cwd / CONFIG_NAME
|
|
51
|
+
data: dict = {}
|
|
52
|
+
if path.exists():
|
|
53
|
+
try:
|
|
54
|
+
data = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
55
|
+
except tomllib.TOMLDecodeError as exc:
|
|
56
|
+
raise ConfigError(f"{CONFIG_NAME}: {exc}") from exc
|
|
57
|
+
branch = data.get("branch", {})
|
|
58
|
+
changelog = data.get("changelog", {})
|
|
59
|
+
duplicate = data.get("duplicate", {})
|
|
60
|
+
threshold = duplicate.get("threshold", DEFAULT_DUPLICATE_THRESHOLD)
|
|
61
|
+
if not isinstance(threshold, (int, float)) or not 0 < threshold <= 1:
|
|
62
|
+
raise ConfigError("[duplicate] threshold must be a number in (0, 1]")
|
|
63
|
+
sections = {**DEFAULT_SECTIONS, **changelog.get("sections", {})}
|
|
64
|
+
return Config(
|
|
65
|
+
branch_pattern=_compile_branch_pattern(branch.get("pattern", DEFAULT_BRANCH_PATTERN)),
|
|
66
|
+
sections=sections,
|
|
67
|
+
line_template=changelog.get("line_template", DEFAULT_LINE_TEMPLATE),
|
|
68
|
+
duplicate_threshold=float(threshold),
|
|
69
|
+
)
|
jearless/doctor.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""doctor: the visible hole — unattributed commits, naming violations, a sick registry.
|
|
2
|
+
|
|
3
|
+
The audit walks the first-parent chain of main: a commit there is attributed
|
|
4
|
+
when it is a merge (the branch name is the key) or touches nothing but
|
|
5
|
+
sanctioned artifacts — the registry, the changelog, timeline snapshots (README).
|
|
6
|
+
Commits that arrived inside a --no-ff merge hang off second parents and are
|
|
7
|
+
attributed by that merge. Everything else on the chain is a hole: a squash,
|
|
8
|
+
a fast-forward or plain drift.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from jearless import config, gitwrap, registry
|
|
14
|
+
from jearless.gitwrap import GitError
|
|
15
|
+
from jearless.registry import REGISTRY_NAME, RegistryError
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _check_registry(cwd: Path, findings: list[str]) -> None:
|
|
19
|
+
try:
|
|
20
|
+
registry.load(cwd)
|
|
21
|
+
except RegistryError as exc:
|
|
22
|
+
findings.append(f"registry: {exc}")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _check_branches(cwd: Path, cfg: config.Config, findings: list[str], remote: str) -> None:
|
|
26
|
+
try:
|
|
27
|
+
heads = gitwrap.remote_heads(cwd, remote)
|
|
28
|
+
except GitError:
|
|
29
|
+
findings.append(f"{remote}: unreachable — branch checks skipped")
|
|
30
|
+
return
|
|
31
|
+
known = set(cfg.sections) | {"chore"}
|
|
32
|
+
for name in heads:
|
|
33
|
+
prefix = name.split("/", 1)[0]
|
|
34
|
+
if prefix in known and not cfg.match_branch(name):
|
|
35
|
+
findings.append(f"branch: '{name}' starts like a task branch but misses the pattern")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# Sanctioned artifacts: bookkeeping, not task work. env.yml version bumps
|
|
39
|
+
# normally ride branches via the hook; a direct one is human bookkeeping.
|
|
40
|
+
_SANCTIONED_EXACT = frozenset({REGISTRY_NAME, "CHANGELOG.md", "env.yml"})
|
|
41
|
+
_SANCTIONED_PREFIXES = ("archive_", "snapshots/")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _is_sanctioned_edit(cwd: Path, sha: str) -> bool:
|
|
45
|
+
files = gitwrap.lines(["diff-tree", "--no-commit-id", "--name-only", "-r", "--root", sha], cwd)
|
|
46
|
+
return all(
|
|
47
|
+
f in _SANCTIONED_EXACT or f.startswith(_SANCTIONED_PREFIXES) for f in files
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _check_unattributed(cwd: Path, findings: list[str], remote: str) -> None:
|
|
52
|
+
ref = f"{remote}/main"
|
|
53
|
+
try:
|
|
54
|
+
# first-parent only: WIP that arrived inside a --no-ff merge is attributed
|
|
55
|
+
# by that merge, not audited on its own
|
|
56
|
+
log = gitwrap.out(["log", "--first-parent", ref, "--format=%H%x1f%P%x1f%s"], cwd)
|
|
57
|
+
except GitError:
|
|
58
|
+
findings.append(f"main: no {ref} — nothing to audit yet")
|
|
59
|
+
return
|
|
60
|
+
for line in log.splitlines():
|
|
61
|
+
if not line.strip():
|
|
62
|
+
continue
|
|
63
|
+
sha, parents, subject = line.split("\x1f", 2)
|
|
64
|
+
if len(parents.split()) > 1:
|
|
65
|
+
continue # a merge: attributed by its branch name
|
|
66
|
+
if _is_sanctioned_edit(cwd, sha):
|
|
67
|
+
continue # a sanctioned human edit: a task, the changelog, a snapshot
|
|
68
|
+
findings.append(
|
|
69
|
+
f"main: unattributed commit {sha[:7]} \"{subject}\" "
|
|
70
|
+
f"(not a merge, not a registry edit) — squash or fast-forward suspected"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def run(cwd: Path, remote: str = "origin") -> int:
|
|
75
|
+
findings: list[str] = []
|
|
76
|
+
_check_registry(cwd, findings)
|
|
77
|
+
cfg = config.load(cwd)
|
|
78
|
+
_check_branches(cwd, cfg, findings, remote)
|
|
79
|
+
_check_unattributed(cwd, findings, remote)
|
|
80
|
+
for line in findings:
|
|
81
|
+
print(line)
|
|
82
|
+
print(f"doctor: {len(findings)} finding(s)")
|
|
83
|
+
return 1 if findings else 0
|
jearless/duplicate.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""The duplicate guard (task tooling/10): cheap keyword overlap with mini-IDF.
|
|
2
|
+
|
|
3
|
+
Simple means by charter: tokenize, weight each word by its scarcity across the
|
|
4
|
+
registry's own summaries, score with weighted Jaccard corrected by containment.
|
|
5
|
+
No stemming frameworks, no edit distance — a duplicate is the same words in a
|
|
6
|
+
different order, and reordering is not the enemy.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
from jearless import registry
|
|
13
|
+
|
|
14
|
+
_STOPWORDS = frozenset(
|
|
15
|
+
[
|
|
16
|
+
"a", "an", "and", "are", "as", "at", "be", "but", "by", "for", "from",
|
|
17
|
+
"has", "have", "in", "into", "is", "it", "its", "of", "on", "or",
|
|
18
|
+
"the", "this", "to", "with", "you", "your",
|
|
19
|
+
]
|
|
20
|
+
)
|
|
21
|
+
_WORD_RE = re.compile(r"[a-z0-9]+")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def tokenize(summary: str) -> list[str]:
|
|
25
|
+
"""Lowercase alphanumeric runs minus stopwords; a naive plural strip.
|
|
26
|
+
|
|
27
|
+
The strip misses branch/branches-class pairs — acceptable for a guard:
|
|
28
|
+
it is a net, not a wall, and misses cost nothing while false alarms cost
|
|
29
|
+
a confirmation each.
|
|
30
|
+
"""
|
|
31
|
+
words: list[str] = []
|
|
32
|
+
for raw in _WORD_RE.findall(summary.lower()):
|
|
33
|
+
if raw in _STOPWORDS:
|
|
34
|
+
continue
|
|
35
|
+
if len(raw) > 3 and raw.endswith("s") and not raw.endswith("ss"):
|
|
36
|
+
raw = raw[:-1] # tasks → task; class stays class
|
|
37
|
+
words.append(raw)
|
|
38
|
+
return words
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _weights(summaries: list[str]) -> dict[str, float]:
|
|
42
|
+
"""Mini-IDF: a word occurring in many summaries weighs almost nothing.
|
|
43
|
+
|
|
44
|
+
This is the whole defense against domain noise: "task" and "registry" are
|
|
45
|
+
in half of our summaries and must not trigger matches on their own.
|
|
46
|
+
"""
|
|
47
|
+
df: dict[str, int] = {}
|
|
48
|
+
for summary in summaries:
|
|
49
|
+
for word in set(tokenize(summary)):
|
|
50
|
+
df[word] = df.get(word, 0) + 1
|
|
51
|
+
return {word: 1.0 / n for word, n in df.items()}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class Match:
|
|
56
|
+
task: registry.Task
|
|
57
|
+
score: float # 0..1
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _score(new: set[str], old: set[str], weights: dict[str, float]) -> float:
|
|
61
|
+
def weight(word: str) -> float:
|
|
62
|
+
return weights.get(word, 1.0) # an unseen word is maximally informative
|
|
63
|
+
|
|
64
|
+
union = new | old
|
|
65
|
+
if not union:
|
|
66
|
+
return 0.0
|
|
67
|
+
shared = new & old
|
|
68
|
+
jaccard = sum(weight(w) for w in shared) / sum(weight(w) for w in union)
|
|
69
|
+
denom = min(sum(weight(w) for w in new), sum(weight(w) for w in old))
|
|
70
|
+
containment = sum(weight(w) for w in shared) / denom if denom else 0.0
|
|
71
|
+
# containment guards the common duplicate: a short summary inside a long one
|
|
72
|
+
return max(jaccard, containment)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def find_similar(
|
|
76
|
+
reg: registry.Registry, summary: str, threshold: float, limit: int = 3
|
|
77
|
+
) -> list[Match]:
|
|
78
|
+
"""Existing tasks whose summaries overlap above the threshold, best first."""
|
|
79
|
+
new = set(tokenize(summary))
|
|
80
|
+
weights = _weights([t.summary for t in reg.all()])
|
|
81
|
+
matches: list[Match] = []
|
|
82
|
+
for task in reg.all():
|
|
83
|
+
score = _score(new, set(tokenize(task.summary)), weights)
|
|
84
|
+
if score >= threshold:
|
|
85
|
+
matches.append(Match(task, score))
|
|
86
|
+
return sorted(matches, key=lambda m: m.score, reverse=True)[:limit]
|
jearless/errors.py
ADDED
jearless/gitwrap.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Git via subprocess: no intermediary libraries, all flags in plain sight.
|
|
2
|
+
|
|
3
|
+
Rationale in PLAN.md: jearless is about the precise semantics of git,
|
|
4
|
+
so git calls must not be hidden behind wrappers like GitPython.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import subprocess
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from jearless.errors import JearlessError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class GitError(JearlessError):
|
|
15
|
+
"""git exited with a non-zero code."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _git(args: list[str], cwd: Path, env: dict[str, str] | None = None) -> subprocess.CompletedProcess:
|
|
19
|
+
return subprocess.run(
|
|
20
|
+
["git", *args],
|
|
21
|
+
cwd=cwd,
|
|
22
|
+
capture_output=True,
|
|
23
|
+
encoding="utf-8",
|
|
24
|
+
errors="replace",
|
|
25
|
+
check=False,
|
|
26
|
+
env={**os.environ, **env} if env else None,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def run(
|
|
31
|
+
args: list[str], cwd: Path, env: dict[str, str] | None = None
|
|
32
|
+
) -> subprocess.CompletedProcess:
|
|
33
|
+
"""Run git; on failure raise GitError with stderr as a single line."""
|
|
34
|
+
proc = _git(args, cwd, env)
|
|
35
|
+
if proc.returncode != 0:
|
|
36
|
+
detail = proc.stderr.strip() or proc.stdout.strip()
|
|
37
|
+
raise GitError(f"git {' '.join(args)}: {detail}")
|
|
38
|
+
return proc
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def out(args: list[str], cwd: Path) -> str:
|
|
42
|
+
return run(args, cwd).stdout.strip()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def lines(args: list[str], cwd: Path) -> list[str]:
|
|
46
|
+
return [ln for ln in run(args, cwd).stdout.splitlines() if ln.strip()]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def is_repo(cwd: Path) -> bool:
|
|
50
|
+
proc = _git(["rev-parse", "--is-inside-work-tree"], cwd)
|
|
51
|
+
return proc.returncode == 0 and proc.stdout.strip() == "true"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def has_commits(cwd: Path) -> bool:
|
|
55
|
+
"""A fresh `git init` is a repository without commits: a Phase 0 empty state."""
|
|
56
|
+
proc = _git(["rev-parse", "--verify", "--quiet", "HEAD"], cwd)
|
|
57
|
+
return proc.returncode == 0
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def remote_heads(cwd: Path, remote: str = "origin", pattern: str | None = None) -> dict[str, str]:
|
|
61
|
+
"""Branches on the remote — the source of truth about occupancy (README), not a cache."""
|
|
62
|
+
args = ["ls-remote", "--heads", remote]
|
|
63
|
+
if pattern:
|
|
64
|
+
args.append(pattern)
|
|
65
|
+
heads: dict[str, str] = {}
|
|
66
|
+
for ln in lines(args, cwd):
|
|
67
|
+
sha, _, ref = ln.partition("\t")
|
|
68
|
+
heads[ref.removeprefix("refs/heads/")] = sha
|
|
69
|
+
return heads
|