@techierathore/techieflow 1.0.4 → 1.1.1
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/flow-master.md +2 -0
- package/.claude/commands/TechieFlow/agents/verifier.md +2 -2
- package/.claude/commands/TechieFlow/tasks/_develop-mode.md +34 -0
- 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/_yolo-mode.md +2 -2
- package/.claude/commands/TechieFlow/tasks/amend-docs.md +2 -2
- package/.claude/commands/TechieFlow/tasks/day1-brownfield.md +3 -3
- package/.claude/commands/TechieFlow/tasks/day1-greenfield.md +5 -5
- package/.claude/commands/TechieFlow/tasks/develop-end-to-end.md +30 -0
- package/.claude/commands/TechieFlow/tasks/fix-issues.md +3 -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 +2 -2
- 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-and-fix.md +2 -2
- package/.claude/commands/TechieFlow/tasks/triage-issues.md +2 -2
- package/.claude/commands/TechieFlow/tasks/verify-phase.md +12 -11
- package/.opencode/plugin/techieflow.js +194 -14
- package/.tfcore/agents/flow-master.md +2 -0
- package/.tfcore/agents/verifier.md +2 -2
- package/.tfcore/core-config.yaml +2 -1
- package/.tfcore/hooks/block-git.sh +4 -2
- package/.tfcore/hooks/guard-build.sh +5 -1
- package/.tfcore/hooks/guard-db.sh +112 -13
- package/.tfcore/hooks/guard-status-html.sh +86 -2
- package/.tfcore/hooks/guard-verify-deps.sh +16 -1
- package/.tfcore/hooks/guard-verify.sh +8 -3
- package/.tfcore/hooks/sweep-artifacts.sh +46 -0
- package/.tfcore/hooks/yolo-allow.sh +27 -0
- package/.tfcore/standards/blazor-zoom.cs.txt +191 -0
- package/.tfcore/standards/coding-standards-dotnet.md +31 -3
- package/.tfcore/standards/owner-words.txt +23 -0
- package/.tfcore/tasks/_develop-mode.md +34 -0
- package/.tfcore/tasks/_owner-language.md +1 -0
- package/.tfcore/tasks/_status-update-gate.md +4 -3
- package/.tfcore/tasks/_yolo-mode.md +2 -2
- package/.tfcore/tasks/amend-docs.md +2 -2
- package/.tfcore/tasks/day1-brownfield.md +3 -3
- package/.tfcore/tasks/day1-greenfield.md +5 -5
- package/.tfcore/tasks/develop-end-to-end.md +30 -0
- package/.tfcore/tasks/fix-issues.md +3 -2
- package/.tfcore/tasks/generate-html.md +2 -2
- package/.tfcore/tasks/handoff-phase.md +2 -2
- package/.tfcore/tasks/mockups.md +2 -2
- package/.tfcore/tasks/productguide.md +2 -2
- package/.tfcore/tasks/render-workflow-docs.md +1 -1
- package/.tfcore/tasks/triage-and-fix.md +2 -2
- package/.tfcore/tasks/triage-issues.md +2 -2
- package/.tfcore/tasks/verify-phase.md +12 -11
- package/.tfcore/telemetry/SCHEMA.md +10 -2
- package/.tfcore/telemetry/install-metrics.sh +3 -2
- package/.tfcore/telemetry/tf-metrics.sh +38 -2
- package/.tfcore/templates/stack-defaults/dotnet.md +3 -1
- package/.tfcore/templates/v4custom/app-agents-md-tmpl.md +6 -0
- package/.tfcore/templates/v4custom/app-architecture-tmpl.md +5 -0
- package/.tfcore/templates/v4custom/app-brd-tmpl.md +7 -4
- package/.tfcore/templates/v4custom/app-checklist-tmpl.md +11 -0
- 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-phases-tmpl.md +2 -1
- 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 +6 -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-split-brd.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-status-facts.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-verify-list.cpython-312.pyc +0 -0
- package/.tfcore/utils/tf-appium.mjs +179 -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 +95 -12
- package/.tfcore/utils/tf-build-list.sh +2 -1
- package/.tfcore/utils/tf-build.sh +163 -15
- package/.tfcore/utils/tf-checklist-edit.py +21 -3
- package/.tfcore/utils/tf-ci-repro.py +685 -0
- package/.tfcore/utils/tf-ci-repro.sh +24 -0
- package/.tfcore/utils/tf-day1-files.py +55 -28
- package/.tfcore/utils/tf-develop.sh +264 -0
- package/.tfcore/utils/tf-devguide-list.py +53 -24
- package/.tfcore/utils/tf-doc-check.py +218 -26
- package/.tfcore/utils/tf-doc-tests.sh +116 -0
- package/.tfcore/utils/tf-emit.sh +132 -45
- package/.tfcore/utils/tf-feedback.sh +13 -0
- package/.tfcore/utils/tf-fix-close.sh +87 -31
- 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-maccatalyst-check.sh +92 -0
- package/.tfcore/utils/tf-menus.mjs +401 -0
- package/.tfcore/utils/tf-misses-md.py +10 -4
- package/.tfcore/utils/tf-mockup-parity.mjs +763 -71
- package/.tfcore/utils/tf-mockup-parity.sh +25 -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 +38 -4
- package/.tfcore/utils/tf-portable.sh +230 -0
- package/.tfcore/utils/tf-render-html.py +79 -5
- package/.tfcore/utils/tf-req-ids.py +36 -0
- package/.tfcore/utils/tf-selfcheck.sh +39 -6
- package/.tfcore/utils/tf-split-brd.py +11 -1
- package/.tfcore/utils/tf-stack-check.sh +3 -0
- package/.tfcore/utils/tf-status-evidence.sh +11 -9
- package/.tfcore/utils/tf-status-facts.py +160 -45
- package/.tfcore/utils/tf-theme.mjs +189 -0
- package/.tfcore/utils/tf-triage.py +74 -17
- package/.tfcore/utils/tf-triage.sh +2 -0
- package/.tfcore/utils/tf-verify-boot.sh +529 -54
- package/.tfcore/utils/tf-verify-emit.sh +57 -17
- package/.tfcore/utils/tf-verify-env.sh +33 -4
- package/.tfcore/utils/tf-verify-list.py +290 -24
- package/.tfcore/utils/tf-verify-list.sh +3 -1
- package/.tfcore/utils/tf-verify-native.mjs +307 -0
- package/.tfcore/utils/tf-verify-screens.mjs +341 -44
- package/.tfcore/utils/tf-verify-screens.sh +23 -5
- package/.tfcore/utils/tf-verify-tests.sh +310 -19
- package/.tfcore/utils/tf-verify-verdict.py +163 -21
- package/.tfcore/utils/tf-verify-zoom.mjs +193 -0
- package/.tfcore/utils/tf-verify-zoom.sh +29 -0
- package/.tfcore/utils/tf-yolo-alias.sh +84 -0
- package/.tfcore/utils/tf-yolo-perms.py +135 -0
- package/.tfcore/utils/tf-yolo-start.sh +44 -0
- package/.tfcore/utils/tf-yolo.sh +31 -3
- package/.tfcore/utils/tf_defect.py +27 -0
- package/.tfcore/utils/tf_feedback.py +272 -0
- package/.tfcore/utils/tf_roadmap.py +35 -0
- package/.tfcore/utils/tf_stack_check.py +93 -0
- package/README.md +6 -2
- package/docs/TechieFlow-Installation.md +5 -3
- package/opencode.jsonc +6 -2
- package/package.json +1 -1
- package/scripts/install.mjs +13 -2
- package/.tfcore/utils/__pycache__/tf-brd-status.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-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-render-html.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-triage.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-verify-verdict.cpython-312.pyc +0 -0
|
@@ -70,12 +70,15 @@ DOC_NAME = re.compile(
|
|
|
70
70
|
r"^(.+?)(?:-P(\d+))?-(BRD|Deployment-Checklist(?:-[\w]+)?|Checklist|UIDesign|DevGuide|Architecture|Coding-Standards|UsageGuide|Usage-Guide|ProductGuide|Phases)\.md$",
|
|
71
71
|
re.I)
|
|
72
72
|
PHASE_FORM = re.compile(r"^(\d+)\s+of\s+(\d+)$", re.I)
|
|
73
|
-
|
|
73
|
+
# kept-whole: a phase finished before the size cap that the owner chose to keep as one document;
|
|
74
|
+
# its cap and word maximum are reported as WARN, not FAIL (Lekhak TF-029)
|
|
75
|
+
PHASE_STATUS = {"planned", "building", "done", "kept-whole"}
|
|
74
76
|
BRD_RANGE = re.compile(r"(?:BRD-)?(\d+)\s*(?:to|-|–|—)\s*(?:BRD-)?(\d+)", re.I)
|
|
75
77
|
|
|
76
78
|
SIZE_NAMES = {"s": "S", "small": "S", "m": "M", "medium": "M", "l": "L", "large": "L"}
|
|
77
79
|
SIZE_LONG = {"S": "Small", "M": "Medium", "L": "Large"}
|
|
78
80
|
REQ_CAP = {"S": 50, "M": 100, "L": 100}
|
|
81
|
+
SCREEN_CAP = {"S": 10, "M": 20, "L": 20} # Large: per phase
|
|
79
82
|
|
|
80
83
|
CHECKLIST_HEADER = "| ID | Requirement | Status | % | Remarks | Details |"
|
|
81
84
|
STATUS_VALUES = {
|
|
@@ -95,9 +98,10 @@ NAMES_SCREEN = re.compile(r"\b(on|opens?|from|in)\b", re.I)
|
|
|
95
98
|
|
|
96
99
|
|
|
97
100
|
def acceptance_words(line: str) -> int:
|
|
98
|
-
"""Words in an acceptance line, without the "Acceptance:" label and without a perf-budget
|
|
101
|
+
"""Words in an acceptance line, without the "Acceptance:" label and without a perf-budget or
|
|
102
|
+
zoom-check tail (the markers the verifier reads)."""
|
|
99
103
|
t = re.sub(r"^\s*[-*]\s*\*?Acceptance:?\*?:?\s*", "", line)
|
|
100
|
-
t = re.split(r"perf-budget:", t, flags=re.I)[0]
|
|
104
|
+
t = re.split(r"perf-budget:|zoom-check:", t, flags=re.I)[0]
|
|
101
105
|
return len(re.findall(r"\S+", t))
|
|
102
106
|
|
|
103
107
|
|
|
@@ -301,6 +305,12 @@ def detect_kind(path: str):
|
|
|
301
305
|
name = os.path.basename(path).lower()
|
|
302
306
|
if re.search(r"-deployment-checklist(-[\w]+)?\.md$", name):
|
|
303
307
|
return "deployment-checklist", "app-deployment-checklist-tmpl.md"
|
|
308
|
+
# phase 2 onward of a Large project: still a BRD to every cross-check, but it carries only
|
|
309
|
+
# its own screens and requirements and points back at phase 1 for the rest (TF-024)
|
|
310
|
+
if re.search(r"-p\d+-brd\.md$", name):
|
|
311
|
+
return "brd", "app-phase-brd-tmpl.md"
|
|
312
|
+
if re.search(r"-p\d+-uidesign\.md$", name):
|
|
313
|
+
return "uidesign", "app-phase-uidesign-tmpl.md"
|
|
304
314
|
for suffix, doc, tmpl in DOC_KINDS:
|
|
305
315
|
if name.endswith(suffix):
|
|
306
316
|
return doc, tmpl
|
|
@@ -324,7 +334,7 @@ def read_core_config(root: str) -> dict:
|
|
|
324
334
|
if os.path.exists(p):
|
|
325
335
|
with open(p, encoding="utf-8") as fh:
|
|
326
336
|
for line in fh:
|
|
327
|
-
m = re.match(r"^(appSize|appKind|appPhase):\s*(\S+)", line)
|
|
337
|
+
m = re.match(r"^(appSize|appKind|appPhase):\s*(\S+)", line) or re.match(r"^\s+(project_type):\s*(\S+)", line)
|
|
328
338
|
if m and m.group(2) not in ("null", "~", "''", '""'):
|
|
329
339
|
out[m.group(1)] = m.group(2).strip("'\"")
|
|
330
340
|
return out
|
|
@@ -338,6 +348,35 @@ def split_name(path: str):
|
|
|
338
348
|
return m.group(1), int(m.group(2) or 1)
|
|
339
349
|
|
|
340
350
|
|
|
351
|
+
def foreign_prefix(path: str):
|
|
352
|
+
"""The name's app when it is not an app of its folder: docs/AppManager-api-usage-guide.md beside
|
|
353
|
+
docs/AppManager-BRD.md is an API reference, not a UsageGuide (AppManager TF-024). A folder
|
|
354
|
+
holding no BRD or checklist anchors nothing, so a lone document is checked by its name."""
|
|
355
|
+
app, _ = split_name(path)
|
|
356
|
+
folder = os.path.dirname(os.path.abspath(path))
|
|
357
|
+
apps = {m.group(1).lower() for m in (re.match(r"(.+?)-(BRD|Checklist)\.md$", f, re.I) for f in os.listdir(folder))
|
|
358
|
+
if m and not re.search(r"-P\d+$|-Deployment$", m.group(1), re.I)}
|
|
359
|
+
return app if app and apps and app.lower() not in apps else None
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def phase_status(path: str, app: str, phase: int):
|
|
363
|
+
"""The Status cell of this phase's row in docs/{App}-Phases.md beside it, lower-cased; None when
|
|
364
|
+
there is no Phases document or no row for the phase."""
|
|
365
|
+
p = os.path.join(os.path.dirname(path), f"{app}-Phases.md")
|
|
366
|
+
if not app or not os.path.isfile(p):
|
|
367
|
+
return None
|
|
368
|
+
with open(p, encoding="utf-8") as fh:
|
|
369
|
+
txt = strip_noise(fh.read())
|
|
370
|
+
for header, rows in tables_in(txt):
|
|
371
|
+
low = [h.lower() for h in header]
|
|
372
|
+
if "phase" in low and "status" in low:
|
|
373
|
+
ip, ist = low.index("phase"), low.index("status")
|
|
374
|
+
for r in rows:
|
|
375
|
+
if len(r) > max(ip, ist) and r[ip].strip("`* ") == str(phase):
|
|
376
|
+
return r[ist].strip("`* ").lower()
|
|
377
|
+
return None
|
|
378
|
+
|
|
379
|
+
|
|
341
380
|
def phase_file(app: str, phase: int, suffix: str) -> str:
|
|
342
381
|
return f"{app}-{suffix}.md" if phase <= 1 else f"{app}-P{phase}-{suffix}.md"
|
|
343
382
|
|
|
@@ -386,6 +425,10 @@ def check_document(path: str, rep: Report, cli_size=None, root=None):
|
|
|
386
425
|
if not doc:
|
|
387
426
|
rep.warn(path, "not a TechieFlow document name; skipped")
|
|
388
427
|
return None
|
|
428
|
+
foreign = foreign_prefix(path)
|
|
429
|
+
if foreign:
|
|
430
|
+
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')
|
|
431
|
+
return None
|
|
389
432
|
try:
|
|
390
433
|
schema = load_schema(tmpl)
|
|
391
434
|
except (OSError, ValueError) as e:
|
|
@@ -428,6 +471,10 @@ def check_document(path: str, rep: Report, cli_size=None, root=None):
|
|
|
428
471
|
rep.fail(rel, f'a phase-{phase} file needs the header row "Phase | {phase} of <m>"')
|
|
429
472
|
elif os.path.exists(phases_doc):
|
|
430
473
|
rep.fail(rel, f'the project has phases ({os.path.relpath(phases_doc, root)}); this file needs the header row "Phase | 1 of <m>"')
|
|
474
|
+
# a phase the owner kept whole past the cap: its size findings are WARN, never a gate failure (Lekhak TF-029)
|
|
475
|
+
kept = doc in PHASED_DOCS and bool(app) and phase_status(path, app, phase) == "kept-whole"
|
|
476
|
+
size_finding = rep.warn if kept else rep.fail
|
|
477
|
+
kept_why = f"; phase {phase} is kept-whole in {app}-Phases.md, so this is reported, not blocking" if kept else ""
|
|
431
478
|
|
|
432
479
|
# 2. sections: strangers, order, presence
|
|
433
480
|
present = [(norm_heading(h), h, txt) for h, txt in split_sections(clean, 2) if h is not None]
|
|
@@ -471,10 +518,18 @@ def check_document(path: str, rep: Report, cli_size=None, root=None):
|
|
|
471
518
|
total = word_count(body)
|
|
472
519
|
if size in schema.budget:
|
|
473
520
|
target, mx = schema.budget[size]
|
|
521
|
+
# The budget holds the size's screen cap of entries. Each entry past it brings its own
|
|
522
|
+
# per-entry target and maximum, which already bound it (AppManager TF-023: 54 screens).
|
|
523
|
+
extra, why = 0, ""
|
|
524
|
+
if schema.entries and schema.per_entry:
|
|
525
|
+
sec = section_text(present, schema.entries[0]) or ""
|
|
526
|
+
extra = max(0, sum(1 for h, _t in split_sections(sec, 3) if h) - SCREEN_CAP[size])
|
|
527
|
+
target, mx = target + extra * schema.per_entry[0], mx + extra * schema.per_entry[1]
|
|
528
|
+
why = f", with {extra} entries past the cap of {SCREEN_CAP[size]}" if extra else ""
|
|
474
529
|
if total > mx:
|
|
475
|
-
|
|
530
|
+
size_finding(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{kept_why}")
|
|
476
531
|
elif total > target:
|
|
477
|
-
rep.warn(rel, f"{total:,} words; the {SIZE_LONG[size]} target is {target:,} (maximum {mx:,})")
|
|
532
|
+
rep.warn(rel, f"{total:,} words; the {SIZE_LONG[size]} target is {target:,} (maximum {mx:,}{why})")
|
|
478
533
|
for key, h, txt in present:
|
|
479
534
|
d = schema.declared(key)
|
|
480
535
|
if d and d[2]:
|
|
@@ -534,7 +589,7 @@ def check_document(path: str, rep: Report, cli_size=None, root=None):
|
|
|
534
589
|
# 5. document rules
|
|
535
590
|
ctx = dict(doc=doc, body=body, clean=clean, nocomment=nocomment, present=present, header=header,
|
|
536
591
|
size=size, kind=kind, root=root, path=path, rel=rel, entry_names=entry_names,
|
|
537
|
-
app=app, phase=phase)
|
|
592
|
+
app=app, phase=phase, size_finding=size_finding, kept_why=kept_why)
|
|
538
593
|
for rule in schema.rules:
|
|
539
594
|
if not rule.startswith("entry-"):
|
|
540
595
|
check_doc_rule(rule, ctx, rep)
|
|
@@ -623,7 +678,7 @@ def check_doc_rule(rule, c, rep):
|
|
|
623
678
|
if dupes:
|
|
624
679
|
rep.fail(rel, "duplicate requirement ids: " + ", ".join(f"BRD-{d}" for d in sorted(dupes, key=int)))
|
|
625
680
|
if len(seen) > REQ_CAP[size]:
|
|
626
|
-
|
|
681
|
+
c["size_finding"](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{c['kept_why']}")
|
|
627
682
|
c["brd_ids"] = seen
|
|
628
683
|
req = section_text(present, "Requirements") or ""
|
|
629
684
|
cur = None
|
|
@@ -638,6 +693,22 @@ def check_doc_rule(rule, c, rep):
|
|
|
638
693
|
elif n_acc > ACC_TARGET:
|
|
639
694
|
rep.warn(rel, f"BRD-{cur} acceptance line is {n_acc} words; the target is {ACC_TARGET} (maximum {ACC_MAX})")
|
|
640
695
|
|
|
696
|
+
elif rule == "phase-pointer":
|
|
697
|
+
# a phase BRD leaves scope, users, the whole-app non-functionals, constraints and risks to
|
|
698
|
+
# phase 1, and a phase UIDesign the design system and the click-through flow; this section is
|
|
699
|
+
# how a reader finds them, so it must actually lead to the phase-1 document of the same kind
|
|
700
|
+
txt = section_text(present, "Where the rest lives")
|
|
701
|
+
if txt is None or not c.get("app"):
|
|
702
|
+
return
|
|
703
|
+
want = phase_file(c["app"], 1, PHASED_SUFFIX[c["doc"]])
|
|
704
|
+
hits = [l for l in re.findall(r"\]\(([^)\s]+)\)", txt)
|
|
705
|
+
if os.path.basename(l.split("#", 1)[0]).lower() == want.lower()]
|
|
706
|
+
if not hits:
|
|
707
|
+
rep.fail(rel, f'the "Where the rest lives" section does not link to the phase-1 document ({want}), '
|
|
708
|
+
f'where everything the whole application shares lives')
|
|
709
|
+
elif not any(resolve(root, path, l) for l in hits):
|
|
710
|
+
rep.fail(rel, f'the "Where the rest lives" link to {hits[0]} points at a file that does not exist')
|
|
711
|
+
|
|
641
712
|
elif rule == "mockup-links":
|
|
642
713
|
for l in sorted(links_to(clean, "mockups")):
|
|
643
714
|
if not resolve(root, path, l):
|
|
@@ -662,6 +733,7 @@ def check_doc_rule(rule, c, rep):
|
|
|
662
733
|
continue
|
|
663
734
|
names.append(r[sc])
|
|
664
735
|
c["brd_screens"] = names
|
|
736
|
+
check_named_screens(c, rep)
|
|
665
737
|
|
|
666
738
|
elif rule == "stack-table":
|
|
667
739
|
txt = section_text(present, "Stack decisions")
|
|
@@ -863,6 +935,26 @@ def check_doc_rule(rule, c, rep):
|
|
|
863
935
|
if not re.search(r"(?i)\bblocking\b|\bnothing is blocked\b", txt):
|
|
864
936
|
rep.fail(rel, 'the Summary must say how many entries are blocking right now, '
|
|
865
937
|
'or "Nothing is blocked" — it is the first thing the owner reads')
|
|
938
|
+
# ... and its counts must be the entries' own. TfLens's read "20 entries, 8 open" over 27
|
|
939
|
+
# entries with 7 fixed upstream, and a TrBlazeUI file "all 28 open, none fixed upstream"
|
|
940
|
+
# under the library's own reply fixing 24 of them (MISS-TechieFlow-20260911-04).
|
|
941
|
+
sys.path.insert(0, SELF_DIR)
|
|
942
|
+
import tf_feedback
|
|
943
|
+
es = tf_feedback.entries(path)
|
|
944
|
+
n = {s: [e["id"] for e in es if e["state"] == s] for s in ("open", "fixed", "closed")}
|
|
945
|
+
truth = (f"{len(es)} entries: {len(n['open'])} open, {len(n['fixed'])} fixed upstream and not yet "
|
|
946
|
+
f"re-checked, {len(n['closed'])} closed")
|
|
947
|
+
head = re.split(r"(?m)^####\s", txt)[0]
|
|
948
|
+
said_total = re.search(r"(?i)\b(\d+)\s+entries\b", head)
|
|
949
|
+
said_open = re.search(r"(?i)\b(?:all\s+)?(\d+)\s+(?:filed and\s+)?open\b", head)
|
|
950
|
+
wrong = ((said_total and int(said_total.group(1)) != len(es))
|
|
951
|
+
or (said_open and int(said_open.group(1)) != len(n["open"])))
|
|
952
|
+
if wrong:
|
|
953
|
+
rep.fail(rel, f"the Summary's counts are not the entries' — the file holds {truth}; "
|
|
954
|
+
f"rewrite it from: bash .tfcore/utils/tf-feedback.sh {os.path.basename(path).split('-')[0]}")
|
|
955
|
+
elif n["fixed"] and not re.search(r"(?i)fixed upstream|re-?check", head):
|
|
956
|
+
rep.fail(rel, f"{len(n['fixed'])} entries are fixed upstream and not yet re-checked here "
|
|
957
|
+
f"({', '.join(n['fixed'][:5])}{' …' if len(n['fixed']) > 5 else ''}); the Summary must say so")
|
|
866
958
|
|
|
867
959
|
elif rule == "paste-back-block":
|
|
868
960
|
# `present` is built from the prose-only copy, which drops fenced blocks — and a
|
|
@@ -919,7 +1011,7 @@ def check_doc_rule(rule, c, rep):
|
|
|
919
1011
|
rep.fail(rel, f"phase {n} BRD range runs backwards ({x} to {y})")
|
|
920
1012
|
st = r[ist].strip("`* ").lower()
|
|
921
1013
|
if st not in PHASE_STATUS:
|
|
922
|
-
rep.fail(rel, f'phase {n} status "{r[ist]}" must be planned, building or
|
|
1014
|
+
rep.fail(rel, f'phase {n} status "{r[ist]}" must be planned, building, done or kept-whole')
|
|
923
1015
|
rows_out.append(dict(n=n, name=r[iname].strip("`* "), screens=screens, lo=lo, hi=hi, ranges=ranges, status=st))
|
|
924
1016
|
nums = [p["n"] for p in rows_out]
|
|
925
1017
|
if nums != list(range(1, len(nums) + 1)):
|
|
@@ -966,15 +1058,25 @@ def check_checklist(c, rep):
|
|
|
966
1058
|
row_status[rid] = status.strip("`* ").lower()
|
|
967
1059
|
if not re.fullmatch(r"REQ-(UI|FN|RAG|NFR)-\d{3}", rid):
|
|
968
1060
|
rep.fail(rel, f'id "{rid}" is not REQ-UI/FN/RAG/NFR- plus three digits')
|
|
1061
|
+
removed = " A removed row reads N/A with % 0, and the date goes in Remarks"
|
|
969
1062
|
if status.strip("`* ").lower() not in STATUS_VALUES:
|
|
970
|
-
rep.fail(rel, f'{rid} status "{status}" is not one of the fixed values'
|
|
1063
|
+
rep.fail(rel, f'{rid} status "{status}" is not one of the fixed values.'
|
|
1064
|
+
+ (removed if status.strip("`* ").lower().startswith("n/a") else ""))
|
|
971
1065
|
if pct.strip("`* ").rstrip("%").strip() not in ("0", "25", "50", "75", "100"):
|
|
972
|
-
rep.fail(rel, f'{rid} % "{pct}" must be 0, 25, 50, 75 or 100')
|
|
1066
|
+
rep.fail(rel, f'{rid} % "{pct}" must be 0, 25, 50, 75 or 100.' + removed)
|
|
973
1067
|
n = len(re.findall(r"\S+", remarks))
|
|
974
1068
|
if n > 60:
|
|
975
1069
|
rep.fail(rel, f"{rid} Remarks is {n} words; at most 60, current state only (history lives in the telemetry streams)")
|
|
976
|
-
|
|
1070
|
+
# text in backticks is quoted — a test's title or message the verdict script copied — not an agent's
|
|
1071
|
+
# claim about a file; an unclosed span (a Remark cut at its word cap) counts as quoted to its end
|
|
1072
|
+
# (Lekhak TF-012)
|
|
1073
|
+
said = re.sub(r"`[^`]*(?:`|$)", " ", remarks)
|
|
1074
|
+
if NOT_PRESENT.search(said) and not NAMES_PATH.search(remarks):
|
|
977
1075
|
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")
|
|
1076
|
+
if row_status[rid] == "n/a":
|
|
1077
|
+
# a removed or reserved row is kept for traceability; it has no screen, mockup or BRD item to
|
|
1078
|
+
# point at, so the detail-entry rules do not apply to it (Lekhak TF-028)
|
|
1079
|
+
continue
|
|
978
1080
|
m = re.search(r"\(#([^)]+)\)", details)
|
|
979
1081
|
if not m:
|
|
980
1082
|
rep.fail(rel, f"{rid} Details cell has no link to its detail entry")
|
|
@@ -983,7 +1085,7 @@ def check_checklist(c, rep):
|
|
|
983
1085
|
if not ids:
|
|
984
1086
|
rep.fail(rel, "the Requirements Status table has no REQ- rows")
|
|
985
1087
|
if len(ids) > REQ_CAP[size]:
|
|
986
|
-
|
|
1088
|
+
c["size_finding"](rel, f"{len(ids)} rows; the {SIZE_LONG[size]} cap is {REQ_CAP[size]}. Split into phases instead of growing this checklist{c['kept_why']}")
|
|
987
1089
|
dupes = sorted({i for i in ids if ids.count(i) > 1})
|
|
988
1090
|
if dupes:
|
|
989
1091
|
rep.fail(rel, "duplicate rows: " + ", ".join(dupes))
|
|
@@ -996,8 +1098,18 @@ def check_checklist(c, rep):
|
|
|
996
1098
|
if not m:
|
|
997
1099
|
continue # reported above through the Details link
|
|
998
1100
|
pos = m.start()
|
|
999
|
-
|
|
1000
|
-
|
|
1101
|
+
# A "### Page: …" heading written straight under the entry's own anchor belongs to the entry.
|
|
1102
|
+
# Stopping at the first "### " cut such an entry off before its content: three false failures
|
|
1103
|
+
# each, and 24 AppManager entries never really checked (TF-002). The next one still ends it.
|
|
1104
|
+
start = pos + 1
|
|
1105
|
+
own = re.match(r"<a id=[^>]*>\s*</a>[^\n]*\n(?:[ \t]*\n)*[ \t]*### [^\n]*", body[pos:])
|
|
1106
|
+
if own:
|
|
1107
|
+
start = pos + own.end()
|
|
1108
|
+
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)
|
|
1109
|
+
entry = body[pos: start + nxt.start()] if nxt else body[pos:]
|
|
1110
|
+
if row_status.get(rid) == "n/a":
|
|
1111
|
+
brd_refs.update(re.findall(r"BRD-(\d+)", entry)) # a retired BRD item still has its row
|
|
1112
|
+
continue
|
|
1001
1113
|
acc = [l for l in entry.splitlines() if ACCEPT_LINE.search(l)]
|
|
1002
1114
|
if len(acc) != 1:
|
|
1003
1115
|
rep.fail(rel, f"{rid} must have exactly one acceptance line; found {len(acc)}")
|
|
@@ -1022,7 +1134,10 @@ def check_checklist(c, rep):
|
|
|
1022
1134
|
brd_refs.update(refs)
|
|
1023
1135
|
if rid.startswith("REQ-UI"):
|
|
1024
1136
|
links = links_to(entry, "mockups")
|
|
1025
|
-
|
|
1137
|
+
# a library or docs project (metrics.project_type, or kind: library) has no screens and no
|
|
1138
|
+
# docs/mockups/: its UI rows map to components, so a missing link is not a finding there; a
|
|
1139
|
+
# link it does carry must still resolve (TrBlazeUI TF-003)
|
|
1140
|
+
if not links and c.get("kind") == "app" and read_core_config(root).get("project_type", "app").lower() not in ("library", "docs"):
|
|
1026
1141
|
rep.fail(rel, f"{rid} is a UI row without a mockup link")
|
|
1027
1142
|
for l in sorted(links):
|
|
1028
1143
|
if not resolve(root, path, l):
|
|
@@ -1039,6 +1154,48 @@ def _screen_key(s):
|
|
|
1039
1154
|
return re.sub(r"\s+", " ", s).strip().lower()
|
|
1040
1155
|
|
|
1041
1156
|
|
|
1157
|
+
# A requirement names its screen in the template's "*Screen:* <name>" field, or in running text as
|
|
1158
|
+
# "**<name>** screen". Either must have a row in some phase's Screens and flow table: the row is what
|
|
1159
|
+
# brings the UI design entry and the mockup after it (TF-030).
|
|
1160
|
+
SCREEN_FIELD = re.compile(r"\*{1,2}Screen:?\*{1,2}:?\s*([^·*|\n]+)")
|
|
1161
|
+
SCREEN_BOLD = re.compile(r"\*\*([^*\n]{2,60})\*\*[ -]screens?\b", re.I)
|
|
1162
|
+
NOT_A_NAME = {"every", "any", "each", "one", "this", "that", "the", "a", "new", "same", "other", "whole", "single", "no"}
|
|
1163
|
+
|
|
1164
|
+
|
|
1165
|
+
def _table_screen_keys(txt):
|
|
1166
|
+
"""Every name in the Screen column of a Screens and flow section, dialogs included."""
|
|
1167
|
+
out = set()
|
|
1168
|
+
for header, rows in tables_in(txt or ""):
|
|
1169
|
+
low = [h.lower() for h in header]
|
|
1170
|
+
if "screen" in low and "route" in low:
|
|
1171
|
+
sc = low.index("screen")
|
|
1172
|
+
out |= {_screen_key(r[sc]) for r in rows if len(r) > sc and r[sc] and not r[sc].startswith("{")}
|
|
1173
|
+
return out
|
|
1174
|
+
|
|
1175
|
+
|
|
1176
|
+
def check_named_screens(c, rep):
|
|
1177
|
+
known = _table_screen_keys(section_text(c["present"], "Screens and flow"))
|
|
1178
|
+
if c.get("app"): # a phase BRD may name a screen another phase's BRD owns
|
|
1179
|
+
for f in sorted(os.listdir(os.path.dirname(c["path"]))):
|
|
1180
|
+
if f != os.path.basename(c["path"]) and re.match(rf"^{re.escape(c['app'])}-(P\d+-)?BRD\.md$", f):
|
|
1181
|
+
with open(os.path.join(os.path.dirname(c["path"]), f), encoding="utf-8") as fh:
|
|
1182
|
+
other = [(norm_heading(h), h, t) for h, t in split_sections(strip_noise(fh.read()), 2) if h is not None]
|
|
1183
|
+
known |= _table_screen_keys(section_text(other, "Screens and flow"))
|
|
1184
|
+
cur, told = None, set()
|
|
1185
|
+
for line in (section_text(c["present"], "Requirements") or "").splitlines():
|
|
1186
|
+
m = re.search(r"\*\*BRD-(\d+)\*\*", line)
|
|
1187
|
+
if m:
|
|
1188
|
+
cur = m.group(1)
|
|
1189
|
+
for name in [x.group(1) for x in SCREEN_FIELD.finditer(line)] + [x.group(1) for x in SCREEN_BOLD.finditer(line)]:
|
|
1190
|
+
key = _screen_key(name)
|
|
1191
|
+
if not key or name.strip().startswith("{") or key in NOT_A_NAME or key in known or key in told:
|
|
1192
|
+
continue
|
|
1193
|
+
told.add(key)
|
|
1194
|
+
where = f"BRD-{cur}" if cur else "a requirement"
|
|
1195
|
+
rep.fail(c["rel"], f'{where} names the screen "{name.strip()}", which has no row in any Screens and flow table; '
|
|
1196
|
+
f'add the row, then its UI design entry and its mockup, before it is built')
|
|
1197
|
+
|
|
1198
|
+
|
|
1042
1199
|
def cross_checks(ctxs: dict, rep: Report, root: str):
|
|
1043
1200
|
"""ctxs is keyed by (doc, phase). Single documents sit at phase 1."""
|
|
1044
1201
|
phases = sorted({p for (_d, p) in ctxs})
|
|
@@ -1092,9 +1249,19 @@ def cross_phase_checks(ctxs, rep, root, phases):
|
|
|
1092
1249
|
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
1250
|
seen.setdefault(i, p)
|
|
1094
1251
|
|
|
1252
|
+
if not ph and app:
|
|
1253
|
+
# It may simply not have been named on the command line. Read it from disk for this rule and
|
|
1254
|
+
# throw its own findings away, since they belong to a run that names it: saying "the Phases
|
|
1255
|
+
# document does not exist" about a file that is there invites writing over it (TF-041).
|
|
1256
|
+
disk = os.path.join(root, "docs", f"{app}-Phases.md")
|
|
1257
|
+
if os.path.isfile(disk):
|
|
1258
|
+
try:
|
|
1259
|
+
ph = check_document(disk, Report(False), None, root)
|
|
1260
|
+
except Exception:
|
|
1261
|
+
ph = None
|
|
1095
1262
|
if not ph:
|
|
1096
1263
|
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
|
|
1264
|
+
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
1265
|
return
|
|
1099
1266
|
rows = ph.get("phases") or []
|
|
1100
1267
|
if not rows:
|
|
@@ -1145,14 +1312,18 @@ NAV_ITEM = re.compile(r"""<(div|li|span|button)\b[^>]*\bclass\s*=\s*["'][^"']*(?
|
|
|
1145
1312
|
|
|
1146
1313
|
|
|
1147
1314
|
def _mock_target(mock_dir: str, t: str):
|
|
1148
|
-
"""Resolve a link the way a browser opening docs/mockups/<file> would. Returns the
|
|
1149
|
-
|
|
1315
|
+
"""Resolve a link the way a browser opening docs/mockups/<file> would. Returns the path
|
|
1316
|
+
relative to the mockup folder when it lands on an existing file inside it, at any depth
|
|
1317
|
+
(Lekhak TF-008: index.html linking to admin/connection-settings.html opens, and was
|
|
1318
|
+
failed), else None. A leading slash, or a path that leaves docs/mockups/, is still None."""
|
|
1150
1319
|
if re.match(r"^(https?:|mailto:|tel:|javascript:|data:)", t, re.I) or t.startswith("/"):
|
|
1151
1320
|
return None
|
|
1321
|
+
t = re.split(r"[?#]", t, maxsplit=1)[0]
|
|
1322
|
+
base = os.path.normpath(mock_dir)
|
|
1152
1323
|
p = os.path.normpath(os.path.join(mock_dir, t))
|
|
1153
|
-
if
|
|
1324
|
+
if not (p == base or p.startswith(base + os.sep)) or not os.path.isfile(p):
|
|
1154
1325
|
return None
|
|
1155
|
-
return os.path.
|
|
1326
|
+
return os.path.relpath(p, base).replace(os.sep, "/")
|
|
1156
1327
|
|
|
1157
1328
|
|
|
1158
1329
|
def check_mockups(root: str, rep: Report):
|
|
@@ -1185,7 +1356,8 @@ def check_mockups(root: str, rep: Report):
|
|
|
1185
1356
|
continue
|
|
1186
1357
|
name = _mock_target(mock_dir, t)
|
|
1187
1358
|
if name is None:
|
|
1188
|
-
hint = "
|
|
1359
|
+
hint = ("a leading slash opens from the disk's root; write the path from docs/mockups/" if t.startswith("/")
|
|
1360
|
+
else "the file does not exist inside docs/mockups/")
|
|
1189
1361
|
rep.fail(rel, f'links to "{t}", which does not open from docs/mockups/ ({hint})')
|
|
1190
1362
|
else:
|
|
1191
1363
|
targets.append(name)
|
|
@@ -1290,6 +1462,7 @@ def main(argv=None):
|
|
|
1290
1462
|
if a.app:
|
|
1291
1463
|
root = root or os.getcwd()
|
|
1292
1464
|
files += app_files(root, a.app)
|
|
1465
|
+
files = list(dict.fromkeys(os.path.abspath(f) for f in files)) # a file given and listed by --app once
|
|
1293
1466
|
if not files:
|
|
1294
1467
|
print(f"tf-doc-check: no documents for app {a.app} under {root}", file=sys.stderr)
|
|
1295
1468
|
return 2
|
|
@@ -1322,9 +1495,16 @@ def main(argv=None):
|
|
|
1322
1495
|
base = json.load(fh) or {}
|
|
1323
1496
|
except Exception:
|
|
1324
1497
|
base = {}
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1498
|
+
# every finding, under whatever it names: the mockup-folder and cross-document checks name
|
|
1499
|
+
# docs/mockups/index.html or another document, not one of the files given, and were never
|
|
1500
|
+
# recorded, so ~60 old findings read as new on every *amend-docs (Lekhak TF-023)
|
|
1501
|
+
new = {os.path.relpath(f, root): set() for f in files}
|
|
1502
|
+
for l in rep.lines:
|
|
1503
|
+
if l.startswith("FAIL ") and ": " in l:
|
|
1504
|
+
head, msg = l.split(": ", 1)
|
|
1505
|
+
new.setdefault(head[len("FAIL "):], set()).add(msg)
|
|
1506
|
+
for r, msgs in new.items():
|
|
1507
|
+
base[r] = sorted(msgs)
|
|
1328
1508
|
os.makedirs(os.path.dirname(base_path), exist_ok=True)
|
|
1329
1509
|
with open(base_path, "w", encoding="utf-8") as fh:
|
|
1330
1510
|
json.dump(base, fh, indent=1)
|
|
@@ -1338,11 +1518,23 @@ def main(argv=None):
|
|
|
1338
1518
|
with open(base_path, encoding="utf-8") as fh:
|
|
1339
1519
|
base = json.load(fh) or {}
|
|
1340
1520
|
relined = []
|
|
1521
|
+
# A size finding carries its count ("20,677 words; the Small maximum is 8,000"), so any
|
|
1522
|
+
# edit to an over-limit document, a cut included, changed the text and read as new
|
|
1523
|
+
# (Lekhak TF-023). The same finding with a count no larger than the old one is old;
|
|
1524
|
+
# a document that grew past its maximum still FAILs.
|
|
1525
|
+
def _shape(m):
|
|
1526
|
+
return re.sub(r"\d[\d,]*", "#", m)
|
|
1527
|
+
def _first(m):
|
|
1528
|
+
n = re.search(r"\d[\d,]*", m)
|
|
1529
|
+
return int(n.group(0).replace(",", "")) if n else None
|
|
1341
1530
|
for l in rep.lines:
|
|
1342
1531
|
if l.startswith("FAIL "):
|
|
1343
1532
|
head, msg = l.split(": ", 1)
|
|
1344
1533
|
r = head[len("FAIL "):]
|
|
1345
|
-
|
|
1534
|
+
was = base.get(r, [])
|
|
1535
|
+
same = msg in set(was) or (re.match(r"^\d[\d,]* (words|lines|rows|requirements|KB|screens)", msg) and any(
|
|
1536
|
+
_shape(o) == _shape(msg) and _first(o) is not None and _first(msg) <= _first(o) for o in was))
|
|
1537
|
+
if same:
|
|
1346
1538
|
relined.append("OLD " + l[5:])
|
|
1347
1539
|
rep.fails -= 1
|
|
1348
1540
|
old += 1
|
|
@@ -0,0 +1,116 @@
|
|
|
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 test projects holding those files, each on its own through tf-build.sh
|
|
11
|
+
# test (TechieRag TF-004), or the solution's tests one project at a time. Prints ONE line:
|
|
12
|
+
# NONE no test reads <docs>; nothing to run exit 0
|
|
13
|
+
# PASS <n> test file(s) read <docs> (<files>); the unit tests pass — <build line> exit 0
|
|
14
|
+
# FAIL <n> test file(s) read <docs> (<files>); the unit tests fail — <build line> exit 1
|
|
15
|
+
# NOT-RUN the tests could not be run — <reason> exit 2
|
|
16
|
+
# A FAIL means the phase is not closed: a test guards text this command changed.
|
|
17
|
+
set -uo pipefail
|
|
18
|
+
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
19
|
+
source "$HERE/tf-portable.sh" # tf_read_lines, for bash 3.2 on a stock Mac
|
|
20
|
+
DOCS=(); TARGET=""
|
|
21
|
+
while [[ $# -gt 0 ]]; do
|
|
22
|
+
case "$1" in
|
|
23
|
+
--target) TARGET="${2:-}"; shift 2 ;;
|
|
24
|
+
-h|--help) sed -n '2,16p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
|
25
|
+
*) DOCS+=("$1"); shift ;;
|
|
26
|
+
esac
|
|
27
|
+
done
|
|
28
|
+
[[ ${#DOCS[@]} -gt 0 ]] || { echo "tf-doc-tests: name at least one document" >&2; exit 2; }
|
|
29
|
+
|
|
30
|
+
READERS="$(python3 - "${DOCS[@]}" <<'PY'
|
|
31
|
+
import os, sys
|
|
32
|
+
names = set()
|
|
33
|
+
for d in sys.argv[1:]:
|
|
34
|
+
b = os.path.basename(d)
|
|
35
|
+
names.add(b)
|
|
36
|
+
stem = os.path.splitext(b)[0]
|
|
37
|
+
if len(stem) >= 6: # "README" alone would match half the code; a real document name is longer
|
|
38
|
+
names.add(stem)
|
|
39
|
+
EXT = (".cs", ".fs", ".vb", ".ts", ".tsx", ".js", ".mjs", ".py", ".java", ".kt", ".go", ".rs", ".rb", ".swift", ".dart", ".php")
|
|
40
|
+
SKIP = {"node_modules", "bin", "obj", ".git", ".tfcore", ".claude", ".opencode", "docs", ".artifacts"}
|
|
41
|
+
hits = []
|
|
42
|
+
for root, dirs, files in os.walk("."):
|
|
43
|
+
dirs[:] = [x for x in dirs if x not in SKIP and not (x.startswith(".") and x != ".")]
|
|
44
|
+
for fn in files:
|
|
45
|
+
p = os.path.join(root, fn)[2:]
|
|
46
|
+
if not fn.endswith(EXT) or "test" not in p.lower():
|
|
47
|
+
continue
|
|
48
|
+
try:
|
|
49
|
+
t = open(p, encoding="utf-8", errors="replace").read()
|
|
50
|
+
except OSError:
|
|
51
|
+
continue
|
|
52
|
+
if any(n in t for n in names):
|
|
53
|
+
hits.append(p)
|
|
54
|
+
print("\n".join(sorted(hits)))
|
|
55
|
+
PY
|
|
56
|
+
)"
|
|
57
|
+
if [[ -z "$READERS" ]]; then
|
|
58
|
+
echo "NONE no test reads ${DOCS[*]}; nothing to run"
|
|
59
|
+
exit 0
|
|
60
|
+
fi
|
|
61
|
+
N="$(wc -l <<<"$READERS" | tr -d ' ')"; LIST="$(tr '\n' ',' <<<"$READERS" | sed 's/,$//; s/,/, /g')"
|
|
62
|
+
|
|
63
|
+
# the same target choice as tf-verify-tests.sh: a root solution, else the only test project under tests/
|
|
64
|
+
ROOTSLN="$(compgen -G '*.sln'; compgen -G '*.slnx'; compgen -G '*.csproj')"
|
|
65
|
+
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)
|
|
66
|
+
[[ -z "$TARGET" && -z "$ROOTSLN" && ${#TESTPROJ[@]} -eq 1 ]] && TARGET="${TESTPROJ[0]}"
|
|
67
|
+
if [[ -z "$TARGET" && -z "$ROOTSLN" ]]; then
|
|
68
|
+
if [[ ${#TESTPROJ[@]} -gt 1 ]]; then
|
|
69
|
+
echo "NOT-RUN $N test file(s) read ${DOCS[*]} ($LIST), but there is no solution here and ${#TESTPROJ[@]} test projects; name one with --target"
|
|
70
|
+
else
|
|
71
|
+
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"
|
|
72
|
+
fi
|
|
73
|
+
exit 2
|
|
74
|
+
fi
|
|
75
|
+
# Only the test projects that hold a reader, each on its own (TechieRag TF-004): the whole solution at
|
|
76
|
+
# once let the local-model tests take the memory, and TechieRag.Tests failed with "Cannot allocate
|
|
77
|
+
# memory" on a test that passes alone. A reader in no .NET test project (a browser spec) falls back to
|
|
78
|
+
# the solution run, which tf-build.sh also splits into one test project at a time.
|
|
79
|
+
OWNERS="$(TF_READERS="$READERS" python3 - <<'PY'
|
|
80
|
+
import glob, os, sys
|
|
81
|
+
out = []
|
|
82
|
+
for r in os.environ.get("TF_READERS", "").split("\n"):
|
|
83
|
+
if not r.strip():
|
|
84
|
+
continue
|
|
85
|
+
d = os.path.dirname(r)
|
|
86
|
+
while d and d not in (".", "/"):
|
|
87
|
+
projs = glob.glob(os.path.join(d, "*.csproj"))
|
|
88
|
+
if projs:
|
|
89
|
+
p = projs[0]
|
|
90
|
+
if p not in out:
|
|
91
|
+
out.append(p)
|
|
92
|
+
break
|
|
93
|
+
d = os.path.dirname(d)
|
|
94
|
+
print("\n".join(out))
|
|
95
|
+
PY
|
|
96
|
+
)"
|
|
97
|
+
if [[ -n "$OWNERS" && -z "$TARGET" ]]; then
|
|
98
|
+
fails=(); notrun=(); passed=0; vl=()
|
|
99
|
+
while IFS= read -r p; do
|
|
100
|
+
[[ -z "$p" ]] && continue
|
|
101
|
+
v="$(bash "$HERE/tf-build.sh" test "$p" 2>&1 | grep -m1 -E '^(PASS|FAIL|NOT-RUN)' || true)"
|
|
102
|
+
vl+=("$(basename "$p" .csproj): ${v:-no verdict}")
|
|
103
|
+
case "$v" in PASS*) passed=$((passed+1)) ;; FAIL*) fails+=("$(basename "$p" .csproj)") ;; *) notrun+=("$(basename "$p" .csproj)") ;; esac
|
|
104
|
+
done <<<"$OWNERS"
|
|
105
|
+
k="$(grep -c . <<<"$OWNERS")"
|
|
106
|
+
if [[ ${#fails[@]} -gt 0 ]]; then LINE="FAIL ${fails[*]} failed ($k test project(s) holding a reader, each run on its own) — ${vl[*]}"
|
|
107
|
+
elif [[ ${#notrun[@]} -gt 0 ]]; then LINE="NOT-RUN ${notrun[*]} could not run — ${vl[*]}"
|
|
108
|
+
else LINE="PASS $k test project(s) holding a reader, each run on its own — ${vl[*]}"; fi
|
|
109
|
+
else
|
|
110
|
+
LINE="$(bash "$HERE/tf-build.sh" test ${TARGET:+"$TARGET"} 2>&1 | grep -m1 -E '^(PASS|FAIL|NOT-RUN)' || true)"
|
|
111
|
+
fi
|
|
112
|
+
case "$LINE" in
|
|
113
|
+
PASS*) echo "PASS $N test file(s) read ${DOCS[*]} ($LIST); the unit tests pass — $LINE"; exit 0 ;;
|
|
114
|
+
FAIL*) echo "FAIL $N test file(s) read ${DOCS[*]} ($LIST); the unit tests fail — $LINE"; exit 1 ;;
|
|
115
|
+
*) echo "NOT-RUN the unit tests could not be run — ${LINE:-tf-build.sh printed no verdict}"; exit 2 ;;
|
|
116
|
+
esac
|