dctracker 1.0.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.
Files changed (87) hide show
  1. dct/__init__.py +6 -0
  2. dct/cli.py +1659 -0
  3. dct/config.py +150 -0
  4. dct/core/__init__.py +0 -0
  5. dct/core/analytics.py +382 -0
  6. dct/core/changelog.py +112 -0
  7. dct/core/checkpoints.py +205 -0
  8. dct/core/decisions.py +238 -0
  9. dct/core/engine.py +32 -0
  10. dct/core/handoff.py +151 -0
  11. dct/core/ids.py +25 -0
  12. dct/core/items.py +382 -0
  13. dct/core/plans/__init__.py +6 -0
  14. dct/core/plans/classify.py +94 -0
  15. dct/core/plans/crud.py +680 -0
  16. dct/core/plans/ingest.py +408 -0
  17. dct/core/plans/parser.py +312 -0
  18. dct/core/projects.py +298 -0
  19. dct/core/sprint.py +315 -0
  20. dct/core/sprints.py +601 -0
  21. dct/core/version.py +116 -0
  22. dct/db.py +558 -0
  23. dct/export.py +107 -0
  24. dct/hooks/check-changelog-on-stop.sh +49 -0
  25. dct/hooks/check-changelog.sh +143 -0
  26. dct/hooks/dct-plan-autoscan.sh +54 -0
  27. dct/hooks/dct-task-mirror.sh +62 -0
  28. dct/server.py +1812 -0
  29. dct/setup.py +258 -0
  30. dct/skills/clog/SKILL.md +73 -0
  31. dct/skills/commit/SKILL.md +153 -0
  32. dct/skills/dct-import/SKILL.md +329 -0
  33. dct/skills/dct-init/SKILL.md +289 -0
  34. dct/skills/handoff/SKILL.md +136 -0
  35. dct/skills/pickup/SKILL.md +115 -0
  36. dct/skills/plan-resolve-uncertain/SKILL.md +82 -0
  37. dct/skills/plan-status/SKILL.md +79 -0
  38. dct/skills/release/SKILL.md +281 -0
  39. dct/skills/track/SKILL.md +121 -0
  40. dct/skills/track-list/SKILL.md +134 -0
  41. dct/skills/track-resolve/SKILL.md +53 -0
  42. dct/skills/track-update/SKILL.md +109 -0
  43. dct/web/__init__.py +1 -0
  44. dct/web/app.py +83 -0
  45. dct/web/blueprints/__init__.py +5 -0
  46. dct/web/blueprints/analytics.py +34 -0
  47. dct/web/blueprints/changelog.py +40 -0
  48. dct/web/blueprints/decisions.py +23 -0
  49. dct/web/blueprints/events.py +69 -0
  50. dct/web/blueprints/handoffs.py +26 -0
  51. dct/web/blueprints/home.py +15 -0
  52. dct/web/blueprints/items.py +128 -0
  53. dct/web/blueprints/plans.py +53 -0
  54. dct/web/blueprints/projects.py +23 -0
  55. dct/web/blueprints/sprints.py +71 -0
  56. dct/web/changes.py +77 -0
  57. dct/web/serializers.py +109 -0
  58. dct/web/static/app.css +609 -0
  59. dct/web/static/app.js +62 -0
  60. dct/web/static/vendor/SOURCES.txt +10 -0
  61. dct/web/static/vendor/htmx.min.js +1 -0
  62. dct/web/static/vendor/uPlot.iife.min.js +2 -0
  63. dct/web/static/vendor/uPlot.min.css +1 -0
  64. dct/web/templates/analytics.html +63 -0
  65. dct/web/templates/base.html +106 -0
  66. dct/web/templates/changelog/list.html +23 -0
  67. dct/web/templates/decisions/list.html +22 -0
  68. dct/web/templates/handoffs/list.html +45 -0
  69. dct/web/templates/home.html +20 -0
  70. dct/web/templates/item_detail.html +91 -0
  71. dct/web/templates/items_list.html +44 -0
  72. dct/web/templates/partials/_bars.html +15 -0
  73. dct/web/templates/partials/_checkpoint_steps.html +22 -0
  74. dct/web/templates/partials/_items_table.html +23 -0
  75. dct/web/templates/partials/_plan_section.html +24 -0
  76. dct/web/templates/partials/_project_card.html +24 -0
  77. dct/web/templates/plans/detail.html +16 -0
  78. dct/web/templates/plans/list.html +15 -0
  79. dct/web/templates/project_home.html +51 -0
  80. dct/web/templates/sprints/detail.html +37 -0
  81. dct/web/templates/sprints/list.html +35 -0
  82. dctracker-1.0.0.dist-info/METADATA +357 -0
  83. dctracker-1.0.0.dist-info/RECORD +87 -0
  84. dctracker-1.0.0.dist-info/WHEEL +5 -0
  85. dctracker-1.0.0.dist-info/entry_points.txt +2 -0
  86. dctracker-1.0.0.dist-info/licenses/LICENSE +21 -0
  87. dctracker-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,312 @@
