@techierathore/techieflow 1.0.3 → 1.1.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/.claude/commands/TechieFlow/agents/analyst.md +5 -8
- package/.claude/commands/TechieFlow/agents/architect.md +1 -10
- package/.claude/commands/TechieFlow/agents/flow-master.md +1 -0
- package/.claude/commands/TechieFlow/agents/verifier.md +1 -0
- package/.claude/commands/TechieFlow/tasks/_owner-language.md +27 -0
- package/.claude/commands/TechieFlow/tasks/_smoke-test-policy.md +1 -1
- package/.claude/commands/TechieFlow/tasks/_status-update-gate.md +4 -3
- package/.claude/commands/TechieFlow/tasks/amend-docs.md +6 -5
- package/.claude/commands/TechieFlow/tasks/build-phase.md +1 -1
- package/.claude/commands/TechieFlow/tasks/create-deep-research-prompt.md +4 -0
- package/.claude/commands/TechieFlow/tasks/create-doc.md +4 -0
- package/.claude/commands/TechieFlow/tasks/create-project-brief.md +22 -0
- package/.claude/commands/TechieFlow/tasks/day1-brownfield.md +3 -3
- package/.claude/commands/TechieFlow/tasks/day1-greenfield.md +3 -3
- package/.claude/commands/TechieFlow/tasks/facilitate-brainstorming-session.md +4 -0
- package/.claude/commands/TechieFlow/tasks/fix-issues.md +2 -2
- package/.claude/commands/TechieFlow/tasks/generate-html.md +7 -3
- package/.claude/commands/TechieFlow/tasks/handoff-phase.md +20 -18
- package/.claude/commands/TechieFlow/tasks/metrics-report.md +2 -0
- package/.claude/commands/TechieFlow/tasks/mockups.md +1 -1
- package/.claude/commands/TechieFlow/tasks/productguide.md +2 -2
- package/.claude/commands/TechieFlow/tasks/render-workflow-docs.md +1 -1
- package/.claude/commands/TechieFlow/tasks/triage-issues.md +2 -2
- package/.claude/commands/TechieFlow/tasks/verify-phase.md +1 -1
- package/.opencode/plugin/techieflow.js +181 -9
- package/.tfcore/agents/analyst.md +5 -8
- package/.tfcore/agents/architect.md +1 -10
- package/.tfcore/agents/flow-master.md +1 -0
- package/.tfcore/agents/verifier.md +1 -0
- package/.tfcore/hooks/block-git.sh +4 -2
- package/.tfcore/hooks/guard-build.sh +5 -1
- package/.tfcore/hooks/guard-db.sh +6 -4
- package/.tfcore/hooks/guard-metrics.sh +13 -1
- package/.tfcore/hooks/guard-status-html.sh +86 -2
- package/.tfcore/hooks/guard-verify-deps.sh +124 -0
- package/.tfcore/hooks/guard-verify.sh +8 -3
- package/.tfcore/hooks/sweep-artifacts.sh +46 -0
- package/.tfcore/routing.yaml +61 -0
- package/.tfcore/standards/owner-words.txt +23 -0
- package/.tfcore/tasks/_owner-language.md +27 -0
- package/.tfcore/tasks/_smoke-test-policy.md +1 -1
- package/.tfcore/tasks/_status-update-gate.md +4 -3
- package/.tfcore/tasks/amend-docs.md +6 -5
- package/.tfcore/tasks/build-phase.md +1 -1
- package/.tfcore/tasks/create-deep-research-prompt.md +4 -0
- package/.tfcore/tasks/create-doc.md +4 -0
- package/.tfcore/tasks/create-project-brief.md +22 -0
- package/.tfcore/tasks/day1-brownfield.md +3 -3
- package/.tfcore/tasks/day1-greenfield.md +3 -3
- package/.tfcore/tasks/facilitate-brainstorming-session.md +4 -0
- package/.tfcore/tasks/fix-issues.md +2 -2
- package/.tfcore/tasks/generate-html.md +7 -3
- package/.tfcore/tasks/handoff-phase.md +20 -18
- package/.tfcore/tasks/metrics-report.md +2 -0
- package/.tfcore/tasks/mockups.md +1 -1
- package/.tfcore/tasks/productguide.md +2 -2
- package/.tfcore/tasks/render-workflow-docs.md +1 -1
- package/.tfcore/tasks/triage-issues.md +2 -2
- package/.tfcore/tasks/verify-phase.md +1 -1
- package/.tfcore/telemetry/SCHEMA.md +61 -1
- package/.tfcore/telemetry/install-metrics.sh +3 -2
- package/.tfcore/telemetry/tf-metrics.sh +435 -21
- package/.tfcore/templates/v4custom/app-agents-md-tmpl.md +6 -0
- package/.tfcore/templates/v4custom/app-brd-tmpl.md +5 -3
- package/.tfcore/templates/v4custom/app-brief-tmpl.md +65 -0
- package/.tfcore/templates/v4custom/app-decision-request-tmpl.md +85 -0
- package/.tfcore/templates/v4custom/app-library-feedback-tmpl.md +86 -31
- package/.tfcore/templates/v4custom/app-phase-brd-tmpl.md +94 -0
- package/.tfcore/templates/v4custom/app-phase-uidesign-tmpl.md +60 -0
- package/.tfcore/templates/v4custom/app-uidesign-tmpl.md +4 -2
- package/.tfcore/templates/v4custom/build-subagent-prompt.md +2 -2
- package/.tfcore/templates/v4custom/html-render-shell.md +4 -2
- 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-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-assets-browser.mjs +96 -0
- package/.tfcore/utils/tf-assets.sh +97 -12
- package/.tfcore/utils/tf-brd-status.py +14 -5
- package/.tfcore/utils/tf-build-list.py +99 -11
- package/.tfcore/utils/tf-build-list.sh +5 -3
- package/.tfcore/utils/tf-build.sh +88 -15
- package/.tfcore/utils/tf-ci-repro.py +685 -0
- package/.tfcore/utils/tf-ci-repro.sh +24 -0
- package/.tfcore/utils/tf-devguide-list.py +47 -22
- package/.tfcore/utils/tf-doc-check.py +242 -10
- package/.tfcore/utils/tf-doc-tests.sh +79 -0
- package/.tfcore/utils/tf-emit.sh +413 -19
- package/.tfcore/utils/tf-feedback.sh +13 -0
- package/.tfcore/utils/tf-fix-close.sh +113 -31
- package/.tfcore/utils/tf-gitignore-audit.sh +50 -3
- package/.tfcore/utils/tf-goal.sh +105 -18
- package/.tfcore/utils/tf-harness.sh +9 -4
- package/.tfcore/utils/tf-lock.sh +29 -0
- package/.tfcore/utils/tf-log-miss.py +34 -8
- package/.tfcore/utils/tf-login.mjs +167 -0
- package/.tfcore/utils/tf-misses-md.py +24 -21
- package/.tfcore/utils/tf-misses-md.sh +5 -4
- package/.tfcore/utils/tf-mockup-parity.mjs +427 -63
- package/.tfcore/utils/tf-mockup-parity.sh +14 -2
- package/.tfcore/utils/tf-model-pick.sh +475 -0
- package/.tfcore/utils/tf-owner-text.py +233 -0
- package/.tfcore/utils/tf-owner-text.sh +14 -0
- package/.tfcore/utils/tf-phase.sh +33 -1
- package/.tfcore/utils/tf-portable.sh +230 -0
- package/.tfcore/utils/tf-render-html.py +80 -2
- package/.tfcore/utils/tf-routing-bind.sh +87 -1
- package/.tfcore/utils/tf-routing.sh +7 -0
- package/.tfcore/utils/tf-selfcheck.sh +356 -0
- package/.tfcore/utils/tf-split-brd.py +17 -2
- package/.tfcore/utils/tf-status-evidence.sh +11 -9
- package/.tfcore/utils/tf-status-facts.py +133 -44
- package/.tfcore/utils/tf-triage.py +62 -16
- package/.tfcore/utils/tf-triage.sh +2 -0
- package/.tfcore/utils/tf-verify-boot.sh +263 -43
- package/.tfcore/utils/tf-verify-emit.sh +20 -0
- package/.tfcore/utils/tf-verify-env.sh +33 -4
- package/.tfcore/utils/tf-verify-list.py +75 -8
- package/.tfcore/utils/tf-verify-screens.mjs +254 -43
- package/.tfcore/utils/tf-verify-screens.sh +18 -4
- package/.tfcore/utils/tf-verify-tests.sh +272 -28
- package/.tfcore/utils/tf-verify-verdict.py +59 -11
- package/.tfcore/utils/tf_defect.py +27 -0
- package/.tfcore/utils/tf_feedback.py +209 -0
- package/.tfcore/utils/tf_roadmap.py +35 -0
- package/README.md +19 -5
- package/docs/TechieFlow-Installation.md +5 -3
- package/opencode.jsonc +24 -0
- package/package.json +1 -1
- package/scripts/install.mjs +4 -0
- package/.tfcore/templates/architecture-tmpl.yaml +0 -650
- package/.tfcore/templates/brownfield-architecture-tmpl.yaml +0 -476
- package/.tfcore/templates/competitor-analysis-tmpl.yaml +0 -306
- package/.tfcore/templates/front-end-architecture-tmpl.yaml +0 -218
- package/.tfcore/templates/fullstack-architecture-tmpl.yaml +0 -823
- package/.tfcore/templates/market-research-tmpl.yaml +0 -252
- package/.tfcore/templates/project-brief-tmpl.yaml +0 -221
- package/.tfcore/utils/techieflow-doc-template.md +0 -325
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# tf-ci-repro.sh — reproduce a CI job here the way a fresh runner runs it (Lekhak TF-019, 2026-09-30).
|
|
3
|
+
#
|
|
4
|
+
# bash .tfcore/utils/tf-ci-repro.sh [<workflow.yml>] [--job <id>] [--list] [--keep] [--run-setup]
|
|
5
|
+
#
|
|
6
|
+
# Run from the repository root. A developer machine hides CI failures in its package caches and its
|
|
7
|
+
# build output: Lekhak's CI failed with NETSDK1112 because the win-x64 runtime pack was never
|
|
8
|
+
# downloaded, while the same commands passed locally where the pack was already cached. A local run
|
|
9
|
+
# with warm caches is therefore no proof that a CI failure is fixed. This script copies the repository
|
|
10
|
+
# without anything .gitignore ignores, to a folder outside it, points every package cache it knows
|
|
11
|
+
# (NuGet, npm, yarn, pnpm, pip, Go, Gradle, Maven) at an empty folder, and runs the job's `run:` steps
|
|
12
|
+
# in the runner's shell, stopping at the first failure. A Windows job runs Windows-side from WSL.
|
|
13
|
+
# `uses:` steps, steps that need a secret and steps that only install tools on the machine are
|
|
14
|
+
# skipped, each with the reason. --list prints the plan and runs nothing; --keep keeps the copy.
|
|
15
|
+
# Prints ONE verdict line last:
|
|
16
|
+
# PASS job <id>: <n> run step(s) passed on a clean copy with empty caches (…) — logs <dir> exit 0
|
|
17
|
+
# FAIL job <id>: step <n> "<name>" failed (exit N) with empty caches — <first error> — log <file> exit 1
|
|
18
|
+
# NOT-RUN <why: no workflow, several jobs, a macOS job off a Mac …> exit 2
|
|
19
|
+
# PyYAML is used when installed; without it (a stock Mac) a built-in reader takes the workflow.
|
|
20
|
+
# Logs: tests/.artifacts/ci-repro/<UTC time>/step-NN.log. The first run downloads every package, so
|
|
21
|
+
# it takes as long as a cold CI restore.
|
|
22
|
+
set -uo pipefail
|
|
23
|
+
case "${1:-}" in -h|--help) sed -n '2,21p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; esac
|
|
24
|
+
exec python3 "$(dirname "${BASH_SOURCE[0]}")/tf-ci-repro.py" "$@"
|
|
@@ -50,9 +50,16 @@ def cfg(key, default):
|
|
|
50
50
|
PRUNE = {"bin", "obj", "node_modules", ".git", ".artifacts", "OldDocs", "dist", ".tfcore", ".claude", ".opencode", "packages", "TestResults"}
|
|
51
51
|
|
|
52
52
|
|
|
53
|
+
# The folders a project keeps its code in, in the order tf-verify-boot.sh looks (Lekhak TF-014: Lekhak's
|
|
54
|
+
# is source/, and --update counted only src/).
|
|
55
|
+
CODE_ROOTS = ("src", "source")
|
|
56
|
+
|
|
57
|
+
|
|
53
58
|
def walk(root, exts, skip_samples=False):
|
|
54
59
|
for d, dirs, files in os.walk(root):
|
|
55
|
-
|
|
60
|
+
# prune in place: never descend into build output, nor into any hidden folder (.tfbuild, .vs,
|
|
61
|
+
# .playwright …), which holds tool output, never a page (Lekhak TF-014)
|
|
62
|
+
dirs[:] = [x for x in dirs if x not in PRUNE and not x.startswith(".")]
|
|
56
63
|
dd = d.replace("\\", "/") + "/"
|
|
57
64
|
if any(s in dd for s in SKIP):
|
|
58
65
|
continue
|
|
@@ -60,20 +67,28 @@ def walk(root, exts, skip_samples=False):
|
|
|
60
67
|
continue
|
|
61
68
|
for f in files:
|
|
62
69
|
if f.lower().endswith(exts):
|
|
63
|
-
|
|
70
|
+
p = os.path.join(d, f).replace("\\", "/")
|
|
71
|
+
# only a leading "./" goes: lstrip("./") also ate the dot of a hidden folder, and
|
|
72
|
+
# .tfbuild/… became tfbuild/…, which does not exist (Lekhak TF-014)
|
|
73
|
+
yield p[2:] if p.startswith("./") else p
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def code_files(exts, skip_samples=True):
|
|
77
|
+
"""Every file with these extensions under the project's code folders (src/, source/)."""
|
|
78
|
+
for r in CODE_ROOTS:
|
|
79
|
+
if os.path.isdir(r):
|
|
80
|
+
yield from walk(r, exts, skip_samples=skip_samples)
|
|
64
81
|
|
|
65
82
|
|
|
66
83
|
def roles_from_usageguide(app):
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
roles.append((c[1].strip("`* "), c[3].strip("`* ")))
|
|
76
|
-
return roles
|
|
84
|
+
# the guide may be {App}-UsageGuide.md or {App}-Usage-Guide.md, its heading ## or ###, and its
|
|
85
|
+
# table the template's numbered shape or one read by its header names (AppManager TF-009):
|
|
86
|
+
# the same reader tf-verify-list.py uses
|
|
87
|
+
import importlib.util
|
|
88
|
+
spec = importlib.util.spec_from_file_location("tf_verify_list", os.path.join(os.path.dirname(os.path.abspath(__file__)), "tf-verify-list.py"))
|
|
89
|
+
vl = importlib.util.module_from_spec(spec)
|
|
90
|
+
spec.loader.exec_module(vl)
|
|
91
|
+
return [(u["user"], u["role"]) for u in vl.test_users(vl.usage_guide(app))]
|
|
77
92
|
|
|
78
93
|
|
|
79
94
|
def screens_from_uidesign(app, phase):
|
|
@@ -88,9 +103,9 @@ def screens_from_uidesign(app, phase):
|
|
|
88
103
|
|
|
89
104
|
def routes_in_code():
|
|
90
105
|
found = []
|
|
91
|
-
for f in walk(".", (".razor", ".cshtml", ".cs", ".ts", ".tsx", ".js", ".jsx", ".vue")):
|
|
92
|
-
if "/" not in f:
|
|
93
|
-
continue # a top-level file is never a page
|
|
106
|
+
for f in walk(".", (".razor", ".cshtml", ".cs", ".ts", ".tsx", ".js", ".jsx", ".vue"), skip_samples=True):
|
|
107
|
+
if "/" not in f or re.search(r"\.(spec|test)\.\w+$", f):
|
|
108
|
+
continue # a top-level file is never a page; a test's `path:` is a screenshot, not a route (TfLens TF-050)
|
|
94
109
|
txt = read(f)
|
|
95
110
|
for ext_re, pat, what in ROUTE_PATTERNS:
|
|
96
111
|
if re.search(ext_re, f, re.I):
|
|
@@ -141,7 +156,7 @@ def main(argv):
|
|
|
141
156
|
kind = argv[argv.index("--kind") + 1].lower() # override for a project whose config lacks it
|
|
142
157
|
sub = "app"
|
|
143
158
|
if kind.startswith("lib"):
|
|
144
|
-
razors =
|
|
159
|
+
razors = list(code_files((".razor",)))
|
|
145
160
|
sub = "ui-library" if razors else "service-library"
|
|
146
161
|
print(f"# tf-devguide-list — {app} — kind {sub} — phase {phase}")
|
|
147
162
|
print()
|
|
@@ -184,9 +199,9 @@ def main(argv):
|
|
|
184
199
|
for k in sorted(endpoints):
|
|
185
200
|
print(f"- {k} — {', '.join(sorted(set(endpoints[k]))[:2])}")
|
|
186
201
|
elif sub == "ui-library":
|
|
187
|
-
comps =
|
|
202
|
+
comps = list(code_files((".razor",)))
|
|
188
203
|
if not comps:
|
|
189
|
-
print("NOTHING: no component under src/. Build first.")
|
|
204
|
+
print("NOTHING: no component under src/ or source/. Build first.")
|
|
190
205
|
return 0
|
|
191
206
|
samples = sample_files((".razor", ".cshtml", ".html"))
|
|
192
207
|
stxt = {f: read(f) for f in samples}
|
|
@@ -196,9 +211,9 @@ def main(argv):
|
|
|
196
211
|
shown = [s for s, t in stxt.items() if re.search(rf"<{re.escape(name)}\b", t)]
|
|
197
212
|
print(f"- {os.path.dirname(f).split('/')[-1]} / {name} — {f} — " + (f"shown in {shown[0]}" + (f" (+{len(shown)-1})" if len(shown) > 1 else "") if shown else "NO SAMPLE shows it: a sample gap to log"))
|
|
198
213
|
else:
|
|
199
|
-
files =
|
|
214
|
+
files = list(code_files((".cs", ".ts", ".py", ".go", ".java")))
|
|
200
215
|
if not files:
|
|
201
|
-
print("NOTHING: no source under src/. Build first.")
|
|
216
|
+
print("NOTHING: no source under src/ or source/. Build first.")
|
|
202
217
|
return 0
|
|
203
218
|
items = []
|
|
204
219
|
for f in files:
|
|
@@ -222,6 +237,16 @@ def main(argv):
|
|
|
222
237
|
print()
|
|
223
238
|
if ver is None:
|
|
224
239
|
print(f"## --update: no existing guide at {gp} (or no \"Verified on\"); everything is new")
|
|
240
|
+
# a guide kept under another name or folder (Lekhak TF-014: docs/devguides/, one file per
|
|
241
|
+
# role) is named, so it is read and carried over, never silently rewritten from scratch
|
|
242
|
+
import glob
|
|
243
|
+
base = os.path.basename(gp)[:-len(".md")]
|
|
244
|
+
others = sorted(f for f in glob.glob(os.path.join("docs", "**", base + "*.md"), recursive=True)
|
|
245
|
+
if os.path.normpath(f) != os.path.normpath(gp))
|
|
246
|
+
if others:
|
|
247
|
+
print(f"- {len(others)} guide file(s) under another name: " + ", ".join(f.replace(os.sep, "/") for f in others[:8])
|
|
248
|
+
+ (" …" if len(others) > 8 else ""))
|
|
249
|
+
print(f" read them and carry every entry that still matches the code into {gp}; do not start from nothing")
|
|
225
250
|
else:
|
|
226
251
|
print(f"## --update: existing guide {gp}, Verified on {ver}, {len(entries)} entries")
|
|
227
252
|
try:
|
|
@@ -230,8 +255,8 @@ def main(argv):
|
|
|
230
255
|
except Exception:
|
|
231
256
|
cutoff = None
|
|
232
257
|
if cutoff:
|
|
233
|
-
changed = [f for f in
|
|
234
|
-
if
|
|
258
|
+
changed = [f for f in code_files((".razor", ".cshtml", ".cs", ".ts", ".tsx", ".js", ".py"), skip_samples=False)
|
|
259
|
+
if os.path.getmtime(f) > cutoff]
|
|
235
260
|
print(f"- {len(changed)} source file(s) changed since then; remap the entries they serve, keep the rest verbatim")
|
|
236
261
|
for f in changed[:30]:
|
|
237
262
|
print(f" - {f}")
|
|
@@ -52,12 +52,20 @@ DOC_KINDS = [
|
|
|
52
52
|
("-devguide.md", "devguide", "app-devguide-tmpl.md"),
|
|
53
53
|
("-productguide.md", "productguide", "app-productguide-tmpl.md"),
|
|
54
54
|
("-phases.md", "phases", "app-phases-tmpl.md"),
|
|
55
|
+
("-brief.md", "brief", "app-brief-tmpl.md"),
|
|
56
|
+
("-decision-request.md", "decision-request", "app-decision-request-tmpl.md"),
|
|
57
|
+
("-feedback.md", "feedback", "app-library-feedback-tmpl.md"),
|
|
55
58
|
]
|
|
56
59
|
SIZED_DOCS = {"brd", "architecture", "uidesign", "checklist", "usageguide", "devguide", "productguide"}
|
|
57
60
|
# Large projects: these four split by phase. Phase 1 keeps the plain name (App-BRD.md);
|
|
58
61
|
# phase 2 onward are App-P2-BRD.md and so on. docs/TechieFlow-Document-Schemas.md §2.
|
|
59
62
|
PHASED_DOCS = {"brd", "checklist", "uidesign", "devguide"}
|
|
60
63
|
PHASED_SUFFIX = {"brd": "BRD", "checklist": "Checklist", "uidesign": "UIDesign", "devguide": "DevGuide"}
|
|
64
|
+
# A feedback entry's heading opens with its id: TF-016, TR-010, TR-RAG-002.
|
|
65
|
+
ENTRY_ID = re.compile(r"^[A-Z][A-Z0-9]*(?:-[A-Z0-9]+)*-\d+\b")
|
|
66
|
+
# A closed entry carries its resolution banner near the top of its body. The banner is not
|
|
67
|
+
# always first: an entry may open with a renumbering or scope note, so the window is generous.
|
|
68
|
+
FEEDBACK_CLOSED = re.compile(r"(?i)(✅|\bfixed upstream\b|\bwill not fix\b|\bwont-fix\b|is \*\*closed\*\*)")
|
|
61
69
|
DOC_NAME = re.compile(
|
|
62
70
|
r"^(.+?)(?:-P(\d+))?-(BRD|Deployment-Checklist(?:-[\w]+)?|Checklist|UIDesign|DevGuide|Architecture|Coding-Standards|UsageGuide|Usage-Guide|ProductGuide|Phases)\.md$",
|
|
63
71
|
re.I)
|
|
@@ -68,6 +76,7 @@ BRD_RANGE = re.compile(r"(?:BRD-)?(\d+)\s*(?:to|-|–|—)\s*(?:BRD-)?(\d+)", re
|
|
|
68
76
|
SIZE_NAMES = {"s": "S", "small": "S", "m": "M", "medium": "M", "l": "L", "large": "L"}
|
|
69
77
|
SIZE_LONG = {"S": "Small", "M": "Medium", "L": "Large"}
|
|
70
78
|
REQ_CAP = {"S": 50, "M": 100, "L": 100}
|
|
79
|
+
SCREEN_CAP = {"S": 10, "M": 20, "L": 20} # Large: per phase
|
|
71
80
|
|
|
72
81
|
CHECKLIST_HEADER = "| ID | Requirement | Status | % | Remarks | Details |"
|
|
73
82
|
STATUS_VALUES = {
|
|
@@ -293,6 +302,12 @@ def detect_kind(path: str):
|
|
|
293
302
|
name = os.path.basename(path).lower()
|
|
294
303
|
if re.search(r"-deployment-checklist(-[\w]+)?\.md$", name):
|
|
295
304
|
return "deployment-checklist", "app-deployment-checklist-tmpl.md"
|
|
305
|
+
# phase 2 onward of a Large project: still a BRD to every cross-check, but it carries only
|
|
306
|
+
# its own screens and requirements and points back at phase 1 for the rest (TF-024)
|
|
307
|
+
if re.search(r"-p\d+-brd\.md$", name):
|
|
308
|
+
return "brd", "app-phase-brd-tmpl.md"
|
|
309
|
+
if re.search(r"-p\d+-uidesign\.md$", name):
|
|
310
|
+
return "uidesign", "app-phase-uidesign-tmpl.md"
|
|
296
311
|
for suffix, doc, tmpl in DOC_KINDS:
|
|
297
312
|
if name.endswith(suffix):
|
|
298
313
|
return doc, tmpl
|
|
@@ -330,6 +345,17 @@ def split_name(path: str):
|
|
|
330
345
|
return m.group(1), int(m.group(2) or 1)
|
|
331
346
|
|
|
332
347
|
|
|
348
|
+
def foreign_prefix(path: str):
|
|
349
|
+
"""The name's app when it is not an app of its folder: docs/AppManager-api-usage-guide.md beside
|
|
350
|
+
docs/AppManager-BRD.md is an API reference, not a UsageGuide (AppManager TF-024). A folder
|
|
351
|
+
holding no BRD or checklist anchors nothing, so a lone document is checked by its name."""
|
|
352
|
+
app, _ = split_name(path)
|
|
353
|
+
folder = os.path.dirname(os.path.abspath(path))
|
|
354
|
+
apps = {m.group(1).lower() for m in (re.match(r"(.+?)-(BRD|Checklist)\.md$", f, re.I) for f in os.listdir(folder))
|
|
355
|
+
if m and not re.search(r"-P\d+$|-Deployment$", m.group(1), re.I)}
|
|
356
|
+
return app if app and apps and app.lower() not in apps else None
|
|
357
|
+
|
|
358
|
+
|
|
333
359
|
def phase_file(app: str, phase: int, suffix: str) -> str:
|
|
334
360
|
return f"{app}-{suffix}.md" if phase <= 1 else f"{app}-P{phase}-{suffix}.md"
|
|
335
361
|
|
|
@@ -378,6 +404,10 @@ def check_document(path: str, rep: Report, cli_size=None, root=None):
|
|
|
378
404
|
if not doc:
|
|
379
405
|
rep.warn(path, "not a TechieFlow document name; skipped")
|
|
380
406
|
return None
|
|
407
|
+
foreign = foreign_prefix(path)
|
|
408
|
+
if foreign:
|
|
409
|
+
rep.warn(os.path.relpath(path, root or find_root(path)), f'"{foreign}" is not an app here (no {foreign}-BRD.md or -Checklist.md beside it); not a TechieFlow document, skipped')
|
|
410
|
+
return None
|
|
381
411
|
try:
|
|
382
412
|
schema = load_schema(tmpl)
|
|
383
413
|
except (OSError, ValueError) as e:
|
|
@@ -429,6 +459,9 @@ def check_document(path: str, rep: Report, cli_size=None, root=None):
|
|
|
429
459
|
continue
|
|
430
460
|
if doc == "checklist" and re.search(r"<a id=['\"]d-req-", txt, re.I):
|
|
431
461
|
continue # a page group holding detail entries
|
|
462
|
+
if doc == "feedback" and ENTRY_ID.match(h.strip()):
|
|
463
|
+
continue # an entry written at ## level: the layout every feedback file shipped
|
|
464
|
+
# with before this schema existed. Read as an entry, not a stranger.
|
|
432
465
|
extra = "; bugs and feedback go to the misses stream, not the checklist" if doc == "checklist" else ""
|
|
433
466
|
rep.fail(rel, f'section "{h}" is not in the template{extra}')
|
|
434
467
|
|
|
@@ -438,6 +471,9 @@ def check_document(path: str, rep: Report, cli_size=None, root=None):
|
|
|
438
471
|
d = schema.declared(key)
|
|
439
472
|
if not d:
|
|
440
473
|
continue
|
|
474
|
+
if doc == "feedback" and d[1] != "required":
|
|
475
|
+
continue # correspondence blocks are appended over time and sit either side
|
|
476
|
+
# of the entries; where they land carries no meaning
|
|
441
477
|
idx = order_of[norm_heading(d[0])]
|
|
442
478
|
if idx < last_idx:
|
|
443
479
|
rep.fail(rel, f'section "{h}" comes after "{last_name}"; the template order is the other way round')
|
|
@@ -457,14 +493,24 @@ def check_document(path: str, rep: Report, cli_size=None, root=None):
|
|
|
457
493
|
total = word_count(body)
|
|
458
494
|
if size in schema.budget:
|
|
459
495
|
target, mx = schema.budget[size]
|
|
496
|
+
# The budget holds the size's screen cap of entries. Each entry past it brings its own
|
|
497
|
+
# per-entry target and maximum, which already bound it (AppManager TF-023: 54 screens).
|
|
498
|
+
extra, why = 0, ""
|
|
499
|
+
if schema.entries and schema.per_entry:
|
|
500
|
+
sec = section_text(present, schema.entries[0]) or ""
|
|
501
|
+
extra = max(0, sum(1 for h, _t in split_sections(sec, 3) if h) - SCREEN_CAP[size])
|
|
502
|
+
target, mx = target + extra * schema.per_entry[0], mx + extra * schema.per_entry[1]
|
|
503
|
+
why = f", with {extra} entries past the cap of {SCREEN_CAP[size]}" if extra else ""
|
|
460
504
|
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")
|
|
505
|
+
rep.fail(rel, f"{total:,} words; the {SIZE_LONG[size]} maximum is {mx:,} (target {target:,}{why}). Shorten prose; never drop a screen, field or requirement to fit")
|
|
462
506
|
elif total > target:
|
|
463
|
-
rep.warn(rel, f"{total:,} words; the {SIZE_LONG[size]} target is {target:,} (maximum {mx:,})")
|
|
507
|
+
rep.warn(rel, f"{total:,} words; the {SIZE_LONG[size]} target is {target:,} (maximum {mx:,}{why})")
|
|
464
508
|
for key, h, txt in present:
|
|
465
509
|
d = schema.declared(key)
|
|
466
510
|
if d and d[2]:
|
|
467
|
-
|
|
511
|
+
# A `####` block inside a section is detail the reader chooses to open, and is not
|
|
512
|
+
# counted — the same rule as a feedback entry's `#### Detail` (owner, 2026-09-08).
|
|
513
|
+
n = word_count(re.split(r"(?m)^####\s", txt)[0])
|
|
468
514
|
if n > d[2]:
|
|
469
515
|
rep.fail(rel, f'section "{h}" is {n} words; at most {d[2]}')
|
|
470
516
|
if schema.max_lines:
|
|
@@ -479,8 +525,14 @@ def check_document(path: str, rep: Report, cli_size=None, root=None):
|
|
|
479
525
|
if schema.entries:
|
|
480
526
|
sec_txt = section_text(present, schema.entries[0])
|
|
481
527
|
prefix = schema.entries[1]
|
|
528
|
+
pairs = list(split_sections(sec_txt, 3)) if sec_txt is not None else []
|
|
529
|
+
if doc == "feedback":
|
|
530
|
+
# Entries may sit under "Entries" as ###, or stand at ## level as every
|
|
531
|
+
# feedback file wrote them before this schema. Both are read the same way.
|
|
532
|
+
pairs += [(h, t) for _k, h, t in present if ENTRY_ID.match(h.strip())]
|
|
533
|
+
sec_txt = sec_txt if sec_txt is not None else ""
|
|
482
534
|
if sec_txt is not None:
|
|
483
|
-
for h3, etxt in
|
|
535
|
+
for h3, etxt in pairs:
|
|
484
536
|
if h3 is None:
|
|
485
537
|
continue
|
|
486
538
|
if prefix and not h3.lower().startswith(prefix.lower()):
|
|
@@ -489,8 +541,16 @@ def check_document(path: str, rep: Report, cli_size=None, root=None):
|
|
|
489
541
|
ename = h3[len(prefix):].strip() if prefix else h3
|
|
490
542
|
ename = re.sub(r"\s*\(.*$", "", ename).strip("` ").strip()
|
|
491
543
|
entry_names.append(ename)
|
|
544
|
+
if doc == "feedback" and FEEDBACK_CLOSED.search(etxt[:2500]):
|
|
545
|
+
continue # a closed entry is the record of what was wrong and why.
|
|
546
|
+
# History may be as long as it needs to be; the shape and the
|
|
547
|
+
# word cap bind the LIVE ones, which are what the owner reads.
|
|
492
548
|
if schema.per_entry:
|
|
493
|
-
|
|
549
|
+
# The cap binds the REPORT, not the working-out. Anything under a `####`
|
|
550
|
+
# heading inside the entry is detail a reader chooses to open, and is not
|
|
551
|
+
# counted: a thorough analysis is worth having, an entry the owner cannot
|
|
552
|
+
# read at a glance is not (owner, 2026-09-08).
|
|
553
|
+
n = word_count(re.split(r"(?m)^####\s", etxt)[0])
|
|
494
554
|
if n > schema.per_entry[1]:
|
|
495
555
|
rep.fail(rel, f'entry "{ename}" is {n} words; maximum {schema.per_entry[1]} (target {schema.per_entry[0]})')
|
|
496
556
|
elif n > schema.per_entry[0]:
|
|
@@ -549,6 +609,29 @@ def check_entry_rule(rule, ename, etxt, rel, root, path, rep):
|
|
|
549
609
|
elif rule == "entry-expected":
|
|
550
610
|
if not re.search(r"(?im)^\s*[-*]?\s*\**expected", etxt):
|
|
551
611
|
rep.fail(rel, f'entry "{ename}" has no "Expected:" line')
|
|
612
|
+
elif rule == "entry-feedback-fields":
|
|
613
|
+
# A feedback entry is a bug report, not an essay. Same eight fields every time,
|
|
614
|
+
# so the upstream team reads them in one shape and the owner reads "Blocks" first.
|
|
615
|
+
for field in ("Severity", "Blocks", "Repro", "Expected", "Actual",
|
|
616
|
+
"Encountered in", "Workaround", "Suggested fix"):
|
|
617
|
+
if not re.search(r"(?im)^\s*[-*]?\s*\**%s\**\s*:" % re.escape(field), etxt):
|
|
618
|
+
rep.fail(rel, f'entry "{ename}" has no "{field}:" line')
|
|
619
|
+
elif rule == "entry-blocks-line":
|
|
620
|
+
# The one word that decides whether the run stops. "no" also has to say what was
|
|
621
|
+
# done instead, because a non-blocking entry is filed and the work carries on.
|
|
622
|
+
m = re.search(r"(?im)^\s*[-*]?\s*\**Blocks\**\s*:\s*(.+)$", etxt)
|
|
623
|
+
if m:
|
|
624
|
+
val = m.group(1).strip().strip("*` ").lower()
|
|
625
|
+
if not re.match(r"^(yes|no)\b", val):
|
|
626
|
+
rep.fail(rel, f'entry "{ename}" says "Blocks: {m.group(1).strip()[:40]}"; it must start "yes" or "no"')
|
|
627
|
+
elif val.startswith("no") and len(val) < 8:
|
|
628
|
+
rep.fail(rel, f'entry "{ename}" says "Blocks: no" and stops; say what was done instead and that the work carried on')
|
|
629
|
+
elif rule == "entry-decision-options":
|
|
630
|
+
if not has_table_with(etxt, "option", "what happens"):
|
|
631
|
+
rep.fail(rel, f'decision "{ename}" has no options table (Option, What happens, What it costs)')
|
|
632
|
+
elif rule == "entry-recommendation":
|
|
633
|
+
if not re.search(r"(?im)^\s*\**My recommendation", etxt):
|
|
634
|
+
rep.fail(rel, f'decision "{ename}" has no "My recommendation:" line — the owner is owed one, with the reason in a sentence')
|
|
552
635
|
|
|
553
636
|
|
|
554
637
|
def check_doc_rule(rule, c, rep):
|
|
@@ -585,6 +668,22 @@ def check_doc_rule(rule, c, rep):
|
|
|
585
668
|
elif n_acc > ACC_TARGET:
|
|
586
669
|
rep.warn(rel, f"BRD-{cur} acceptance line is {n_acc} words; the target is {ACC_TARGET} (maximum {ACC_MAX})")
|
|
587
670
|
|
|
671
|
+
elif rule == "phase-pointer":
|
|
672
|
+
# a phase BRD leaves scope, users, the whole-app non-functionals, constraints and risks to
|
|
673
|
+
# phase 1, and a phase UIDesign the design system and the click-through flow; this section is
|
|
674
|
+
# how a reader finds them, so it must actually lead to the phase-1 document of the same kind
|
|
675
|
+
txt = section_text(present, "Where the rest lives")
|
|
676
|
+
if txt is None or not c.get("app"):
|
|
677
|
+
return
|
|
678
|
+
want = phase_file(c["app"], 1, PHASED_SUFFIX[c["doc"]])
|
|
679
|
+
hits = [l for l in re.findall(r"\]\(([^)\s]+)\)", txt)
|
|
680
|
+
if os.path.basename(l.split("#", 1)[0]).lower() == want.lower()]
|
|
681
|
+
if not hits:
|
|
682
|
+
rep.fail(rel, f'the "Where the rest lives" section does not link to the phase-1 document ({want}), '
|
|
683
|
+
f'where everything the whole application shares lives')
|
|
684
|
+
elif not any(resolve(root, path, l) for l in hits):
|
|
685
|
+
rep.fail(rel, f'the "Where the rest lives" link to {hits[0]} points at a file that does not exist')
|
|
686
|
+
|
|
588
687
|
elif rule == "mockup-links":
|
|
589
688
|
for l in sorted(links_to(clean, "mockups")):
|
|
590
689
|
if not resolve(root, path, l):
|
|
@@ -609,6 +708,7 @@ def check_doc_rule(rule, c, rep):
|
|
|
609
708
|
continue
|
|
610
709
|
names.append(r[sc])
|
|
611
710
|
c["brd_screens"] = names
|
|
711
|
+
check_named_screens(c, rep)
|
|
612
712
|
|
|
613
713
|
elif rule == "stack-table":
|
|
614
714
|
txt = section_text(present, "Stack decisions")
|
|
@@ -782,6 +882,70 @@ def check_doc_rule(rule, c, rep):
|
|
|
782
882
|
if re.search(r"\b(and|or)\b|,|/", tgt):
|
|
783
883
|
rep.fail(rel, f'Hosting target "{tgt}" names more than one target; one document per hosting target')
|
|
784
884
|
|
|
885
|
+
elif rule == "brief-must-do":
|
|
886
|
+
txt = section_text(present, "Must do")
|
|
887
|
+
if txt is None:
|
|
888
|
+
return
|
|
889
|
+
n = len(re.findall(r"(?m)^\s*\d+[.)]\s+\S", txt))
|
|
890
|
+
if n == 0:
|
|
891
|
+
rep.fail(rel, 'the "Must do" section is not a numbered list; day-1 turns each line into requirements, so one line is one thing the product does')
|
|
892
|
+
elif n > 25:
|
|
893
|
+
rep.fail(rel, f'the "Must do" section has {n} lines; a brief that long is a BRD. Keep the page, and let day-1 expand it')
|
|
894
|
+
|
|
895
|
+
elif rule == "no-glossary":
|
|
896
|
+
# A document written for the owner explains itself in plain words or it is
|
|
897
|
+
# written wrong. A section that defines the document's own vocabulary before it
|
|
898
|
+
# can ask its question is the tell: the fix is to change the words, never to
|
|
899
|
+
# teach them (owner, 2026-09-08).
|
|
900
|
+
for _key, h, _txt in present:
|
|
901
|
+
if re.search(r"(?i)\b(glossary|terminology|words this document uses|"
|
|
902
|
+
r"terms? (used|you)|what these words mean|definitions)\b", h):
|
|
903
|
+
rep.fail(rel, f'section "{h}" defines this document\'s own vocabulary; '
|
|
904
|
+
f'write it in plain English instead — if a word needs a glossary, use a different word')
|
|
905
|
+
|
|
906
|
+
elif rule == "feedback-blocking-count":
|
|
907
|
+
txt = section_text(present, "Summary")
|
|
908
|
+
if txt is None:
|
|
909
|
+
return
|
|
910
|
+
if not re.search(r"(?i)\bblocking\b|\bnothing is blocked\b", txt):
|
|
911
|
+
rep.fail(rel, 'the Summary must say how many entries are blocking right now, '
|
|
912
|
+
'or "Nothing is blocked" — it is the first thing the owner reads')
|
|
913
|
+
# ... and its counts must be the entries' own. TfLens's read "20 entries, 8 open" over 27
|
|
914
|
+
# entries with 7 fixed upstream, and a TrBlazeUI file "all 28 open, none fixed upstream"
|
|
915
|
+
# under the library's own reply fixing 24 of them (MISS-TechieFlow-20260911-04).
|
|
916
|
+
sys.path.insert(0, SELF_DIR)
|
|
917
|
+
import tf_feedback
|
|
918
|
+
es = tf_feedback.entries(path)
|
|
919
|
+
n = {s: [e["id"] for e in es if e["state"] == s] for s in ("open", "fixed", "closed")}
|
|
920
|
+
truth = (f"{len(es)} entries: {len(n['open'])} open, {len(n['fixed'])} fixed upstream and not yet "
|
|
921
|
+
f"re-checked, {len(n['closed'])} closed")
|
|
922
|
+
head = re.split(r"(?m)^####\s", txt)[0]
|
|
923
|
+
said_total = re.search(r"(?i)\b(\d+)\s+entries\b", head)
|
|
924
|
+
said_open = re.search(r"(?i)\b(?:all\s+)?(\d+)\s+(?:filed and\s+)?open\b", head)
|
|
925
|
+
wrong = ((said_total and int(said_total.group(1)) != len(es))
|
|
926
|
+
or (said_open and int(said_open.group(1)) != len(n["open"])))
|
|
927
|
+
if wrong:
|
|
928
|
+
rep.fail(rel, f"the Summary's counts are not the entries' — the file holds {truth}; "
|
|
929
|
+
f"rewrite it from: bash .tfcore/utils/tf-feedback.sh {os.path.basename(path).split('-')[0]}")
|
|
930
|
+
elif n["fixed"] and not re.search(r"(?i)fixed upstream|re-?check", head):
|
|
931
|
+
rep.fail(rel, f"{len(n['fixed'])} entries are fixed upstream and not yet re-checked here "
|
|
932
|
+
f"({', '.join(n['fixed'][:5])}{' …' if len(n['fixed']) > 5 else ''}); the Summary must say so")
|
|
933
|
+
|
|
934
|
+
elif rule == "paste-back-block":
|
|
935
|
+
# `present` is built from the prose-only copy, which drops fenced blocks — and a
|
|
936
|
+
# fenced block is exactly what this rule looks for. Re-split the uncommented source.
|
|
937
|
+
raw_present = [(norm_heading(h), h, t) for h, t in split_sections(nocomment, 2) if h is not None]
|
|
938
|
+
txt = section_text(raw_present, "Copy this back to me")
|
|
939
|
+
if txt is None:
|
|
940
|
+
return
|
|
941
|
+
blocks = re.findall(r"^```", txt, re.M)
|
|
942
|
+
if len(blocks) < 2:
|
|
943
|
+
rep.fail(rel, 'the "Copy this back to me" section has no fenced block; '
|
|
944
|
+
'the owner answers by pasting it, so it must be there to paste')
|
|
945
|
+
n_dec = len(c.get("entry_names") or [])
|
|
946
|
+
if n_dec and len(blocks) // 2 < n_dec:
|
|
947
|
+
rep.fail(rel, f'{n_dec} decisions but {len(blocks) // 2} paste-back block(s); one per decision')
|
|
948
|
+
|
|
785
949
|
elif rule == "phases-table":
|
|
786
950
|
txt = section_text(present, "Phases")
|
|
787
951
|
if txt is None:
|
|
@@ -876,7 +1040,11 @@ def check_checklist(c, rep):
|
|
|
876
1040
|
n = len(re.findall(r"\S+", remarks))
|
|
877
1041
|
if n > 60:
|
|
878
1042
|
rep.fail(rel, f"{rid} Remarks is {n} words; at most 60, current state only (history lives in the telemetry streams)")
|
|
879
|
-
|
|
1043
|
+
# text in backticks is quoted — a test's title or message the verdict script copied — not an agent's
|
|
1044
|
+
# claim about a file; an unclosed span (a Remark cut at its word cap) counts as quoted to its end
|
|
1045
|
+
# (Lekhak TF-012)
|
|
1046
|
+
said = re.sub(r"`[^`]*(?:`|$)", " ", remarks)
|
|
1047
|
+
if NOT_PRESENT.search(said) and not NAMES_PATH.search(remarks):
|
|
880
1048
|
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
1049
|
m = re.search(r"\(#([^)]+)\)", details)
|
|
882
1050
|
if not m:
|
|
@@ -899,8 +1067,15 @@ def check_checklist(c, rep):
|
|
|
899
1067
|
if not m:
|
|
900
1068
|
continue # reported above through the Details link
|
|
901
1069
|
pos = m.start()
|
|
902
|
-
|
|
903
|
-
|
|
1070
|
+
# A "### Page: …" heading written straight under the entry's own anchor belongs to the entry.
|
|
1071
|
+
# Stopping at the first "### " cut such an entry off before its content: three false failures
|
|
1072
|
+
# each, and 24 AppManager entries never really checked (TF-002). The next one still ends it.
|
|
1073
|
+
start = pos + 1
|
|
1074
|
+
own = re.match(r"<a id=[^>]*>\s*</a>[^\n]*\n(?:[ \t]*\n)*[ \t]*### [^\n]*", body[pos:])
|
|
1075
|
+
if own:
|
|
1076
|
+
start = pos + own.end()
|
|
1077
|
+
nxt = re.search(r"\n\s*(?:[-*]\s*)?<a id=|\n## |\n### ", body[start:]) # an entry is a list item: the dash precedes the anchor (miss 21)
|
|
1078
|
+
entry = body[pos: start + nxt.start()] if nxt else body[pos:]
|
|
904
1079
|
acc = [l for l in entry.splitlines() if ACCEPT_LINE.search(l)]
|
|
905
1080
|
if len(acc) != 1:
|
|
906
1081
|
rep.fail(rel, f"{rid} must have exactly one acceptance line; found {len(acc)}")
|
|
@@ -942,6 +1117,48 @@ def _screen_key(s):
|
|
|
942
1117
|
return re.sub(r"\s+", " ", s).strip().lower()
|
|
943
1118
|
|
|
944
1119
|
|
|
1120
|
+
# A requirement names its screen in the template's "*Screen:* <name>" field, or in running text as
|
|
1121
|
+
# "**<name>** screen". Either must have a row in some phase's Screens and flow table: the row is what
|
|
1122
|
+
# brings the UI design entry and the mockup after it (TF-030).
|
|
1123
|
+
SCREEN_FIELD = re.compile(r"\*{1,2}Screen:?\*{1,2}:?\s*([^·*|\n]+)")
|
|
1124
|
+
SCREEN_BOLD = re.compile(r"\*\*([^*\n]{2,60})\*\*[ -]screens?\b", re.I)
|
|
1125
|
+
NOT_A_NAME = {"every", "any", "each", "one", "this", "that", "the", "a", "new", "same", "other", "whole", "single", "no"}
|
|
1126
|
+
|
|
1127
|
+
|
|
1128
|
+
def _table_screen_keys(txt):
|
|
1129
|
+
"""Every name in the Screen column of a Screens and flow section, dialogs included."""
|
|
1130
|
+
out = set()
|
|
1131
|
+
for header, rows in tables_in(txt or ""):
|
|
1132
|
+
low = [h.lower() for h in header]
|
|
1133
|
+
if "screen" in low and "route" in low:
|
|
1134
|
+
sc = low.index("screen")
|
|
1135
|
+
out |= {_screen_key(r[sc]) for r in rows if len(r) > sc and r[sc] and not r[sc].startswith("{")}
|
|
1136
|
+
return out
|
|
1137
|
+
|
|
1138
|
+
|
|
1139
|
+
def check_named_screens(c, rep):
|
|
1140
|
+
known = _table_screen_keys(section_text(c["present"], "Screens and flow"))
|
|
1141
|
+
if c.get("app"): # a phase BRD may name a screen another phase's BRD owns
|
|
1142
|
+
for f in sorted(os.listdir(os.path.dirname(c["path"]))):
|
|
1143
|
+
if f != os.path.basename(c["path"]) and re.match(rf"^{re.escape(c['app'])}-(P\d+-)?BRD\.md$", f):
|
|
1144
|
+
with open(os.path.join(os.path.dirname(c["path"]), f), encoding="utf-8") as fh:
|
|
1145
|
+
other = [(norm_heading(h), h, t) for h, t in split_sections(strip_noise(fh.read()), 2) if h is not None]
|
|
1146
|
+
known |= _table_screen_keys(section_text(other, "Screens and flow"))
|
|
1147
|
+
cur, told = None, set()
|
|
1148
|
+
for line in (section_text(c["present"], "Requirements") or "").splitlines():
|
|
1149
|
+
m = re.search(r"\*\*BRD-(\d+)\*\*", line)
|
|
1150
|
+
if m:
|
|
1151
|
+
cur = m.group(1)
|
|
1152
|
+
for name in [x.group(1) for x in SCREEN_FIELD.finditer(line)] + [x.group(1) for x in SCREEN_BOLD.finditer(line)]:
|
|
1153
|
+
key = _screen_key(name)
|
|
1154
|
+
if not key or name.strip().startswith("{") or key in NOT_A_NAME or key in known or key in told:
|
|
1155
|
+
continue
|
|
1156
|
+
told.add(key)
|
|
1157
|
+
where = f"BRD-{cur}" if cur else "a requirement"
|
|
1158
|
+
rep.fail(c["rel"], f'{where} names the screen "{name.strip()}", which has no row in any Screens and flow table; '
|
|
1159
|
+
f'add the row, then its UI design entry and its mockup, before it is built')
|
|
1160
|
+
|
|
1161
|
+
|
|
945
1162
|
def cross_checks(ctxs: dict, rep: Report, root: str):
|
|
946
1163
|
"""ctxs is keyed by (doc, phase). Single documents sit at phase 1."""
|
|
947
1164
|
phases = sorted({p for (_d, p) in ctxs})
|
|
@@ -995,9 +1212,19 @@ def cross_phase_checks(ctxs, rep, root, phases):
|
|
|
995
1212
|
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
1213
|
seen.setdefault(i, p)
|
|
997
1214
|
|
|
1215
|
+
if not ph and app:
|
|
1216
|
+
# It may simply not have been named on the command line. Read it from disk for this rule and
|
|
1217
|
+
# throw its own findings away, since they belong to a run that names it: saying "the Phases
|
|
1218
|
+
# document does not exist" about a file that is there invites writing over it (TF-041).
|
|
1219
|
+
disk = os.path.join(root, "docs", f"{app}-Phases.md")
|
|
1220
|
+
if os.path.isfile(disk):
|
|
1221
|
+
try:
|
|
1222
|
+
ph = check_document(disk, Report(False), None, root)
|
|
1223
|
+
except Exception:
|
|
1224
|
+
ph = None
|
|
998
1225
|
if not ph:
|
|
999
1226
|
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
|
|
1227
|
+
rep.fail(f"docs/{app}-Phases.md", f"phase files exist ({', '.join(f'P{p}' for p in phases if p > 1)}) but no Phases document is on disk; write it from app-phases-tmpl.md")
|
|
1001
1228
|
return
|
|
1002
1229
|
rows = ph.get("phases") or []
|
|
1003
1230
|
if not rows:
|
|
@@ -1148,7 +1375,7 @@ def check_mockups(root: str, rep: Report):
|
|
|
1148
1375
|
# ----------------------------------------------------------------------------
|
|
1149
1376
|
def app_files(root: str, app: str):
|
|
1150
1377
|
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",
|
|
1378
|
+
names = [f"{app}-Brief.md", f"{app}-Phases.md", f"{app}-BRD.md", f"{app}-Architecture.md", f"{app}-UIDesign.md", f"{app}-Checklist.md",
|
|
1152
1379
|
f"{app}-Coding-Standards.md", f"{app}-UsageGuide.md", f"{app}-DevGuide.md", f"{app}-ProductGuide.md"]
|
|
1153
1380
|
# later phases of a Large project: App-P2-BRD.md, App-P2-Checklist.md, … in phase order
|
|
1154
1381
|
extra = []
|
|
@@ -1160,6 +1387,11 @@ def app_files(root: str, app: str):
|
|
|
1160
1387
|
names += [f for _p, _o, f in sorted(extra)]
|
|
1161
1388
|
if os.path.isdir(docs):
|
|
1162
1389
|
names += sorted(f for f in os.listdir(docs) if re.match(rf"^{re.escape(app)}-Deployment-Checklist(-[\w]+)?\.md$", f))
|
|
1390
|
+
# the two documents written for a reader outside this project: the owner's open
|
|
1391
|
+
# decisions, and whatever this project has reported upstream (one file per upstream)
|
|
1392
|
+
names += sorted(f for f in os.listdir(docs)
|
|
1393
|
+
if re.match(rf"^{re.escape(app)}-Decision-Request\.md$", f)
|
|
1394
|
+
or re.match(rf"^{re.escape(app)}-[\w.]+-Feedback\.md$", f))
|
|
1163
1395
|
out = [os.path.join(docs, n) for n in names if os.path.exists(os.path.join(docs, n))]
|
|
1164
1396
|
ps = os.path.join(root, "PROJECT-STATUS.md")
|
|
1165
1397
|
if os.path.exists(ps):
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# tf-doc-tests.sh — run the unit tests when a test reads a document this command changed (Lekhak TF-016, 2026-09-30).
|
|
3
|
+
#
|
|
4
|
+
# bash .tfcore/utils/tf-doc-tests.sh <doc> [<doc>…] [--target <sln|csproj>]
|
|
5
|
+
#
|
|
6
|
+
# A project may guard a document with a unit test: Lekhak's VerificationRuleDocTests reads
|
|
7
|
+
# docs/Lekhak-UsageGuide.md and fails when the REQ-NFR-042 paragraph is gone. *amend-docs removed that
|
|
8
|
+
# paragraph, ran no test, closed green, and only CI noticed. This script looks for test source files
|
|
9
|
+
# (a path containing "test") that name one of the documents, by file name or by name without ".md".
|
|
10
|
+
# When one does, it runs the unit tests through tf-build.sh test. Prints ONE line:
|
|
11
|
+
# NONE no test reads <docs>; nothing to run exit 0
|
|
12
|
+
# PASS <n> test file(s) read <docs> (<files>); the unit tests pass — <build line> exit 0
|
|
13
|
+
# FAIL <n> test file(s) read <docs> (<files>); the unit tests fail — <build line> exit 1
|
|
14
|
+
# NOT-RUN the tests could not be run — <reason> exit 2
|
|
15
|
+
# A FAIL means the phase is not closed: a test guards text this command changed.
|
|
16
|
+
set -uo pipefail
|
|
17
|
+
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
18
|
+
source "$HERE/tf-portable.sh" # tf_read_lines, for bash 3.2 on a stock Mac
|
|
19
|
+
DOCS=(); TARGET=""
|
|
20
|
+
while [[ $# -gt 0 ]]; do
|
|
21
|
+
case "$1" in
|
|
22
|
+
--target) TARGET="${2:-}"; shift 2 ;;
|
|
23
|
+
-h|--help) sed -n '2,15p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
|
24
|
+
*) DOCS+=("$1"); shift ;;
|
|
25
|
+
esac
|
|
26
|
+
done
|
|
27
|
+
[[ ${#DOCS[@]} -gt 0 ]] || { echo "tf-doc-tests: name at least one document" >&2; exit 2; }
|
|
28
|
+
|
|
29
|
+
READERS="$(python3 - "${DOCS[@]}" <<'PY'
|
|
30
|
+
import os, sys
|
|
31
|
+
names = set()
|
|
32
|
+
for d in sys.argv[1:]:
|
|
33
|
+
b = os.path.basename(d)
|
|
34
|
+
names.add(b)
|
|
35
|
+
stem = os.path.splitext(b)[0]
|
|
36
|
+
if len(stem) >= 6: # "README" alone would match half the code; a real document name is longer
|
|
37
|
+
names.add(stem)
|
|
38
|
+
EXT = (".cs", ".fs", ".vb", ".ts", ".tsx", ".js", ".mjs", ".py", ".java", ".kt", ".go", ".rs", ".rb", ".swift", ".dart", ".php")
|
|
39
|
+
SKIP = {"node_modules", "bin", "obj", ".git", ".tfcore", ".claude", ".opencode", "docs", ".artifacts"}
|
|
40
|
+
hits = []
|
|
41
|
+
for root, dirs, files in os.walk("."):
|
|
42
|
+
dirs[:] = [x for x in dirs if x not in SKIP and not (x.startswith(".") and x != ".")]
|
|
43
|
+
for fn in files:
|
|
44
|
+
p = os.path.join(root, fn)[2:]
|
|
45
|
+
if not fn.endswith(EXT) or "test" not in p.lower():
|
|
46
|
+
continue
|
|
47
|
+
try:
|
|
48
|
+
t = open(p, encoding="utf-8", errors="replace").read()
|
|
49
|
+
except OSError:
|
|
50
|
+
continue
|
|
51
|
+
if any(n in t for n in names):
|
|
52
|
+
hits.append(p)
|
|
53
|
+
print("\n".join(sorted(hits)))
|
|
54
|
+
PY
|
|
55
|
+
)"
|
|
56
|
+
if [[ -z "$READERS" ]]; then
|
|
57
|
+
echo "NONE no test reads ${DOCS[*]}; nothing to run"
|
|
58
|
+
exit 0
|
|
59
|
+
fi
|
|
60
|
+
N="$(wc -l <<<"$READERS" | tr -d ' ')"; LIST="$(tr '\n' ',' <<<"$READERS" | sed 's/,$//; s/,/, /g')"
|
|
61
|
+
|
|
62
|
+
# the same target choice as tf-verify-tests.sh: a root solution, else the only test project under tests/
|
|
63
|
+
ROOTSLN="$(compgen -G '*.sln'; compgen -G '*.slnx'; compgen -G '*.csproj')"
|
|
64
|
+
tf_read_lines TESTPROJ < <(find tests -name '*.csproj' -not -path 'tests/.artifacts/*' -not -path '*/bin/*' -not -path '*/obj/*' -not -path '*/node_modules/*' 2>/dev/null | sort)
|
|
65
|
+
[[ -z "$TARGET" && -z "$ROOTSLN" && ${#TESTPROJ[@]} -eq 1 ]] && TARGET="${TESTPROJ[0]}"
|
|
66
|
+
if [[ -z "$TARGET" && -z "$ROOTSLN" ]]; then
|
|
67
|
+
if [[ ${#TESTPROJ[@]} -gt 1 ]]; then
|
|
68
|
+
echo "NOT-RUN $N test file(s) read ${DOCS[*]} ($LIST), but there is no solution here and ${#TESTPROJ[@]} test projects; name one with --target"
|
|
69
|
+
else
|
|
70
|
+
echo "NOT-RUN $N test file(s) read ${DOCS[*]} ($LIST), but no .NET solution or test project is here; run the project's own unit tests and report them"
|
|
71
|
+
fi
|
|
72
|
+
exit 2
|
|
73
|
+
fi
|
|
74
|
+
LINE="$(bash "$HERE/tf-build.sh" test ${TARGET:+"$TARGET"} 2>&1 | grep -m1 -E '^(PASS|FAIL|NOT-RUN)' || true)"
|
|
75
|
+
case "$LINE" in
|
|
76
|
+
PASS*) echo "PASS $N test file(s) read ${DOCS[*]} ($LIST); the unit tests pass — $LINE"; exit 0 ;;
|
|
77
|
+
FAIL*) echo "FAIL $N test file(s) read ${DOCS[*]} ($LIST); the unit tests fail — $LINE"; exit 1 ;;
|
|
78
|
+
*) echo "NOT-RUN the unit tests could not be run — ${LINE:-tf-build.sh printed no verdict}"; exit 2 ;;
|
|
79
|
+
esac
|