@techierathore/techieflow 1.0.1 → 1.0.3
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/.claude/commands/TechieFlow/agents/analyst.md +0 -5
- package/.claude/commands/TechieFlow/agents/architect.md +0 -6
- package/.claude/commands/TechieFlow/agents/flow-master.md +41 -215
- package/.claude/commands/TechieFlow/agents/verifier.md +23 -64
- package/.claude/commands/TechieFlow/tasks/_metrics-emit-gate.md +24 -117
- package/.claude/commands/TechieFlow/tasks/_smoke-test-policy.md +20 -84
- package/.claude/commands/TechieFlow/tasks/_status-update-gate.md +10 -74
- package/.claude/commands/TechieFlow/tasks/_yolo-mode.md +20 -58
- package/.claude/commands/TechieFlow/tasks/amend-docs.md +20 -143
- package/.claude/commands/TechieFlow/tasks/build-phase.md +14 -224
- package/.claude/commands/TechieFlow/tasks/day1-brownfield.md +31 -505
- package/.claude/commands/TechieFlow/tasks/day1-greenfield.md +28 -213
- package/.claude/commands/TechieFlow/tasks/deploy-checklist.md +19 -0
- package/.claude/commands/TechieFlow/tasks/devguide.md +10 -219
- package/.claude/commands/TechieFlow/tasks/fix-issues.md +10 -129
- package/.claude/commands/TechieFlow/tasks/log-miss.md +19 -158
- package/.claude/commands/TechieFlow/tasks/metrics-report.md +1 -1
- package/.claude/commands/TechieFlow/tasks/mockups.md +12 -117
- package/.claude/commands/TechieFlow/tasks/refresh-status.md +9 -130
- package/.claude/commands/TechieFlow/tasks/split-brd.md +10 -126
- package/.claude/commands/TechieFlow/tasks/triage-and-fix.md +14 -0
- package/.claude/commands/TechieFlow/tasks/triage-issues.md +11 -152
- package/.claude/commands/TechieFlow/tasks/verify-phase.md +36 -572
- package/.opencode/plugin/techieflow.js +39 -12
- package/.tfcore/agents/analyst.md +0 -5
- package/.tfcore/agents/architect.md +0 -6
- package/.tfcore/agents/flow-master.md +41 -215
- package/.tfcore/agents/verifier.md +23 -64
- package/.tfcore/core-config.yaml +10 -0
- package/.tfcore/hooks/block-git.sh +4 -4
- package/.tfcore/hooks/guard-artifacts.sh +2 -2
- package/.tfcore/hooks/guard-build.sh +76 -0
- package/.tfcore/hooks/guard-db.sh +90 -0
- package/.tfcore/hooks/guard-metrics.sh +56 -0
- package/.tfcore/hooks/guard-status-html.sh +116 -71
- package/.tfcore/hooks/guard-status.sh +43 -16
- package/.tfcore/hooks/guard-verify.sh +70 -48
- package/.tfcore/hooks/sweep-artifacts.sh +2 -2
- package/.tfcore/install-manifest.yaml +0 -18
- package/.tfcore/routing.yaml +25 -14
- package/.tfcore/standards/coding-standards-core.md +61 -0
- package/.tfcore/standards/coding-standards-dotnet.md +93 -0
- package/.tfcore/tasks/_metrics-emit-gate.md +24 -117
- package/.tfcore/tasks/_smoke-test-policy.md +20 -84
- package/.tfcore/tasks/_status-update-gate.md +10 -74
- package/.tfcore/tasks/_yolo-mode.md +20 -58
- package/.tfcore/tasks/amend-docs.md +20 -143
- package/.tfcore/tasks/build-phase.md +14 -224
- package/.tfcore/tasks/day1-brownfield.md +31 -505
- package/.tfcore/tasks/day1-greenfield.md +28 -213
- package/.tfcore/tasks/deploy-checklist.md +19 -0
- package/.tfcore/tasks/devguide.md +10 -219
- package/.tfcore/tasks/fix-issues.md +10 -129
- package/.tfcore/tasks/log-miss.md +19 -158
- package/.tfcore/tasks/metrics-report.md +1 -1
- package/.tfcore/tasks/mockups.md +12 -117
- package/.tfcore/tasks/refresh-status.md +9 -130
- package/.tfcore/tasks/split-brd.md +10 -126
- package/.tfcore/tasks/triage-and-fix.md +14 -0
- package/.tfcore/tasks/triage-issues.md +11 -152
- package/.tfcore/tasks/verify-phase.md +36 -572
- package/.tfcore/telemetry/SCHEMA.md +43 -7
- package/.tfcore/telemetry/tf-metrics.sh +152 -17
- package/.tfcore/templates/project-brief-tmpl.yaml +1 -1
- package/.tfcore/templates/stack-defaults/dotnet.md +35 -0
- package/.tfcore/templates/stack-questions.md +77 -0
- package/.tfcore/templates/v4custom/app-agents-md-tmpl.md +3 -4
- package/.tfcore/templates/v4custom/app-architecture-tmpl.md +107 -115
- package/.tfcore/templates/v4custom/app-brd-tmpl.md +108 -184
- package/.tfcore/templates/v4custom/app-checklist-tmpl.md +48 -73
- package/.tfcore/templates/v4custom/app-claude-md-tmpl.md +1 -1
- package/.tfcore/templates/v4custom/app-coding-standards-tmpl.md +52 -0
- package/.tfcore/templates/v4custom/app-deployment-checklist-tmpl.md +86 -0
- package/.tfcore/templates/v4custom/app-devguide-tmpl.md +69 -146
- package/.tfcore/templates/v4custom/app-phases-tmpl.md +35 -0
- package/.tfcore/templates/v4custom/app-productguide-tmpl.md +38 -35
- package/.tfcore/templates/v4custom/app-project-status-tmpl.md +63 -73
- package/.tfcore/templates/v4custom/app-uidesign-tmpl.md +62 -37
- package/.tfcore/templates/v4custom/app-usageguide-tmpl.md +69 -54
- package/.tfcore/templates/v4custom/build-invocation-ladder.md +17 -180
- package/.tfcore/templates/v4custom/build-subagent-prompt.md +17 -0
- package/.tfcore/templates/v4custom/metrics-report-template.md +2 -2
- package/.tfcore/user-guide.md +0 -23
- package/.tfcore/utils/__pycache__/tf-brd-status.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-checklist-edit.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-codex-bind.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-codex-telemetry.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-day1-files.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-devguide-list.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-doc-check.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-log-miss.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-misses-md.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-mockups-locate.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-opencode-audit.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-perf-grade.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-render-html.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-split-brd.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-status-facts.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-triage.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-verify-list.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-verify-verdict.cpython-312.pyc +0 -0
- package/.tfcore/utils/tf-brd-status.py +176 -0
- package/.tfcore/utils/tf-brd-status.sh +19 -0
- package/.tfcore/utils/tf-build-list.py +191 -0
- package/.tfcore/utils/tf-build-list.sh +20 -0
- package/.tfcore/utils/tf-build.sh +143 -0
- package/.tfcore/utils/tf-cdp-relay.ps1 +64 -0
- package/.tfcore/utils/tf-checklist-edit.py +133 -0
- package/.tfcore/utils/tf-day1-files.py +208 -0
- package/.tfcore/utils/tf-day1-files.sh +20 -0
- package/.tfcore/utils/tf-devguide-list.py +249 -0
- package/.tfcore/utils/tf-devguide-list.sh +18 -0
- package/.tfcore/utils/tf-doc-check.py +1267 -0
- package/.tfcore/utils/tf-doc-check.sh +29 -0
- package/.tfcore/utils/tf-emit.sh +203 -13
- package/.tfcore/utils/tf-fix-close.sh +83 -0
- package/.tfcore/utils/tf-goal.sh +193 -62
- package/.tfcore/utils/tf-harness.sh +4 -9
- package/.tfcore/utils/tf-log-miss.py +197 -0
- package/.tfcore/utils/tf-log-miss.sh +14 -0
- package/.tfcore/utils/tf-misses-md.py +211 -0
- package/.tfcore/utils/tf-misses-md.sh +13 -0
- package/.tfcore/utils/tf-mockups-locate.py +66 -0
- package/.tfcore/utils/tf-mockups-locate.sh +18 -0
- package/.tfcore/utils/tf-opencode-audit.py +23 -7
- package/.tfcore/utils/tf-perf-grade.py +97 -0
- package/.tfcore/utils/tf-perf-grade.sh +12 -0
- package/.tfcore/utils/tf-phase.sh +74 -0
- package/.tfcore/utils/tf-routing-bind.sh +10 -5
- package/.tfcore/utils/tf-routing.sh +6 -7
- package/.tfcore/utils/tf-split-brd.py +304 -0
- package/.tfcore/utils/tf-split-brd.sh +22 -0
- package/.tfcore/utils/tf-status-evidence.sh +68 -0
- package/.tfcore/utils/tf-status-facts.py +309 -0
- package/.tfcore/utils/tf-status-facts.sh +20 -0
- package/.tfcore/utils/tf-triage.py +243 -0
- package/.tfcore/utils/tf-triage.sh +16 -0
- package/.tfcore/utils/tf-verify-boot.sh +250 -0
- package/.tfcore/utils/tf-verify-emit.sh +110 -0
- package/.tfcore/utils/tf-verify-env.sh +88 -0
- package/.tfcore/utils/tf-verify-list.py +270 -0
- package/.tfcore/utils/tf-verify-list.sh +13 -0
- package/.tfcore/utils/tf-verify-screens.mjs +283 -0
- package/.tfcore/utils/tf-verify-screens.sh +22 -0
- package/.tfcore/utils/tf-verify-tests.sh +123 -0
- package/.tfcore/utils/tf-verify-verdict.py +283 -0
- package/.tfcore/utils/tf-verify-verdict.sh +15 -0
- package/.tfcore/utils/tf-yolo.sh +59 -0
- package/README.md +114 -1149
- package/docs/TechieFlow-Installation.md +27 -7
- package/opencode.jsonc +13 -33
- package/package.json +4 -7
- package/scripts/install.mjs +73 -46
- package/scripts/npm-cleanup.mjs +137 -0
- package/scripts/npm-postinstall.mjs +49 -0
- package/.claude/commands/TechieFlow/tasks/advanced-elicitation.md +0 -117
- package/.claude/commands/TechieFlow/tasks/author-brd.md +0 -79
- package/.claude/commands/TechieFlow/tasks/document-project.md +0 -343
- package/.claude/commands/TechieFlow/tasks/execute-checklist.md +0 -86
- package/.claude/commands/TechieFlow/tasks/index-docs.md +0 -173
- package/.claude/commands/TechieFlow/tasks/kb-mode-interaction.md +0 -75
- package/.claude/commands/TechieFlow/tasks/shard-doc.md +0 -185
- package/.codex/config.toml +0 -46
- package/.codex/hooks.json +0 -63
- package/.codex/rules/techieflow.rules +0 -8
- package/.tfcore/hooks/codex-adapter.py +0 -248
- package/.tfcore/tasks/advanced-elicitation.md +0 -117
- package/.tfcore/tasks/author-brd.md +0 -79
- package/.tfcore/tasks/document-project.md +0 -343
- package/.tfcore/tasks/execute-checklist.md +0 -86
- package/.tfcore/tasks/index-docs.md +0 -173
- package/.tfcore/tasks/kb-mode-interaction.md +0 -75
- package/.tfcore/tasks/shard-doc.md +0 -185
- package/.tfcore/utils/tf-codex-bind.py +0 -196
- package/.tfcore/utils/tf-codex-telemetry.py +0 -60
- package/WORKFLOW.html +0 -1986
- package/scripts/test-install.mjs +0 -505
- package/scripts/validate.mjs +0 -163
|
@@ -0,0 +1,1267 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""tf-doc-check.py — check a TechieFlow human document against its template schema.
|
|
3
|
+
|
|
4
|
+
Every template under .tfcore/templates/v4custom/ opens with a `<!-- tf-schema ... -->`
|
|
5
|
+
block: required sections in order, word budgets per size, per-entry limits, and the
|
|
6
|
+
named row rules. This script reads that block and checks a generated document
|
|
7
|
+
against it. One line per problem, in plain words:
|
|
8
|
+
|
|
9
|
+
FAIL docs/MyApp-BRD.md: section "Scope" is missing
|
|
10
|
+
WARN docs/MyApp-BRD.md: 6,410 words; the Small target is 6,000 (maximum 8,000)
|
|
11
|
+
|
|
12
|
+
Exit 0 when nothing FAILs, 1 when something does, 2 when it could not run.
|
|
13
|
+
`--warn` prints every finding as WARN and exits 0 (report mode for existing projects).
|
|
14
|
+
|
|
15
|
+
Called through tf-doc-check.sh. Python 3 standard library only.
|
|
16
|
+
Readable description of the same rules: docs/TechieFlow-Document-Schemas.md.
|
|
17
|
+
|
|
18
|
+
Schema grammar (one `key: value` per line inside the comment):
|
|
19
|
+
doc: brd file: docs/{App}-BRD.md
|
|
20
|
+
header: App, Kind, Size fields that must be present and filled in
|
|
21
|
+
section: Name | flag | max N flag = required | optional | optional-small (required for M/L)
|
|
22
|
+
| app (required when Kind=app) | library (required when Kind=library)
|
|
23
|
+
a trailing * on the name matches any heading with that prefix
|
|
24
|
+
budget: S 6000 8000 | M … target and maximum words per size (code blocks and comments excluded)
|
|
25
|
+
entries: Section | Prefix: the section holding one ### entry per screen/task; optional H3 prefix
|
|
26
|
+
per-entry: 250 400 target and maximum words per entry
|
|
27
|
+
max-lines: 120 target-lines: 60
|
|
28
|
+
rule: name a named check implemented below (rules starting entry- run per entry)
|
|
29
|
+
"""
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import argparse
|
|
33
|
+
import json
|
|
34
|
+
import os
|
|
35
|
+
import re
|
|
36
|
+
import sys
|
|
37
|
+
|
|
38
|
+
SELF_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
39
|
+
TEMPLATE_DIR = os.path.normpath(os.path.join(SELF_DIR, "..", "templates", "v4custom"))
|
|
40
|
+
|
|
41
|
+
# file-name suffix (lower-case) -> (doc id, template file)
|
|
42
|
+
DOC_KINDS = [
|
|
43
|
+
("-brd.md", "brd", "app-brd-tmpl.md"),
|
|
44
|
+
("-architecture.md", "architecture", "app-architecture-tmpl.md"),
|
|
45
|
+
("-uidesign.md", "uidesign", "app-uidesign-tmpl.md"),
|
|
46
|
+
("-deployment-checklist.md", "deployment-checklist", "app-deployment-checklist-tmpl.md"), # before -checklist.md
|
|
47
|
+
("-checklist.md", "checklist", "app-checklist-tmpl.md"),
|
|
48
|
+
("coding-standards.md", "coding-standards", "app-coding-standards-tmpl.md"),
|
|
49
|
+
("project-status.md", "project-status", "app-project-status-tmpl.md"),
|
|
50
|
+
("-usageguide.md", "usageguide", "app-usageguide-tmpl.md"),
|
|
51
|
+
("-usage-guide.md", "usageguide", "app-usageguide-tmpl.md"),
|
|
52
|
+
("-devguide.md", "devguide", "app-devguide-tmpl.md"),
|
|
53
|
+
("-productguide.md", "productguide", "app-productguide-tmpl.md"),
|
|
54
|
+
("-phases.md", "phases", "app-phases-tmpl.md"),
|
|
55
|
+
]
|
|
56
|
+
SIZED_DOCS = {"brd", "architecture", "uidesign", "checklist", "usageguide", "devguide", "productguide"}
|
|
57
|
+
# Large projects: these four split by phase. Phase 1 keeps the plain name (App-BRD.md);
|
|
58
|
+
# phase 2 onward are App-P2-BRD.md and so on. docs/TechieFlow-Document-Schemas.md §2.
|
|
59
|
+
PHASED_DOCS = {"brd", "checklist", "uidesign", "devguide"}
|
|
60
|
+
PHASED_SUFFIX = {"brd": "BRD", "checklist": "Checklist", "uidesign": "UIDesign", "devguide": "DevGuide"}
|
|
61
|
+
DOC_NAME = re.compile(
|
|
62
|
+
r"^(.+?)(?:-P(\d+))?-(BRD|Deployment-Checklist(?:-[\w]+)?|Checklist|UIDesign|DevGuide|Architecture|Coding-Standards|UsageGuide|Usage-Guide|ProductGuide|Phases)\.md$",
|
|
63
|
+
re.I)
|
|
64
|
+
PHASE_FORM = re.compile(r"^(\d+)\s+of\s+(\d+)$", re.I)
|
|
65
|
+
PHASE_STATUS = {"planned", "building", "done"}
|
|
66
|
+
BRD_RANGE = re.compile(r"(?:BRD-)?(\d+)\s*(?:to|-|–|—)\s*(?:BRD-)?(\d+)", re.I)
|
|
67
|
+
|
|
68
|
+
SIZE_NAMES = {"s": "S", "small": "S", "m": "M", "medium": "M", "l": "L", "large": "L"}
|
|
69
|
+
SIZE_LONG = {"S": "Small", "M": "Medium", "L": "Large"}
|
|
70
|
+
REQ_CAP = {"S": 50, "M": 100, "L": 100}
|
|
71
|
+
|
|
72
|
+
CHECKLIST_HEADER = "| ID | Requirement | Status | % | Remarks | Details |"
|
|
73
|
+
STATUS_VALUES = {
|
|
74
|
+
"not started", "in progress", "implemented", "verified", "done (pre-existing)",
|
|
75
|
+
"needs re-verify", "partial", "fail", "blocked", "n/a", "owner-uat",
|
|
76
|
+
}
|
|
77
|
+
NOT_PRESENT = re.compile(r"\b(not|never)\s+(present|found|installed|available)\b|does not exist|no such file", re.I)
|
|
78
|
+
NAMES_PATH = re.compile(r"(?:\.tfcore|\.claude|\.opencode|docs|src|tests)/[\w./-]+|[\w-]+\.(?:sh|py|md|json|yaml|razor|cs)\b")
|
|
79
|
+
PERF_BUDGET = re.compile(
|
|
80
|
+
r"perf-budget:\s*(p50|p95|max)\s+(ttfb|load)\s*<=\s*\d+\s*ms(\s*@\s*concurrency\s+\d+)?", re.I
|
|
81
|
+
)
|
|
82
|
+
ACCEPT_LINE = re.compile(r"\*Acceptance:?\*|^\s*[-*]\s*Acceptance:", re.I)
|
|
83
|
+
ACCEPT_FORM = re.compile(
|
|
84
|
+
r"^\s*[-*]\s*\*?Acceptance:?\*?:?\s*(?:Given\b[^,]*,\s*)?When\b.+?,\s*then\b.+", re.I
|
|
85
|
+
)
|
|
86
|
+
NAMES_SCREEN = re.compile(r"\b(on|opens?|from|in)\b", re.I)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def acceptance_words(line: str) -> int:
|
|
90
|
+
"""Words in an acceptance line, without the "Acceptance:" label and without a perf-budget tail."""
|
|
91
|
+
t = re.sub(r"^\s*[-*]\s*\*?Acceptance:?\*?:?\s*", "", line)
|
|
92
|
+
t = re.split(r"perf-budget:", t, flags=re.I)[0]
|
|
93
|
+
return len(re.findall(r"\S+", t))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
ACC_TARGET, ACC_MAX = 20, 30 # owner, 2026-09-06 (miss 13): a line a person can read at a glance
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ----------------------------------------------------------------------------
|
|
100
|
+
# helpers
|
|
101
|
+
# ----------------------------------------------------------------------------
|
|
102
|
+
def norm_heading(text: str) -> str:
|
|
103
|
+
"""'## 3. Users and roles (all)' -> 'users and roles'."""
|
|
104
|
+
h = text.strip().rstrip(":")
|
|
105
|
+
h = re.sub(r"^[#\s]*", "", h)
|
|
106
|
+
h = re.sub(r"^(?:§\s*)?\d+(?:\.\d+)*[.)]?\s+", "", h) # leading numbering
|
|
107
|
+
h = re.sub(r"[\U0001F300-\U0001FAFF☀-➿]", "", h) # emoji
|
|
108
|
+
h = re.sub(r"\s*[—(].*$", "", h) # trailing qualifiers: "— …" or "(…)"
|
|
109
|
+
h = re.sub(r"\s+", " ", h).strip().lower()
|
|
110
|
+
return h
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def key_matches(declared_key: str, present_key: str) -> bool:
|
|
114
|
+
if declared_key.endswith("*"):
|
|
115
|
+
return present_key.startswith(declared_key[:-1].rstrip())
|
|
116
|
+
return declared_key == present_key
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def strip_comments(text: str) -> str:
|
|
120
|
+
return re.sub(r"<!--.*?-->", "", text, flags=re.S)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def strip_noise(text: str) -> str:
|
|
124
|
+
"""Remove HTML comments and fenced code blocks (they are not prose)."""
|
|
125
|
+
return re.sub(r"```.*?```", "", strip_comments(text), flags=re.S)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def word_count(text: str) -> int:
|
|
129
|
+
return len(re.findall(r"\S+", strip_noise(text)))
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def split_sections(body: str, level: int = 2):
|
|
133
|
+
"""Return [(heading_text or None, section_text)] split on headings of `level`."""
|
|
134
|
+
pat = re.compile(rf"(?m)^{'#' * level}\s+(.+)$")
|
|
135
|
+
out, last, last_head = [], 0, None
|
|
136
|
+
for m in pat.finditer(body):
|
|
137
|
+
out.append((last_head, body[last:m.start()]))
|
|
138
|
+
last_head, last = m.group(1).strip(), m.end()
|
|
139
|
+
out.append((last_head, body[last:]))
|
|
140
|
+
return out
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def parse_header(body: str) -> dict:
|
|
144
|
+
"""Header fields from YAML frontmatter and from the first `| Key | Value |` table."""
|
|
145
|
+
fields = {}
|
|
146
|
+
text = strip_comments(body).lstrip()
|
|
147
|
+
fm = re.match(r"^---\s*\n(.*?)\n---\s*\n", text, re.S)
|
|
148
|
+
if fm:
|
|
149
|
+
for line in fm.group(1).splitlines():
|
|
150
|
+
m = re.match(r"^\s*([A-Za-z_][\w -]*):\s*(.*?)\s*$", line)
|
|
151
|
+
if m:
|
|
152
|
+
fields[m.group(1).strip().lower()] = m.group(2).strip()
|
|
153
|
+
head = text.split("\n## ", 1)[0]
|
|
154
|
+
for m in re.finditer(r"(?m)^\|\s*([^|]+?)\s*\|\s*([^|]*?)\s*\|\s*$", head):
|
|
155
|
+
key, val = m.group(1).strip().lower(), m.group(2).strip()
|
|
156
|
+
if key and not set(key) <= set("-: ") and key not in fields:
|
|
157
|
+
fields[key] = val
|
|
158
|
+
return fields
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def is_placeholder(val: str) -> bool:
|
|
162
|
+
v = val.strip()
|
|
163
|
+
return v == "" or v.startswith("{") or v.startswith("<") or v in ("…", "...")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def tables_in(text: str):
|
|
167
|
+
"""Yield (header_cells, rows) for every markdown table in text."""
|
|
168
|
+
lines = strip_noise(text).splitlines()
|
|
169
|
+
i = 0
|
|
170
|
+
while i < len(lines) - 1:
|
|
171
|
+
if lines[i].lstrip().startswith("|") and re.match(r"^\s*\|?\s*:?-{2,}", lines[i + 1]):
|
|
172
|
+
header = [c.strip() for c in lines[i].strip().strip("|").split("|")]
|
|
173
|
+
rows, j = [], i + 2
|
|
174
|
+
while j < len(lines) and lines[j].lstrip().startswith("|"):
|
|
175
|
+
rows.append([c.strip() for c in lines[j].strip().strip("|").split("|")])
|
|
176
|
+
j += 1
|
|
177
|
+
yield header, rows
|
|
178
|
+
i = j
|
|
179
|
+
else:
|
|
180
|
+
i += 1
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def has_table_with(text: str, *cols: str) -> bool:
|
|
184
|
+
want = [c.lower() for c in cols]
|
|
185
|
+
for header, _rows in tables_in(text):
|
|
186
|
+
low = " | ".join(h.lower() for h in header)
|
|
187
|
+
if all(w in low for w in want):
|
|
188
|
+
return True
|
|
189
|
+
return False
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def links_to(text: str, folder: str):
|
|
193
|
+
"""All paths in the text that point into `folder` (mockups, screenshots)."""
|
|
194
|
+
return {m.group(1).rstrip(".,;:") for m in re.finditer(rf"((?:\./|\.\./|docs/)?{folder}/[^\s)\]\"'`>|]+)", text)}
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def resolve(root: str, doc_path: str, link: str) -> bool:
|
|
198
|
+
link = link.split("#", 1)[0]
|
|
199
|
+
cands = [os.path.join(root, link), os.path.join(os.path.dirname(doc_path), link), os.path.join(root, "docs", link)]
|
|
200
|
+
return any(os.path.exists(os.path.normpath(c)) for c in cands)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# ----------------------------------------------------------------------------
|
|
204
|
+
# schema
|
|
205
|
+
# ----------------------------------------------------------------------------
|
|
206
|
+
class Schema:
|
|
207
|
+
def __init__(self, text: str):
|
|
208
|
+
self.doc = self.file = None
|
|
209
|
+
self.header = []
|
|
210
|
+
self.sections = [] # (name, flag, max_words)
|
|
211
|
+
self.budget = {} # size -> (target, max)
|
|
212
|
+
self.entries = None # (section name, h3 prefix)
|
|
213
|
+
self.per_entry = None # (target, max)
|
|
214
|
+
self.max_lines = self.target_lines = None
|
|
215
|
+
self.rules = []
|
|
216
|
+
m = re.search(r"<!--\s*tf-schema\s*\n(.*?)-->", text, re.S)
|
|
217
|
+
if not m:
|
|
218
|
+
raise ValueError("no tf-schema block")
|
|
219
|
+
for raw in m.group(1).splitlines():
|
|
220
|
+
line = raw.strip()
|
|
221
|
+
if not line or line.startswith("#") or ":" not in line:
|
|
222
|
+
continue
|
|
223
|
+
key, val = line.split(":", 1)
|
|
224
|
+
key, val = key.strip().lower(), val.strip()
|
|
225
|
+
if key == "doc":
|
|
226
|
+
self.doc = val
|
|
227
|
+
elif key == "file":
|
|
228
|
+
self.file = val
|
|
229
|
+
elif key == "header":
|
|
230
|
+
self.header = [h.strip() for h in val.split(",") if h.strip()]
|
|
231
|
+
elif key == "section":
|
|
232
|
+
parts = [p.strip() for p in val.split("|")]
|
|
233
|
+
flag = parts[1].lower() if len(parts) > 1 and parts[1] else "required"
|
|
234
|
+
mx = None
|
|
235
|
+
for p in parts[2:]:
|
|
236
|
+
mm = re.match(r"max\s+(\d+)", p, re.I)
|
|
237
|
+
if mm:
|
|
238
|
+
mx = int(mm.group(1))
|
|
239
|
+
self.sections.append((parts[0], flag, mx))
|
|
240
|
+
elif key == "budget":
|
|
241
|
+
for part in val.split("|"):
|
|
242
|
+
bits = part.split()
|
|
243
|
+
if len(bits) == 3:
|
|
244
|
+
self.budget[bits[0].upper()] = (int(bits[1]), int(bits[2]))
|
|
245
|
+
elif key == "entries":
|
|
246
|
+
parts = [p.strip() for p in val.split("|")]
|
|
247
|
+
self.entries = (parts[0], parts[1] if len(parts) > 1 else "")
|
|
248
|
+
elif key == "per-entry":
|
|
249
|
+
bits = val.split()
|
|
250
|
+
self.per_entry = (int(bits[0]), int(bits[1]))
|
|
251
|
+
elif key == "max-lines":
|
|
252
|
+
self.max_lines = int(val)
|
|
253
|
+
elif key == "target-lines":
|
|
254
|
+
self.target_lines = int(val)
|
|
255
|
+
elif key == "rule":
|
|
256
|
+
self.rules.append(val.lower())
|
|
257
|
+
|
|
258
|
+
def declared(self, present_key: str):
|
|
259
|
+
for name, flag, mx in self.sections:
|
|
260
|
+
if key_matches(norm_heading(name), present_key):
|
|
261
|
+
return name, flag, mx
|
|
262
|
+
return None
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def load_schema(template_file: str) -> Schema:
|
|
266
|
+
with open(os.path.join(TEMPLATE_DIR, template_file), encoding="utf-8") as fh:
|
|
267
|
+
return Schema(fh.read())
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
# ----------------------------------------------------------------------------
|
|
271
|
+
# reporting and context
|
|
272
|
+
# ----------------------------------------------------------------------------
|
|
273
|
+
class Report:
|
|
274
|
+
def __init__(self, warn_only: bool):
|
|
275
|
+
self.warn_only = warn_only
|
|
276
|
+
self.fails = self.warns = 0
|
|
277
|
+
self.lines = []
|
|
278
|
+
|
|
279
|
+
def fail(self, path, msg):
|
|
280
|
+
if self.warn_only:
|
|
281
|
+
self.warns += 1
|
|
282
|
+
self.lines.append(f"WARN {path}: {msg}")
|
|
283
|
+
else:
|
|
284
|
+
self.fails += 1
|
|
285
|
+
self.lines.append(f"FAIL {path}: {msg}")
|
|
286
|
+
|
|
287
|
+
def warn(self, path, msg):
|
|
288
|
+
self.warns += 1
|
|
289
|
+
self.lines.append(f"WARN {path}: {msg}")
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def detect_kind(path: str):
|
|
293
|
+
name = os.path.basename(path).lower()
|
|
294
|
+
if re.search(r"-deployment-checklist(-[\w]+)?\.md$", name):
|
|
295
|
+
return "deployment-checklist", "app-deployment-checklist-tmpl.md"
|
|
296
|
+
for suffix, doc, tmpl in DOC_KINDS:
|
|
297
|
+
if name.endswith(suffix):
|
|
298
|
+
return doc, tmpl
|
|
299
|
+
return None, None
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def find_root(path: str) -> str:
|
|
303
|
+
d = os.path.dirname(os.path.abspath(path))
|
|
304
|
+
while True:
|
|
305
|
+
if os.path.isdir(os.path.join(d, ".tfcore")) or os.path.isdir(os.path.join(d, "docs")):
|
|
306
|
+
return d
|
|
307
|
+
parent = os.path.dirname(d)
|
|
308
|
+
if parent == d:
|
|
309
|
+
return os.path.dirname(os.path.abspath(path))
|
|
310
|
+
d = parent
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def read_core_config(root: str) -> dict:
|
|
314
|
+
out = {}
|
|
315
|
+
p = os.path.join(root, ".tfcore", "core-config.yaml")
|
|
316
|
+
if os.path.exists(p):
|
|
317
|
+
with open(p, encoding="utf-8") as fh:
|
|
318
|
+
for line in fh:
|
|
319
|
+
m = re.match(r"^(appSize|appKind|appPhase):\s*(\S+)", line)
|
|
320
|
+
if m and m.group(2) not in ("null", "~", "''", '""'):
|
|
321
|
+
out[m.group(1)] = m.group(2).strip("'\"")
|
|
322
|
+
return out
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def split_name(path: str):
|
|
326
|
+
"""'docs/MyApp-P2-BRD.md' -> ('MyApp', 2); 'docs/MyApp-BRD.md' -> ('MyApp', 1); other -> (None, 1)."""
|
|
327
|
+
m = DOC_NAME.match(os.path.basename(path))
|
|
328
|
+
if not m:
|
|
329
|
+
return None, 1
|
|
330
|
+
return m.group(1), int(m.group(2) or 1)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def phase_file(app: str, phase: int, suffix: str) -> str:
|
|
334
|
+
return f"{app}-{suffix}.md" if phase <= 1 else f"{app}-P{phase}-{suffix}.md"
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def sibling_brd_header(path: str) -> dict:
|
|
338
|
+
"""Size/Kind from docs/<App>-BRD.md when another document of the app lacks them."""
|
|
339
|
+
base = os.path.basename(path)
|
|
340
|
+
m = re.match(r"(.+?)-(Architecture|UIDesign|Checklist|Coding-Standards|UsageGuide|Usage-Guide|DevGuide|ProductGuide)\.md$", base, re.I)
|
|
341
|
+
if not m:
|
|
342
|
+
return {}
|
|
343
|
+
brd = os.path.join(os.path.dirname(path), f"{m.group(1)}-BRD.md")
|
|
344
|
+
if not os.path.exists(brd):
|
|
345
|
+
return {}
|
|
346
|
+
with open(brd, encoding="utf-8") as fh:
|
|
347
|
+
return parse_header(fh.read())
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def resolve_size(header, brd_header, cfg, cli_size, rep, rel, need):
|
|
351
|
+
raw = header.get("size") or brd_header.get("size") or cfg.get("appSize") or cli_size
|
|
352
|
+
if raw:
|
|
353
|
+
size = SIZE_NAMES.get(str(raw).strip().lower())
|
|
354
|
+
if size:
|
|
355
|
+
return size
|
|
356
|
+
if need:
|
|
357
|
+
rep.warn(rel, f'Size "{raw}" is not Small, Medium or Large; Small assumed')
|
|
358
|
+
elif need:
|
|
359
|
+
rep.warn(rel, "no Size recorded (document header, the BRD header, or appSize in core-config.yaml); Small assumed")
|
|
360
|
+
return "S"
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def resolve_kind(header, brd_header, cfg) -> str:
|
|
364
|
+
raw = (header.get("kind") or brd_header.get("kind") or cfg.get("appKind") or "app").strip().lower()
|
|
365
|
+
return "library" if raw.startswith("lib") else "app"
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def section_text(present, name):
|
|
369
|
+
key = norm_heading(name)
|
|
370
|
+
return next((txt for k, _h, txt in present if key_matches(key, k)), None)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
# ----------------------------------------------------------------------------
|
|
374
|
+
# the checker
|
|
375
|
+
# ----------------------------------------------------------------------------
|
|
376
|
+
def check_document(path: str, rep: Report, cli_size=None, root=None):
|
|
377
|
+
doc, tmpl = detect_kind(path)
|
|
378
|
+
if not doc:
|
|
379
|
+
rep.warn(path, "not a TechieFlow document name; skipped")
|
|
380
|
+
return None
|
|
381
|
+
try:
|
|
382
|
+
schema = load_schema(tmpl)
|
|
383
|
+
except (OSError, ValueError) as e:
|
|
384
|
+
rep.warn(path, f"template {tmpl} has no usable tf-schema block ({e}); skipped")
|
|
385
|
+
return None
|
|
386
|
+
with open(path, encoding="utf-8") as fh:
|
|
387
|
+
body = fh.read()
|
|
388
|
+
root = root or find_root(path)
|
|
389
|
+
rel = os.path.relpath(path, root)
|
|
390
|
+
cfg = read_core_config(root)
|
|
391
|
+
header = parse_header(body)
|
|
392
|
+
brd_header = sibling_brd_header(path) if doc != "brd" else {}
|
|
393
|
+
size = resolve_size(header, brd_header, cfg, cli_size, rep, rel, doc in SIZED_DOCS)
|
|
394
|
+
kind = resolve_kind(header, brd_header, cfg)
|
|
395
|
+
clean = strip_noise(body)
|
|
396
|
+
nocomment = strip_comments(body)
|
|
397
|
+
|
|
398
|
+
# 1. header fields
|
|
399
|
+
for field in schema.header:
|
|
400
|
+
val = header.get(field.lower())
|
|
401
|
+
if val is None:
|
|
402
|
+
rep.fail(rel, f'header field "{field}" is missing')
|
|
403
|
+
elif is_placeholder(val):
|
|
404
|
+
rep.fail(rel, f'header field "{field}" is still a placeholder')
|
|
405
|
+
|
|
406
|
+
# 1b. the Phase field of a phased document (Large projects; Schemas §2)
|
|
407
|
+
app, phase = split_name(path)
|
|
408
|
+
if doc in PHASED_DOCS and app:
|
|
409
|
+
phases_doc = os.path.join(os.path.dirname(path), f"{app}-Phases.md")
|
|
410
|
+
pval = header.get("phase")
|
|
411
|
+
if pval is not None and is_placeholder(pval):
|
|
412
|
+
rep.fail(rel, 'header field "Phase" is still a placeholder; write "1 of 3", or delete the row when the project has no phases')
|
|
413
|
+
elif pval is not None:
|
|
414
|
+
m = PHASE_FORM.match(pval.strip("`* "))
|
|
415
|
+
if not m:
|
|
416
|
+
rep.fail(rel, f'header field "Phase" reads "{pval}"; it must read "<n> of <m>", for example "2 of 3"')
|
|
417
|
+
elif int(m.group(1)) != phase:
|
|
418
|
+
rep.fail(rel, f'header field "Phase" says {m.group(1)} but the file name says phase {phase} (phase 1 is {app}-{PHASED_SUFFIX[doc]}.md; phase n is {app}-Pn-{PHASED_SUFFIX[doc]}.md)')
|
|
419
|
+
elif phase > 1:
|
|
420
|
+
rep.fail(rel, f'a phase-{phase} file needs the header row "Phase | {phase} of <m>"')
|
|
421
|
+
elif os.path.exists(phases_doc):
|
|
422
|
+
rep.fail(rel, f'the project has phases ({os.path.relpath(phases_doc, root)}); this file needs the header row "Phase | 1 of <m>"')
|
|
423
|
+
|
|
424
|
+
# 2. sections: strangers, order, presence
|
|
425
|
+
present = [(norm_heading(h), h, txt) for h, txt in split_sections(clean, 2) if h is not None]
|
|
426
|
+
always_ok = {"table of contents"} | ({"recovery note"} if doc == "project-status" else set())
|
|
427
|
+
for key, h, txt in present:
|
|
428
|
+
if schema.declared(key) or key in always_ok:
|
|
429
|
+
continue
|
|
430
|
+
if doc == "checklist" and re.search(r"<a id=['\"]d-req-", txt, re.I):
|
|
431
|
+
continue # a page group holding detail entries
|
|
432
|
+
extra = "; bugs and feedback go to the misses stream, not the checklist" if doc == "checklist" else ""
|
|
433
|
+
rep.fail(rel, f'section "{h}" is not in the template{extra}')
|
|
434
|
+
|
|
435
|
+
order_of = {norm_heading(n): i for i, (n, _f, _m) in enumerate(schema.sections)}
|
|
436
|
+
last_idx, last_name = -1, None
|
|
437
|
+
for key, h, _txt in present:
|
|
438
|
+
d = schema.declared(key)
|
|
439
|
+
if not d:
|
|
440
|
+
continue
|
|
441
|
+
idx = order_of[norm_heading(d[0])]
|
|
442
|
+
if idx < last_idx:
|
|
443
|
+
rep.fail(rel, f'section "{h}" comes after "{last_name}"; the template order is the other way round')
|
|
444
|
+
else:
|
|
445
|
+
last_idx, last_name = idx, h
|
|
446
|
+
|
|
447
|
+
present_keys = [k for k, _h, _t in present]
|
|
448
|
+
for name, flag, _mx in schema.sections:
|
|
449
|
+
key = norm_heading(name)
|
|
450
|
+
needed = (flag == "required" or (flag == "optional-small" and size in ("M", "L"))
|
|
451
|
+
or (flag == "app" and kind == "app") or (flag == "library" and kind == "library"))
|
|
452
|
+
if needed and not any(key_matches(key, k) for k in present_keys):
|
|
453
|
+
why = "" if flag == "required" else f" (required for {SIZE_LONG[size] if flag == 'optional-small' else kind})"
|
|
454
|
+
rep.fail(rel, f'section "{name.rstrip("*")}" is missing{why}')
|
|
455
|
+
|
|
456
|
+
# 3. budgets
|
|
457
|
+
total = word_count(body)
|
|
458
|
+
if size in schema.budget:
|
|
459
|
+
target, mx = schema.budget[size]
|
|
460
|
+
if total > mx:
|
|
461
|
+
rep.fail(rel, f"{total:,} words; the {SIZE_LONG[size]} maximum is {mx:,} (target {target:,}). Shorten prose; never drop a screen, field or requirement to fit")
|
|
462
|
+
elif total > target:
|
|
463
|
+
rep.warn(rel, f"{total:,} words; the {SIZE_LONG[size]} target is {target:,} (maximum {mx:,})")
|
|
464
|
+
for key, h, txt in present:
|
|
465
|
+
d = schema.declared(key)
|
|
466
|
+
if d and d[2]:
|
|
467
|
+
n = word_count(txt)
|
|
468
|
+
if n > d[2]:
|
|
469
|
+
rep.fail(rel, f'section "{h}" is {n} words; at most {d[2]}')
|
|
470
|
+
if schema.max_lines:
|
|
471
|
+
n_lines = nocomment.strip().count("\n") + 1
|
|
472
|
+
if n_lines > schema.max_lines:
|
|
473
|
+
rep.fail(rel, f"{n_lines} lines; at most {schema.max_lines}")
|
|
474
|
+
elif schema.target_lines and n_lines > schema.target_lines:
|
|
475
|
+
rep.warn(rel, f"{n_lines} lines; the target is {schema.target_lines} (maximum {schema.max_lines})")
|
|
476
|
+
|
|
477
|
+
# 4. per-entry checks (screens, tasks, components)
|
|
478
|
+
entry_names = []
|
|
479
|
+
if schema.entries:
|
|
480
|
+
sec_txt = section_text(present, schema.entries[0])
|
|
481
|
+
prefix = schema.entries[1]
|
|
482
|
+
if sec_txt is not None:
|
|
483
|
+
for h3, etxt in split_sections(sec_txt, 3):
|
|
484
|
+
if h3 is None:
|
|
485
|
+
continue
|
|
486
|
+
if prefix and not h3.lower().startswith(prefix.lower()):
|
|
487
|
+
rep.fail(rel, f'entry "{h3}" must start with "{prefix}"')
|
|
488
|
+
continue
|
|
489
|
+
ename = h3[len(prefix):].strip() if prefix else h3
|
|
490
|
+
ename = re.sub(r"\s*\(.*$", "", ename).strip("` ").strip()
|
|
491
|
+
entry_names.append(ename)
|
|
492
|
+
if schema.per_entry:
|
|
493
|
+
n = word_count(etxt)
|
|
494
|
+
if n > schema.per_entry[1]:
|
|
495
|
+
rep.fail(rel, f'entry "{ename}" is {n} words; maximum {schema.per_entry[1]} (target {schema.per_entry[0]})')
|
|
496
|
+
elif n > schema.per_entry[0]:
|
|
497
|
+
rep.warn(rel, f'entry "{ename}" is {n} words; target {schema.per_entry[0]} (maximum {schema.per_entry[1]})')
|
|
498
|
+
for rule in schema.rules:
|
|
499
|
+
if rule.startswith("entry-"):
|
|
500
|
+
check_entry_rule(rule, ename, etxt, rel, root, path, rep)
|
|
501
|
+
if not entry_names and kind == "app":
|
|
502
|
+
rep.fail(rel, f'section "{schema.entries[0].rstrip("*")}" has no "###" entries')
|
|
503
|
+
|
|
504
|
+
# 5. document rules
|
|
505
|
+
ctx = dict(doc=doc, body=body, clean=clean, nocomment=nocomment, present=present, header=header,
|
|
506
|
+
size=size, kind=kind, root=root, path=path, rel=rel, entry_names=entry_names,
|
|
507
|
+
app=app, phase=phase)
|
|
508
|
+
for rule in schema.rules:
|
|
509
|
+
if not rule.startswith("entry-"):
|
|
510
|
+
check_doc_rule(rule, ctx, rep)
|
|
511
|
+
return ctx
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def check_entry_rule(rule, ename, etxt, rel, root, path, rep):
|
|
515
|
+
if rule == "entry-mockup":
|
|
516
|
+
links = links_to(etxt, "mockups")
|
|
517
|
+
if not links:
|
|
518
|
+
rep.fail(rel, f'screen "{ename}" has no mockup link (docs/mockups/<screen>.html)')
|
|
519
|
+
for l in sorted(links):
|
|
520
|
+
if not resolve(root, path, l):
|
|
521
|
+
rep.fail(rel, f'screen "{ename}" links mockup {l}, which does not exist')
|
|
522
|
+
elif rule == "entry-screenshot":
|
|
523
|
+
imgs = re.findall(r"!\[[^\]]*\]\(([^)]+)\)", etxt)
|
|
524
|
+
if not imgs:
|
|
525
|
+
rep.fail(rel, f'entry "{ename}" has no screenshot image')
|
|
526
|
+
for l in imgs:
|
|
527
|
+
if not resolve(root, path, l):
|
|
528
|
+
rep.fail(rel, f'entry "{ename}" screenshot {l} does not exist')
|
|
529
|
+
elif rule == "entry-fields-table":
|
|
530
|
+
if not has_table_with(etxt, "field", "type"):
|
|
531
|
+
rep.fail(rel, f'screen "{ename}" has no fields table (Field, Type, Required, Validation)')
|
|
532
|
+
elif rule == "entry-regions-table":
|
|
533
|
+
if not has_table_with(etxt, "region", "control"):
|
|
534
|
+
rep.fail(rel, f'screen "{ename}" has no regions-to-controls table')
|
|
535
|
+
elif rule == "entry-states":
|
|
536
|
+
low = etxt.lower()
|
|
537
|
+
missing = [s for s in ("empty", "loading", "error") if s not in low]
|
|
538
|
+
if missing:
|
|
539
|
+
rep.fail(rel, f'screen "{ename}" does not say what it shows when {", ".join(missing)}')
|
|
540
|
+
elif rule == "entry-break-table":
|
|
541
|
+
if not has_table_with(etxt, "file", "function", "watch", "expected"):
|
|
542
|
+
rep.fail(rel, f'entry "{ename}" has no where-to-break table (File and line, Function, Watch, Expected value)')
|
|
543
|
+
elif rule == "entry-call-chain":
|
|
544
|
+
if not re.search(r"(?im)^\**call chain:?\**", etxt):
|
|
545
|
+
rep.fail(rel, f'entry "{ename}" has no "Call chain:" line')
|
|
546
|
+
elif rule == "entry-steps":
|
|
547
|
+
if not re.search(r"(?mi)^\s*(?:[-*]\s*[*_]*steps?[*_:]*\s*)?1[.)]\s+\S", etxt):
|
|
548
|
+
rep.fail(rel, f'entry "{ename}" has no numbered steps')
|
|
549
|
+
elif rule == "entry-expected":
|
|
550
|
+
if not re.search(r"(?im)^\s*[-*]?\s*\**expected", etxt):
|
|
551
|
+
rep.fail(rel, f'entry "{ename}" has no "Expected:" line')
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def check_doc_rule(rule, c, rep):
|
|
555
|
+
rel, root, path, body, clean, nocomment, present, size = (
|
|
556
|
+
c["rel"], c["root"], c["path"], c["body"], c["clean"], c["nocomment"], c["present"], c["size"])
|
|
557
|
+
|
|
558
|
+
if rule == "brd-ledger":
|
|
559
|
+
ids = re.findall(r"\*\*BRD-(\d+)\*\*", clean)
|
|
560
|
+
# the Non-functional table carries ids too (| BRD-31 | Performance | … |); they count,
|
|
561
|
+
# they need checklist rows, and they must sit inside the phase's range
|
|
562
|
+
nfr = section_text(present, "Non-functional requirements") or ""
|
|
563
|
+
ids += re.findall(r"(?m)^\s*\|\s*`?\**BRD-(\d+)", nfr)
|
|
564
|
+
if not ids:
|
|
565
|
+
rep.fail(rel, "no **BRD-N** items found in the Requirements section")
|
|
566
|
+
return
|
|
567
|
+
seen, dupes = set(), set()
|
|
568
|
+
for i in ids:
|
|
569
|
+
(dupes if i in seen else seen).add(i)
|
|
570
|
+
if dupes:
|
|
571
|
+
rep.fail(rel, "duplicate requirement ids: " + ", ".join(f"BRD-{d}" for d in sorted(dupes, key=int)))
|
|
572
|
+
if len(seen) > REQ_CAP[size]:
|
|
573
|
+
rep.fail(rel, f"{len(seen)} requirements; the {SIZE_LONG[size]} cap is {REQ_CAP[size]}. Split into phases (each phase its own BRD) instead of growing this one")
|
|
574
|
+
c["brd_ids"] = seen
|
|
575
|
+
req = section_text(present, "Requirements") or ""
|
|
576
|
+
cur = None
|
|
577
|
+
for line in req.splitlines():
|
|
578
|
+
m = re.search(r"\*\*BRD-(\d+)\*\*", line)
|
|
579
|
+
if m:
|
|
580
|
+
cur = m.group(1)
|
|
581
|
+
if cur and ACCEPT_LINE.search(line):
|
|
582
|
+
n_acc = acceptance_words(line)
|
|
583
|
+
if n_acc > ACC_MAX:
|
|
584
|
+
rep.fail(rel, f"BRD-{cur} acceptance line is {n_acc} words; at most {ACC_MAX} (target {ACC_TARGET}). One behaviour per item: split it, do not bundle steps")
|
|
585
|
+
elif n_acc > ACC_TARGET:
|
|
586
|
+
rep.warn(rel, f"BRD-{cur} acceptance line is {n_acc} words; the target is {ACC_TARGET} (maximum {ACC_MAX})")
|
|
587
|
+
|
|
588
|
+
elif rule == "mockup-links":
|
|
589
|
+
for l in sorted(links_to(clean, "mockups")):
|
|
590
|
+
if not resolve(root, path, l):
|
|
591
|
+
rep.fail(rel, f"mockup link {l} points at a file that does not exist")
|
|
592
|
+
|
|
593
|
+
elif rule == "screens-table":
|
|
594
|
+
txt = section_text(present, "Screens and flow")
|
|
595
|
+
if txt is None:
|
|
596
|
+
return
|
|
597
|
+
if not has_table_with(txt, "screen", "route", "mockup"):
|
|
598
|
+
rep.fail(rel, 'the "Screens and flow" table needs the columns Screen, Route, Role, Mockup, Fields')
|
|
599
|
+
return
|
|
600
|
+
names = []
|
|
601
|
+
for header, rows in tables_in(txt):
|
|
602
|
+
low = [h.lower() for h in header]
|
|
603
|
+
if "screen" in low and "route" in low:
|
|
604
|
+
sc, rc = low.index("screen"), low.index("route")
|
|
605
|
+
for r in rows:
|
|
606
|
+
if len(r) <= max(sc, rc) or not r[sc] or r[sc].startswith("{"):
|
|
607
|
+
continue
|
|
608
|
+
if "dialog" in r[sc].lower() or r[rc].strip("` ").lower().startswith("on "):
|
|
609
|
+
continue
|
|
610
|
+
names.append(r[sc])
|
|
611
|
+
c["brd_screens"] = names
|
|
612
|
+
|
|
613
|
+
elif rule == "stack-table":
|
|
614
|
+
txt = section_text(present, "Stack decisions")
|
|
615
|
+
if txt is not None and not any(True for _ in tables_in(txt)):
|
|
616
|
+
rep.fail(rel, 'the "Stack decisions" section needs a table (one row per stack question)')
|
|
617
|
+
|
|
618
|
+
elif rule == "solution-table":
|
|
619
|
+
txt = section_text(present, "Solution structure")
|
|
620
|
+
if txt is not None and not has_table_with(txt, "project", "kind"):
|
|
621
|
+
rep.fail(rel, 'the "Solution structure" table needs the columns Project, Kind, Purpose')
|
|
622
|
+
|
|
623
|
+
elif rule == "stack-rows":
|
|
624
|
+
# one row per stack question; Q9 and Q10 (hosting, production secrets) are decided after UAT
|
|
625
|
+
txt = section_text(present, "Stack decisions")
|
|
626
|
+
if txt is None:
|
|
627
|
+
return
|
|
628
|
+
have = set()
|
|
629
|
+
for _h, rows in tables_in(txt):
|
|
630
|
+
for r in rows:
|
|
631
|
+
m = re.match(r"q\s*(\d+)", r[0].strip("`* ").lower()) if r else None
|
|
632
|
+
if m:
|
|
633
|
+
have.add(int(m.group(1)))
|
|
634
|
+
missing = [q for q in (1, 2, 3, 4, 5, 6, 7, 8, 11) if q not in have]
|
|
635
|
+
if missing:
|
|
636
|
+
rep.fail(rel, "the Stack decisions table has no row for " + ", ".join(f"Q{q}" for q in missing)
|
|
637
|
+
+ "; one row per stack question, citing its source (Q9 and Q10 are decided after UAT)")
|
|
638
|
+
|
|
639
|
+
elif rule == "head-project":
|
|
640
|
+
# an app's primary head project is named exactly <App>; <App>.App is refused (Stack Q7, owner 2026-09-06)
|
|
641
|
+
txt = section_text(present, "Solution structure")
|
|
642
|
+
app = c.get("app")
|
|
643
|
+
if txt is None or c["kind"] != "app" or not app:
|
|
644
|
+
return
|
|
645
|
+
names = [r[0].strip("`* ") for _h, rows in tables_in(txt) for r in rows if r and r[0].strip()]
|
|
646
|
+
names = [n for n in names if not is_placeholder(n)]
|
|
647
|
+
bad = [n for n in names if re.fullmatch(rf"{re.escape(app)}\.App", n, re.I)]
|
|
648
|
+
if bad:
|
|
649
|
+
rep.fail(rel, f'project "{bad[0]}" is named <App>.App; the primary head is named exactly "{app}" (Stack Q7)')
|
|
650
|
+
elif names and not any(n.lower() == app.lower() for n in names):
|
|
651
|
+
rep.fail(rel, f'no project named exactly "{app}" in the Solution structure; the primary head carries the product name (Stack Q7)')
|
|
652
|
+
|
|
653
|
+
elif rule == "er-diagram":
|
|
654
|
+
if section_text(present, "Data model") is not None and not re.search(r"```mermaid\s*\n\s*erDiagram", nocomment):
|
|
655
|
+
rep.fail(rel, 'the "Data model" section needs a mermaid erDiagram')
|
|
656
|
+
|
|
657
|
+
elif rule == "decisions-log":
|
|
658
|
+
txt = section_text(present, "Decisions log")
|
|
659
|
+
if txt is not None and not has_table_with(txt, "date", "decision", "why", "status"):
|
|
660
|
+
rep.fail(rel, 'the "Decisions log" table needs the columns Date, Decision, Why, Status')
|
|
661
|
+
|
|
662
|
+
elif rule == "request-flow":
|
|
663
|
+
txt = section_text(present, "Component map")
|
|
664
|
+
if txt is not None and not re.search(r"(?im)^\**how a request travels", txt):
|
|
665
|
+
rep.fail(rel, 'the "Component map" section needs a "How a request travels" numbered list')
|
|
666
|
+
|
|
667
|
+
elif rule == "execution-code":
|
|
668
|
+
seg = re.search(r"(?ms)^##\s+(?:\d+[.)]\s+)?Execution guide.*?\n(.*?)(?=^## |\Z)", nocomment)
|
|
669
|
+
if seg and "```" not in seg.group(1):
|
|
670
|
+
rep.fail(rel, 'the "Execution guide" needs the start commands in a code block')
|
|
671
|
+
|
|
672
|
+
elif rule == "test-users-table":
|
|
673
|
+
txt = section_text(present, "Test users")
|
|
674
|
+
if txt is not None and not has_table_with(txt, "user", "role"):
|
|
675
|
+
rep.fail(rel, 'the "Test users" section needs a table with User and Role columns')
|
|
676
|
+
|
|
677
|
+
elif rule == "next-command-blocks":
|
|
678
|
+
seg = re.search(r"(?ms)^## Next command to run\s*\n(.*?)(?=^## |\Z)", nocomment)
|
|
679
|
+
if not seg:
|
|
680
|
+
return
|
|
681
|
+
txt = seg.group(1)
|
|
682
|
+
blocks = re.findall(r"```[^\n]*\n(.*?)```", txt, re.S)
|
|
683
|
+
if len(blocks) != 2:
|
|
684
|
+
rep.fail(rel, f'"Next command to run" must hold exactly two code blocks, Claude Code then OpenCode; found {len(blocks)}')
|
|
685
|
+
return
|
|
686
|
+
for label, blk in zip(("Claude Code", "OpenCode"), blocks):
|
|
687
|
+
lines = [l for l in blk.splitlines() if l.strip()]
|
|
688
|
+
if len(lines) != 1:
|
|
689
|
+
rep.fail(rel, f"the {label} command block must be one line; found {len(lines)}")
|
|
690
|
+
parts = txt.split("```")
|
|
691
|
+
if "claude code" not in parts[0].lower():
|
|
692
|
+
rep.fail(rel, 'the first command block must be labelled "Claude Code" on the line before it')
|
|
693
|
+
if len(parts) > 2 and "opencode" not in parts[2].lower():
|
|
694
|
+
rep.fail(rel, 'the second command block must be labelled "OpenCode" on the line before it')
|
|
695
|
+
|
|
696
|
+
elif rule == "verification-log":
|
|
697
|
+
txt = section_text(present, "Verification log")
|
|
698
|
+
if txt is None:
|
|
699
|
+
return
|
|
700
|
+
for _h, rows in tables_in(txt):
|
|
701
|
+
if len(rows) > 5:
|
|
702
|
+
rep.fail(rel, f"the Verification log holds {len(rows)} rows; keep the last five, the rest lives in gates.jsonl and runs.jsonl")
|
|
703
|
+
for r in rows:
|
|
704
|
+
for cell in r:
|
|
705
|
+
n = len(re.findall(r"\S+", cell))
|
|
706
|
+
if n > 20:
|
|
707
|
+
rep.fail(rel, f"a Verification log cell is {n} words; at most 20, a result is a count not a story")
|
|
708
|
+
break
|
|
709
|
+
|
|
710
|
+
elif rule == "open-requirements-max-10":
|
|
711
|
+
txt = section_text(present, "Open requirements")
|
|
712
|
+
if txt is not None:
|
|
713
|
+
n = len(re.findall(r"(?m)^\s*[-*]\s*\[?[ x]?\]?\s*REQ-", txt))
|
|
714
|
+
if n > 10:
|
|
715
|
+
rep.fail(rel, f"Open requirements names {n} rows; show counts by status and at most ten named rows")
|
|
716
|
+
|
|
717
|
+
elif rule == "checklist-rows":
|
|
718
|
+
check_checklist(c, rep)
|
|
719
|
+
|
|
720
|
+
elif rule == "standards-pointer":
|
|
721
|
+
if ".tfcore/standards/" not in body:
|
|
722
|
+
rep.fail(rel, "must name the framework standard files it applies (.tfcore/standards/...)")
|
|
723
|
+
|
|
724
|
+
elif rule == "deploy-who-table":
|
|
725
|
+
txt = section_text(present, "Who does what")
|
|
726
|
+
if txt is not None and not has_table_with(txt, "step", "done by"):
|
|
727
|
+
rep.fail(rel, 'the "Who does what" section needs a table with the columns Step and Done by')
|
|
728
|
+
|
|
729
|
+
elif rule == "deploy-secrets-once":
|
|
730
|
+
txt = section_text(present, "Secrets and settings")
|
|
731
|
+
if txt is None:
|
|
732
|
+
return
|
|
733
|
+
names = []
|
|
734
|
+
for header, rows in tables_in(txt):
|
|
735
|
+
low = [h.lower() for h in header]
|
|
736
|
+
if "name" in low:
|
|
737
|
+
names += [r[low.index("name")].strip("`* ") for r in rows if r and r[low.index("name")].strip()]
|
|
738
|
+
if not names:
|
|
739
|
+
rep.fail(rel, 'the "Secrets and settings" table needs the columns Name, Where it is set, What breaks without it, and at least one row')
|
|
740
|
+
return
|
|
741
|
+
dupes = sorted({n for n in names if names.count(n) > 1})
|
|
742
|
+
if dupes:
|
|
743
|
+
rep.fail(rel, "a secret or setting is listed more than once: " + ", ".join(dupes))
|
|
744
|
+
# named again as the first cell of a row in any other table: re-described elsewhere
|
|
745
|
+
for key, h, other in present:
|
|
746
|
+
if key_matches(norm_heading("Secrets and settings"), key):
|
|
747
|
+
continue
|
|
748
|
+
for _hdr, rows in tables_in(other):
|
|
749
|
+
for r in rows:
|
|
750
|
+
if r and r[0].strip("`* ") in names:
|
|
751
|
+
rep.fail(rel, f'"{r[0].strip("`* ")}" is described again under "{h}"; each secret or setting appears in exactly one row of Secrets and settings')
|
|
752
|
+
|
|
753
|
+
elif rule == "deploy-checkboxes":
|
|
754
|
+
for name in ("Before the first deploy", "Deploy", "After the deploy", "Rollback"):
|
|
755
|
+
txt = section_text(present, name)
|
|
756
|
+
if txt is None:
|
|
757
|
+
continue
|
|
758
|
+
boxes = 0
|
|
759
|
+
for line in txt.splitlines():
|
|
760
|
+
t = line.strip()
|
|
761
|
+
if not t or t.startswith("|") or t.startswith("<"):
|
|
762
|
+
continue
|
|
763
|
+
if re.match(r"^[-*]\s*\[[ xX]\]\s+\S", t):
|
|
764
|
+
boxes += 1
|
|
765
|
+
elif name == "Rollback" and re.match(r"^A rollback does not undo", t, re.I):
|
|
766
|
+
continue
|
|
767
|
+
else:
|
|
768
|
+
rep.fail(rel, f'"{name}" holds a line that is not a checkbox: "{t[:60]}"; every item is "- [ ] action — result", no narrative')
|
|
769
|
+
if boxes == 0:
|
|
770
|
+
rep.fail(rel, f'"{name}" has no checkbox items')
|
|
771
|
+
if name == "Rollback" and not re.search(r"(?im)^A rollback does not undo", txt):
|
|
772
|
+
rep.fail(rel, 'the "Rollback" section needs the line "A rollback does not undo: …"')
|
|
773
|
+
|
|
774
|
+
elif rule == "deploy-proven":
|
|
775
|
+
val = (c["header"].get("proven") or "").strip("`* ")
|
|
776
|
+
if val and not (val.lower() == "never" or re.fullmatch(r"\d{4}-\d{2}-\d{2}", val)):
|
|
777
|
+
rep.fail(rel, f'header field "Proven" reads "{val}"; write "never" or the date of the last real deploy')
|
|
778
|
+
txt = section_text(present, "Proven")
|
|
779
|
+
if txt is not None and not has_table_with(txt, "what", "executed", "when"):
|
|
780
|
+
rep.fail(rel, 'the "Proven" section needs a table with the columns What, Executed for real, When')
|
|
781
|
+
tgt = (c["header"].get("hosting target") or "")
|
|
782
|
+
if re.search(r"\b(and|or)\b|,|/", tgt):
|
|
783
|
+
rep.fail(rel, f'Hosting target "{tgt}" names more than one target; one document per hosting target')
|
|
784
|
+
|
|
785
|
+
elif rule == "phases-table":
|
|
786
|
+
txt = section_text(present, "Phases")
|
|
787
|
+
if txt is None:
|
|
788
|
+
return
|
|
789
|
+
if not has_table_with(txt, "phase", "name", "screens", "brd range", "status"):
|
|
790
|
+
rep.fail(rel, 'the "Phases" table needs the columns Phase, Name, Screens, BRD range, Status')
|
|
791
|
+
return
|
|
792
|
+
rows_out = []
|
|
793
|
+
for header, rows in tables_in(txt):
|
|
794
|
+
low = [h.lower() for h in header]
|
|
795
|
+
if not all(k in low for k in ("phase", "name", "screens", "brd range", "status")):
|
|
796
|
+
continue
|
|
797
|
+
ip, iname, iscr, irng, ist = (low.index(k) for k in ("phase", "name", "screens", "brd range", "status"))
|
|
798
|
+
for r in rows:
|
|
799
|
+
if len(r) <= max(ip, iname, iscr, irng, ist):
|
|
800
|
+
continue
|
|
801
|
+
pnum = r[ip].strip("`* ")
|
|
802
|
+
if not pnum.isdigit():
|
|
803
|
+
rep.fail(rel, f'Phases row "{r[ip]}": the Phase cell must be a number')
|
|
804
|
+
continue
|
|
805
|
+
n = int(pnum)
|
|
806
|
+
if is_placeholder(r[iname]):
|
|
807
|
+
rep.fail(rel, f"phase {n} has no name")
|
|
808
|
+
screens = [s.strip("`* ") for s in r[iscr].split(",") if s.strip()]
|
|
809
|
+
if not screens or any(is_placeholder(s) for s in screens):
|
|
810
|
+
rep.warn(rel, f"phase {n} lists no screens")
|
|
811
|
+
screens = [s for s in screens if not is_placeholder(s)]
|
|
812
|
+
# one range, or several separated by commas when items were added to an earlier phase
|
|
813
|
+
# after a later one existed ("BRD-1 to BRD-73, BRD-111 to BRD-118"): ids run on, never renumber
|
|
814
|
+
ranges = [(int(x), int(y)) for x, y in BRD_RANGE.findall(r[irng])] if not is_placeholder(r[irng]) else []
|
|
815
|
+
lo = hi = None
|
|
816
|
+
if not ranges:
|
|
817
|
+
rep.fail(rel, f'phase {n} BRD range "{r[irng]}" must read "BRD-<a> to BRD-<b>" (several ranges separated by commas are allowed)')
|
|
818
|
+
else:
|
|
819
|
+
lo, hi = ranges[0]
|
|
820
|
+
for x, y in ranges:
|
|
821
|
+
if x > y:
|
|
822
|
+
rep.fail(rel, f"phase {n} BRD range runs backwards ({x} to {y})")
|
|
823
|
+
st = r[ist].strip("`* ").lower()
|
|
824
|
+
if st not in PHASE_STATUS:
|
|
825
|
+
rep.fail(rel, f'phase {n} status "{r[ist]}" must be planned, building or done')
|
|
826
|
+
rows_out.append(dict(n=n, name=r[iname].strip("`* "), screens=screens, lo=lo, hi=hi, ranges=ranges, status=st))
|
|
827
|
+
nums = [p["n"] for p in rows_out]
|
|
828
|
+
if nums != list(range(1, len(nums) + 1)):
|
|
829
|
+
rep.fail(rel, f"phases must be numbered 1, 2, 3 … in order; found {', '.join(str(x) for x in nums) or 'none'}")
|
|
830
|
+
for a in rows_out:
|
|
831
|
+
for b in rows_out:
|
|
832
|
+
if a["n"] >= b["n"]:
|
|
833
|
+
continue
|
|
834
|
+
for x1, y1 in a["ranges"]:
|
|
835
|
+
for x2, y2 in b["ranges"]:
|
|
836
|
+
if x1 <= y2 and x2 <= y1:
|
|
837
|
+
rep.fail(rel, f"phase {a['n']} (BRD-{x1} to BRD-{y1}) and phase {b['n']} (BRD-{x2} to BRD-{y2}) overlap; ids run on across phases and are never reused")
|
|
838
|
+
seen = {}
|
|
839
|
+
for p in rows_out:
|
|
840
|
+
for s in p["screens"]:
|
|
841
|
+
key = s.lower()
|
|
842
|
+
if key in seen and seen[key] != p["n"]:
|
|
843
|
+
rep.fail(rel, f'screen "{s}" is in phase {seen[key]} and phase {p["n"]}; a screen sits in exactly one phase')
|
|
844
|
+
seen[key] = p["n"]
|
|
845
|
+
if not rows_out:
|
|
846
|
+
rep.fail(rel, 'the "Phases" table has no rows')
|
|
847
|
+
c["phases"] = rows_out
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
def check_checklist(c, rep):
|
|
851
|
+
rel, root, path, body, clean, present, size = (
|
|
852
|
+
c["rel"], c["root"], c["path"], c["body"], c["clean"], c["present"], c["size"])
|
|
853
|
+
txt = section_text(present, "Requirements Status")
|
|
854
|
+
if txt is None:
|
|
855
|
+
return
|
|
856
|
+
if not any(re.sub(r"\s+", " ", l.strip()) == CHECKLIST_HEADER for l in clean.splitlines()):
|
|
857
|
+
rep.fail(rel, f"the Requirements Status table header must be exactly {CHECKLIST_HEADER}")
|
|
858
|
+
rows = [l for l in txt.splitlines() if re.match(r"^\s*\|\s*REQ-", l)]
|
|
859
|
+
ids = []
|
|
860
|
+
row_status = {}
|
|
861
|
+
for l in rows:
|
|
862
|
+
cells = [x.strip() for x in l.strip().strip("|").split("|")]
|
|
863
|
+
if len(cells) < 6:
|
|
864
|
+
rep.fail(rel, f"row {cells[0] if cells else '?'} has {len(cells)} cells; six are needed")
|
|
865
|
+
continue
|
|
866
|
+
rid, _req, status, pct, remarks, details = cells[:6]
|
|
867
|
+
rid = rid.strip("`* ")
|
|
868
|
+
ids.append(rid)
|
|
869
|
+
row_status[rid] = status.strip("`* ").lower()
|
|
870
|
+
if not re.fullmatch(r"REQ-(UI|FN|RAG|NFR)-\d{3}", rid):
|
|
871
|
+
rep.fail(rel, f'id "{rid}" is not REQ-UI/FN/RAG/NFR- plus three digits')
|
|
872
|
+
if status.strip("`* ").lower() not in STATUS_VALUES:
|
|
873
|
+
rep.fail(rel, f'{rid} status "{status}" is not one of the fixed values')
|
|
874
|
+
if pct.strip("`* ").rstrip("%").strip() not in ("0", "25", "50", "75", "100"):
|
|
875
|
+
rep.fail(rel, f'{rid} % "{pct}" must be 0, 25, 50, 75 or 100')
|
|
876
|
+
n = len(re.findall(r"\S+", remarks))
|
|
877
|
+
if n > 60:
|
|
878
|
+
rep.fail(rel, f"{rid} Remarks is {n} words; at most 60, current state only (history lives in the telemetry streams)")
|
|
879
|
+
if NOT_PRESENT.search(remarks) and not NAMES_PATH.search(remarks):
|
|
880
|
+
rep.fail(rel, f"{rid} Remarks says something is not present without naming the path that was tried; search tools skip .tfcore/, so read the literal path first")
|
|
881
|
+
m = re.search(r"\(#([^)]+)\)", details)
|
|
882
|
+
if not m:
|
|
883
|
+
rep.fail(rel, f"{rid} Details cell has no link to its detail entry")
|
|
884
|
+
elif not re.search(rf"<a id=['\"]{re.escape(m.group(1))}['\"]", body):
|
|
885
|
+
rep.fail(rel, f"{rid} Details link #{m.group(1)} has no matching anchor in this file")
|
|
886
|
+
if not ids:
|
|
887
|
+
rep.fail(rel, "the Requirements Status table has no REQ- rows")
|
|
888
|
+
if len(ids) > REQ_CAP[size]:
|
|
889
|
+
rep.fail(rel, f"{len(ids)} rows; the {SIZE_LONG[size]} cap is {REQ_CAP[size]}. Split into phases instead of growing this checklist")
|
|
890
|
+
dupes = sorted({i for i in ids if ids.count(i) > 1})
|
|
891
|
+
if dupes:
|
|
892
|
+
rep.fail(rel, "duplicate rows: " + ", ".join(dupes))
|
|
893
|
+
|
|
894
|
+
brd_refs = set()
|
|
895
|
+
for rid in ids:
|
|
896
|
+
# a row logged from UAT by *triage-issues or *log-miss has no BRD item until *amend-docs
|
|
897
|
+
# gives it one; it carries the marker BRD-pending and stays Not Started (Sitting 4c, 2026-09-06)
|
|
898
|
+
m = re.search(rf"<a id=['\"]d-{re.escape(rid.lower())}['\"]", body)
|
|
899
|
+
if not m:
|
|
900
|
+
continue # reported above through the Details link
|
|
901
|
+
pos = m.start()
|
|
902
|
+
nxt = re.search(r"\n\s*(?:[-*]\s*)?<a id=|\n## |\n### ", body[pos + 1:]) # an entry is a list item: the dash precedes the anchor (miss 21)
|
|
903
|
+
entry = body[pos: pos + 1 + nxt.start()] if nxt else body[pos:]
|
|
904
|
+
acc = [l for l in entry.splitlines() if ACCEPT_LINE.search(l)]
|
|
905
|
+
if len(acc) != 1:
|
|
906
|
+
rep.fail(rel, f"{rid} must have exactly one acceptance line; found {len(acc)}")
|
|
907
|
+
elif not ACCEPT_FORM.match(acc[0]):
|
|
908
|
+
rep.fail(rel, f'{rid} acceptance line does not read "When <actor> <does what> on <screen>, then <observable result>"')
|
|
909
|
+
elif rid.startswith(("REQ-UI", "REQ-FN")) and not NAMES_SCREEN.search(acc[0].split(", then", 1)[0]):
|
|
910
|
+
rep.fail(rel, f'{rid} acceptance line must name the screen ("… on <screen>, then …")')
|
|
911
|
+
if len(acc) == 1:
|
|
912
|
+
n_acc = acceptance_words(acc[0])
|
|
913
|
+
if n_acc > ACC_MAX:
|
|
914
|
+
rep.fail(rel, f"{rid} acceptance line is {n_acc} words; at most {ACC_MAX} (target {ACC_TARGET}). One behaviour per row: split it, do not bundle steps")
|
|
915
|
+
elif n_acc > ACC_TARGET:
|
|
916
|
+
rep.warn(rel, f"{rid} acceptance line is {n_acc} words; the target is {ACC_TARGET} (maximum {ACC_MAX})")
|
|
917
|
+
for l in entry.splitlines():
|
|
918
|
+
if "perf-budget:" in l.lower() and not PERF_BUDGET.search(l):
|
|
919
|
+
rep.fail(rel, f"{rid} perf-budget line is not in the form perf-budget: <p50|p95|max> <ttfb|load> <= <N>ms [@ concurrency <N>]")
|
|
920
|
+
refs = re.findall(r"BRD-(\d+)", entry)
|
|
921
|
+
if not refs and "brd-pending" in entry.lower() and row_status.get(rid) == "not started":
|
|
922
|
+
rep.warn(rel, f"{rid} is a Not Started row logged from UAT with BRD-pending; *amend-docs gives it its BRD item")
|
|
923
|
+
elif not refs:
|
|
924
|
+
rep.fail(rel, f"{rid} detail entry does not name its BRD-N item")
|
|
925
|
+
brd_refs.update(refs)
|
|
926
|
+
if rid.startswith("REQ-UI"):
|
|
927
|
+
links = links_to(entry, "mockups")
|
|
928
|
+
if not links:
|
|
929
|
+
rep.fail(rel, f"{rid} is a UI row without a mockup link")
|
|
930
|
+
for l in sorted(links):
|
|
931
|
+
if not resolve(root, path, l):
|
|
932
|
+
rep.fail(rel, f"{rid} mockup {l} does not exist")
|
|
933
|
+
c["checklist_brd_refs"] = brd_refs
|
|
934
|
+
c["checklist_ids"] = ids
|
|
935
|
+
|
|
936
|
+
|
|
937
|
+
# ----------------------------------------------------------------------------
|
|
938
|
+
# cross-document rules
|
|
939
|
+
# ----------------------------------------------------------------------------
|
|
940
|
+
def _screen_key(s):
|
|
941
|
+
s = re.sub(r"\s*[—(].*$", "", s.strip("`* ")) # "Profile (planned)" -> "Profile"
|
|
942
|
+
return re.sub(r"\s+", " ", s).strip().lower()
|
|
943
|
+
|
|
944
|
+
|
|
945
|
+
def cross_checks(ctxs: dict, rep: Report, root: str):
|
|
946
|
+
"""ctxs is keyed by (doc, phase). Single documents sit at phase 1."""
|
|
947
|
+
phases = sorted({p for (_d, p) in ctxs})
|
|
948
|
+
for p in phases:
|
|
949
|
+
brd, ui, cl = ctxs.get(("brd", p)), ctxs.get(("uidesign", p)), ctxs.get(("checklist", p))
|
|
950
|
+
if brd and ui and brd.get("brd_screens") is not None and ui["kind"] == "app":
|
|
951
|
+
b = {_screen_key(s) for s in brd["brd_screens"]}
|
|
952
|
+
u = {_screen_key(s) for s in ui["entry_names"]}
|
|
953
|
+
for s in sorted(b - u):
|
|
954
|
+
rep.fail(brd["rel"], f'screen "{s}" is in the BRD but has no "### Screen:" entry in the UIDesign')
|
|
955
|
+
for s in sorted(u - b):
|
|
956
|
+
rep.fail(ui["rel"], f'screen "{s}" is in the UIDesign but not in the BRD screens table')
|
|
957
|
+
if brd and cl and brd.get("brd_ids") is not None and cl.get("checklist_brd_refs") is not None:
|
|
958
|
+
missing = sorted(brd["brd_ids"] - cl["checklist_brd_refs"], key=int)
|
|
959
|
+
if missing:
|
|
960
|
+
shown = ", ".join(f"BRD-{m}" for m in missing[:20]) + (" …" if len(missing) > 20 else "")
|
|
961
|
+
rep.fail(cl["rel"], f"BRD items with no checklist row ({len(missing)}): {shown}")
|
|
962
|
+
uis = [ctxs[k] for k in sorted(ctxs) if k[0] == "uidesign"]
|
|
963
|
+
if uis:
|
|
964
|
+
mock_dir = os.path.join(root, "docs", "mockups")
|
|
965
|
+
if os.path.isdir(mock_dir):
|
|
966
|
+
linked = set()
|
|
967
|
+
for ui in uis:
|
|
968
|
+
linked |= {os.path.basename(l.split("#")[0]) for l in links_to(ui["clean"], "mockups")}
|
|
969
|
+
for f in sorted(os.listdir(mock_dir)):
|
|
970
|
+
if f.lower().endswith(".html") and f not in linked:
|
|
971
|
+
rep.warn(uis[0]["rel"], f"mockup {f} is not linked from any screen")
|
|
972
|
+
check_mockups(root, rep)
|
|
973
|
+
cross_phase_checks(ctxs, rep, root, phases)
|
|
974
|
+
|
|
975
|
+
|
|
976
|
+
def cross_phase_checks(ctxs, rep, root, phases):
|
|
977
|
+
"""Large projects: ids never reused across phases, every screen in exactly one phase,
|
|
978
|
+
every phase row backed by its files (docs/TechieFlow-Document-Schemas.md §2, §3.11)."""
|
|
979
|
+
ph = ctxs.get(("phases", 1))
|
|
980
|
+
brds = {p: c for (d, p), c in ctxs.items() if d == "brd"}
|
|
981
|
+
cls = {p: c for (d, p), c in ctxs.items() if d == "checklist"}
|
|
982
|
+
uis = {p: c for (d, p), c in ctxs.items() if d == "uidesign"}
|
|
983
|
+
app = next((c["app"] for c in ctxs.values() if c.get("app")), None)
|
|
984
|
+
|
|
985
|
+
seen = {}
|
|
986
|
+
for p in sorted(brds):
|
|
987
|
+
for i in sorted(brds[p].get("brd_ids") or (), key=int):
|
|
988
|
+
if i in seen:
|
|
989
|
+
rep.fail(brds[p]["rel"], f"BRD-{i} is also in phase {seen[i]}'s BRD; ids run on across phases and are never reused")
|
|
990
|
+
seen[i] = p
|
|
991
|
+
seen = {}
|
|
992
|
+
for p in sorted(cls):
|
|
993
|
+
for i in cls[p].get("checklist_ids") or ():
|
|
994
|
+
if i in seen and seen[i] != p:
|
|
995
|
+
rep.fail(cls[p]["rel"], f"{i} is also in phase {seen[i]}'s checklist; REQ ids run on across phases and are never reused")
|
|
996
|
+
seen.setdefault(i, p)
|
|
997
|
+
|
|
998
|
+
if not ph:
|
|
999
|
+
if len(phases) > 1 and app:
|
|
1000
|
+
rep.fail(f"docs/{app}-Phases.md", f"phase files exist ({', '.join(f'P{p}' for p in phases if p > 1)}) but the Phases document does not; write it from app-phases-tmpl.md")
|
|
1001
|
+
return
|
|
1002
|
+
rows = ph.get("phases") or []
|
|
1003
|
+
if not rows:
|
|
1004
|
+
return
|
|
1005
|
+
docs = os.path.dirname(ph["path"])
|
|
1006
|
+
app = app or ph["app"]
|
|
1007
|
+
for r in rows:
|
|
1008
|
+
n = r["n"]
|
|
1009
|
+
brd_name = phase_file(app, n, "BRD")
|
|
1010
|
+
if not os.path.exists(os.path.join(docs, brd_name)):
|
|
1011
|
+
rep.fail(ph["rel"], f"phase {n} has no BRD: docs/{brd_name} does not exist")
|
|
1012
|
+
cl_name = phase_file(app, n, "Checklist")
|
|
1013
|
+
if not os.path.exists(os.path.join(docs, cl_name)):
|
|
1014
|
+
rep.warn(ph["rel"], f"phase {n} has no checklist yet (docs/{cl_name}); day-1 stage 2 writes it")
|
|
1015
|
+
b = brds.get(n)
|
|
1016
|
+
if b and b.get("brd_ids") and r.get("ranges"):
|
|
1017
|
+
out = sorted((i for i in b["brd_ids"] if not any(x <= int(i) <= y for x, y in r["ranges"])), key=int)
|
|
1018
|
+
if out:
|
|
1019
|
+
shown = ", ".join(f"BRD-{i}" for i in out[:10]) + (" …" if len(out) > 10 else "")
|
|
1020
|
+
rng = ", ".join(f"BRD-{x} to BRD-{y}" for x, y in r["ranges"])
|
|
1021
|
+
rep.fail(b["rel"], f"{len(out)} requirement(s) outside phase {n}'s range {rng}: {shown}; add a second range to the Phases row for items added later")
|
|
1022
|
+
u = uis.get(n)
|
|
1023
|
+
if u and u["kind"] == "app":
|
|
1024
|
+
want = {_screen_key(s) for s in r["screens"]}
|
|
1025
|
+
have = {_screen_key(s) for s in u["entry_names"]}
|
|
1026
|
+
for s in sorted(have - want):
|
|
1027
|
+
rep.fail(ph["rel"], f'screen "{s}" is in phase {n}\'s UIDesign but not in its Phases row')
|
|
1028
|
+
for s in sorted(want - have):
|
|
1029
|
+
rep.fail(ph["rel"], f'screen "{s}" is listed under phase {n} but has no "### Screen:" entry in {os.path.basename(u["path"])}')
|
|
1030
|
+
listed = {n for n in (p for p in phases) if any(r["n"] == n for r in rows)}
|
|
1031
|
+
for p in sorted(phases):
|
|
1032
|
+
if p not in listed and (p in brds or p in cls or p in uis):
|
|
1033
|
+
rep.fail(ph["rel"], f"phase {p} has files but no row in the Phases table")
|
|
1034
|
+
|
|
1035
|
+
|
|
1036
|
+
ENTRY_NAMES = ("login", "signin", "sign-in", "onboarding", "welcome", "splash", "lock", "index", "home", "dashboard", "landing", "start")
|
|
1037
|
+
ANCHOR_HREF = re.compile(r"""<a\b[^>]*\bhref\s*=\s*["']([^"'#?]+)""", re.I)
|
|
1038
|
+
LINK_TAG = re.compile(r"<link\b[^>]*>", re.I)
|
|
1039
|
+
TAG_HREF = re.compile(r"""\bhref\s*=\s*["']([^"'#?]+)""", re.I)
|
|
1040
|
+
FORM_ACTION = re.compile(r"""<form\b[^>]*\baction\s*=\s*["']([^"'#?]+)""", re.I)
|
|
1041
|
+
BUTTON = re.compile(r"<button\b[^>]*>", re.I)
|
|
1042
|
+
# navigation done by script instead of a link: works only where scripts run, so a viewer
|
|
1043
|
+
# without them (a preview pane, a rendered-doc iframe) shows a dead page (owner, 2026-09-05)
|
|
1044
|
+
SCRIPT_NAV = re.compile(r"""onclick\s*=\s*["'][^"']*(?:location\.href|window\.location|location\.(?:assign|replace)|history\.(?:back|go))[^"']*["']""", re.I)
|
|
1045
|
+
SCRIPT_NAV_TARGET = re.compile(r"""(?:location\.href|window\.location(?:\.href)?|location\.(?:assign|replace))\s*(?:=|\()\s*["']([^"'#?]+)""", re.I)
|
|
1046
|
+
NAV_BLOCK = re.compile(r"<nav\b[^>]*>(.*?)</nav>", re.I | re.S)
|
|
1047
|
+
NAV_ITEM = re.compile(r"""<(div|li|span|button)\b[^>]*\bclass\s*=\s*["'][^"']*(?:item|link|tab)[^"']*["'][^>]*>(.*?)</\1>""", re.I | re.S)
|
|
1048
|
+
|
|
1049
|
+
|
|
1050
|
+
def _mock_target(mock_dir: str, t: str):
|
|
1051
|
+
"""Resolve a link the way a browser opening docs/mockups/<file> would. Returns the file
|
|
1052
|
+
name when it lands on an existing file inside the mockup folder, else None."""
|
|
1053
|
+
if re.match(r"^(https?:|mailto:|tel:|javascript:|data:)", t, re.I) or t.startswith("/"):
|
|
1054
|
+
return None
|
|
1055
|
+
p = os.path.normpath(os.path.join(mock_dir, t))
|
|
1056
|
+
if os.path.dirname(p) != os.path.normpath(mock_dir) or not os.path.isfile(p):
|
|
1057
|
+
return None
|
|
1058
|
+
return os.path.basename(p)
|
|
1059
|
+
|
|
1060
|
+
|
|
1061
|
+
def check_mockups(root: str, rep: Report):
|
|
1062
|
+
"""A mockup set is a click-through: every link resolves, every screen is reachable from
|
|
1063
|
+
the entry screen, every screen has a way out, every button does something, and every
|
|
1064
|
+
file carries data-testid anchors the verifier can grade (FR-53, owner rule 2026-09-05).
|
|
1065
|
+
Since Sitting 4b: navigation is an <a href>, never a script; a menu item goes somewhere;
|
|
1066
|
+
a link is resolved from the mockup's own folder, not by its file name; stylesheets exist."""
|
|
1067
|
+
mock_dir = os.path.join(root, "docs", "mockups")
|
|
1068
|
+
files = sorted(f for f in os.listdir(mock_dir) if f.lower().endswith((".html", ".htm")))
|
|
1069
|
+
if not files:
|
|
1070
|
+
return
|
|
1071
|
+
rel_dir = "docs/mockups"
|
|
1072
|
+
out_links = {}
|
|
1073
|
+
for f in files:
|
|
1074
|
+
try:
|
|
1075
|
+
with open(os.path.join(mock_dir, f), encoding="utf-8", errors="replace") as fh:
|
|
1076
|
+
html = fh.read()
|
|
1077
|
+
except Exception:
|
|
1078
|
+
continue
|
|
1079
|
+
rel = f"{rel_dir}/{f}"
|
|
1080
|
+
if "data-testid" not in html:
|
|
1081
|
+
rep.fail(rel, "mockup has no data-testid anchors; the verifier cannot compare the built screen to it")
|
|
1082
|
+
targets = []
|
|
1083
|
+
for m in list(ANCHOR_HREF.finditer(html)) + list(FORM_ACTION.finditer(html)):
|
|
1084
|
+
t = m.group(1).strip()
|
|
1085
|
+
if not t or re.match(r"^(https?:|mailto:|tel:|javascript:|data:)", t, re.I):
|
|
1086
|
+
continue
|
|
1087
|
+
if not t.lower().endswith((".html", ".htm")):
|
|
1088
|
+
continue
|
|
1089
|
+
name = _mock_target(mock_dir, t)
|
|
1090
|
+
if name is None:
|
|
1091
|
+
hint = "write the file name alone, not a folder or a leading slash" if ("/" in t) else "the file does not exist"
|
|
1092
|
+
rep.fail(rel, f'links to "{t}", which does not open from docs/mockups/ ({hint})')
|
|
1093
|
+
else:
|
|
1094
|
+
targets.append(name)
|
|
1095
|
+
for m in LINK_TAG.finditer(html):
|
|
1096
|
+
tag = m.group(0)
|
|
1097
|
+
if not re.search(r"""rel\s*=\s*["']stylesheet["']""", tag, re.I):
|
|
1098
|
+
continue
|
|
1099
|
+
h = TAG_HREF.search(tag)
|
|
1100
|
+
if h and not re.match(r"^https?:", h.group(1), re.I) and _mock_target(mock_dir, h.group(1).strip()) is None:
|
|
1101
|
+
rep.fail(rel, f'stylesheet "{h.group(1)}" does not open from docs/mockups/; the mockup renders unstyled')
|
|
1102
|
+
nav_by_script = SCRIPT_NAV.findall(html)
|
|
1103
|
+
if nav_by_script:
|
|
1104
|
+
rep.fail(rel, f'{len(nav_by_script)} element(s) navigate by script (onclick location.href); write <a href="screen.html"> so the link works in every viewer')
|
|
1105
|
+
for m in SCRIPT_NAV_TARGET.finditer(html):
|
|
1106
|
+
name = _mock_target(mock_dir, m.group(1).strip())
|
|
1107
|
+
if name:
|
|
1108
|
+
targets.append(name) # keep the graph honest, the file is still refused above
|
|
1109
|
+
for block in NAV_BLOCK.findall(html):
|
|
1110
|
+
for m in NAV_ITEM.finditer(block):
|
|
1111
|
+
tag_and_body = m.group(0)
|
|
1112
|
+
if re.search(r"<a\b[^>]*\bhref", tag_and_body, re.I) or re.search(r"\bonclick\s*=", tag_and_body, re.I):
|
|
1113
|
+
continue
|
|
1114
|
+
if re.search(r"""\b(active|current|selected)\b""", re.match(r"<[^>]*>", tag_and_body).group(0), re.I):
|
|
1115
|
+
continue # the item for the page itself
|
|
1116
|
+
label = re.sub(r"<[^>]+>", " ", m.group(2))
|
|
1117
|
+
label = re.sub(r"\s+", " ", label).strip()[:40] or "(unnamed)"
|
|
1118
|
+
rep.fail(rel, f'menu item "{label}" goes nowhere; every menu item is an <a href> to its screen')
|
|
1119
|
+
out_links[f] = set(targets) - {f}
|
|
1120
|
+
inert = 0
|
|
1121
|
+
for m in BUTTON.finditer(html):
|
|
1122
|
+
tag = m.group(0)
|
|
1123
|
+
if re.search(r"\bonclick\s*=", tag, re.I):
|
|
1124
|
+
continue
|
|
1125
|
+
if re.search(r"""type\s*=\s*["']submit["']""", tag, re.I) and FORM_ACTION.search(html):
|
|
1126
|
+
continue
|
|
1127
|
+
inert += 1
|
|
1128
|
+
if inert:
|
|
1129
|
+
rep.fail(rel, f"{inert} button(s) do nothing: give each an onclick that shows a message, make it a form submit with an action, or make it a link")
|
|
1130
|
+
if len(files) < 2:
|
|
1131
|
+
return
|
|
1132
|
+
for f in files:
|
|
1133
|
+
if not out_links.get(f):
|
|
1134
|
+
rep.fail(f"{rel_dir}/{f}", "has no link to any other mockup; every screen needs a way out (a menu, a back link or a logout)")
|
|
1135
|
+
entry = next((f for n in ENTRY_NAMES for f in files if f.lower().startswith(n)), files[0])
|
|
1136
|
+
seen, todo = {entry}, [entry]
|
|
1137
|
+
while todo:
|
|
1138
|
+
cur = todo.pop()
|
|
1139
|
+
for t in out_links.get(cur, ()):
|
|
1140
|
+
if t not in seen:
|
|
1141
|
+
seen.add(t)
|
|
1142
|
+
todo.append(t)
|
|
1143
|
+
unreachable = [f for f in files if f not in seen]
|
|
1144
|
+
if unreachable:
|
|
1145
|
+
rep.fail(rel_dir, f"{len(unreachable)} mockup(s) cannot be reached by clicking from {entry}: " + ", ".join(unreachable[:10]) + (" …" if len(unreachable) > 10 else ""))
|
|
1146
|
+
|
|
1147
|
+
|
|
1148
|
+
# ----------------------------------------------------------------------------
|
|
1149
|
+
def app_files(root: str, app: str):
|
|
1150
|
+
docs = os.path.join(root, "docs")
|
|
1151
|
+
names = [f"{app}-Phases.md", f"{app}-BRD.md", f"{app}-Architecture.md", f"{app}-UIDesign.md", f"{app}-Checklist.md",
|
|
1152
|
+
f"{app}-Coding-Standards.md", f"{app}-UsageGuide.md", f"{app}-DevGuide.md", f"{app}-ProductGuide.md"]
|
|
1153
|
+
# later phases of a Large project: App-P2-BRD.md, App-P2-Checklist.md, … in phase order
|
|
1154
|
+
extra = []
|
|
1155
|
+
if os.path.isdir(docs):
|
|
1156
|
+
for f in os.listdir(docs):
|
|
1157
|
+
m = re.match(rf"^{re.escape(app)}-P(\d+)-(BRD|Checklist|UIDesign|DevGuide)\.md$", f)
|
|
1158
|
+
if m:
|
|
1159
|
+
extra.append((int(m.group(1)), ["BRD", "UIDesign", "Checklist", "DevGuide"].index(m.group(2)), f))
|
|
1160
|
+
names += [f for _p, _o, f in sorted(extra)]
|
|
1161
|
+
if os.path.isdir(docs):
|
|
1162
|
+
names += sorted(f for f in os.listdir(docs) if re.match(rf"^{re.escape(app)}-Deployment-Checklist(-[\w]+)?\.md$", f))
|
|
1163
|
+
out = [os.path.join(docs, n) for n in names if os.path.exists(os.path.join(docs, n))]
|
|
1164
|
+
ps = os.path.join(root, "PROJECT-STATUS.md")
|
|
1165
|
+
if os.path.exists(ps):
|
|
1166
|
+
out.append(ps)
|
|
1167
|
+
return out
|
|
1168
|
+
|
|
1169
|
+
|
|
1170
|
+
def main(argv=None):
|
|
1171
|
+
ap = argparse.ArgumentParser(description="Check TechieFlow documents against their template schemas.")
|
|
1172
|
+
ap.add_argument("files", nargs="*", help="document paths")
|
|
1173
|
+
ap.add_argument("--app", help="check every human document of this app under <root>/docs plus PROJECT-STATUS.md")
|
|
1174
|
+
ap.add_argument("--root", help="project root (default: found from the first file, or the current directory)")
|
|
1175
|
+
ap.add_argument("--size", help="Small|Medium|Large when no header or core-config carries it")
|
|
1176
|
+
ap.add_argument("--warn", action="store_true", help="report only: every finding is WARN and the exit code is 0")
|
|
1177
|
+
ap.add_argument("--quiet", action="store_true", help="print findings and the summary only")
|
|
1178
|
+
ap.add_argument("--strict", action="store_true", help="ignore the baseline: every finding FAILs, old or new")
|
|
1179
|
+
ap.add_argument("--baseline-write", action="store_true",
|
|
1180
|
+
help="record the current findings of these files as the baseline (.tfcore/.session/doc-check-baseline.json) and print nothing else")
|
|
1181
|
+
a = ap.parse_args(argv)
|
|
1182
|
+
|
|
1183
|
+
if not os.path.isdir(TEMPLATE_DIR):
|
|
1184
|
+
print(f"tf-doc-check: template folder not found at {TEMPLATE_DIR}", file=sys.stderr)
|
|
1185
|
+
return 2
|
|
1186
|
+
root = os.path.abspath(a.root) if a.root else None
|
|
1187
|
+
files = list(a.files)
|
|
1188
|
+
if a.app:
|
|
1189
|
+
root = root or os.getcwd()
|
|
1190
|
+
files += app_files(root, a.app)
|
|
1191
|
+
if not files:
|
|
1192
|
+
print(f"tf-doc-check: no documents for app {a.app} under {root}", file=sys.stderr)
|
|
1193
|
+
return 2
|
|
1194
|
+
if not files:
|
|
1195
|
+
ap.print_help()
|
|
1196
|
+
return 2
|
|
1197
|
+
root = root or find_root(files[0])
|
|
1198
|
+
|
|
1199
|
+
rep = Report(a.warn)
|
|
1200
|
+
ctxs, checked = {}, 0
|
|
1201
|
+
for f in files:
|
|
1202
|
+
if not os.path.exists(f):
|
|
1203
|
+
rep.fail(os.path.relpath(f, root), "file not found")
|
|
1204
|
+
continue
|
|
1205
|
+
ctx = check_document(f, rep, a.size, root)
|
|
1206
|
+
checked += 1
|
|
1207
|
+
if ctx:
|
|
1208
|
+
ctxs[(ctx["doc"], ctx["phase"])] = ctx
|
|
1209
|
+
if len(ctxs) > 1:
|
|
1210
|
+
cross_checks(ctxs, rep, root)
|
|
1211
|
+
|
|
1212
|
+
# The baseline (Sitting 4c, 2026-09-06; Schemas §7.1 decision 8): findings that were already
|
|
1213
|
+
# there when the command started are printed as OLD and do not block; only a finding this
|
|
1214
|
+
# command introduced FAILs. tf-phase.sh start writes the baseline; --strict ignores it.
|
|
1215
|
+
base_path = os.path.join(root, ".tfcore", ".session", "doc-check-baseline.json")
|
|
1216
|
+
if a.baseline_write:
|
|
1217
|
+
base = {}
|
|
1218
|
+
try:
|
|
1219
|
+
with open(base_path, encoding="utf-8") as fh:
|
|
1220
|
+
base = json.load(fh) or {}
|
|
1221
|
+
except Exception:
|
|
1222
|
+
base = {}
|
|
1223
|
+
for f in files:
|
|
1224
|
+
r = os.path.relpath(f, root)
|
|
1225
|
+
base[r] = sorted({l.split(": ", 1)[1] for l in rep.lines if l.startswith("FAIL ") and l.split(": ", 1)[0].endswith(" " + r)})
|
|
1226
|
+
os.makedirs(os.path.dirname(base_path), exist_ok=True)
|
|
1227
|
+
with open(base_path, "w", encoding="utf-8") as fh:
|
|
1228
|
+
json.dump(base, fh, indent=1)
|
|
1229
|
+
print(f"tf-doc-check: baseline written for {len(files)} file(s) ({sum(len(v) for v in base.values())} old finding(s))")
|
|
1230
|
+
return 0
|
|
1231
|
+
old = 0
|
|
1232
|
+
if not a.strict and not a.warn and os.path.isfile(base_path):
|
|
1233
|
+
try:
|
|
1234
|
+
import time as _t
|
|
1235
|
+
if _t.time() - os.path.getmtime(base_path) < 24 * 3600:
|
|
1236
|
+
with open(base_path, encoding="utf-8") as fh:
|
|
1237
|
+
base = json.load(fh) or {}
|
|
1238
|
+
relined = []
|
|
1239
|
+
for l in rep.lines:
|
|
1240
|
+
if l.startswith("FAIL "):
|
|
1241
|
+
head, msg = l.split(": ", 1)
|
|
1242
|
+
r = head[len("FAIL "):]
|
|
1243
|
+
if msg in set(base.get(r, [])):
|
|
1244
|
+
relined.append("OLD " + l[5:])
|
|
1245
|
+
rep.fails -= 1
|
|
1246
|
+
old += 1
|
|
1247
|
+
continue
|
|
1248
|
+
relined.append(l)
|
|
1249
|
+
rep.lines = relined
|
|
1250
|
+
except Exception:
|
|
1251
|
+
pass
|
|
1252
|
+
|
|
1253
|
+
for line in rep.lines:
|
|
1254
|
+
print(line)
|
|
1255
|
+
if not a.quiet:
|
|
1256
|
+
for f in files:
|
|
1257
|
+
if os.path.exists(f):
|
|
1258
|
+
r = os.path.relpath(f, root)
|
|
1259
|
+
if not any(l.split(": ", 1)[0].endswith(" " + r) for l in rep.lines):
|
|
1260
|
+
print(f"OK {r}")
|
|
1261
|
+
print(f"tf-doc-check: {rep.fails} FAIL, {rep.warns} WARN in {checked} document(s)"
|
|
1262
|
+
+ (f"; {old} OLD finding(s) from before this command, not blocking (repair through *amend-docs)" if old else ""))
|
|
1263
|
+
return 1 if rep.fails else 0
|
|
1264
|
+
|
|
1265
|
+
|
|
1266
|
+
if __name__ == "__main__":
|
|
1267
|
+
sys.exit(main())
|