1
+ r"""Mechanical markdown parser for dct plans.
2
+
3
+ Pure — takes file content (str), returns a ParseResult dataclass. No I/O,
4
+ no DB, no LLM. Deterministic regex + state machine, code-fence-aware.
5
+
6
+ Grammar per spec §7:
7
+ 1. YAML frontmatter — skipped for now (reserved for future use).
8
+ 2. Plan ULID anchor — first ``<!-- dct:plan:<ULID> -->`` on any line.
9
+ 3. Code fences — ``` ``` ``` or ``~~~`` open/close; skip rules inside.
10
+ 4. Headings — `^(#{1,6})\s+(.+)$` outside fences.
11
+ 5. Section anchor — next non-empty line after heading may be
12
+ ``<!-- dct:section:<ULID> parent=<ULID> -->``.
13
+ 6. Section type — regex heuristic on heading text (sprint/task/...).
14
+ 7. Checkboxes — `^(\s*)-\s+\[([ x])\]\s+(.*)$`.
15
+ 8. Checkpoint anchor — ``<!-- dct:cp:<ULID> parent=<ULID> -->`` between
16
+ ``[x/' ']`` and the text.
17
+ 9. Checkpoint state — reported as 'done' / 'pending' based on
18
+ ``x`` / ``' '``. Callers decide whether to respect
19
+ or ignore it (first-import vs rescan, §9.3).
20
+
21
+ Returns all sections/checkpoints even when they lack ULID anchors — missing
22
+ ULIDs are reported as `None`, so callers (ingest.py) can generate and
23
+ write-back synchronously (§9.1.3 step 2).
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import re
29
+ import unicodedata
30
+ from dataclasses import dataclass, field
31
+ from typing import Literal
32
+
33
+
34
+ ULID_PATTERN = r"[0-9A-HJKMNP-TV-Z]{26}"
35
+
36
+ RE_PLAN_ANCHOR = re.compile(
37
+ rf"<!--\s*dct:plan:(?P<ulid>{ULID_PATTERN})\s*-->"
38
+ )
39
+ RE_SECTION_ANCHOR = re.compile(
40
+ rf"<!--\s*dct:section:(?P<ulid>{ULID_PATTERN})"
41
+ rf"(?:\s+parent=(?P<parent>{ULID_PATTERN}))?\s*-->"
42
+ )
43
+ RE_CP_ANCHOR = re.compile(
44
+ rf"<!--\s*dct:cp:(?P<ulid>{ULID_PATTERN})"
45
+ rf"(?:\s+parent=(?P<parent>{ULID_PATTERN}))?\s*-->"
46
+ )
47
+
48
+ RE_HEADING = re.compile(r"^(?P<hashes>#{1,6})\s+(?P<text>.+?)\s*$")
49
+ RE_CHECKBOX = re.compile(
50
+ r"^(?P<indent>\s*)-\s+\[(?P<state>[ xX])\]\s+(?P<rest>.*)$"
51
+ )
52
+ RE_FENCE = re.compile(r"^(?P<indent>\s*)(?P<marker>```+|~~~+)(?P<info>.*)$")
53
+
54
+ # Confidence-high patterns for section_type classification.
55
+ # Ordering matters — first match wins. All case-insensitive.
56
+ SECTION_TYPE_RULES: list[tuple[str, re.Pattern[str]]] = [
57
+ ("sprint", re.compile(r"^sprint\s+[\d.]+", re.IGNORECASE)),
58
+ ("phase", re.compile(r"^phase\s+([\d]+|[a-z])\b", re.IGNORECASE)),
59
+ ("task", re.compile(r"^task\s+[\w.-]+", re.IGNORECASE)),
60
+ ("block", re.compile(r"^block\s+[a-z]\d*", re.IGNORECASE)),
61
+ ("milestone", re.compile(r"^milestone\s+[ma-z]\d+", re.IGNORECASE)),
62
+ ("decision", re.compile(r"^adr[- ]\d+", re.IGNORECASE)),
63
+ ("changelog", re.compile(r"^(added|changed|fixed|removed)\s*$", re.IGNORECASE)),
64
+ ("adr_section", re.compile(r"^(context|decision|consequences|alternatives)\s*$", re.IGNORECASE)),
65
+ ("reference", re.compile(r"^(appendix|references?|open\s+questions?)\b", re.IGNORECASE)),
66
+ # Numbered spec/plan headings like "1. Problem", "4.1 Glossary",
67
+ # "9.1.1 Discovery", and "9.1 `/dct-import` — discover". These are
68
+ # narrative chapters (prose) — not sprints, not tasks, not references.
69
+ # Placed last so specific patterns above (sprint/phase/task/etc.) still
70
+ # win when the heading starts with e.g. "1. Sprint Foo" (matches sprint
71
+ # pattern via the `Sprint` word). The trailing `\S` (non-whitespace)
72
+ # requirement excludes lone numbers like "1." with no title while
73
+ # accepting titles that start with backticks, brackets, parens, etc.
74
+ ("prose", re.compile(r"^\d+(\.\d+)*\.?\s+\S", re.IGNORECASE)),
75
+ ]
76
+
77
+
78
+ @dataclass
79
+ class ParsedSection:
80
+ """A heading-anchored section in the plan. `ulid`/`parent_ulid`=None → caller must mint."""
81
+
82
+ heading: str
83
+ depth: int
84
+ slug: str
85
+ sort_order: int
86
+ section_type: str
87
+ section_type_confidence: Literal["high", "low"] = "high"
88
+ ulid: str | None = None
89
+ parent_ulid: str | None = None
90
+ # Line positions — 1-based, used by writer to inject ULIDs.
91
+ heading_line: int = -1
92
+ anchor_line: int | None = None # existing anchor line if present
93
+ source_lines: str | None = None # "5-87" span; filled on finalize
94
+
95
+
96
+ @dataclass
97
+ class ParsedCheckpoint:
98
+ """A `- [ ]` / `- [x]` item under a section."""
99
+
100
+ text: str
101
+ initial_state: Literal["pending", "done"]
102
+ sort_order: int
103
+ ulid: str | None = None
104
+ parent_section_ulid: str | None = None
105
+ checkbox_line: int = -1
106
+ source_lines: str | None = None
107
+
108
+
109
+ @dataclass
110
+ class ParseResult:
111
+ plan_ulid: str | None = None
112
+ plan_anchor_line: int | None = None # 1-based line where the plan anchor sits, if any
113
+ sections: list[ParsedSection] = field(default_factory=list)
114
+ checkpoints: list[ParsedCheckpoint] = field(default_factory=list)
115
+ warnings: list[str] = field(default_factory=list)
116
+
117
+
118
+ def slugify(text: str) -> str:
119
+ """Produce a URL-friendly kebab-case slug from heading text.
120
+
121
+ - Strip NFKD combining marks (so `Łąka` → `laka`).
122
+ - Lowercase.
123
+ - Replace non-alphanumeric runs with a single hyphen.
124
+ - Trim edge hyphens; clamp to 200 chars.
125
+ """
126
+ normalized = unicodedata.normalize("NFKD", text)
127
+ stripped = "".join(c for c in normalized if not unicodedata.combining(c))
128
+ lower = stripped.lower()
129
+ slug = re.sub(r"[^a-z0-9]+", "-", lower).strip("-")
130
+ return slug[:200]
131
+
132
+
133
+ def classify_heading(text: str) -> tuple[str, Literal["high", "low"]]:
134
+ """Return (section_type, confidence) for a heading text.
135
+
136
+ High-confidence patterns in SECTION_TYPE_RULES win first-match.
137
+ Everything else falls back to ('uncertain', 'low') for LLM review
138
+ downstream (or 'generic' if the caller disables classification).
139
+ """
140
+ for kind, pattern in SECTION_TYPE_RULES:
141
+ if pattern.match(text):
142
+ return kind, "high"
143
+ return "uncertain", "low"
144
+
145
+
146
+ def parse_plan(content: str) -> ParseResult:
147
+ """Parse a markdown plan file body and return structured sections/checkpoints.
148
+
149
+ Pure function — no I/O. `content` is the full file content (UTF-8 string).
150
+ Line numbers in the result are 1-based relative to `content`. Thread-safe:
151
+ all state lives in local variables, no module-level mutation.
152
+ """
153
+ result = ParseResult()
154
+ lines = content.splitlines()
155
+
156
+ in_fence = False
157
+ fence_marker: str | None = None
158
+
159
+ # Pending anchor from a comment that immediately precedes / follows a heading.
160
+ # We attach the NEXT non-empty line's anchor to the heading above.
161
+ last_heading_index: int | None = None # index into result.sections
162
+
163
+ # Stack of (depth, section_index) for parent derivation via nesting.
164
+ heading_stack: list[tuple[int, int]] = []
165
+
166
+ # Structural parent derived from nesting — kept local per call (thread-safe).
167
+ section_parent_index: dict[int, int | None] = {}
168
+
169
+ section_counter = 0
170
+ checkpoint_counter = 0
171
+
172
+ for i, raw_line in enumerate(lines, start=1):
173
+ line = raw_line.rstrip("\n")
174
+
175
+ # 1. Fence tracking — toggles open/close; skip most rules inside.
176
+ fence_match = RE_FENCE.match(line)
177
+ if fence_match:
178
+ marker = fence_match.group("marker")[:3] # normalize to first 3 backticks/tildes
179
+ if not in_fence:
180
+ in_fence = True
181
+ fence_marker = marker
182
+ else:
183
+ # Close only if same fence kind (``` closes ```, not ~~~)
184
+ if fence_marker and marker == fence_marker:
185
+ in_fence = False
186
+ fence_marker = None
187
+ continue
188
+
189
+ if in_fence:
190
+ continue
191
+
192
+ # 2. Plan anchor — first one wins.
193
+ plan_match = RE_PLAN_ANCHOR.search(line)
194
+ if plan_match and result.plan_ulid is None:
195
+ result.plan_ulid = plan_match.group("ulid")
196
+ result.plan_anchor_line = i
197
+ # Don't `continue` — an anchor can share a line with other content,
198
+ # though the convention is its own line. Keep processing heading/etc.
199
+
200
+ # 3. Heading?
201
+ heading_match = RE_HEADING.match(line)
202
+ if heading_match:
203
+ hashes = heading_match.group("hashes")
204
+ text = heading_match.group("text").strip()
205
+ depth = len(hashes)
206
+ section_type, confidence = classify_heading(text)
207
+ section = ParsedSection(
208
+ heading=text,
209
+ depth=depth,
210
+ slug=slugify(text),
211
+ sort_order=section_counter * 10,
212
+ section_type=section_type,
213
+ section_type_confidence=confidence,
214
+ heading_line=i,
215
+ )
216
+ section_counter += 1
217
+
218
+ # Parent derivation: pop stack until we find a depth < ours.
219
+ while heading_stack and heading_stack[-1][0] >= depth:
220
+ heading_stack.pop()
221
+ # We store ULID parents only after anchors are resolved (§ below);
222
+ # for now, remember the parent section index (for post-pass fill-in).
223
+ parent_section_index = heading_stack[-1][1] if heading_stack else None
224
+
225
+ result.sections.append(section)
226
+ current_index = len(result.sections) - 1
227
+ heading_stack.append((depth, current_index))
228
+ last_heading_index = current_index
229
+
230
+ # Stash parent index in a local sidecar for the post-pass.
231
+ section_parent_index[current_index] = parent_section_index
232
+ continue
233
+
234
+ # 4. Section anchor (HTML comment attached to the most recent heading).
235
+ section_anchor_match = RE_SECTION_ANCHOR.search(line)
236
+ if section_anchor_match and last_heading_index is not None:
237
+ target = result.sections[last_heading_index]
238
+ if target.ulid is None:
239
+ target.ulid = section_anchor_match.group("ulid")
240
+ target.parent_ulid = section_anchor_match.group("parent")
241
+ target.anchor_line = i
242
+ else:
243
+ result.warnings.append(
244
+ f"duplicate_section_anchor at line {i} for heading "
245
+ f"'{target.heading}' — first anchor preserved"
246
+ )
247
+ continue
248
+
249
+ # 5. Checkbox?
250
+ checkbox_match = RE_CHECKBOX.match(line)
251
+ if checkbox_match:
252
+ state_char = checkbox_match.group("state").lower()
253
+ rest = checkbox_match.group("rest")
254
+
255
+ # Checkpoint anchor is embedded inside `rest`, before the text.
256
+ cp_anchor_match = RE_CP_ANCHOR.search(rest)
257
+ if cp_anchor_match:
258
+ cp_ulid = cp_anchor_match.group("ulid")
259
+ cp_parent = cp_anchor_match.group("parent")
260
+ text = rest[cp_anchor_match.end():].strip()
261
+ else:
262
+ cp_ulid = None
263
+ cp_parent = None
264
+ text = rest.strip()
265
+
266
+ if last_heading_index is None:
267
+ result.warnings.append(
268
+ f"orphan_checkpoint at line {i}: no preceding heading"
269
+ )
270
+ # Emit it anyway so the caller can still fix the source.
271
+ parent_section_ulid = None
272
+ else:
273
+ parent_section_ulid = result.sections[last_heading_index].ulid
274
+ # parent_section_ulid may still be None if the section has
275
+ # no anchor yet; the post-pass / writer will fill it in.
276
+
277
+ # Cross-check: if the anchor embeds a parent that disagrees with
278
+ # the structural parent, warn (possible cross-plan copy).
279
+ if (
280
+ cp_parent is not None
281
+ and parent_section_ulid is not None
282
+ and cp_parent != parent_section_ulid
283
+ ):
284
+ result.warnings.append(
285
+ f"parent_mismatch at line {i}: checkpoint parent={cp_parent} "
286
+ f"but enclosing section={parent_section_ulid}"
287
+ )
288
+
289
+ result.checkpoints.append(ParsedCheckpoint(
290
+ text=text,
291
+ initial_state="done" if state_char == "x" else "pending",
292
+ sort_order=checkpoint_counter * 10,
293
+ ulid=cp_ulid,
294
+ parent_section_ulid=cp_parent or parent_section_ulid,
295
+ checkbox_line=i,
296
+ ))
297
+ checkpoint_counter += 1
298
+
299
+ # Post-pass: fill `parent_ulid` on sections that have an anchored parent.
300
+ for idx, section in enumerate(result.sections):
301
+ parent_idx = section_parent_index.get(idx)
302
+ if parent_idx is not None:
303
+ parent_section = result.sections[parent_idx]
304
+ # Only set if the anchor itself didn't specify a different parent.
305
+ if section.parent_ulid is None:
306
+ section.parent_ulid = parent_section.ulid # may still be None
307
+
308
+ # Warnings for missing anchors
309
+ if result.plan_ulid is None:
310
+ result.warnings.append("plan_anchor_missing")
311
+
312
+ return result
dct/core/projects.py ADDED
@@ -0,0 +1,298 @@
1
+ """Project CRUD operations."""
2
+
3
+ import os
4
+ from datetime import datetime, timezone
5
+
6
+ import sqlalchemy as sa
7
+
8
+ from dct.db import projects, row_to_dict
9
+
10
+
11
+ def create_project(
12
+ engine: sa.Engine,
13
+ slug: str,
14
+ name: str,
15
+ path: str | None = None,
16
+ description: str | None = None,
17
+ current_version: str | None = None,
18
+ ) -> dict:
19
+ """Create (or undelete) a project row.
20
+
21
+ When `current_version` is not supplied and `path` is set, attempts best-effort
22
+ version detection from VERSION / pyproject.toml / package.json / Cargo.toml /
23
+ src-tauri/tauri.conf.json. The returned dict carries a transient
24
+ `_detected_source` key naming the file that produced the version — callers
25
+ (CLI) may surface it to the user; it is not persisted.
26
+ """
27
+ if not slug or not slug.strip():
28
+ raise ValueError("Project slug cannot be empty")
29
+ if not name or not name.strip():
30
+ raise ValueError("Project name cannot be empty")
31
+
32
+ # #227: store the canonical (symlink-resolved) path so CWD detection — which
33
+ # passes os.getcwd() (physical) — always matches what we persisted here.
34
+ if path is not None:
35
+ path = os.path.realpath(path)
36
+
37
+ detected_source: str | None = None
38
+ if current_version is None and path:
39
+ from dct.core.version import detect_version
40
+ detected_version, detected_source = detect_version(path)
41
+ if detected_version:
42
+ current_version = detected_version
43
+ else:
44
+ detected_source = None # no detection → nothing to report
45
+
46
+ with engine.begin() as conn:
47
+ existing = conn.execute(
48
+ sa.select(projects).where(projects.c.slug == slug, projects.c.deleted_at.is_(None))
49
+ ).first()
50
+ if existing:
51
+ raise ValueError(f"Project '{slug}' already exists")
52
+
53
+ deleted = conn.execute(
54
+ sa.select(projects).where(projects.c.slug == slug, projects.c.deleted_at.isnot(None))
55
+ ).first()
56
+ if deleted:
57
+ conn.execute(
58
+ projects.update()
59
+ .where(projects.c.id == deleted._mapping["id"])
60
+ .values(name=name, path=path, description=description, current_version=current_version, deleted_at=None, updated_at=datetime.now(timezone.utc))
61
+ )
62
+ row = conn.execute(sa.select(projects).where(projects.c.id == deleted._mapping["id"])).first()
63
+ assert row is not None, "undelete must return its row"
64
+ result_dict = row_to_dict(row)
65
+ if detected_source:
66
+ result_dict["_detected_source"] = detected_source
67
+ return result_dict
68
+
69
+ inserted = conn.execute(
70
+ projects.insert()
71
+ .values(slug=slug, name=name, path=path, description=description, current_version=current_version)
72
+ .returning(projects)
73
+ ).first()
74
+ assert inserted is not None, "INSERT RETURNING must return a row"
75
+ result_dict = row_to_dict(inserted)
76
+ if detected_source:
77
+ result_dict["_detected_source"] = detected_source
78
+ return result_dict
79
+
80
+
81
+ def list_projects(engine: sa.Engine, include_deleted: bool = False) -> list[dict]:
82
+ with engine.begin() as conn:
83
+ q = sa.select(projects).order_by(projects.c.slug)
84
+ if not include_deleted:
85
+ q = q.where(projects.c.deleted_at.is_(None))
86
+ return [row_to_dict(r) for r in conn.execute(q)]
87
+
88
+
89
+ def get_project(engine: sa.Engine, slug: str) -> dict | None:
90
+ with engine.begin() as conn:
91
+ row = conn.execute(
92
+ sa.select(projects).where(projects.c.slug == slug, projects.c.deleted_at.is_(None))
93
+ ).first()
94
+ return row_to_dict(row) if row else None
95
+
96
+
97
+ def get_project_by_path(engine: sa.Engine, path: str) -> dict | None:
98
+ """Find the project whose registered path is a prefix of `path`.
99
+
100
+ Both the query path and every stored path are canonicalized with
101
+ os.path.realpath before comparison (#227). Rationale: `dct project add`
102
+ historically stored os.path.abspath (symlinks intact) while
103
+ `dct project detect` passes os.getcwd(), which returns the PHYSICAL path
104
+ (symlinks resolved) on macOS. A raw-string prefix compare therefore made a
105
+ project registered under a symlinked path (e.g. /tmp → /private/tmp, or a
106
+ repo under a symlinked root) silently undetectable, which turned the
107
+ changelog hook OFF for that repo. realpath on both sides closes the gap and
108
+ is idempotent for already-canonical paths.
109
+ """
110
+ target = os.path.realpath(path)
111
+ with engine.begin() as conn:
112
+ rows = conn.execute(
113
+ sa.select(projects).where(projects.c.deleted_at.is_(None), projects.c.path.isnot(None))
114
+ ).fetchall()
115
+ # Match longest stored path first (most specific project).
116
+ matches = []
117
+ for r in rows:
118
+ stored = os.path.realpath(r._mapping["path"])
119
+ if target == stored or target.startswith(stored.rstrip("/") + "/"):
120
+ matches.append((r, len(stored)))
121
+ if not matches:
122
+ return None
123
+ matches.sort(key=lambda x: x[1], reverse=True)
124
+ return row_to_dict(matches[0][0])
125
+
126
+
127
+ def delete_project(engine: sa.Engine, slug: str) -> bool:
128
+ with engine.begin() as conn:
129
+ result = conn.execute(
130
+ projects.update()
131
+ .where(projects.c.slug == slug, projects.c.deleted_at.is_(None))
132
+ .values(deleted_at=datetime.now(timezone.utc))
133
+ )
134
+ return result.rowcount > 0
135
+
136
+
137
+ def set_version(engine: sa.Engine, slug: str, version: str) -> dict | None:
138
+ with engine.begin() as conn:
139
+ result = conn.execute(
140
+ projects.update()
141
+ .where(projects.c.slug == slug, projects.c.deleted_at.is_(None))
142
+ .values(current_version=version, updated_at=datetime.now(timezone.utc))
143
+ )
144
+ if result.rowcount == 0:
145
+ return None
146
+ return get_project(engine, slug)
147
+
148
+
149
+ def set_manages_changelog(engine: sa.Engine, slug: str, value: bool) -> dict | None:
150
+ """Opt a project in/out of dct changelog-hook enforcement (#222).
151
+
152
+ Returns the updated project row, or None when the slug does not resolve to
153
+ a live project.
154
+ """
155
+ with engine.begin() as conn:
156
+ result = conn.execute(
157
+ projects.update()
158
+ .where(projects.c.slug == slug, projects.c.deleted_at.is_(None))
159
+ .values(manages_changelog=value, updated_at=datetime.now(timezone.utc))
160
+ )
161
+ if result.rowcount == 0:
162
+ return None
163
+ return get_project(engine, slug)
164
+
165
+
166
+ def project_manages_changelog(engine: sa.Engine, slug: str) -> bool:
167
+ """True iff the project opts into dct changelog enforcement (#222).
168
+
169
+ False when the project does not exist — the changelog hook treats an
170
+ unknown/unresolved project as 'not managed' (skip), so it never blocks.
171
+ """
172
+ with engine.begin() as conn:
173
+ row = conn.execute(
174
+ sa.select(projects.c.manages_changelog).where(
175
+ projects.c.slug == slug, projects.c.deleted_at.is_(None)
176
+ )
177
+ ).first()
178
+ return bool(row._mapping["manages_changelog"]) if row else False
179
+
180
+
181
+ def set_changelog_path(engine: sa.Engine, slug: str, relpath: str) -> dict | None:
182
+ """Set a project's changelog file location, relative to its root (#225).
183
+
184
+ Default is 'changelog.md' (repo root). A project whose changelog lives in a
185
+ subdirectory (e.g. a monorepo package) points it there, and /release writes
186
+ {project.path}/{changelog_path}. The value is normalized and constrained to
187
+ the project root — a leading '/' is stripped and any '..' escape is rejected
188
+ — so /release can only ever write inside the project tree.
189
+
190
+ Returns the updated project row, or None when the slug does not resolve to a
191
+ live project. Raises ValueError on an empty path or a '..' escape.
192
+ """
193
+ if relpath is None or not relpath.strip():
194
+ raise ValueError("changelog_path cannot be empty")
195
+ rel = os.path.normpath(relpath.strip().lstrip("/"))
196
+ if rel.startswith("..") or os.path.isabs(rel):
197
+ raise ValueError("changelog_path must stay within the project root (no '..' escape)")
198
+ with engine.begin() as conn:
199
+ result = conn.execute(
200
+ projects.update()
201
+ .where(projects.c.slug == slug, projects.c.deleted_at.is_(None))
202
+ .values(changelog_path=rel, updated_at=datetime.now(timezone.utc))
203
+ )
204
+ if result.rowcount == 0:
205
+ return None
206
+ return get_project(engine, slug)
207
+
208
+
209
+ def resolve_changelog_path(project: dict) -> str | None:
210
+ """Absolute path to a project's changelog file: {path}/{changelog_path} (#225).
211
+
212
+ Returns None when the project has no registered path (nothing to resolve
213
+ against). changelog_path falls back to 'changelog.md' if missing/empty so a
214
+ row predating the column still resolves to the repo-root default.
215
+ """
216
+ path = project.get("path")
217
+ if not path:
218
+ return None
219
+ return os.path.join(path, project.get("changelog_path") or "changelog.md")
220
+
221
+
222
+ def update_project(
223
+ engine: sa.Engine,
224
+ slug: str,
225
+ new_slug: str | None = None,
226
+ new_name: str | None = None,
227
+ new_description: str | None = None,
228
+ new_path: str | None = None,
229
+ ) -> dict | None:
230
+ """Update one or more mutable fields on a project row.
231
+
232
+ Any non-None kwarg overwrites. Returns the updated row, or None if the
233
+ source `slug` does not resolve to a live project. Raises ValueError when:
234
+ - no fields are supplied (nothing to do — caller bug)
235
+ - `new_slug` or `new_name` is an empty/whitespace string
236
+ - `new_slug` collides with an existing live project
237
+
238
+ project_id stays stable: items/changelog/checkpoints/handoffs keep their
239
+ FK without any cascade needed — rename is purely cosmetic at the DB level.
240
+ """
241
+ updates: dict = {}
242
+ if new_slug is not None:
243
+ if not new_slug.strip():
244
+ raise ValueError("new_slug cannot be empty")
245
+ updates["slug"] = new_slug
246
+ if new_name is not None:
247
+ if not new_name.strip():
248
+ raise ValueError("new_name cannot be empty")
249
+ updates["name"] = new_name
250
+ if new_description is not None:
251
+ updates["description"] = new_description
252
+ if new_path is not None:
253
+ updates["path"] = new_path
254
+
255
+ if not updates:
256
+ raise ValueError("update_project: no fields supplied (pass at least one new_* kwarg)")
257
+
258
+ updates["updated_at"] = datetime.now(timezone.utc)
259
+
260
+ with engine.begin() as conn:
261
+ current = conn.execute(
262
+ sa.select(projects).where(projects.c.slug == slug, projects.c.deleted_at.is_(None))
263
+ ).first()
264
+ if not current:
265
+ return None
266
+
267
+ if new_slug is not None and new_slug != slug:
268
+ collision = conn.execute(
269
+ sa.select(projects.c.id).where(
270
+ projects.c.slug == new_slug, projects.c.deleted_at.is_(None)
271
+ )
272
+ ).first()
273
+ if collision:
274
+ raise ValueError(f"Project slug '{new_slug}' already exists")
275
+
276
+ conn.execute(
277
+ projects.update()
278
+ .where(projects.c.id == current._mapping["id"])
279
+ .values(**updates)
280
+ )
281
+
282
+ return get_project(engine, new_slug or slug)
283
+
284
+
285
+ def resolve_project_id(conn: sa.Connection, slug: str) -> int:
286
+ """Get project ID by slug. Raises ValueError if not found.
287
+
288
+ Shared helper used by items / changelog / sprint / handoff core modules
289
+ inside an already-open connection, so they avoid opening nested engine
290
+ transactions. Public (no underscore) because its callers live outside
291
+ this module.
292
+ """
293
+ row = conn.execute(
294
+ sa.select(projects.c.id).where(projects.c.slug == slug, projects.c.deleted_at.is_(None))
295
+ ).first()
296
+ if not row:
297
+ raise ValueError(f"Project '{slug}' not found")
298
+ return row._mapping["id"]