@techierathore/techieflow 1.0.4 → 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/tasks/_owner-language.md +1 -0
- package/.claude/commands/TechieFlow/tasks/_status-update-gate.md +4 -3
- package/.claude/commands/TechieFlow/tasks/amend-docs.md +1 -1
- package/.claude/commands/TechieFlow/tasks/day1-brownfield.md +1 -1
- package/.claude/commands/TechieFlow/tasks/day1-greenfield.md +1 -1
- package/.claude/commands/TechieFlow/tasks/fix-issues.md +2 -2
- package/.claude/commands/TechieFlow/tasks/generate-html.md +2 -2
- package/.claude/commands/TechieFlow/tasks/handoff-phase.md +2 -2
- 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/.opencode/plugin/techieflow.js +179 -7
- 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-status-html.sh +86 -2
- package/.tfcore/hooks/guard-verify.sh +8 -3
- package/.tfcore/hooks/sweep-artifacts.sh +46 -0
- package/.tfcore/standards/owner-words.txt +23 -0
- package/.tfcore/tasks/_owner-language.md +1 -0
- package/.tfcore/tasks/_status-update-gate.md +4 -3
- package/.tfcore/tasks/amend-docs.md +1 -1
- package/.tfcore/tasks/day1-brownfield.md +1 -1
- package/.tfcore/tasks/day1-greenfield.md +1 -1
- package/.tfcore/tasks/fix-issues.md +2 -2
- package/.tfcore/tasks/generate-html.md +2 -2
- package/.tfcore/tasks/handoff-phase.md +2 -2
- 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/telemetry/SCHEMA.md +4 -0
- package/.tfcore/telemetry/install-metrics.sh +3 -2
- package/.tfcore/telemetry/tf-metrics.sh +19 -2
- 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-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-devguide-list.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-doc-check.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-misses-md.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 +80 -12
- package/.tfcore/utils/tf-build-list.sh +2 -1
- 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 +136 -6
- package/.tfcore/utils/tf-doc-tests.sh +79 -0
- package/.tfcore/utils/tf-emit.sh +92 -35
- package/.tfcore/utils/tf-feedback.sh +13 -0
- package/.tfcore/utils/tf-fix-close.sh +79 -29
- package/.tfcore/utils/tf-goal.sh +12 -12
- 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 +10 -4
- package/.tfcore/utils/tf-mockup-parity.mjs +427 -63
- package/.tfcore/utils/tf-mockup-parity.sh +14 -2
- package/.tfcore/utils/tf-owner-text.py +233 -0
- package/.tfcore/utils/tf-owner-text.sh +14 -0
- package/.tfcore/utils/tf-phase.sh +15 -1
- package/.tfcore/utils/tf-portable.sh +230 -0
- package/.tfcore/utils/tf-render-html.py +60 -2
- package/.tfcore/utils/tf-selfcheck.sh +39 -6
- package/.tfcore/utils/tf-split-brd.py +11 -1
- package/.tfcore/utils/tf-status-evidence.sh +11 -9
- package/.tfcore/utils/tf-status-facts.py +72 -32
- 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 +171 -32
- package/.tfcore/utils/tf-verify-screens.sh +8 -3
- package/.tfcore/utils/tf-verify-tests.sh +215 -14
- package/.tfcore/utils/tf-verify-verdict.py +48 -9
- 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 +4 -0
- package/docs/TechieFlow-Installation.md +5 -3
- package/package.json +1 -1
|
@@ -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}")
|
|
@@ -76,6 +76,7 @@ BRD_RANGE = re.compile(r"(?:BRD-)?(\d+)\s*(?:to|-|–|—)\s*(?:BRD-)?(\d+)", re
|
|
|
76
76
|
SIZE_NAMES = {"s": "S", "small": "S", "m": "M", "medium": "M", "l": "L", "large": "L"}
|
|
77
77
|
SIZE_LONG = {"S": "Small", "M": "Medium", "L": "Large"}
|
|
78
78
|
REQ_CAP = {"S": 50, "M": 100, "L": 100}
|
|
79
|
+
SCREEN_CAP = {"S": 10, "M": 20, "L": 20} # Large: per phase
|
|
79
80
|
|
|
80
81
|
CHECKLIST_HEADER = "| ID | Requirement | Status | % | Remarks | Details |"
|
|
81
82
|
STATUS_VALUES = {
|
|
@@ -301,6 +302,12 @@ def detect_kind(path: str):
|
|
|
301
302
|
name = os.path.basename(path).lower()
|
|
302
303
|
if re.search(r"-deployment-checklist(-[\w]+)?\.md$", name):
|
|
303
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"
|
|
304
311
|
for suffix, doc, tmpl in DOC_KINDS:
|
|
305
312
|
if name.endswith(suffix):
|
|
306
313
|
return doc, tmpl
|
|
@@ -338,6 +345,17 @@ def split_name(path: str):
|
|
|
338
345
|
return m.group(1), int(m.group(2) or 1)
|
|
339
346
|
|
|
340
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
|
+
|
|
341
359
|
def phase_file(app: str, phase: int, suffix: str) -> str:
|
|
342
360
|
return f"{app}-{suffix}.md" if phase <= 1 else f"{app}-P{phase}-{suffix}.md"
|
|
343
361
|
|
|
@@ -386,6 +404,10 @@ def check_document(path: str, rep: Report, cli_size=None, root=None):
|
|
|
386
404
|
if not doc:
|
|
387
405
|
rep.warn(path, "not a TechieFlow document name; skipped")
|
|
388
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
|
|
389
411
|
try:
|
|
390
412
|
schema = load_schema(tmpl)
|
|
391
413
|
except (OSError, ValueError) as e:
|
|
@@ -471,10 +493,18 @@ def check_document(path: str, rep: Report, cli_size=None, root=None):
|
|
|
471
493
|
total = word_count(body)
|
|
472
494
|
if size in schema.budget:
|
|
473
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 ""
|
|
474
504
|
if total > mx:
|
|
475
|
-
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")
|
|
476
506
|
elif total > target:
|
|
477
|
-
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})")
|
|
478
508
|
for key, h, txt in present:
|
|
479
509
|
d = schema.declared(key)
|
|
480
510
|
if d and d[2]:
|
|
@@ -638,6 +668,22 @@ def check_doc_rule(rule, c, rep):
|
|
|
638
668
|
elif n_acc > ACC_TARGET:
|
|
639
669
|
rep.warn(rel, f"BRD-{cur} acceptance line is {n_acc} words; the target is {ACC_TARGET} (maximum {ACC_MAX})")
|
|
640
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
|
+
|
|
641
687
|
elif rule == "mockup-links":
|
|
642
688
|
for l in sorted(links_to(clean, "mockups")):
|
|
643
689
|
if not resolve(root, path, l):
|
|
@@ -662,6 +708,7 @@ def check_doc_rule(rule, c, rep):
|
|
|
662
708
|
continue
|
|
663
709
|
names.append(r[sc])
|
|
664
710
|
c["brd_screens"] = names
|
|
711
|
+
check_named_screens(c, rep)
|
|
665
712
|
|
|
666
713
|
elif rule == "stack-table":
|
|
667
714
|
txt = section_text(present, "Stack decisions")
|
|
@@ -863,6 +910,26 @@ def check_doc_rule(rule, c, rep):
|
|
|
863
910
|
if not re.search(r"(?i)\bblocking\b|\bnothing is blocked\b", txt):
|
|
864
911
|
rep.fail(rel, 'the Summary must say how many entries are blocking right now, '
|
|
865
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")
|
|
866
933
|
|
|
867
934
|
elif rule == "paste-back-block":
|
|
868
935
|
# `present` is built from the prose-only copy, which drops fenced blocks — and a
|
|
@@ -973,7 +1040,11 @@ def check_checklist(c, rep):
|
|
|
973
1040
|
n = len(re.findall(r"\S+", remarks))
|
|
974
1041
|
if n > 60:
|
|
975
1042
|
rep.fail(rel, f"{rid} Remarks is {n} words; at most 60, current state only (history lives in the telemetry streams)")
|
|
976
|
-
|
|
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):
|
|
977
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")
|
|
978
1049
|
m = re.search(r"\(#([^)]+)\)", details)
|
|
979
1050
|
if not m:
|
|
@@ -996,8 +1067,15 @@ def check_checklist(c, rep):
|
|
|
996
1067
|
if not m:
|
|
997
1068
|
continue # reported above through the Details link
|
|
998
1069
|
pos = m.start()
|
|
999
|
-
|
|
1000
|
-
|
|
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:]
|
|
1001
1079
|
acc = [l for l in entry.splitlines() if ACCEPT_LINE.search(l)]
|
|
1002
1080
|
if len(acc) != 1:
|
|
1003
1081
|
rep.fail(rel, f"{rid} must have exactly one acceptance line; found {len(acc)}")
|
|
@@ -1039,6 +1117,48 @@ def _screen_key(s):
|
|
|
1039
1117
|
return re.sub(r"\s+", " ", s).strip().lower()
|
|
1040
1118
|
|
|
1041
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
|
+
|
|
1042
1162
|
def cross_checks(ctxs: dict, rep: Report, root: str):
|
|
1043
1163
|
"""ctxs is keyed by (doc, phase). Single documents sit at phase 1."""
|
|
1044
1164
|
phases = sorted({p for (_d, p) in ctxs})
|
|
@@ -1092,9 +1212,19 @@ def cross_phase_checks(ctxs, rep, root, phases):
|
|
|
1092
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")
|
|
1093
1213
|
seen.setdefault(i, p)
|
|
1094
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
|
|
1095
1225
|
if not ph:
|
|
1096
1226
|
if len(phases) > 1 and app:
|
|
1097
|
-
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")
|
|
1098
1228
|
return
|
|
1099
1229
|
rows = ph.get("phases") or []
|
|
1100
1230
|
if not rows:
|
|
@@ -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
|