@grifhinz/logics-manager 2.0.0
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.
- package/LICENSE +21 -0
- package/README.md +452 -0
- package/VERSION +1 -0
- package/logics_manager/__init__.py +5 -0
- package/logics_manager/__main__.py +9 -0
- package/logics_manager/assist.py +2211 -0
- package/logics_manager/audit.py +990 -0
- package/logics_manager/bootstrap.py +123 -0
- package/logics_manager/cli.py +183 -0
- package/logics_manager/config.py +251 -0
- package/logics_manager/doctor.py +127 -0
- package/logics_manager/flow.py +1449 -0
- package/logics_manager/index.py +142 -0
- package/logics_manager/lint.py +622 -0
- package/logics_manager/sync.py +604 -0
- package/package.json +162 -0
- package/pyproject.toml +15 -0
- package/scripts/logics-manager.py +15 -0
- package/scripts/npm/logics-manager.mjs +96 -0
|
@@ -0,0 +1,622 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
import unicodedata
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Iterable
|
|
11
|
+
|
|
12
|
+
from .config import find_repo_root
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class Kind:
|
|
17
|
+
directory: str
|
|
18
|
+
prefix: str
|
|
19
|
+
requires_progress: bool
|
|
20
|
+
required_indicators: tuple[str, ...]
|
|
21
|
+
allowed_statuses: tuple[str, ...]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
KINDS = {
|
|
25
|
+
"request": Kind("logics/request", "req", False, ("From version", "Understanding", "Confidence"), ("Draft", "Ready", "In progress", "Blocked", "Done", "Obsolete", "Archived")),
|
|
26
|
+
"backlog": Kind("logics/backlog", "item", True, ("From version", "Understanding", "Confidence", "Progress"), ("Draft", "Ready", "In progress", "Blocked", "Done", "Obsolete", "Archived")),
|
|
27
|
+
"task": Kind("logics/tasks", "task", True, ("From version", "Understanding", "Confidence", "Progress"), ("Draft", "Ready", "In progress", "Blocked", "Done", "Obsolete", "Archived")),
|
|
28
|
+
"product": Kind("logics/product", "prod", False, ("Date", "Status", "Related request", "Related backlog", "Related task", "Related architecture", "Reminder"), ("Draft", "Proposed", "Active", "Validated", "Rejected", "Superseded", "Archived")),
|
|
29
|
+
"architecture": Kind("logics/architecture", "adr", False, ("Date", "Status", "Drivers", "Related request", "Related backlog", "Related task", "Reminder"), ("Draft", "Proposed", "Accepted", "Rejected", "Superseded", "Archived")),
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
WORKFLOW_KINDS = {"request", "backlog", "task"}
|
|
33
|
+
ACTIVE_WORKFLOW_STATUSES = {"ready", "in progress", "done"}
|
|
34
|
+
CRITICAL_INDICATOR_PLACEHOLDERS = {
|
|
35
|
+
"From version": {"X.X.X"},
|
|
36
|
+
"Understanding": {"??%"},
|
|
37
|
+
"Confidence": {"??%"},
|
|
38
|
+
"Progress": {"??%"},
|
|
39
|
+
}
|
|
40
|
+
TEMPLATE_PLACEHOLDER_SNIPPETS = (
|
|
41
|
+
"Describe the need",
|
|
42
|
+
"Add context and constraints",
|
|
43
|
+
"Describe the problem and user impact",
|
|
44
|
+
"Define an objective acceptance check",
|
|
45
|
+
"First implementation step",
|
|
46
|
+
"Second implementation step",
|
|
47
|
+
"Third implementation step",
|
|
48
|
+
)
|
|
49
|
+
NON_SEMANTIC_EDIT_MARKERS = (
|
|
50
|
+
"> Maintenance edit:",
|
|
51
|
+
"> Non-semantic edit:",
|
|
52
|
+
)
|
|
53
|
+
BLOCKING_TRACEABILITY_PLACEHOLDER_SNIPPETS = (
|
|
54
|
+
"Proof: TODO",
|
|
55
|
+
"TODO: map this acceptance criterion",
|
|
56
|
+
)
|
|
57
|
+
MERMAID_LABEL_MAX_WORDS = 6
|
|
58
|
+
MERMAID_LABEL_MAX_CHARS = 42
|
|
59
|
+
MERMAID_FALLBACKS = {
|
|
60
|
+
"request_backlog": "Backlog slice",
|
|
61
|
+
"backlog_task": "Execution task",
|
|
62
|
+
"task_report": "Done report",
|
|
63
|
+
}
|
|
64
|
+
REF_PREFIXES = {
|
|
65
|
+
"request": "req",
|
|
66
|
+
"backlog": "item",
|
|
67
|
+
"task": "task",
|
|
68
|
+
}
|
|
69
|
+
MERMAID_BLOCK_PATTERN = re.compile(r"```mermaid\s*\n(.*?)\n```", re.DOTALL)
|
|
70
|
+
MERMAID_SIGNATURE_PATTERN = re.compile(r"^\s*%%\s*logics-signature:\s*(.+?)\s*$", re.MULTILINE)
|
|
71
|
+
AI_CONTEXT_FIELD_PATTERN = re.compile(r"^\s*-\s*([^:]+)\s*:\s*(.+?)\s*$")
|
|
72
|
+
AI_KEYWORD_STOPWORDS = {
|
|
73
|
+
"about",
|
|
74
|
+
"after",
|
|
75
|
+
"before",
|
|
76
|
+
"being",
|
|
77
|
+
"between",
|
|
78
|
+
"define",
|
|
79
|
+
"deliver",
|
|
80
|
+
"delivery",
|
|
81
|
+
"focus",
|
|
82
|
+
"from",
|
|
83
|
+
"have",
|
|
84
|
+
"into",
|
|
85
|
+
"needs",
|
|
86
|
+
"review",
|
|
87
|
+
"scope",
|
|
88
|
+
"should",
|
|
89
|
+
"task",
|
|
90
|
+
"that",
|
|
91
|
+
"this",
|
|
92
|
+
"through",
|
|
93
|
+
"when",
|
|
94
|
+
"with",
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _read_lines(path: Path) -> list[str]:
|
|
99
|
+
return path.read_text(encoding="utf-8").splitlines()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _extract_first_heading(lines: list[str]) -> str | None:
|
|
103
|
+
for line in lines:
|
|
104
|
+
if line.startswith("## "):
|
|
105
|
+
return line
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _indicator_value(lines: list[str], key: str) -> str | None:
|
|
110
|
+
pattern = re.compile(rf"^\s*>\s*{re.escape(key)}\s*:\s*(.+)\s*$")
|
|
111
|
+
for line in lines:
|
|
112
|
+
match = pattern.match(line)
|
|
113
|
+
if match:
|
|
114
|
+
return match.group(1).strip()
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _has_indicator(lines: list[str], key: str) -> bool:
|
|
119
|
+
return _indicator_value(lines, key) is not None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _section_lines(lines: list[str], heading: str) -> list[str]:
|
|
123
|
+
start_idx = None
|
|
124
|
+
target = heading.strip().lower()
|
|
125
|
+
for idx, line in enumerate(lines):
|
|
126
|
+
if line.startswith("# ") and line[2:].strip().lower() == target:
|
|
127
|
+
start_idx = idx + 1
|
|
128
|
+
break
|
|
129
|
+
if start_idx is None:
|
|
130
|
+
return []
|
|
131
|
+
out: list[str] = []
|
|
132
|
+
for idx in range(start_idx, len(lines)):
|
|
133
|
+
line = lines[idx]
|
|
134
|
+
if line.startswith("# "):
|
|
135
|
+
break
|
|
136
|
+
out.append(line)
|
|
137
|
+
return out
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _extract_refs(text: str, prefix: str) -> list[str]:
|
|
141
|
+
pattern = re.compile(rf"\b{re.escape(prefix)}_\d{{3}}_[a-z0-9_]+\b")
|
|
142
|
+
return sorted({match.group(0) for match in pattern.finditer(text)})
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _strip_mermaid_blocks(text: str) -> str:
|
|
146
|
+
return MERMAID_BLOCK_PATTERN.sub("", text)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _plain_text(value: str) -> str:
|
|
150
|
+
text = unicodedata.normalize("NFKD", value)
|
|
151
|
+
text = text.encode("ascii", "ignore").decode("ascii")
|
|
152
|
+
text = re.sub(r"`+", "", text)
|
|
153
|
+
text = text.replace("&", " and ")
|
|
154
|
+
text = re.sub(r"[/{}[\]()+*#]", " ", text)
|
|
155
|
+
text = re.sub(r"[^A-Za-z0-9:._ -]+", " ", text)
|
|
156
|
+
text = re.sub(r"\s+", " ", text).strip(" .:-")
|
|
157
|
+
return text
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _safe_mermaid_label(value: str, fallback: str) -> str:
|
|
161
|
+
text = _plain_text(value)
|
|
162
|
+
if not text:
|
|
163
|
+
text = fallback
|
|
164
|
+
words = text.split()
|
|
165
|
+
if len(words) > MERMAID_LABEL_MAX_WORDS:
|
|
166
|
+
text = " ".join(words[:MERMAID_LABEL_MAX_WORDS])
|
|
167
|
+
if len(text) > MERMAID_LABEL_MAX_CHARS:
|
|
168
|
+
text = text[:MERMAID_LABEL_MAX_CHARS].rstrip(" .:-")
|
|
169
|
+
return text or fallback
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _rendered_list_items(text: str) -> list[str]:
|
|
173
|
+
items: list[str] = []
|
|
174
|
+
for line in text.splitlines():
|
|
175
|
+
stripped = line.strip()
|
|
176
|
+
if not stripped:
|
|
177
|
+
continue
|
|
178
|
+
stripped = re.sub(r"^- \[[ xX]\]\s*", "", stripped)
|
|
179
|
+
if stripped.startswith("- "):
|
|
180
|
+
stripped = stripped[2:].strip()
|
|
181
|
+
items.append(stripped)
|
|
182
|
+
return items
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _pick_mermaid_summary(candidates: list[str], fallback: str) -> str:
|
|
186
|
+
for candidate in candidates:
|
|
187
|
+
label = _safe_mermaid_label(candidate, "")
|
|
188
|
+
if label:
|
|
189
|
+
return label
|
|
190
|
+
return fallback
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _mermaid_signature_part(value: str) -> str:
|
|
194
|
+
text = _plain_text(value).lower()
|
|
195
|
+
text = re.sub(r"[^a-z0-9]+", "-", text).strip("-")
|
|
196
|
+
return text[:40]
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _compose_mermaid_signature(kind_name: str, *parts: str) -> str:
|
|
200
|
+
signature_parts = [_mermaid_signature_part(kind_name)]
|
|
201
|
+
for part in parts:
|
|
202
|
+
rendered = _mermaid_signature_part(part)
|
|
203
|
+
if rendered:
|
|
204
|
+
signature_parts.append(rendered)
|
|
205
|
+
return "|".join(signature_parts)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _extract_title(lines: list[str]) -> str:
|
|
209
|
+
for line in lines:
|
|
210
|
+
if line.startswith("## "):
|
|
211
|
+
match = re.match(r"^##\s+\S+\s*-\s*(.+?)\s*$", line)
|
|
212
|
+
if match:
|
|
213
|
+
return match.group(1).strip()
|
|
214
|
+
return line.removeprefix("## ").strip()
|
|
215
|
+
return ""
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _section_block(text: str, heading: str, fallback: str = "") -> str:
|
|
219
|
+
cleaned = [line.rstrip() for line in _section_lines(text.splitlines(), heading) if line.strip()]
|
|
220
|
+
if cleaned:
|
|
221
|
+
return "\n".join(cleaned)
|
|
222
|
+
return fallback
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _ref_placeholder(text: str, prefix: str, fallback: str = "(none yet)") -> str:
|
|
226
|
+
refs = sorted(_extract_refs(text, prefix))
|
|
227
|
+
if refs:
|
|
228
|
+
return ", ".join(f"`{ref}`" for ref in refs)
|
|
229
|
+
return fallback
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _workflow_mermaid_values_from_doc(text: str, kind_name: str) -> dict[str, str]:
|
|
233
|
+
ref_text = _strip_mermaid_blocks(text)
|
|
234
|
+
if kind_name == "request":
|
|
235
|
+
return {
|
|
236
|
+
"NEEDS_PLACEHOLDER": _section_block(text, "Needs", "- Describe the need"),
|
|
237
|
+
"CONTEXT_PLACEHOLDER": _section_block(text, "Context", "- Add the relevant context"),
|
|
238
|
+
"ACCEPTANCE_PLACEHOLDER": _section_block(text, "Acceptance criteria", "- AC1: Define a measurable outcome"),
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if kind_name == "backlog":
|
|
242
|
+
return {
|
|
243
|
+
"PROBLEM_PLACEHOLDER": _section_block(text, "Problem", "- Describe the problem and user impact"),
|
|
244
|
+
"ACCEPTANCE_BLOCK": _section_block(text, "Acceptance criteria", "- AC1: Define an objective acceptance check"),
|
|
245
|
+
"REQUEST_LINK_PLACEHOLDER": _ref_placeholder(ref_text, REF_PREFIXES["request"]),
|
|
246
|
+
"TASK_LINK_PLACEHOLDER": _ref_placeholder(ref_text, REF_PREFIXES["task"]),
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if kind_name == "task":
|
|
250
|
+
return {
|
|
251
|
+
"PLAN_BLOCK": _section_block(
|
|
252
|
+
text,
|
|
253
|
+
"Plan",
|
|
254
|
+
"- [ ] 1. Confirm scope\n- [ ] 2. Implement scope\n- [ ] 3. Validate result",
|
|
255
|
+
),
|
|
256
|
+
"VALIDATION_BLOCK": _section_block(
|
|
257
|
+
text,
|
|
258
|
+
"Validation",
|
|
259
|
+
"- Run the relevant automated tests before closing the current wave or step.",
|
|
260
|
+
),
|
|
261
|
+
"BACKLOG_LINK_PLACEHOLDER": _ref_placeholder(
|
|
262
|
+
ref_text,
|
|
263
|
+
REF_PREFIXES["backlog"],
|
|
264
|
+
"(add: Derived from `logics/backlog/item_XXX_...`)",
|
|
265
|
+
),
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
raise ValueError(f"Unsupported Mermaid workflow kind: {kind_name}")
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _render_request_mermaid(title: str, values: dict[str, str]) -> str:
|
|
272
|
+
need_items = _rendered_list_items(values.get("NEEDS_PLACEHOLDER", ""))
|
|
273
|
+
context_items = _rendered_list_items(values.get("CONTEXT_PLACEHOLDER", ""))
|
|
274
|
+
acceptance_items = _rendered_list_items(values.get("ACCEPTANCE_PLACEHOLDER", ""))
|
|
275
|
+
title_label = _safe_mermaid_label(title, "Request need")
|
|
276
|
+
need_label = _pick_mermaid_summary([*need_items, *context_items, title], "Need scope")
|
|
277
|
+
outcome_label = _pick_mermaid_summary([*acceptance_items, *context_items], "Acceptance target")
|
|
278
|
+
feedback_label = _safe_mermaid_label(MERMAID_FALLBACKS["request_backlog"], MERMAID_FALLBACKS["request_backlog"])
|
|
279
|
+
signature = _compose_mermaid_signature("request", title, need_label, outcome_label)
|
|
280
|
+
return "\n".join(
|
|
281
|
+
[
|
|
282
|
+
"```mermaid",
|
|
283
|
+
"%% logics-kind: request",
|
|
284
|
+
f"%% logics-signature: {signature}",
|
|
285
|
+
"flowchart TD",
|
|
286
|
+
f" Trigger[{title_label}] --> Need[{need_label}]",
|
|
287
|
+
f" Need --> Outcome[{outcome_label}]",
|
|
288
|
+
f" Outcome --> Backlog[{feedback_label}]",
|
|
289
|
+
"```",
|
|
290
|
+
]
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _render_backlog_mermaid(title: str, values: dict[str, str]) -> str:
|
|
295
|
+
request_refs = _extract_refs(values.get("REQUEST_LINK_PLACEHOLDER", ""), REF_PREFIXES["request"])
|
|
296
|
+
task_refs = _extract_refs(values.get("TASK_LINK_PLACEHOLDER", ""), REF_PREFIXES["task"])
|
|
297
|
+
problem_items = _rendered_list_items(values.get("PROBLEM_PLACEHOLDER", ""))
|
|
298
|
+
acceptance_items = _rendered_list_items(values.get("ACCEPTANCE_BLOCK", ""))
|
|
299
|
+
source_label = _pick_mermaid_summary([*request_refs, title], "Request source")
|
|
300
|
+
problem_label = _pick_mermaid_summary([*problem_items, title], "Problem scope")
|
|
301
|
+
scope_label = _safe_mermaid_label(title, "Scoped delivery")
|
|
302
|
+
acceptance_label = _pick_mermaid_summary(acceptance_items, "Acceptance check")
|
|
303
|
+
task_label = _pick_mermaid_summary(task_refs, MERMAID_FALLBACKS["backlog_task"])
|
|
304
|
+
signature = _compose_mermaid_signature("backlog", title, source_label, problem_label, acceptance_label)
|
|
305
|
+
return "\n".join(
|
|
306
|
+
[
|
|
307
|
+
"```mermaid",
|
|
308
|
+
"%% logics-kind: backlog",
|
|
309
|
+
f"%% logics-signature: {signature}",
|
|
310
|
+
"flowchart TD",
|
|
311
|
+
f" Request[{source_label}] --> Problem[{problem_label}]",
|
|
312
|
+
f" Problem --> Scope[{scope_label}]",
|
|
313
|
+
f" Scope --> Acceptance[{acceptance_label}]",
|
|
314
|
+
f" Acceptance --> Tasks[{task_label}]",
|
|
315
|
+
"```",
|
|
316
|
+
]
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _render_task_mermaid(title: str, values: dict[str, str]) -> str:
|
|
321
|
+
backlog_refs = _extract_refs(values.get("BACKLOG_LINK_PLACEHOLDER", ""), REF_PREFIXES["backlog"])
|
|
322
|
+
plan_items = [
|
|
323
|
+
item
|
|
324
|
+
for item in _rendered_list_items(values.get("PLAN_BLOCK", ""))
|
|
325
|
+
if not item.lower().startswith("final:")
|
|
326
|
+
]
|
|
327
|
+
validation_items = _rendered_list_items(values.get("VALIDATION_BLOCK", ""))
|
|
328
|
+
source_label = _pick_mermaid_summary([*backlog_refs, title], "Backlog source")
|
|
329
|
+
step_one = _pick_mermaid_summary(plan_items[:1], "Confirm scope")
|
|
330
|
+
step_two = _pick_mermaid_summary(plan_items[1:2], "Implement change")
|
|
331
|
+
step_three = _pick_mermaid_summary(plan_items[2:3], "Validate result")
|
|
332
|
+
validation_label = _pick_mermaid_summary(validation_items, "Validation")
|
|
333
|
+
report_label = _safe_mermaid_label(MERMAID_FALLBACKS["task_report"], MERMAID_FALLBACKS["task_report"])
|
|
334
|
+
signature = _compose_mermaid_signature("task", title, source_label, step_one, validation_label)
|
|
335
|
+
return "\n".join(
|
|
336
|
+
[
|
|
337
|
+
"```mermaid",
|
|
338
|
+
"%% logics-kind: task",
|
|
339
|
+
f"%% logics-signature: {signature}",
|
|
340
|
+
"stateDiagram-v2",
|
|
341
|
+
f' state "{source_label}" as Backlog',
|
|
342
|
+
f' state "{step_one}" as Scope',
|
|
343
|
+
f' state "{step_two}" as Build',
|
|
344
|
+
f' state "{step_three}" as Verify',
|
|
345
|
+
f' state "{validation_label}" as Validation',
|
|
346
|
+
f' state "{report_label}" as Report',
|
|
347
|
+
" [*] --> Backlog",
|
|
348
|
+
" Backlog --> Scope",
|
|
349
|
+
" Scope --> Build",
|
|
350
|
+
" Build --> Verify",
|
|
351
|
+
" Verify --> Validation",
|
|
352
|
+
" Validation --> Report",
|
|
353
|
+
" Report --> [*]",
|
|
354
|
+
"```",
|
|
355
|
+
]
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def expected_workflow_mermaid_signature(kind_name: str, lines: list[str]) -> str:
|
|
360
|
+
text = "\n".join(lines)
|
|
361
|
+
title = _extract_title(lines)
|
|
362
|
+
if not title:
|
|
363
|
+
return ""
|
|
364
|
+
values = _workflow_mermaid_values_from_doc(text, kind_name)
|
|
365
|
+
if kind_name == "request":
|
|
366
|
+
rendered = _render_request_mermaid(title, values)
|
|
367
|
+
elif kind_name == "backlog":
|
|
368
|
+
rendered = _render_backlog_mermaid(title, values)
|
|
369
|
+
elif kind_name == "task":
|
|
370
|
+
rendered = _render_task_mermaid(title, values)
|
|
371
|
+
else:
|
|
372
|
+
return ""
|
|
373
|
+
match = MERMAID_SIGNATURE_PATTERN.search(rendered)
|
|
374
|
+
return match.group(1) if match is not None else ""
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _run_git(repo_root: Path, args: list[str]) -> str:
|
|
378
|
+
try:
|
|
379
|
+
result = subprocess.run(["git", *args], cwd=repo_root, check=False, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True)
|
|
380
|
+
except OSError:
|
|
381
|
+
return ""
|
|
382
|
+
if result.returncode != 0:
|
|
383
|
+
return ""
|
|
384
|
+
return result.stdout
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _git_modified_paths(repo_root: Path) -> set[Path]:
|
|
388
|
+
paths: set[Path] = set()
|
|
389
|
+
for args in (
|
|
390
|
+
["diff", "--name-only", "--diff-filter=ACMRT"],
|
|
391
|
+
["diff", "--cached", "--name-only", "--diff-filter=ACMRT"],
|
|
392
|
+
["ls-files", "--others", "--exclude-standard"],
|
|
393
|
+
):
|
|
394
|
+
for line in _run_git(repo_root, args).splitlines():
|
|
395
|
+
line = line.strip()
|
|
396
|
+
if line:
|
|
397
|
+
paths.add(Path(line))
|
|
398
|
+
if not paths:
|
|
399
|
+
for line in _run_git(repo_root, ["diff-tree", "--no-commit-id", "--name-only", "-r", "--diff-filter=ACMRT", "HEAD"]).splitlines():
|
|
400
|
+
line = line.strip()
|
|
401
|
+
if line:
|
|
402
|
+
paths.add(Path(line))
|
|
403
|
+
return paths
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _git_untracked_paths(repo_root: Path) -> set[Path]:
|
|
407
|
+
paths: set[Path] = set()
|
|
408
|
+
for line in _run_git(repo_root, ["ls-files", "--others", "--exclude-standard"]).splitlines():
|
|
409
|
+
line = line.strip()
|
|
410
|
+
if line:
|
|
411
|
+
paths.add(Path(line))
|
|
412
|
+
return paths
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _doc_diff(repo_root: Path, rel_path: Path) -> str:
|
|
416
|
+
diff = _run_git(repo_root, ["diff", "--unified=0", "--", str(rel_path)])
|
|
417
|
+
diff += _run_git(repo_root, ["diff", "--cached", "--unified=0", "--", str(rel_path)])
|
|
418
|
+
if diff:
|
|
419
|
+
return diff
|
|
420
|
+
if rel_path in _git_modified_paths(repo_root):
|
|
421
|
+
return _run_git(repo_root, ["show", "--format=", "--unified=0", "HEAD", "--", str(rel_path)])
|
|
422
|
+
return ""
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _diff_has_indicator_changes(repo_root: Path, rel_path: Path, indicators: set[str]) -> bool:
|
|
426
|
+
if not indicators:
|
|
427
|
+
return True
|
|
428
|
+
diff = _doc_diff(repo_root, rel_path)
|
|
429
|
+
if not diff:
|
|
430
|
+
return False
|
|
431
|
+
for line in diff.splitlines():
|
|
432
|
+
if not line.startswith(("+", "-")):
|
|
433
|
+
continue
|
|
434
|
+
if line.startswith(("+++ ", "--- ")):
|
|
435
|
+
continue
|
|
436
|
+
for key in indicators:
|
|
437
|
+
if f"> {key}:" in line:
|
|
438
|
+
return True
|
|
439
|
+
return False
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _diff_is_status_only_normalization(repo_root: Path, rel_path: Path) -> bool:
|
|
443
|
+
diff = _doc_diff(repo_root, rel_path)
|
|
444
|
+
if not diff:
|
|
445
|
+
return False
|
|
446
|
+
saw_change = False
|
|
447
|
+
for line in diff.splitlines():
|
|
448
|
+
if not line.startswith(("+", "-")):
|
|
449
|
+
continue
|
|
450
|
+
if line.startswith(("+++ ", "--- ")):
|
|
451
|
+
continue
|
|
452
|
+
changed = line[1:].strip()
|
|
453
|
+
if not changed:
|
|
454
|
+
continue
|
|
455
|
+
saw_change = True
|
|
456
|
+
if changed.startswith("> Status:"):
|
|
457
|
+
continue
|
|
458
|
+
return False
|
|
459
|
+
return saw_change
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _has_non_semantic_edit_marker(lines: list[str]) -> bool:
|
|
463
|
+
text = "\n".join(lines)
|
|
464
|
+
return any(marker in text for marker in NON_SEMANTIC_EDIT_MARKERS)
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _workflow_status_is_active(lines: list[str]) -> bool:
|
|
468
|
+
status_value = _indicator_value(lines, "Status")
|
|
469
|
+
if status_value is None:
|
|
470
|
+
return False
|
|
471
|
+
return " ".join(status_value.split()).lower() in ACTIVE_WORKFLOW_STATUSES
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _blocking_placeholder_hits(lines: list[str]) -> list[str]:
|
|
475
|
+
text = "\n".join(lines)
|
|
476
|
+
hits: list[str] = []
|
|
477
|
+
for snippet in TEMPLATE_PLACEHOLDER_SNIPPETS:
|
|
478
|
+
if snippet in text:
|
|
479
|
+
hits.append(snippet)
|
|
480
|
+
for snippet in BLOCKING_TRACEABILITY_PLACEHOLDER_SNIPPETS:
|
|
481
|
+
if snippet in text:
|
|
482
|
+
hits.append(snippet)
|
|
483
|
+
return sorted(set(hits))
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _mermaid_warnings(kind_name: str, lines: list[str]) -> list[str]:
|
|
487
|
+
text = "\n".join(lines)
|
|
488
|
+
match = re.search(r"```mermaid\s*\n(.*?)\n```", text, flags=re.DOTALL)
|
|
489
|
+
if match is None:
|
|
490
|
+
return ["missing Mermaid overview block"]
|
|
491
|
+
block = match.group(1)
|
|
492
|
+
warnings: list[str] = []
|
|
493
|
+
signature_match = re.search(r"^\s*%%\s*logics-signature:\s*(.+?)\s*$", block, flags=re.MULTILINE)
|
|
494
|
+
expected_signature = expected_workflow_mermaid_signature(kind_name, lines)
|
|
495
|
+
if signature_match is None:
|
|
496
|
+
warnings.append("missing Mermaid context signature comment")
|
|
497
|
+
elif expected_signature and signature_match.group(1).strip() != expected_signature:
|
|
498
|
+
warnings.append(f"Mermaid context signature is stale: expected `{expected_signature}`")
|
|
499
|
+
return warnings
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _lint_file(path: Path, kind_name: str, kind: Kind, require_status: bool, check_changed_doc_rules: bool) -> tuple[list[str], list[str]]:
|
|
503
|
+
issues: list[str] = []
|
|
504
|
+
warnings: list[str] = []
|
|
505
|
+
name = path.name
|
|
506
|
+
if not re.match(rf"^{re.escape(kind.prefix)}_\d{{3}}_[a-z0-9_]+\.md$", name):
|
|
507
|
+
issues.append(f"bad filename: {name}")
|
|
508
|
+
|
|
509
|
+
lines = _read_lines(path)
|
|
510
|
+
heading = _extract_first_heading(lines)
|
|
511
|
+
if heading is None:
|
|
512
|
+
issues.append("missing first heading (expected '## ...')")
|
|
513
|
+
else:
|
|
514
|
+
expected_prefix = f"## {path.stem} - "
|
|
515
|
+
if not heading.startswith(expected_prefix):
|
|
516
|
+
issues.append(f"bad heading: expected '{expected_prefix}<Title>'")
|
|
517
|
+
|
|
518
|
+
for key in kind.required_indicators:
|
|
519
|
+
if not _has_indicator(lines, key):
|
|
520
|
+
issues.append(f"missing indicator: {key}")
|
|
521
|
+
|
|
522
|
+
status_value = _indicator_value(lines, "Status")
|
|
523
|
+
if status_value is None:
|
|
524
|
+
if require_status:
|
|
525
|
+
issues.append("missing indicator: Status")
|
|
526
|
+
elif " ".join(status_value.split()).lower() not in {status.lower() for status in kind.allowed_statuses}:
|
|
527
|
+
issues.append("invalid Status value: " + status_value + " (allowed: " + " | ".join(kind.allowed_statuses) + ")")
|
|
528
|
+
|
|
529
|
+
if check_changed_doc_rules and kind_name in WORKFLOW_KINDS:
|
|
530
|
+
for key, disallowed_values in CRITICAL_INDICATOR_PLACEHOLDERS.items():
|
|
531
|
+
if key not in kind.required_indicators:
|
|
532
|
+
continue
|
|
533
|
+
current = _indicator_value(lines, key)
|
|
534
|
+
if current in disallowed_values:
|
|
535
|
+
issues.append(f"placeholder indicator: {key} = {current}")
|
|
536
|
+
|
|
537
|
+
text = "\n".join(lines)
|
|
538
|
+
placeholder_hits = [snippet for snippet in TEMPLATE_PLACEHOLDER_SNIPPETS if snippet in text]
|
|
539
|
+
blocking_hits = _blocking_placeholder_hits(lines)
|
|
540
|
+
if _workflow_status_is_active(lines) and blocking_hits:
|
|
541
|
+
issues.append("blocking placeholder content in active workflow doc: " + ", ".join(blocking_hits))
|
|
542
|
+
elif placeholder_hits:
|
|
543
|
+
warnings.append("contains template placeholder content: " + ", ".join(sorted(set(placeholder_hits))))
|
|
544
|
+
warnings.extend(_mermaid_warnings(kind_name, lines))
|
|
545
|
+
|
|
546
|
+
return issues, warnings
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def lint_payload(repo_root: Path, *, require_status: bool = False) -> dict[str, object]:
|
|
550
|
+
all_issues: list[tuple[Path, list[str]]] = []
|
|
551
|
+
all_warnings: list[tuple[Path, list[str]]] = []
|
|
552
|
+
modified_paths = _git_modified_paths(repo_root)
|
|
553
|
+
untracked_paths = _git_untracked_paths(repo_root)
|
|
554
|
+
|
|
555
|
+
for kind_name, kind in KINDS.items():
|
|
556
|
+
directory = repo_root / kind.directory
|
|
557
|
+
if not directory.is_dir():
|
|
558
|
+
continue
|
|
559
|
+
for path in sorted(directory.glob("*.md")):
|
|
560
|
+
rel_path = path.relative_to(repo_root)
|
|
561
|
+
issues, warnings = _lint_file(
|
|
562
|
+
path,
|
|
563
|
+
kind_name,
|
|
564
|
+
kind,
|
|
565
|
+
require_status=require_status,
|
|
566
|
+
check_changed_doc_rules=rel_path in modified_paths,
|
|
567
|
+
)
|
|
568
|
+
if rel_path in modified_paths and rel_path not in untracked_paths:
|
|
569
|
+
required = set(kind.required_indicators)
|
|
570
|
+
if (
|
|
571
|
+
not _diff_has_indicator_changes(repo_root, rel_path, required)
|
|
572
|
+
and not _diff_is_status_only_normalization(repo_root, rel_path)
|
|
573
|
+
and not _has_non_semantic_edit_marker(_read_lines(path))
|
|
574
|
+
):
|
|
575
|
+
issues.append("modified without updating indicators: " + ", ".join(sorted(required)))
|
|
576
|
+
if issues:
|
|
577
|
+
all_issues.append((rel_path, issues))
|
|
578
|
+
if warnings:
|
|
579
|
+
all_warnings.append((rel_path, warnings))
|
|
580
|
+
|
|
581
|
+
return {
|
|
582
|
+
"ok": not all_issues,
|
|
583
|
+
"issue_count": sum(len(issues) for _path, issues in all_issues),
|
|
584
|
+
"warning_count": sum(len(warnings) for _path, warnings in all_warnings),
|
|
585
|
+
"issues": [{"path": path.as_posix(), "message": issue} for path, issues in all_issues for issue in issues],
|
|
586
|
+
"warnings": [{"path": path.as_posix(), "message": warning} for path, warnings in all_warnings for warning in warnings],
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def render_lint(repo_root: Path, *, require_status: bool = False, output_format: str = "text") -> str:
|
|
591
|
+
payload = lint_payload(repo_root, require_status=require_status)
|
|
592
|
+
if output_format == "json":
|
|
593
|
+
return json.dumps(payload, indent=2, sort_keys=True)
|
|
594
|
+
if not payload["issues"] and not payload["warnings"]:
|
|
595
|
+
return "Logics lint: OK"
|
|
596
|
+
if not payload["issues"]:
|
|
597
|
+
lines = ["Logics lint: OK (warnings)"]
|
|
598
|
+
for warning in payload["warnings"]:
|
|
599
|
+
lines.append(f"- {warning['path']}: WARNING: {warning['message']}")
|
|
600
|
+
return "\n".join(lines)
|
|
601
|
+
lines = ["Logics lint: FAILED"]
|
|
602
|
+
for issue in payload["issues"]:
|
|
603
|
+
lines.append(f"- {issue['path']}: {issue['message']}")
|
|
604
|
+
for warning in payload["warnings"]:
|
|
605
|
+
lines.append(f"- {warning['path']}: WARNING: {warning['message']}")
|
|
606
|
+
return "\n".join(lines)
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
610
|
+
parser = argparse.ArgumentParser(prog="logics-manager lint", description="Lint Logics docs (filenames, headings, indicators).")
|
|
611
|
+
parser.add_argument("--require-status", action="store_true", help="Require `Status` indicator in all supported Logics docs.")
|
|
612
|
+
parser.add_argument("--format", choices=("text", "json"), default="text")
|
|
613
|
+
return parser
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def main(argv: list[str]) -> int:
|
|
617
|
+
args = build_parser().parse_args(argv)
|
|
618
|
+
repo_root = find_repo_root(Path.cwd())
|
|
619
|
+
output = render_lint(repo_root, require_status=args.require_status, output_format=args.format)
|
|
620
|
+
print(output)
|
|
621
|
+
payload = lint_payload(repo_root, require_status=args.require_status)
|
|
622
|
+
return 0 if not payload["issues"] else 1
|